From ffcbac6e5e0d85b6e23025707a5491f40cb9c0ed Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:49:36 +0900 Subject: [PATCH 01/36] refactor: extract browser audio utilities Move audio decoding and worker lifecycle management out of the app component, and colocate the worker entry point with the audio implementation. Co-authored-by: OpenCode --- packages/app/src/app.tsx | 114 +++++++----------- packages/app/src/lib/audio/decode.ts | 26 ++++ packages/app/src/lib/audio/separate-worker.ts | 57 +++++++++ packages/app/src/lib/audio/worker.ts | 26 ++++ packages/app/src/main.tsx | 14 ++- packages/app/src/worker.ts | 27 ----- 6 files changed, 160 insertions(+), 104 deletions(-) create mode 100644 packages/app/src/lib/audio/decode.ts create mode 100644 packages/app/src/lib/audio/separate-worker.ts create mode 100644 packages/app/src/lib/audio/worker.ts delete mode 100644 packages/app/src/worker.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index cd031ac..eabb768 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,5 +1,6 @@ import { Check, CircleHelp, Plus } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { decodeAudioFile, type DecodedAudio } from "./lib/audio/decode"; import { isModelFilename, requiredModelFiles, @@ -7,13 +8,12 @@ import { type ModelSource, } from "./lib/audio/models"; import type { SeparateRequest, SeparatedStem } from "./lib/audio/separate"; +import { separateInWorker } from "./lib/audio/separate-worker"; 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 }) { @@ -49,7 +49,7 @@ export function App() { const [now, setNow] = useState(Date.now()); const [status, setStatus] = useState(""); const [outputs, setOutputs] = useState([]); - const workerRef = useRef(null); + const runAbortRef = useRef(null); const outputUrlsRef = useRef([]); const decodeIdRef = useRef(0); const { model, method, shifts } = preferences; @@ -106,7 +106,7 @@ export function App() { useEffect( () => () => { - workerRef.current?.terminate(); + runAbortRef.current?.abort(); for (const url of outputUrlsRef.current) { URL.revokeObjectURL(url); } @@ -135,22 +135,13 @@ export function App() { 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); + const audio = await decodeAudioFile(file); if (decodeId !== decodeIdRef.current) { return; } - const left = buffer.getChannelData(0); - const right = - buffer.numberOfChannels > 1 ? buffer.getChannelData(1) : left; - setDecoded({ left, right }); + setDecoded(audio); setStatus( - `decoded: ${(buffer.length / 44100).toFixed(2)}s, ${buffer.numberOfChannels}ch @44.1k`, + `decoded: ${audio.duration.toFixed(2)}s, ${audio.numberOfChannels}ch @${audio.sampleRate / 1000}k`, ); } catch (error) { if (decodeId === decodeIdRef.current) { @@ -161,17 +152,7 @@ export function App() { } } - function finishRun(worker: Worker) { - if (workerRef.current !== worker) { - return false; - } - workerRef.current = null; - worker.terminate(); - setRunning(false); - return true; - } - - function handleRun() { + async function handleRun() { if (!decoded || !modelSource) { return; } @@ -189,57 +170,46 @@ export function App() { 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}`); - } - }; - 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`, - ); - finishRun(worker); - } else { - setRunProgress(null); - setStatus(`error: ${message.message}`); - finishRun(worker); - } - }; - - const left = decoded.left.slice(); - const right = decoded.right.slice(); const request: SeparateRequest = { - left, - right, + left: decoded.left.slice(), + right: decoded.right.slice(), model, twoStems: twoStems ? { source: twoStems, method } : undefined, shifts, modelSource, }; - worker.postMessage(request, [left.buffer, right.buffer]); + const controller = new AbortController(); + runAbortRef.current = controller; + try { + const separated = await separateInWorker(request, { + signal: controller.signal, + onProgress: (event, at) => + setRunProgress((progress) => + progress ? updateRunProgress(progress, event, at) : progress, + ), + }); + const nextOutputs = separated.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`, + ); + } catch (error) { + if (!controller.signal.aborted) { + setRunProgress(null); + setStatus( + `error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } finally { + if (runAbortRef.current === controller) { + runAbortRef.current = null; + setRunning(false); + } + } } return ( diff --git a/packages/app/src/lib/audio/decode.ts b/packages/app/src/lib/audio/decode.ts new file mode 100644 index 0000000..39ca781 --- /dev/null +++ b/packages/app/src/lib/audio/decode.ts @@ -0,0 +1,26 @@ +export interface DecodedAudio { + left: Float32Array; + right: Float32Array; + duration: number; + numberOfChannels: number; + sampleRate: number; +} + +export async function decodeAudioFile(file: File): Promise { + const context = new OfflineAudioContext({ + numberOfChannels: 2, + length: 1, + sampleRate: 44100, + }); + const buffer = await context.decodeAudioData(await file.arrayBuffer()); + const left = buffer.getChannelData(0); + const right = buffer.numberOfChannels > 1 ? buffer.getChannelData(1) : left; + + return { + left, + right, + duration: buffer.duration, + numberOfChannels: buffer.numberOfChannels, + sampleRate: buffer.sampleRate, + }; +} diff --git a/packages/app/src/lib/audio/separate-worker.ts b/packages/app/src/lib/audio/separate-worker.ts new file mode 100644 index 0000000..8d32975 --- /dev/null +++ b/packages/app/src/lib/audio/separate-worker.ts @@ -0,0 +1,57 @@ +import type { ProgressEvent, SeparatedStem, SeparateRequest } from "./separate"; + +export type WorkerResponse = + | { type: "progress"; event: ProgressEvent; at: number } + | { type: "done"; outputs: SeparatedStem[] } + | { type: "error"; message: string }; + +export interface SeparateInWorkerOptions { + onProgress?: (event: ProgressEvent, at: number) => void; + signal?: AbortSignal; +} + +export function separateInWorker( + request: SeparateRequest, + options: SeparateInWorkerOptions = {}, +): Promise { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(options.signal.reason); + return; + } + + const worker = new Worker(new URL("./worker.ts", import.meta.url), { + type: "module", + }); + const finish = () => { + options.signal?.removeEventListener("abort", handleAbort); + worker.terminate(); + }; + const handleAbort = () => { + finish(); + reject(options.signal?.reason); + }; + + options.signal?.addEventListener("abort", handleAbort, { once: true }); + worker.onerror = (event) => { + finish(); + reject(new Error(`Worker failed: ${event.message}`)); + }; + worker.onmessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === "progress") { + options.onProgress?.(message.event, message.at); + return; + } + + finish(); + if (message.type === "done") { + resolve(message.outputs); + } else { + reject(new Error(message.message)); + } + }; + + worker.postMessage(request, [request.left.buffer, request.right.buffer]); + }); +} diff --git a/packages/app/src/lib/audio/worker.ts b/packages/app/src/lib/audio/worker.ts new file mode 100644 index 0000000..27a639d --- /dev/null +++ b/packages/app/src/lib/audio/worker.ts @@ -0,0 +1,26 @@ +// Worker: messaging and the browser host for the Rust/WASM separation flow. +// Input is a SeparateRequest verbatim; output is the WorkerResponse union. +import { separate, type SeparateRequest } from "./separate"; +import type { WorkerResponse } from "./separate-worker"; + +function main() { + self.onmessage = async (event: MessageEvent) => { + const post = (message: WorkerResponse, transfer?: Transferable[]) => + (self as unknown as Worker).postMessage(message, transfer ?? []); + try { + const outputs = await separate(event.data, { + onProgress: (progress) => + post({ type: "progress", event: progress, at: Date.now() }), + }); + const transfers = outputs.flatMap((output) => [ + output.left.buffer, + output.right.buffer, + ]); + post({ type: "done", outputs }, transfers); + } catch (error) { + post({ type: "error", message: String(error) }); + } + }; +} + +main(); diff --git a/packages/app/src/main.tsx b/packages/app/src/main.tsx index cc565f3..7b925f5 100644 --- a/packages/app/src/main.tsx +++ b/packages/app/src/main.tsx @@ -3,8 +3,12 @@ import { createRoot } from "react-dom/client"; import { App } from "./app"; import "./styles.css"; -createRoot(document.getElementById("root")!).render( - - - , -); +function main() { + createRoot(document.getElementById("root")!).render( + + + , + ); +} + +main(); diff --git a/packages/app/src/worker.ts b/packages/app/src/worker.ts deleted file mode 100644 index f71b368..0000000 --- a/packages/app/src/worker.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Worker: messaging and the browser host for the Rust/WASM separation flow. -// Input is a SeparateRequest verbatim; output is the WorkerResponse union. -import { - separate, - type ProgressEvent, - type SeparatedStem, - type SeparateRequest, -} from "./lib/audio/separate"; - -export type WorkerResponse = - | { type: "progress"; event: ProgressEvent; at: number } - | { type: "done"; outputs: SeparatedStem[] } - | { type: "error"; message: string }; - -self.onmessage = async (e: MessageEvent) => { - const post = (m: WorkerResponse, t?: Transferable[]) => - (self as unknown as Worker).postMessage(m, t ?? []); - try { - const outputs = await separate(e.data, { - onProgress: (event) => post({ type: "progress", event, at: Date.now() }), - }); - const transfers = outputs.flatMap((o) => [o.left.buffer, o.right.buffer]); - post({ type: "done", outputs }, transfers); - } catch (err) { - post({ type: "error", message: String(err) }); - } -}; From 57ca0197ad4b71a17fb7607dcd349c20020f5a3f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:54:08 +0900 Subject: [PATCH 02/36] refactor: manage audio decoding with react query Add the application query client and express file decoding as a mutation while preserving latest-selection callback behavior. Co-authored-by: OpenCode --- packages/app/package.json | 1 + packages/app/src/app.tsx | 34 ++++++++++++++-------------------- packages/app/src/main.tsx | 7 ++++++- pnpm-lock.yaml | 18 ++++++++++++++++++ 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index 546d535..770b529 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -11,6 +11,7 @@ "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", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e17389c..0b6b46b 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,3 +1,4 @@ +import { useMutation } from "@tanstack/react-query"; import { Check, CircleHelp, Plus } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { decodeAudioFile, type DecodedAudio } from "./lib/audio/decode"; @@ -51,7 +52,6 @@ export function App() { const [outputs, setOutputs] = useState([]); const runAbortRef = useRef(null); const outputUrlsRef = useRef([]); - const decodeIdRef = useRef(0); const { model, method, shifts } = preferences; const twoStems = preferences.outputMode === "two-stems" ? preferences.targetStem : ""; @@ -124,9 +124,11 @@ export function App() { useEffect(() => savePreferences(preferences), [preferences]); - async function handleAudioFile(file: File | undefined) { - const decodeId = ++decodeIdRef.current; + const decodeMutation = useMutation({ mutationFn: decodeAudioFile }); + + function handleAudioFile(file: File | undefined) { if (!file) { + decodeMutation.reset(); setDecoded(null); setStatus(""); return; @@ -134,22 +136,16 @@ export function App() { setDecoded(null); setStatus("decoding..."); - try { - const audio = await decodeAudioFile(file); - if (decodeId !== decodeIdRef.current) { - return; - } - setDecoded(audio); - setStatus( - `decoded: ${audio.duration.toFixed(2)}s, ${audio.numberOfChannels}ch @${audio.sampleRate / 1000}k`, - ); - } catch (error) { - if (decodeId === decodeIdRef.current) { + decodeMutation.mutate(file, { + onSuccess: (audio) => { + setDecoded(audio); setStatus( - `error: failed to decode audio: ${error instanceof Error ? error.message : String(error)}`, + `decoded: ${audio.duration.toFixed(2)}s, ${audio.numberOfChannels}ch @${audio.sampleRate / 1000}k`, ); - } - } + }, + onError: (error) => + setStatus(`error: failed to decode audio: ${error.message}`), + }); } async function handleRun() { @@ -249,9 +245,7 @@ export function App() { type="file" id="file" accept="audio/*" - onChange={(event) => - void handleAudioFile(event.target.files?.[0]) - } + onChange={(event) => handleAudioFile(event.target.files?.[0])} /> diff --git a/packages/app/src/main.tsx b/packages/app/src/main.tsx index 7b925f5..ea23767 100644 --- a/packages/app/src/main.tsx +++ b/packages/app/src/main.tsx @@ -1,12 +1,17 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./app"; import "./styles.css"; function main() { + const queryClient = new QueryClient(); + createRoot(document.getElementById("root")!).render( - + + + , ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4428bee..0630fda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: packages/app: dependencies: + '@tanstack/react-query': + specifier: ^5.101.2 + version: 5.101.2(react@19.2.7) lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.7) @@ -1052,6 +1055,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2522,6 +2533,13 @@ snapshots: tailwindcss: 4.3.2 vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0) + '@tanstack/query-core@5.101.2': {} + + '@tanstack/react-query@5.101.2(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.2 + react: 19.2.7 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 From 8255c76d89c2b89fef3e4109f76c7cc275f03328 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:55:23 +0900 Subject: [PATCH 03/36] feat: show mutation errors as toasts Add a global React Query mutation error fallback and clear transient decode status locally when decoding fails. Co-authored-by: OpenCode --- packages/app/package.json | 1 + packages/app/src/app.tsx | 3 +-- packages/app/src/main.tsx | 13 ++++++++++++- pnpm-lock.yaml | 14 ++++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index 770b529..da99aef 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -15,6 +15,7 @@ "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 0b6b46b..50dbf8d 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -143,8 +143,7 @@ export function App() { `decoded: ${audio.duration.toFixed(2)}s, ${audio.numberOfChannels}ch @${audio.sampleRate / 1000}k`, ); }, - onError: (error) => - setStatus(`error: failed to decode audio: ${error.message}`), + onError: () => setStatus(""), }); } diff --git a/packages/app/src/main.tsx b/packages/app/src/main.tsx index ea23767..7619d15 100644 --- a/packages/app/src/main.tsx +++ b/packages/app/src/main.tsx @@ -1,16 +1,27 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { Toaster, toast } from "sonner"; import { App } from "./app"; import "./styles.css"; function main() { - const queryClient = new QueryClient(); + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + onError: (error) => { + console.error(error); + toast.error(error.message); + }, + }, + }, + }); createRoot(document.getElementById("root")!).render( + , ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0630fda..18b0c1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: react-dom: specifier: ^19.2.3 version: 19.2.7(react@19.2.7) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) zod: specifier: ^4.4.3 version: 4.4.3 @@ -1722,6 +1725,12 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3135,6 +3144,11 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + source-map-js@1.2.1: {} stackback@0.0.2: {} From 19620dbd8d038e1c64fc481811a610ddb8756547 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:57:20 +0900 Subject: [PATCH 04/36] refactor: rename audio worker client Co-authored-by: OpenCode --- packages/app/src/app.tsx | 2 +- .../app/src/lib/audio/{separate-worker.ts => worker-client.ts} | 0 packages/app/src/lib/audio/worker.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/app/src/lib/audio/{separate-worker.ts => worker-client.ts} (100%) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 50dbf8d..58de967 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -9,7 +9,7 @@ import { type ModelSource, } from "./lib/audio/models"; import type { SeparateRequest, SeparatedStem } from "./lib/audio/separate"; -import { separateInWorker } from "./lib/audio/separate-worker"; +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"; diff --git a/packages/app/src/lib/audio/separate-worker.ts b/packages/app/src/lib/audio/worker-client.ts similarity index 100% rename from packages/app/src/lib/audio/separate-worker.ts rename to packages/app/src/lib/audio/worker-client.ts diff --git a/packages/app/src/lib/audio/worker.ts b/packages/app/src/lib/audio/worker.ts index 27a639d..02f0fe3 100644 --- a/packages/app/src/lib/audio/worker.ts +++ b/packages/app/src/lib/audio/worker.ts @@ -1,7 +1,7 @@ // Worker: messaging and the browser host for the Rust/WASM separation flow. // Input is a SeparateRequest verbatim; output is the WorkerResponse union. import { separate, type SeparateRequest } from "./separate"; -import type { WorkerResponse } from "./separate-worker"; +import type { WorkerResponse } from "./worker-client"; function main() { self.onmessage = async (event: MessageEvent) => { From 2c2bc0e58d18e380c18756238dc966054dd73b86 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:00:55 +0900 Subject: [PATCH 05/36] nit todo --- packages/app/src/app.tsx | 51 ++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 58de967..9305311 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -15,24 +15,9 @@ import { updateRunProgress, type RunProgress } from "./lib/progress/model"; import { RunProgressPanel } from "./lib/progress/panel"; import { encodeWavF32 } from "./lib/wav"; +// TODO: bad? type Output = SeparatedStem & { url: string }; -function FieldHelp({ children }: { children: React.ReactNode }) { - return ( -
- - -
- {children} -
-
- ); -} - export function App() { const [decoded, setDecoded] = useState(null); const [modelFiles, setModelFiles] = useState< @@ -44,14 +29,25 @@ export function App() { const [modelFileErrors, setModelFileErrors] = useState< Partial> >({}); - const [preferences, setPreferences] = useState(loadPreferences); const [running, setRunning] = useState(false); const [runProgress, setRunProgress] = useState(null); + + // TODO: hook const [now, setNow] = useState(Date.now()); + + // TODO: probably bad const [status, setStatus] = useState(""); - const [outputs, setOutputs] = useState([]); + + // TODO: bad const runAbortRef = useRef(null); + + // TODO: bad. probably mutation result + const [outputs, setOutputs] = useState([]); const outputUrlsRef = useRef([]); + + const [preferences, setPreferences] = useState(loadPreferences); + useEffect(() => savePreferences(preferences), [preferences]); + const { model, method, shifts } = preferences; const twoStems = preferences.outputMode === "two-stems" ? preferences.targetStem : ""; @@ -122,8 +118,7 @@ export function App() { return () => window.clearInterval(timer); }, [running]); - useEffect(() => savePreferences(preferences), [preferences]); - + // TODO: bad const decodeMutation = useMutation({ mutationFn: decodeAudioFile }); function handleAudioFile(file: File | undefined) { @@ -554,3 +549,19 @@ function ModelFileSlot({ ); } + +function FieldHelp({ children }: { children: React.ReactNode }) { + return ( +
+ + +
+ {children} +
+
+ ); +} From 779d36b8d59d7660bf6031b1e964fdab3dca8465 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:00:55 +0900 Subject: [PATCH 06/36] nit todo --- packages/app/src/app.tsx | 53 +++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 58de967..b084371 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -15,26 +15,13 @@ import { updateRunProgress, type RunProgress } from "./lib/progress/model"; import { RunProgressPanel } from "./lib/progress/panel"; import { encodeWavF32 } from "./lib/wav"; +// TODO: bad? type Output = SeparatedStem & { url: string }; -function FieldHelp({ children }: { children: React.ReactNode }) { - return ( -
- - -
- {children} -
-
- ); -} - export function App() { + // TODO: bad. mutation result? const [decoded, setDecoded] = useState(null); + const [modelFiles, setModelFiles] = useState< Partial> >({}); @@ -44,14 +31,25 @@ export function App() { const [modelFileErrors, setModelFileErrors] = useState< Partial> >({}); - const [preferences, setPreferences] = useState(loadPreferences); const [running, setRunning] = useState(false); const [runProgress, setRunProgress] = useState(null); + + // TODO: hook const [now, setNow] = useState(Date.now()); + + // TODO: probably bad const [status, setStatus] = useState(""); - const [outputs, setOutputs] = useState([]); + + // TODO: bad const runAbortRef = useRef(null); + + // TODO: bad. probably mutation result + const [outputs, setOutputs] = useState([]); const outputUrlsRef = useRef([]); + + const [preferences, setPreferences] = useState(loadPreferences); + useEffect(() => savePreferences(preferences), [preferences]); + const { model, method, shifts } = preferences; const twoStems = preferences.outputMode === "two-stems" ? preferences.targetStem : ""; @@ -122,8 +120,7 @@ export function App() { return () => window.clearInterval(timer); }, [running]); - useEffect(() => savePreferences(preferences), [preferences]); - + // TODO: bad. pending/error? const decodeMutation = useMutation({ mutationFn: decodeAudioFile }); function handleAudioFile(file: File | undefined) { @@ -554,3 +551,19 @@ function ModelFileSlot({ ); } + +function FieldHelp({ children }: { children: React.ReactNode }) { + return ( +
+ + +
+ {children} +
+
+ ); +} From 5d98d7befd0aadf650b2172797f0afdc7147d75c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:04:04 +0900 Subject: [PATCH 07/36] refactor: extract current time hook Co-authored-by: OpenCode --- packages/app/src/app.tsx | 13 ++----------- packages/app/src/lib/use-now.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 11 deletions(-) create mode 100644 packages/app/src/lib/use-now.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index b084371..467e620 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -13,6 +13,7 @@ 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 { useNow } from "./lib/use-now"; import { encodeWavF32 } from "./lib/wav"; // TODO: bad? @@ -34,8 +35,7 @@ export function App() { const [running, setRunning] = useState(false); const [runProgress, setRunProgress] = useState(null); - // TODO: hook - const [now, setNow] = useState(Date.now()); + const now = useNow(running); // TODO: probably bad const [status, setStatus] = useState(""); @@ -112,14 +112,6 @@ export function App() { [], ); - useEffect(() => { - if (!running) { - return; - } - const timer = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(timer); - }, [running]); - // TODO: bad. pending/error? const decodeMutation = useMutation({ mutationFn: decodeAudioFile }); @@ -152,7 +144,6 @@ export function App() { clearOutputs(); setRunning(true); const startedAt = Date.now(); - setNow(startedAt); setRunProgress({ phase: "preparing", startedAt, diff --git a/packages/app/src/lib/use-now.ts b/packages/app/src/lib/use-now.ts new file mode 100644 index 0000000..086ee7a --- /dev/null +++ b/packages/app/src/lib/use-now.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +export function useNow(enabled: boolean) { + const [now, setNow] = useState(Date.now); + + useEffect(() => { + if (!enabled) { + return; + } + + setNow(Date.now()); + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [enabled]); + + return now; +} From 8d71e3f055b777af55a76a511952481e99d90e11 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:09:42 +0900 Subject: [PATCH 08/36] refactor: keep progress ticker in panel Co-authored-by: OpenCode --- packages/app/src/app.tsx | 7 +------ packages/app/src/lib/progress/panel.tsx | 21 +++++++++++++-------- packages/app/src/lib/use-now.ts | 17 ----------------- 3 files changed, 14 insertions(+), 31 deletions(-) delete mode 100644 packages/app/src/lib/use-now.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 467e620..0322b56 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -13,7 +13,6 @@ 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 { useNow } from "./lib/use-now"; import { encodeWavF32 } from "./lib/wav"; // TODO: bad? @@ -35,8 +34,6 @@ export function App() { const [running, setRunning] = useState(false); const [runProgress, setRunProgress] = useState(null); - const now = useNow(running); - // TODO: probably bad const [status, setStatus] = useState(""); @@ -429,9 +426,7 @@ export function App() { > Separate track - {runProgress && ( - - )} + {runProgress && } {!running && status && (

