diff --git a/packages/app/e2e/preferences.spec.ts b/packages/app/e2e/preferences.spec.ts index 76d6e21..0e3cec8 100644 --- a/packages/app/e2e/preferences.spec.ts +++ b/packages/app/e2e/preferences.spec.ts @@ -3,11 +3,10 @@ import { expect, test } from "@playwright/test"; test("restores and updates configuration preferences", async ({ page }) => { await page.addInitScript(() => { localStorage.setItem( - "demucs-onnx:main:v1", + "demucs-onnx:main:v2", JSON.stringify({ model: "htdemucs_ft", - outputMode: "two-stems", - targetStem: "bass", + twoStems: "bass", method: "minus", shifts: 3, }), @@ -32,7 +31,7 @@ test("restores and updates configuration preferences", async ({ page }) => { ); await expect .poll(() => - page.evaluate(() => localStorage.getItem("demucs-onnx:main:v1")), + page.evaluate(() => localStorage.getItem("demucs-onnx:main:v2")), ) - .toContain('"outputMode":"four-stems"'); + .toContain('"twoStems":null'); }); diff --git a/packages/app/e2e/separate.spec.ts b/packages/app/e2e/separate.spec.ts index 6dfd590..c1352b7 100644 --- a/packages/app/e2e/separate.spec.ts +++ b/packages/app/e2e/separate.spec.ts @@ -1,10 +1,5 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; -// Flow e2e: upload -> decode -> separate -> stems rendered with players + downloads. -// Exercises the whole client pipeline (decodeAudioData, wasm core, onnxruntime-web); -// numeric parity vs the native CLI is covered by the CLI-side comparisons, not here. -// -// Requires models in data/onnx-lean (see README.md for the regeneration chain). import { test, expect } from "@playwright/test"; const MODELS_DIR = resolve(import.meta.dirname, "../../../data/onnx-lean"); @@ -23,7 +18,7 @@ test("separates a clip fully client-side", async ({ page }) => { page.getByTestId("model-file-slot").getByText("Ready"), ).toHaveCount(2); await page.setInputFiles("#file", FIXTURE); - await expect(page.locator("#status")).toContainText("decoded: 2.00s"); + await expect(page.locator("#audio-status")).toContainText("Decoded: 2.00s"); await page.click("#run"); await expect( diff --git a/packages/app/package.json b/packages/app/package.json index 546d535..da99aef 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -11,9 +11,11 @@ "test-e2e": "playwright test" }, "dependencies": { + "@tanstack/react-query": "^5.101.2", "lucide-react": "^0.562.0", "react": "^19.2.3", "react-dom": "^19.2.3", + "sonner": "^2.0.7", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 7c362b9..655dada 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,39 +1,26 @@ +import { useMutation } from "@tanstack/react-query"; import { Check, CircleHelp, Plus } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { AUDIO_SAMPLE_RATE } from "./lib/audio/constants"; +import { decodeAudioFile } from "./lib/audio/decode"; import { isModelFilename, requiredModelFiles, type ModelFilename, type ModelSource, } from "./lib/audio/models"; -import type { SeparateRequest, SeparatedStem } from "./lib/audio/separate"; +import type { SeparateRequest } from "./lib/audio/separate"; +import { encodeWavF32 } from "./lib/audio/wav"; +import { separateInWorker } from "./lib/audio/worker-client"; import { loadPreferences, savePreferences } from "./lib/preferences"; import { updateRunProgress, type RunProgress } from "./lib/progress/model"; import { RunProgressPanel } from "./lib/progress/panel"; -import { encodeWavF32 } from "./lib/wav"; -import type { WorkerResponse } from "./worker"; - -type DecodedAudio = { left: Float32Array; right: Float32Array }; -type Output = SeparatedStem & { url: string }; - -function FieldHelp({ children }: { children: React.ReactNode }) { - return ( -
- - -
- {children} -
-
- ); -} export function App() { - const [decoded, setDecoded] = useState(null); + // synchronize preferences with localStorage + const [preferences, setPreferences] = useState(loadPreferences); + useEffect(() => savePreferences(preferences), [preferences]); + const [modelFiles, setModelFiles] = useState< Partial> >({}); @@ -43,31 +30,18 @@ export function App() { const [modelFileErrors, setModelFileErrors] = useState< Partial> >({}); - const [preferences, setPreferences] = useState(loadPreferences); - const [running, setRunning] = useState(false); - const [runProgress, setRunProgress] = useState(null); - const [now, setNow] = useState(Date.now()); - const [status, setStatus] = useState(""); - const [outputs, setOutputs] = useState([]); - const workerRef = useRef(null); - const outputUrlsRef = useRef([]); - const decodeIdRef = useRef(0); - const { model, method, shifts } = preferences; - const twoStems = - preferences.outputMode === "two-stems" ? preferences.targetStem : ""; - const selectedModelFiles = Object.values(modelFiles); + const { model, method, shifts, twoStems } = preferences; + const requiredFiles = requiredModelFiles( model, twoStems || undefined, twoStems ? method : undefined, ); - const missingModelFiles = requiredFiles.filter( - (filename) => !modelFiles[filename], - ); - const modelsReady = missingModelFiles.length === 0; - const modelSource: ModelSource | null = modelsReady - ? { files: selectedModelFiles } + const modelSource: ModelSource | null = requiredFiles.every( + (filename) => modelFiles[filename], + ) + ? { files: Object.values(modelFiles) } : null; function addModelFiles(files: File[], expected?: ModelFilename) { const accepted = files.filter( @@ -96,151 +70,83 @@ export function App() { } } - function clearOutputs() { - for (const url of outputUrlsRef.current) { - URL.revokeObjectURL(url); - } - outputUrlsRef.current = []; - setOutputs([]); - } - - useEffect( - () => () => { - workerRef.current?.terminate(); - for (const url of outputUrlsRef.current) { - URL.revokeObjectURL(url); + const handleAudioFileMutation = useMutation({ + mutationFn: async (file: File | undefined) => { + if (file) { + return decodeAudioFile(file); } + return null; }, - [], - ); - - useEffect(() => { - if (!running) { - return; - } - const timer = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(timer); - }, [running]); - - useEffect(() => savePreferences(preferences), [preferences]); + }); + const decodedAudio = handleAudioFileMutation.data ?? null; - async function handleAudioFile(file: File | undefined) { - const decodeId = ++decodeIdRef.current; - if (!file) { - setDecoded(null); - setStatus(""); - return; - } + const [runProgress, setRunProgress] = useState(null); - setDecoded(null); - setStatus("decoding..."); - try { - const bytes = await file.arrayBuffer(); - const context = new OfflineAudioContext({ - numberOfChannels: 2, - length: 1, - sampleRate: 44100, - }); - const buffer = await context.decodeAudioData(bytes); - if (decodeId !== decodeIdRef.current) { - return; + const outputCleanupRef = useRef void>>([]); + const handleRunMutation = useMutation({ + mutationFn: async () => { + if (!decodedAudio || !modelSource) { + throw new Error("Audio and model files are required"); } - const left = buffer.getChannelData(0); - const right = - buffer.numberOfChannels > 1 ? buffer.getChannelData(1) : left; - setDecoded({ left, right }); - setStatus( - `decoded: ${(buffer.length / 44100).toFixed(2)}s, ${buffer.numberOfChannels}ch @44.1k`, - ); - } catch (error) { - if (decodeId === decodeIdRef.current) { - setStatus( - `error: failed to decode audio: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - } - - function finishRun(worker: Worker) { - if (workerRef.current !== worker) { - return false; - } - workerRef.current = null; - worker.terminate(); - setRunning(false); - return true; - } - - function handleRun() { - if (!decoded || !modelSource) { - return; - } - - clearOutputs(); - setRunning(true); - const startedAt = Date.now(); - setNow(startedAt); - setRunProgress({ - phase: "preparing", - startedAt, - done: 0, - total: 0, - models: [], - finalizeMs: 0, - }); - const started = performance.now(); - const worker = new Worker(new URL("./worker.ts", import.meta.url), { - type: "module", - }); - workerRef.current = worker; - worker.onerror = (event) => { - if (finishRun(worker)) { - setRunProgress(null); - setStatus(`error: worker failed: ${event.message}`); + for (const cleanup of outputCleanupRef.current) { + cleanup(); } - }; - worker.onmessage = (event: MessageEvent) => { - if (workerRef.current !== worker) { - return; - } - const message = event.data; - if (message.type === "progress") { - setRunProgress((progress) => - progress - ? updateRunProgress(progress, message.event, message.at) - : progress, - ); - } else if (message.type === "done") { - const nextOutputs = message.outputs.map((output) => { - const blob = encodeWavF32([output.left, output.right], 44100); - return { ...output, url: URL.createObjectURL(blob) }; - }); - outputUrlsRef.current = nextOutputs.map((output) => output.url); - setOutputs(nextOutputs); - setStatus( - `Done in ${((performance.now() - started) / 1000).toFixed(1)}s`, + outputCleanupRef.current = []; + + const startedAt = Date.now(); + setRunProgress({ + phase: "preparing", + startedAt, + done: 0, + total: 0, + models: [], + finalizeMs: 0, + }); + const started = performance.now(); + const request: SeparateRequest = { + left: decodedAudio.left.slice(), + right: decodedAudio.right.slice(), + model, + twoStems: twoStems ? { source: twoStems, method } : undefined, + shifts, + modelSource, + }; + const separated = await separateInWorker(request, { + onProgress: (event, at) => + setRunProgress((progress) => + progress ? updateRunProgress(progress, event, at) : progress, + ), + }); + const nextOutputs = separated.map((output) => { + const blob = encodeWavF32( + [output.left, output.right], + AUDIO_SAMPLE_RATE, ); - finishRun(worker); - } else { + return { ...output, url: URL.createObjectURL(blob) }; + }); + outputCleanupRef.current = nextOutputs.map( + (output) => () => URL.revokeObjectURL(output.url), + ); + return { outputs: nextOutputs, durationMs: performance.now() - started }; + }, + onSettled: (_data, error) => { + if (error) { setRunProgress(null); - setStatus(`error: ${message.message}`); - finishRun(worker); } - }; + }, + }); - const left = decoded.left.slice(); - const right = decoded.right.slice(); - const request: SeparateRequest = { - left, - right, - model, - twoStems: twoStems ? { source: twoStems, method } : undefined, - shifts, - modelSource, - }; - worker.postMessage(request, [left.buffer, right.buffer]); - } + const outputs = handleRunMutation.data?.outputs ?? []; + + const audioFileStatusText = handleAudioFileMutation.isPending + ? "Decoding..." + : decodedAudio + ? `Decoded: ${decodedAudio.duration.toFixed(2)}s, ${decodedAudio.numberOfChannels}ch @${decodedAudio.sampleRate / 1000}k` + : ""; + const separationStatusText = handleRunMutation.data + ? `Done in ${(handleRunMutation.data.durationMs / 1000).toFixed(1)}s` + : ""; return (
@@ -280,9 +186,14 @@ export function App() { id="file" accept="audio/*" onChange={(event) => - void handleAudioFile(event.target.files?.[0]) + handleAudioFileMutation.mutate(event.target.files?.[0]) } /> + {audioFileStatusText && ( +

+ {audioFileStatusText} +

+ )} @@ -350,16 +261,12 @@ export function App() {