Skip to content

Commit 5ca589d

Browse files
feat: download separated stems as ZIP (#60)
Co-authored-by: hi-ogawa-agent <266689927+hi-ogawa-agent@users.noreply.github.com>
1 parent 29b93bd commit 5ca589d

8 files changed

Lines changed: 180 additions & 27 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ pnpm dev
8181

8282
Open `http://localhost:5173`, choose a local audio file and the required model files from `data/onnx-lean/`, then run separation. Audio and models stay in the browser.
8383

84+
When separation finishes, the app automatically downloads all generated stems as a source-named archive such as `song_wav.stems.zip`. Individual stem previews and WAV downloads remain available in the results.
85+
8486
Build the static app with:
8587

8688
```bash

packages/app/e2e/separate.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ test("separates a clip fully client-side", async ({ page }) => {
2020
await page.setInputFiles("#file", FIXTURE);
2121
await expect(page.locator("#audio-status")).toContainText("Decoded: 2.00s");
2222

23+
const downloadPromise = page.waitForEvent("download");
2324
await page.click("#run");
2425
await expect(
2526
page.getByRole("progressbar", { name: "Overall separation progress" }),
@@ -31,6 +32,8 @@ test("separates a clip fully client-side", async ({ page }) => {
3132
timeout: 300_000,
3233
});
3334
await expect(page.getByTestId("timing-summary")).toContainText("Inference");
35+
const download = await downloadPromise;
36+
expect(download.suggestedFilename()).toBe("sine-2s_wav.stems.zip");
3437

3538
const stems = page.locator("#stems > div");
3639
await expect(stems).toHaveCount(4);
@@ -39,4 +42,7 @@ test("separates a clip fully client-side", async ({ page }) => {
3942
}
4043
await expect(page.locator("#stems audio")).toHaveCount(4);
4144
await expect(page.locator("#stems a")).toHaveCount(4);
45+
await expect(
46+
page.getByRole("link", { name: "Download ZIP" }),
47+
).toHaveAttribute("download", "sine-2s_wav.stems.zip");
4248
});

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
},
1414
"dependencies": {
1515
"@tanstack/react-query": "^5.101.2",
16+
"jszip": "^3.10.1",
1617
"lucide-react": "^0.562.0",
1718
"react": "^19.2.3",
1819
"react-dom": "^19.2.3",

packages/app/src/app.tsx

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import {
1212
type ModelSource,
1313
} from "./lib/audio/models";
1414
import type { SeparateRequest } from "./lib/audio/separate";
15+
import {
16+
createStemArchive,
17+
downloadBlob,
18+
toStemArchiveFilename,
19+
} from "./lib/audio/stem-archive";
1520
import { encodeWavF32 } from "./lib/audio/wav";
1621
import { separateInWorker } from "./lib/audio/worker-client";
1722
import { loadPreferences, savePreferences } from "./lib/preferences";
@@ -104,14 +109,9 @@ export function App() {
104109
: "";
105110

106111
const handleAudioFileMutation = useMutation({
107-
mutationFn: async (file: File | undefined) => {
108-
if (file) {
109-
return decodeAudioFile(file);
110-
}
111-
return null;
112-
},
112+
mutationFn: decodeAudioFile,
113113
});
114-
const decodedAudio = handleAudioFileMutation.data ?? null;
114+
const decodedAudio = handleAudioFileMutation.data;
115115

116116
const [runProgress, setRunProgress] = useState<RunProgress | null>(null);
117117

@@ -156,12 +156,22 @@ export function App() {
156156
[output.left, output.right],
157157
AUDIO_SAMPLE_RATE,
158158
);
159-
return { ...output, url: URL.createObjectURL(blob) };
159+
return { ...output, blob, url: URL.createObjectURL(blob) };
160160
});
161161
outputCleanupRef.current = nextOutputs.map(
162162
(output) => () => URL.revokeObjectURL(output.url),
163163
);
164-
return { outputs: nextOutputs, durationMs: performance.now() - started };
164+
const durationMs = performance.now() - started;
165+
const archiveBlob = await createStemArchive(nextOutputs);
166+
const archive = {
167+
name: toStemArchiveFilename(decodedAudio.name),
168+
url: URL.createObjectURL(archiveBlob),
169+
};
170+
outputCleanupRef.current.push(() => URL.revokeObjectURL(archive.url));
171+
return { outputs: nextOutputs, archive, durationMs };
172+
},
173+
onSuccess: ({ archive }) => {
174+
downloadBlob(archive.url, archive.name);
165175
},
166176
onSettled: (_data, error) => {
167177
if (error) {
@@ -218,9 +228,12 @@ export function App() {
218228
type="file"
219229
id="file"
220230
accept="audio/*"
221-
onChange={(event) =>
222-
handleAudioFileMutation.mutate(event.target.files?.[0])
223-
}
231+
onChange={(event) => {
232+
const file = event.target.files?.[0];
233+
if (file) {
234+
handleAudioFileMutation.mutate(file);
235+
}
236+
}}
224237
/>
225238
{audioFileStatusText && (
226239
<p className="text-muted mt-3.5 text-sm" id="audio-status">
@@ -304,10 +317,10 @@ export function App() {
304317
}
305318
>
306319
<option value="">off</option>
320+
<option>vocals</option>
307321
<option>drums</option>
308322
<option>bass</option>
309323
<option>other</option>
310-
<option>vocals</option>
311324
</select>
312325
</div>
313326
<div className="grid gap-2">
@@ -441,16 +454,25 @@ export function App() {
441454
className="bg-surface shadow-card min-w-0 rounded-lg border px-5 py-6 sm:p-9"
442455
aria-labelledby="results-title"
443456
>
444-
<div className="mb-7">
445-
<p className="text-primary-strong mb-2.5 text-xs font-extrabold tracking-[0.14em] uppercase">
446-
Separation complete
447-
</p>
448-
<h2
449-
className="text-3xl font-semibold tracking-[-0.025em]"
450-
id="results-title"
457+
<div className="mb-7 flex items-end justify-between gap-4">
458+
<div>
459+
<p className="text-primary-strong mb-2.5 text-xs font-extrabold tracking-[0.14em] uppercase">
460+
Separation complete
461+
</p>
462+
<h2
463+
className="text-3xl font-semibold tracking-[-0.025em]"
464+
id="results-title"
465+
>
466+
Your stems
467+
</h2>
468+
</div>
469+
<a
470+
className="text-primary hover:text-accent shrink-0 text-sm font-semibold underline underline-offset-3"
471+
href={handleRunMutation.data?.archive.url}
472+
download={handleRunMutation.data?.archive.name}
451473
>
452-
Your stems
453-
</h2>
474+
Download ZIP
475+
</a>
454476
</div>
455477
<div className="grid gap-3.5" id="stems">
456478
{outputs.map((output) => (

packages/app/src/lib/audio/decode.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AUDIO_SAMPLE_RATE } from "./constants";
22

33
export interface DecodedAudio {
4+
name: string;
45
left: Float32Array;
56
right: Float32Array;
67
duration: number;
@@ -19,6 +20,7 @@ export async function decodeAudioFile(file: File): Promise<DecodedAudio> {
1920
const right = buffer.numberOfChannels > 1 ? buffer.getChannelData(1) : left;
2021

2122
return {
23+
name: file.name,
2224
left,
2325
right,
2426
duration: buffer.duration,

packages/app/src/lib/audio/separate.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
MODEL_INPUT_LENGTH,
1212
MODEL_OUTPUT_LENGTH,
1313
MODEL_SEGMENT,
14-
SOURCES,
1514
} from "./constants";
1615
import { readModelFile, type ModelFilename, type ModelSource } from "./models";
1716

@@ -151,10 +150,18 @@ export async function separate(
151150
req.right,
152151
host,
153152
);
154-
const names = req.twoStems
155-
? [req.twoStems.source, `no_${req.twoStems.source}`]
156-
: SOURCES;
157-
return names.map((name, index) => ({
153+
const stemOrder: { name: string; index: number }[] = req.twoStems
154+
? [
155+
{ name: `no_${req.twoStems.source}`, index: 1 },
156+
{ name: req.twoStems.source, index: 0 },
157+
]
158+
: [
159+
{ name: "vocals", index: 3 },
160+
{ name: "drums", index: 0 },
161+
{ name: "bass", index: 1 },
162+
{ name: "other", index: 2 },
163+
];
164+
return stemOrder.map(({ name, index }) => ({
158165
name,
159166
left: tracks[2 * index],
160167
right: tracks[2 * index + 1],
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import JSZip from "jszip";
2+
3+
export interface StemFile {
4+
name: string;
5+
blob: Blob;
6+
}
7+
8+
export function toStemArchiveFilename(inputFilename: string): string {
9+
const basename = inputFilename.replaceAll(".", "_");
10+
return `${basename || "demucs"}.stems.zip`;
11+
}
12+
13+
export async function createStemArchive(stems: StemFile[]): Promise<Blob> {
14+
const zip = new JSZip();
15+
for (const stem of stems) {
16+
zip.file(`${stem.name}.wav`, stem.blob, { compression: "STORE" });
17+
}
18+
return zip.generateAsync({ type: "blob", compression: "STORE" });
19+
}
20+
21+
export function downloadBlob(url: string, filename: string): void {
22+
const link = document.createElement("a");
23+
link.href = url;
24+
link.download = filename;
25+
document.body.append(link);
26+
link.click();
27+
link.remove();
28+
}

0 commit comments

Comments
 (0)