count + 1, 0); + useEffect(() => { + if (progress.completedAt !== undefined) { + return; + } + const timer = window.setInterval(rerender, 1000); + return () => window.clearInterval(timer); + }, [progress.completedAt]); + + const elapsed = (progress.completedAt ?? Date.now()) - progress.startedAt; const loadMs = progress.models.reduce( (sum, model) => sum + (model.loadMs ?? 0), 0, diff --git a/packages/app/src/lib/use-now.ts b/packages/app/src/lib/use-now.ts deleted file mode 100644 index 086ee7a..0000000 --- a/packages/app/src/lib/use-now.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useEffect, useState } from "react"; - -export function useNow(enabled: boolean) { - const [now, setNow] = useState(Date.now); - - useEffect(() => { - if (!enabled) { - return; - } - - setNow(Date.now()); - const timer = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(timer); - }, [enabled]); - - return now; -} From 3fef1e4bb0fb0fd51a4127f82bf06e872324bdd9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:12:13 +0900 Subject: [PATCH 09/36] refactor: run separation as mutation Use React Query mutation state for the separation lifecycle while preserving progress updates and worker cancellation. Co-authored-by: OpenCode --- packages/app/src/app.tsx | 113 +++++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 57 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0322b56..03f3219 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -31,7 +31,6 @@ export function App() { const [modelFileErrors, setModelFileErrors] = useState< Partial> >({}); - const [running, setRunning] = useState(false); const [runProgress, setRunProgress] = useState(null); // TODO: probably bad @@ -133,64 +132,62 @@ export function App() { }); } - async function handleRun() { - if (!decoded || !modelSource) { - return; - } + const handleRunMutation = useMutation({ + mutationFn: async () => { + if (!decoded || !modelSource) { + throw new Error("Audio and model files are required"); + } - clearOutputs(); - setRunning(true); - const startedAt = Date.now(); - setRunProgress({ - phase: "preparing", - startedAt, - done: 0, - total: 0, - models: [], - finalizeMs: 0, - }); - const started = performance.now(); - const request: SeparateRequest = { - left: decoded.left.slice(), - right: decoded.right.slice(), - model, - twoStems: twoStems ? { source: twoStems, method } : undefined, - shifts, - modelSource, - }; - const controller = new AbortController(); - runAbortRef.current = controller; - try { - const separated = await separateInWorker(request, { - signal: controller.signal, - onProgress: (event, at) => - setRunProgress((progress) => - progress ? updateRunProgress(progress, event, at) : progress, - ), - }); - const nextOutputs = separated.map((output) => { - const blob = encodeWavF32([output.left, output.right], 44100); - return { ...output, url: URL.createObjectURL(blob) }; + clearOutputs(); + const startedAt = Date.now(); + setRunProgress({ + phase: "preparing", + startedAt, + done: 0, + total: 0, + models: [], + finalizeMs: 0, }); - outputUrlsRef.current = nextOutputs.map((output) => output.url); - setOutputs(nextOutputs); - setStatus( - `Done in ${((performance.now() - started) / 1000).toFixed(1)}s`, - ); - } catch (error) { - if (!controller.signal.aborted) { - setRunProgress(null); + const started = performance.now(); + const request: SeparateRequest = { + left: decoded.left.slice(), + right: decoded.right.slice(), + model, + twoStems: twoStems ? { source: twoStems, method } : undefined, + shifts, + modelSource, + }; + const controller = new AbortController(); + runAbortRef.current = controller; + try { + const separated = await separateInWorker(request, { + signal: controller.signal, + onProgress: (event, at) => + setRunProgress((progress) => + progress ? updateRunProgress(progress, event, at) : progress, + ), + }); + const nextOutputs = separated.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( - `error: ${error instanceof Error ? error.message : String(error)}`, + `Done in ${((performance.now() - started) / 1000).toFixed(1)}s`, ); + } catch (error) { + if (!controller.signal.aborted) { + setRunProgress(null); + throw error; + } + } finally { + if (runAbortRef.current === controller) { + runAbortRef.current = null; + } } - } finally { - if (runAbortRef.current === controller) { - runAbortRef.current = null; - setRunning(false); - } - } - } + }, + }); return (

@@ -421,13 +418,15 @@ export function App() { {runProgress && } - {!running && status && ( + {!handleRunMutation.isPending && status && (

Date: Sun, 12 Jul 2026 18:13:13 +0900 Subject: [PATCH 10/36] refactor: inline output cleanup Co-authored-by: OpenCode --- packages/app/src/app.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 03f3219..0d54c97 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -90,14 +90,6 @@ export function App() { } } - function clearOutputs() { - for (const url of outputUrlsRef.current) { - URL.revokeObjectURL(url); - } - outputUrlsRef.current = []; - setOutputs([]); - } - useEffect( () => () => { runAbortRef.current?.abort(); @@ -138,7 +130,11 @@ export function App() { throw new Error("Audio and model files are required"); } - clearOutputs(); + for (const url of outputUrlsRef.current) { + URL.revokeObjectURL(url); + } + outputUrlsRef.current = []; + setOutputs([]); const startedAt = Date.now(); setRunProgress({ phase: "preparing", From c17001f8dfe67ef13b31eefd0f7d5f41b7413ba1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:14:14 +0900 Subject: [PATCH 11/36] nit --- packages/app/src/app.tsx | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0d54c97..76de300 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -36,13 +36,6 @@ export function App() { // TODO: probably bad const [status, setStatus] = useState(""); - // TODO: bad - const runAbortRef = useRef(null); - - // TODO: bad. probably mutation result - const [outputs, setOutputs] = useState([]); - const outputUrlsRef = useRef([]); - const [preferences, setPreferences] = useState(loadPreferences); useEffect(() => savePreferences(preferences), [preferences]); @@ -90,16 +83,6 @@ export function App() { } } - useEffect( - () => () => { - runAbortRef.current?.abort(); - for (const url of outputUrlsRef.current) { - URL.revokeObjectURL(url); - } - }, - [], - ); - // TODO: bad. pending/error? const decodeMutation = useMutation({ mutationFn: decodeAudioFile }); @@ -124,6 +107,23 @@ export function App() { }); } + // TODO: bad + const runAbortRef = useRef(null); + + // TODO: bad. probably mutation result + const [outputs, setOutputs] = useState([]); + const outputUrlsRef = useRef([]); + + useEffect( + () => () => { + runAbortRef.current?.abort(); + for (const url of outputUrlsRef.current) { + URL.revokeObjectURL(url); + } + }, + [], + ); + const handleRunMutation = useMutation({ mutationFn: async () => { if (!decoded || !modelSource) { From 96acf87fa5f29b67904b5495a2d8aa0f14cd37b9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:14:33 +0900 Subject: [PATCH 12/36] refactor: inline output state type Co-authored-by: OpenCode --- packages/app/src/app.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 76de300..183dec3 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -15,9 +15,6 @@ import { updateRunProgress, type RunProgress } from "./lib/progress/model"; import { RunProgressPanel } from "./lib/progress/panel"; import { encodeWavF32 } from "./lib/wav"; -// TODO: bad? -type Output = SeparatedStem & { url: string }; - export function App() { // TODO: bad. mutation result? const [decoded, setDecoded] = useState(null); @@ -111,7 +108,9 @@ export function App() { const runAbortRef = useRef(null); // TODO: bad. probably mutation result - const [outputs, setOutputs] = useState([]); + const [outputs, setOutputs] = useState<(SeparatedStem & { url: string })[]>( + [], + ); const outputUrlsRef = useRef([]); useEffect( From a0f61a84dfea193ed5c1c291137a2b0e29de8dae Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:16:50 +0900 Subject: [PATCH 13/36] refactor: keep outputs in mutation result Derive rendered outputs from separation mutation data and tie object URL cleanup to that data lifecycle. Co-authored-by: OpenCode --- packages/app/src/app.tsx | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 183dec3..79a457f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -8,7 +8,7 @@ import { type ModelFilename, type ModelSource, } from "./lib/audio/models"; -import type { SeparateRequest, SeparatedStem } from "./lib/audio/separate"; +import type { SeparateRequest } from "./lib/audio/separate"; import { separateInWorker } from "./lib/audio/worker-client"; import { loadPreferences, savePreferences } from "./lib/preferences"; import { updateRunProgress, type RunProgress } from "./lib/progress/model"; @@ -107,18 +107,9 @@ export function App() { // TODO: bad const runAbortRef = useRef(null); - // TODO: bad. probably mutation result - const [outputs, setOutputs] = useState<(SeparatedStem & { url: string })[]>( - [], - ); - const outputUrlsRef = useRef([]); - useEffect( () => () => { runAbortRef.current?.abort(); - for (const url of outputUrlsRef.current) { - URL.revokeObjectURL(url); - } }, [], ); @@ -129,11 +120,6 @@ export function App() { throw new Error("Audio and model files are required"); } - for (const url of outputUrlsRef.current) { - URL.revokeObjectURL(url); - } - outputUrlsRef.current = []; - setOutputs([]); const startedAt = Date.now(); setRunProgress({ phase: "preparing", @@ -166,11 +152,10 @@ export function App() { 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`, ); + return nextOutputs; } catch (error) { if (!controller.signal.aborted) { setRunProgress(null); @@ -184,6 +169,16 @@ export function App() { }, }); + const outputs = handleRunMutation.data ?? []; + useEffect(() => { + const currentOutputs = handleRunMutation.data; + return () => { + for (const output of currentOutputs ?? []) { + URL.revokeObjectURL(output.url); + } + }; + }, [handleRunMutation.data]); + return (

From 3543149a40e0b404700bac6a15499c78bd689787 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:17:10 +0900 Subject: [PATCH 14/36] refactor: remove unreachable app cleanup Co-authored-by: OpenCode --- packages/app/src/app.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 79a457f..ebf918c 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -107,13 +107,6 @@ export function App() { // TODO: bad const runAbortRef = useRef(null); - useEffect( - () => () => { - runAbortRef.current?.abort(); - }, - [], - ); - const handleRunMutation = useMutation({ mutationFn: async () => { if (!decoded || !modelSource) { From 84e87511c2aa6a8b56ba622167626a8d741f349f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:18:28 +0900 Subject: [PATCH 15/36] refactor: use mutation settlement cleanup Remove the unused run abort controller and clear failed progress through the mutation lifecycle. Co-authored-by: OpenCode --- packages/app/src/app.tsx | 50 +++++++++++++++------------------------- 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index ebf918c..d512c5f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,6 +1,6 @@ import { useMutation } from "@tanstack/react-query"; import { Check, CircleHelp, Plus } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { decodeAudioFile, type DecodedAudio } from "./lib/audio/decode"; import { isModelFilename, @@ -104,9 +104,6 @@ export function App() { }); } - // TODO: bad - const runAbortRef = useRef(null); - const handleRunMutation = useMutation({ mutationFn: async () => { if (!decoded || !modelSource) { @@ -131,33 +128,24 @@ export function App() { shifts, modelSource, }; - const controller = new AbortController(); - runAbortRef.current = controller; - try { - const separated = await separateInWorker(request, { - signal: controller.signal, - onProgress: (event, at) => - setRunProgress((progress) => - progress ? updateRunProgress(progress, event, at) : progress, - ), - }); - const nextOutputs = separated.map((output) => { - const blob = encodeWavF32([output.left, output.right], 44100); - return { ...output, url: URL.createObjectURL(blob) }; - }); - setStatus( - `Done in ${((performance.now() - started) / 1000).toFixed(1)}s`, - ); - return nextOutputs; - } catch (error) { - if (!controller.signal.aborted) { - setRunProgress(null); - throw error; - } - } finally { - if (runAbortRef.current === controller) { - runAbortRef.current = null; - } + 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], 44100); + return { ...output, url: URL.createObjectURL(blob) }; + }); + setStatus( + `Done in ${((performance.now() - started) / 1000).toFixed(1)}s`, + ); + return nextOutputs; + }, + onSettled: (_data, error) => { + if (error) { + setRunProgress(null); } }, }); From 1d722d6cdddcc8dda3389bb091e3a038926ce854 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:19:22 +0900 Subject: [PATCH 16/36] refactor: remove unused worker cancellation Co-authored-by: OpenCode --- packages/app/src/lib/audio/worker-client.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/packages/app/src/lib/audio/worker-client.ts b/packages/app/src/lib/audio/worker-client.ts index 8d32975..59d4019 100644 --- a/packages/app/src/lib/audio/worker-client.ts +++ b/packages/app/src/lib/audio/worker-client.ts @@ -7,7 +7,6 @@ export type WorkerResponse = export interface SeparateInWorkerOptions { onProgress?: (event: ProgressEvent, at: number) => void; - signal?: AbortSignal; } export function separateInWorker( @@ -15,26 +14,12 @@ export function separateInWorker( options: SeparateInWorkerOptions = {}, ): Promise { return new Promise((resolve, reject) => { - if (options.signal?.aborted) { - reject(options.signal.reason); - return; - } - const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module", }); - const finish = () => { - options.signal?.removeEventListener("abort", handleAbort); - worker.terminate(); - }; - const handleAbort = () => { - finish(); - reject(options.signal?.reason); - }; - options.signal?.addEventListener("abort", handleAbort, { once: true }); worker.onerror = (event) => { - finish(); + worker.terminate(); reject(new Error(`Worker failed: ${event.message}`)); }; worker.onmessage = (event: MessageEvent) => { @@ -44,7 +29,7 @@ export function separateInWorker( return; } - finish(); + worker.terminate(); if (message.type === "done") { resolve(message.outputs); } else { From f64a828107dd0768e0ea50dbc55dabe3ced9453d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:19:51 +0900 Subject: [PATCH 17/36] refactor: require worker progress callback Co-authored-by: OpenCode --- packages/app/src/lib/audio/worker-client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/lib/audio/worker-client.ts b/packages/app/src/lib/audio/worker-client.ts index 59d4019..a26d731 100644 --- a/packages/app/src/lib/audio/worker-client.ts +++ b/packages/app/src/lib/audio/worker-client.ts @@ -6,12 +6,12 @@ export type WorkerResponse = | { type: "error"; message: string }; export interface SeparateInWorkerOptions { - onProgress?: (event: ProgressEvent, at: number) => void; + onProgress: (event: ProgressEvent, at: number) => void; } export function separateInWorker( request: SeparateRequest, - options: SeparateInWorkerOptions = {}, + options: SeparateInWorkerOptions, ): Promise { return new Promise((resolve, reject) => { const worker = new Worker(new URL("./worker.ts", import.meta.url), { @@ -25,7 +25,7 @@ export function separateInWorker( worker.onmessage = (event: MessageEvent) => { const message = event.data; if (message.type === "progress") { - options.onProgress?.(message.event, message.at); + options.onProgress(message.event, message.at); return; } From 47ae385acb4339be4c311098936fae88f18b8aa3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:26:01 +0900 Subject: [PATCH 18/36] refactor: fold audio selection into mutation Co-authored-by: OpenCode --- packages/app/src/app.tsx | 45 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index d512c5f..132c241 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -80,29 +80,28 @@ export function App() { } } - // TODO: bad. pending/error? - const decodeMutation = useMutation({ mutationFn: decodeAudioFile }); - - function handleAudioFile(file: File | undefined) { - if (!file) { - decodeMutation.reset(); + const handleAudioFileMutation = useMutation({ + mutationFn: async (file: File | undefined) => { setDecoded(null); - setStatus(""); - return; - } + if (!file) { + setStatus(""); + return null; + } - setDecoded(null); - setStatus("decoding..."); - decodeMutation.mutate(file, { - onSuccess: (audio) => { - setDecoded(audio); - setStatus( - `decoded: ${audio.duration.toFixed(2)}s, ${audio.numberOfChannels}ch @${audio.sampleRate / 1000}k`, - ); - }, - onError: () => setStatus(""), - }); - } + setStatus("decoding..."); + const audio = await decodeAudioFile(file); + setDecoded(audio); + setStatus( + `decoded: ${audio.duration.toFixed(2)}s, ${audio.numberOfChannels}ch @${audio.sampleRate / 1000}k`, + ); + return audio; + }, + onSettled: (_data, error) => { + if (error) { + setStatus(""); + } + }, + }); const handleRunMutation = useMutation({ mutationFn: async () => { @@ -197,7 +196,9 @@ export function App() { type="file" id="file" accept="audio/*" - onChange={(event) => handleAudioFile(event.target.files?.[0])} + onChange={(event) => + handleAudioFileMutation.mutate(event.target.files?.[0]) + } /> From 4c5ccc441cb25e4cb5d8bc9f61ed87506345324c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:26:29 +0900 Subject: [PATCH 19/36] refactor: derive decoded audio from mutation Co-authored-by: OpenCode --- packages/app/src/app.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 132c241..a99a5c6 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,7 +1,7 @@ import { useMutation } from "@tanstack/react-query"; import { Check, CircleHelp, Plus } from "lucide-react"; import { useEffect, useState } from "react"; -import { decodeAudioFile, type DecodedAudio } from "./lib/audio/decode"; +import { decodeAudioFile } from "./lib/audio/decode"; import { isModelFilename, requiredModelFiles, @@ -16,9 +16,6 @@ import { RunProgressPanel } from "./lib/progress/panel"; import { encodeWavF32 } from "./lib/wav"; export function App() { - // TODO: bad. mutation result? - const [decoded, setDecoded] = useState(null); - const [modelFiles, setModelFiles] = useState< Partial> >({}); @@ -82,7 +79,6 @@ export function App() { const handleAudioFileMutation = useMutation({ mutationFn: async (file: File | undefined) => { - setDecoded(null); if (!file) { setStatus(""); return null; @@ -90,7 +86,6 @@ export function App() { setStatus("decoding..."); const audio = await decodeAudioFile(file); - setDecoded(audio); setStatus( `decoded: ${audio.duration.toFixed(2)}s, ${audio.numberOfChannels}ch @${audio.sampleRate / 1000}k`, ); @@ -102,6 +97,7 @@ export function App() { } }, }); + const decoded = handleAudioFileMutation.data ?? null; const handleRunMutation = useMutation({ mutationFn: async () => { From 244152a349375292b6ea54b59b930aa7fb6a21f3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:28:33 +0900 Subject: [PATCH 20/36] refactor: derive status from mutations Return run timing with mutation data and derive visible status from the latest async action. Co-authored-by: OpenCode --- packages/app/src/app.tsx | 44 ++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index a99a5c6..53af1b7 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -27,9 +27,6 @@ export function App() { >({}); const [runProgress, setRunProgress] = useState(null); - // TODO: probably bad - const [status, setStatus] = useState(""); - const [preferences, setPreferences] = useState(loadPreferences); useEffect(() => savePreferences(preferences), [preferences]); @@ -78,24 +75,8 @@ export function App() { } const handleAudioFileMutation = useMutation({ - mutationFn: async (file: File | undefined) => { - if (!file) { - setStatus(""); - return null; - } - - setStatus("decoding..."); - const audio = await decodeAudioFile(file); - setStatus( - `decoded: ${audio.duration.toFixed(2)}s, ${audio.numberOfChannels}ch @${audio.sampleRate / 1000}k`, - ); - return audio; - }, - onSettled: (_data, error) => { - if (error) { - setStatus(""); - } - }, + mutationFn: (file: File | undefined) => + file ? decodeAudioFile(file) : Promise.resolve(null), }); const decoded = handleAudioFileMutation.data ?? null; @@ -133,10 +114,7 @@ export function App() { const blob = encodeWavF32([output.left, output.right], 44100); return { ...output, url: URL.createObjectURL(blob) }; }); - setStatus( - `Done in ${((performance.now() - started) / 1000).toFixed(1)}s`, - ); - return nextOutputs; + return { outputs: nextOutputs, durationMs: performance.now() - started }; }, onSettled: (_data, error) => { if (error) { @@ -145,9 +123,9 @@ export function App() { }, }); - const outputs = handleRunMutation.data ?? []; + const outputs = handleRunMutation.data?.outputs ?? []; useEffect(() => { - const currentOutputs = handleRunMutation.data; + const currentOutputs = handleRunMutation.data?.outputs; return () => { for (const output of currentOutputs ?? []) { URL.revokeObjectURL(output.url); @@ -155,6 +133,18 @@ export function App() { }; }, [handleRunMutation.data]); + const runIsLatest = + handleRunMutation.submittedAt > handleAudioFileMutation.submittedAt; + const status = runIsLatest + ? handleRunMutation.data + ? `Done in ${(handleRunMutation.data.durationMs / 1000).toFixed(1)}s` + : "" + : handleAudioFileMutation.isPending + ? "decoding..." + : decoded + ? `decoded: ${decoded.duration.toFixed(2)}s, ${decoded.numberOfChannels}ch @${decoded.sampleRate / 1000}k` + : ""; + return (
From b70423efff99e129bebd0fd434c387a8dc71e9f0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:31:17 +0900 Subject: [PATCH 21/36] nit --- packages/app/src/app.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 53af1b7..cc32f52 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -16,6 +16,11 @@ import { RunProgressPanel } from "./lib/progress/panel"; import { encodeWavF32 } from "./lib/wav"; export function App() { + // synchronize preferences with localStorage + const [preferences, setPreferences] = useState(loadPreferences); + useEffect(() => savePreferences(preferences), [preferences]); + + // TODO: probably do something with model stuff const [modelFiles, setModelFiles] = useState< Partial> >({}); @@ -25,10 +30,6 @@ export function App() { const [modelFileErrors, setModelFileErrors] = useState< Partial> >({}); - const [runProgress, setRunProgress] = useState(null); - - const [preferences, setPreferences] = useState(loadPreferences); - useEffect(() => savePreferences(preferences), [preferences]); const { model, method, shifts } = preferences; const twoStems = @@ -75,11 +76,17 @@ export function App() { } const handleAudioFileMutation = useMutation({ - mutationFn: (file: File | undefined) => - file ? decodeAudioFile(file) : Promise.resolve(null), + mutationFn: async (file: File | undefined) => { + if (file) { + return decodeAudioFile(file); + } + return null; + }, }); const decoded = handleAudioFileMutation.data ?? null; + const [runProgress, setRunProgress] = useState(null); + const handleRunMutation = useMutation({ mutationFn: async () => { if (!decoded || !modelSource) { @@ -124,6 +131,7 @@ export function App() { }); const outputs = handleRunMutation.data?.outputs ?? []; + useEffect(() => { const currentOutputs = handleRunMutation.data?.outputs; return () => { From ed029ce71fecb48599cb18334b9d473f834bf5b9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:34:55 +0900 Subject: [PATCH 22/36] refactor: colocate output URL cleanup Co-authored-by: OpenCode --- packages/app/src/app.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index cc32f52..4ce47ec 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,6 +1,6 @@ import { useMutation } from "@tanstack/react-query"; import { Check, CircleHelp, Plus } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { decodeAudioFile } from "./lib/audio/decode"; import { isModelFilename, @@ -87,12 +87,18 @@ export function App() { const [runProgress, setRunProgress] = useState(null); + const outputCleanupRef = useRef void>>([]); const handleRunMutation = useMutation({ mutationFn: async () => { if (!decoded || !modelSource) { throw new Error("Audio and model files are required"); } + for (const cleanup of outputCleanupRef.current) { + cleanup(); + } + outputCleanupRef.current = []; + const startedAt = Date.now(); setRunProgress({ phase: "preparing", @@ -121,6 +127,9 @@ export function App() { const blob = encodeWavF32([output.left, output.right], 44100); 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) => { @@ -132,15 +141,6 @@ export function App() { const outputs = handleRunMutation.data?.outputs ?? []; - useEffect(() => { - const currentOutputs = handleRunMutation.data?.outputs; - return () => { - for (const output of currentOutputs ?? []) { - URL.revokeObjectURL(output.url); - } - }; - }, [handleRunMutation.data]); - const runIsLatest = handleRunMutation.submittedAt > handleAudioFileMutation.submittedAt; const status = runIsLatest From f896f2e4c4b07afcd9a21d541582749225aced55 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:36:23 +0900 Subject: [PATCH 23/36] refactor: inline latest mutation check Co-authored-by: OpenCode --- packages/app/src/app.tsx | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 4ce47ec..a803524 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -141,17 +141,16 @@ export function App() { const outputs = handleRunMutation.data?.outputs ?? []; - const runIsLatest = - handleRunMutation.submittedAt > handleAudioFileMutation.submittedAt; - const status = runIsLatest - ? handleRunMutation.data - ? `Done in ${(handleRunMutation.data.durationMs / 1000).toFixed(1)}s` - : "" - : handleAudioFileMutation.isPending - ? "decoding..." - : decoded - ? `decoded: ${decoded.duration.toFixed(2)}s, ${decoded.numberOfChannels}ch @${decoded.sampleRate / 1000}k` - : ""; + const status = + handleRunMutation.submittedAt > handleAudioFileMutation.submittedAt + ? handleRunMutation.data + ? `Done in ${(handleRunMutation.data.durationMs / 1000).toFixed(1)}s` + : "" + : handleAudioFileMutation.isPending + ? "decoding..." + : decoded + ? `decoded: ${decoded.duration.toFixed(2)}s, ${decoded.numberOfChannels}ch @${decoded.sampleRate / 1000}k` + : ""; return (
From 992cb8728d8f7a897f9d50f68f75c943b72ce33f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:36:55 +0900 Subject: [PATCH 24/36] fix: capitalize audio status text Co-authored-by: OpenCode --- packages/app/e2e/separate.spec.ts | 2 +- packages/app/src/app.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/separate.spec.ts b/packages/app/e2e/separate.spec.ts index 6dfd590..ec3da9c 100644 --- a/packages/app/e2e/separate.spec.ts +++ b/packages/app/e2e/separate.spec.ts @@ -23,7 +23,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("#status")).toContainText("Decoded: 2.00s"); await page.click("#run"); await expect( diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index a803524..6dace93 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -147,9 +147,9 @@ export function App() { ? `Done in ${(handleRunMutation.data.durationMs / 1000).toFixed(1)}s` : "" : handleAudioFileMutation.isPending - ? "decoding..." + ? "Decoding..." : decoded - ? `decoded: ${decoded.duration.toFixed(2)}s, ${decoded.numberOfChannels}ch @${decoded.sampleRate / 1000}k` + ? `Decoded: ${decoded.duration.toFixed(2)}s, ${decoded.numberOfChannels}ch @${decoded.sampleRate / 1000}k` : ""; return ( From 77cc3d18cde0be46314bda701f7fc8cfebc6a200 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:37:57 +0900 Subject: [PATCH 25/36] fix: colocate async status messages Render decoding feedback with the audio picker and separation completion with the run action. Co-authored-by: OpenCode --- packages/app/e2e/separate.spec.ts | 2 +- packages/app/src/app.tsx | 27 +++++++++++++++------------ 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/app/e2e/separate.spec.ts b/packages/app/e2e/separate.spec.ts index ec3da9c..d2c4a7d 100644 --- a/packages/app/e2e/separate.spec.ts +++ b/packages/app/e2e/separate.spec.ts @@ -23,7 +23,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/src/app.tsx b/packages/app/src/app.tsx index 6dace93..5d73942 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -141,16 +141,14 @@ export function App() { const outputs = handleRunMutation.data?.outputs ?? []; - const status = - handleRunMutation.submittedAt > handleAudioFileMutation.submittedAt - ? handleRunMutation.data - ? `Done in ${(handleRunMutation.data.durationMs / 1000).toFixed(1)}s` - : "" - : handleAudioFileMutation.isPending - ? "Decoding..." - : decoded - ? `Decoded: ${decoded.duration.toFixed(2)}s, ${decoded.numberOfChannels}ch @${decoded.sampleRate / 1000}k` - : ""; + const audioStatus = handleAudioFileMutation.isPending + ? "Decoding..." + : decoded + ? `Decoded: ${decoded.duration.toFixed(2)}s, ${decoded.numberOfChannels}ch @${decoded.sampleRate / 1000}k` + : ""; + const runStatus = handleRunMutation.data + ? `Done in ${(handleRunMutation.data.durationMs / 1000).toFixed(1)}s` + : ""; return (
@@ -193,6 +191,11 @@ export function App() { handleAudioFileMutation.mutate(event.target.files?.[0]) } /> + {audioStatus && ( +

+ {audioStatus} +

+ )} @@ -391,12 +394,12 @@ export function App() { Separate track {runProgress && } - {!handleRunMutation.isPending && status && ( + {runStatus && (

- {status} + {runStatus}

)} From fb7fd4abb0e06e71c1e6c68c4f681287f7f3b071 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:39:43 +0900 Subject: [PATCH 26/36] refactor: clarify async state names Co-authored-by: OpenCode --- packages/app/src/app.tsx | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5d73942..5c3dd22 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -83,14 +83,14 @@ export function App() { return null; }, }); - const decoded = handleAudioFileMutation.data ?? null; + const decodedAudio = handleAudioFileMutation.data ?? null; const [runProgress, setRunProgress] = useState(null); const outputCleanupRef = useRef void>>([]); const handleRunMutation = useMutation({ mutationFn: async () => { - if (!decoded || !modelSource) { + if (!decodedAudio || !modelSource) { throw new Error("Audio and model files are required"); } @@ -110,8 +110,8 @@ export function App() { }); const started = performance.now(); const request: SeparateRequest = { - left: decoded.left.slice(), - right: decoded.right.slice(), + left: decodedAudio.left.slice(), + right: decodedAudio.right.slice(), model, twoStems: twoStems ? { source: twoStems, method } : undefined, shifts, @@ -141,12 +141,12 @@ export function App() { const outputs = handleRunMutation.data?.outputs ?? []; - const audioStatus = handleAudioFileMutation.isPending + const audioFileStatusText = handleAudioFileMutation.isPending ? "Decoding..." - : decoded - ? `Decoded: ${decoded.duration.toFixed(2)}s, ${decoded.numberOfChannels}ch @${decoded.sampleRate / 1000}k` + : decodedAudio + ? `Decoded: ${decodedAudio.duration.toFixed(2)}s, ${decodedAudio.numberOfChannels}ch @${decodedAudio.sampleRate / 1000}k` : ""; - const runStatus = handleRunMutation.data + const separationStatusText = handleRunMutation.data ? `Done in ${(handleRunMutation.data.durationMs / 1000).toFixed(1)}s` : ""; @@ -191,9 +191,9 @@ export function App() { handleAudioFileMutation.mutate(event.target.files?.[0]) } /> - {audioStatus && ( + {audioFileStatusText && (

- {audioStatus} + {audioFileStatusText}

)} @@ -387,19 +387,19 @@ export function App() { className="bg-primary-bright text-primary-foreground shadow-action hover:not-disabled:bg-primary-bright-hover disabled:border-primary-border disabled:bg-primary-soft disabled:text-primary-muted min-h-13 w-full cursor-pointer rounded-md border border-transparent font-bold disabled:cursor-not-allowed disabled:shadow-none" id="run" disabled={ - handleRunMutation.isPending || !decoded || !modelsReady + handleRunMutation.isPending || !decodedAudio || !modelsReady } onClick={() => handleRunMutation.mutate()} > Separate track {runProgress && } - {runStatus && ( + {separationStatusText && (

- {runStatus} + {separationStatusText}

)} From 9fb8d365d14b6abc569d924c5f7634f18e016a2e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:45:23 +0900 Subject: [PATCH 27/36] refactor: derive model readiness from source Co-authored-by: OpenCode --- packages/app/src/app.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5c3dd22..5ae0daf 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -41,11 +41,9 @@ export function App() { twoStems || undefined, twoStems ? method : undefined, ); - const missingModelFiles = requiredFiles.filter( - (filename) => !modelFiles[filename], - ); - const modelsReady = missingModelFiles.length === 0; - const modelSource: ModelSource | null = modelsReady + const modelSource: ModelSource | null = requiredFiles.every( + (filename) => modelFiles[filename], + ) ? { files: selectedModelFiles } : null; function addModelFiles(files: File[], expected?: ModelFilename) { @@ -387,7 +385,7 @@ export function App() { className="bg-primary-bright text-primary-foreground shadow-action hover:not-disabled:bg-primary-bright-hover disabled:border-primary-border disabled:bg-primary-soft disabled:text-primary-muted min-h-13 w-full cursor-pointer rounded-md border border-transparent font-bold disabled:cursor-not-allowed disabled:shadow-none" id="run" disabled={ - handleRunMutation.isPending || !decodedAudio || !modelsReady + handleRunMutation.isPending || !decodedAudio || !modelSource } onClick={() => handleRunMutation.mutate()} > From 5e305675021384d89bcf78fb4cdf3bb489e15ff2 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:48:52 +0900 Subject: [PATCH 28/36] refactor: unify output selection preference Represent four-stem output as a null target stem and reset persisted preferences with storage v2. Co-authored-by: OpenCode --- packages/app/src/app.tsx | 13 ++++--------- packages/app/src/lib/preferences.ts | 8 +++----- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5ae0daf..51184f0 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -32,8 +32,7 @@ export function App() { >({}); const { model, method, shifts } = preferences; - const twoStems = - preferences.outputMode === "two-stems" ? preferences.targetStem : ""; + const twoStems = preferences.targetStem; const selectedModelFiles = Object.values(modelFiles); const requiredFiles = requiredModelFiles( @@ -261,16 +260,12 @@ export function App() {