D-r-e commited on
Commit
9f424a8
·
verified ·
1 Parent(s): cefc597

Publish Audio Separator

Browse files
Files changed (4) hide show
  1. .gitignore +11 -0
  2. README.md +11 -5
  3. app.py +1000 -0
  4. requirements.txt +8 -0
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .deps/
2
+ .stem_work/
3
+ .venv/
4
+ .playwright-mcp/
5
+ __pycache__/
6
+ separated/
7
+ Audio-Separator/
8
+ *.pyc
9
+ *.wav
10
+ *.zip
11
+ *.ckpt
README.md CHANGED
@@ -1,13 +1,19 @@
1
  ---
2
  title: Audio Separator
3
- emoji: 🌍
4
- colorFrom: green
5
- colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.25.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
1
  ---
2
  title: Audio Separator
3
+ emoji: 🎛️
4
+ colorFrom: purple
5
+ colorTo: pink
6
  sdk: gradio
7
  sdk_version: 6.25.0
8
+ python_version: 3.12.12
9
  app_file: app.py
10
  pinned: false
11
+ suggested_hardware: zero-a10g
12
+ short_description: Separate songs into synchronized, mixable instrument tracks.
13
  ---
14
 
15
+ # Audio Separator
16
+
17
+ Upload a song, separate its layers, audition them on a shared timeline, and download each track individually or together.
18
+
19
+ The app uses MLX on Apple Silicon and Torch on hosted Linux environments.
app.py ADDED
@@ -0,0 +1,1000 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import html
6
+ import importlib.metadata
7
+ import json
8
+ import os
9
+ import platform
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import sysconfig
15
+ import traceback
16
+ import zipfile
17
+ from collections import deque
18
+ from pathlib import Path
19
+ from urllib.parse import quote
20
+
21
+
22
+ APP_DIR = Path(__file__).resolve().parent
23
+ OUTPUT_ROOT = APP_DIR / "separated"
24
+ WORK_ROOT = APP_DIR / ".stem_work"
25
+
26
+ MODEL_BY_MODE = {
27
+ "2": "roformer-model-bs-roformer-leap-vocals-by-pcunwa",
28
+ "4": "roformer-model-bs-roformer-musdb18hq-by-zfturbo",
29
+ "6": "roformer-model-bs-roformer-sw-by-jarredou",
30
+ "53": "roformer-model-bs-roformer-mvsep-mega-53-stems",
31
+ }
32
+
33
+ EXPECTED_STEMS = {
34
+ "2": ["vocals", "background"],
35
+ "4": ["vocals", "drums", "bass", "other"],
36
+ "6": ["vocals", "drums", "bass", "guitar", "piano", "other"],
37
+ }
38
+
39
+ COMMON_53_STEMS = [
40
+ "vocals",
41
+ "drums",
42
+ "bass",
43
+ "guitar",
44
+ "piano",
45
+ "other",
46
+ "synth",
47
+ "strings",
48
+ "percussion",
49
+ ]
50
+
51
+ MODE_CHOICES = [
52
+ "Voice + music",
53
+ "Core instruments",
54
+ "Full band",
55
+ "Every detail",
56
+ ]
57
+
58
+ MODE_BY_CHOICE = dict(zip(MODE_CHOICES, ["2", "4", "6", "53"]))
59
+
60
+
61
+ def _run(cmd: list[str], cwd: Path | None = None) -> None:
62
+ print("+", " ".join(map(str, cmd)), flush=True)
63
+ subprocess.run([str(item) for item in cmd], cwd=cwd, check=True)
64
+
65
+
66
+ def validate_runtime() -> tuple[str, str]:
67
+ try:
68
+ version = importlib.metadata.version("bs-roformer-infer")
69
+ except importlib.metadata.PackageNotFoundError as exc:
70
+ raise RuntimeError(
71
+ "Dependencies are not installed. Run: python -m pip install -r requirements.txt"
72
+ ) from exc
73
+ backend = "mlx" if sys.platform == "darwin" and platform.machine() == "arm64" else "torch"
74
+ return version, backend
75
+
76
+
77
+ RUNTIME_COMMIT, COMPUTE_BACKEND = validate_runtime()
78
+
79
+ import gradio as gr
80
+ import imageio_ffmpeg
81
+ import numpy as np
82
+ import soundfile as sf
83
+ if COMPUTE_BACKEND == "mlx":
84
+ import mlx.core as mx
85
+ else:
86
+ import torch
87
+ if os.environ.get("SPACE_ID"):
88
+ import spaces
89
+ else:
90
+ class _LocalSpaces:
91
+ @staticmethod
92
+ def GPU(*_args, **_kwargs):
93
+ return lambda fn: fn
94
+
95
+ spaces = _LocalSpaces()
96
+ from bs_roformer import MODEL_REGISTRY, ensure_model_assets
97
+
98
+
99
+ def sha256_file(path: Path, block_size: int = 8 * 1024 * 1024) -> str:
100
+ h = hashlib.sha256()
101
+ with path.open("rb") as f:
102
+ while chunk := f.read(block_size):
103
+ h.update(chunk)
104
+ return h.hexdigest()
105
+
106
+
107
+ def safe_stem_name(name: str) -> str:
108
+ cleaned = "".join(c if c.isalnum() or c in "._-" else "_" for c in name)
109
+ cleaned = cleaned.strip("._")
110
+ return cleaned or "audio"
111
+
112
+
113
+ def normalized_token(path: Path) -> str:
114
+ return re.sub(r"[^a-z0-9]+", "_", path.stem.lower()).strip("_")
115
+
116
+
117
+ def best_match(stem_name: str, paths: list[Path]) -> Path | None:
118
+ stem = stem_name.lower()
119
+ matches = []
120
+ for path in paths:
121
+ token = normalized_token(path)
122
+ parts = token.split("_")
123
+ if stem in parts:
124
+ rank = 0
125
+ elif token.endswith(stem) or token.startswith(stem):
126
+ rank = 1
127
+ elif stem in token:
128
+ rank = 2
129
+ else:
130
+ continue
131
+ matches.append((rank, len(path.name), path))
132
+
133
+ if not matches:
134
+ return None
135
+
136
+ matches.sort(key=lambda x: (x[0], x[1]))
137
+ return matches[0][2]
138
+
139
+
140
+ def copy_atomic(src: Path, dst: Path) -> None:
141
+ dst.parent.mkdir(parents=True, exist_ok=True)
142
+ tmp = dst.with_suffix(dst.suffix + ".tmp")
143
+ tmp.unlink(missing_ok=True)
144
+ shutil.copy2(src, tmp)
145
+ tmp.replace(dst)
146
+
147
+
148
+ def stage_audio(input_audio: Path, input_hash: str) -> Path:
149
+ stage_dir = WORK_ROOT / "inputs" / input_hash[:16]
150
+ stage_dir.mkdir(parents=True, exist_ok=True)
151
+ stage_wav = stage_dir / "input.wav"
152
+ stage_meta = stage_dir / "input.json"
153
+
154
+ valid = False
155
+ if stage_wav.exists() and stage_meta.exists():
156
+ try:
157
+ meta = json.loads(stage_meta.read_text())
158
+ valid = meta.get("sha256") == input_hash
159
+ except Exception:
160
+ pass
161
+
162
+ if valid:
163
+ return stage_wav
164
+
165
+ ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
166
+ tmp_wav = stage_dir / "input.tmp.wav"
167
+ tmp_wav.unlink(missing_ok=True)
168
+
169
+ cmd = [
170
+ ffmpeg,
171
+ "-hide_banner",
172
+ "-loglevel",
173
+ "warning",
174
+ "-y",
175
+ "-i",
176
+ str(input_audio),
177
+ "-vn",
178
+ "-ac",
179
+ "2",
180
+ "-ar",
181
+ "44100",
182
+ "-c:a",
183
+ "pcm_f32le",
184
+ str(tmp_wav),
185
+ ]
186
+ _run(cmd)
187
+ tmp_wav.replace(stage_wav)
188
+
189
+ stage_meta.write_text(json.dumps({
190
+ "source_name": input_audio.name,
191
+ "sha256": input_hash,
192
+ "sample_rate": 44100,
193
+ "channels": 2,
194
+ }, indent=2))
195
+ return stage_wav
196
+
197
+
198
+ def cli_path() -> Path:
199
+ found = shutil.which("bs-roformer-infer")
200
+ if found:
201
+ return Path(found)
202
+
203
+ candidate = Path(sysconfig.get_path("scripts")) / "bs-roformer-infer"
204
+ if candidate.exists():
205
+ return candidate
206
+
207
+ raise RuntimeError("Could not locate the bs-roformer-infer console script.")
208
+
209
+
210
+ def run_inference(stage_wav: Path, raw_dir: Path, model: str) -> None:
211
+ infer_input_dir = WORK_ROOT / "jobs" / stage_wav.parent.name / model / "input"
212
+ infer_input_dir.mkdir(parents=True, exist_ok=True)
213
+ staged_link = infer_input_dir / "input.wav"
214
+
215
+ if staged_link.exists() or staged_link.is_symlink():
216
+ staged_link.unlink()
217
+ try:
218
+ staged_link.symlink_to(stage_wav.resolve())
219
+ except OSError:
220
+ shutil.copy2(stage_wav, staged_link)
221
+
222
+ if raw_dir.exists():
223
+ shutil.rmtree(raw_dir)
224
+ raw_dir.mkdir(parents=True, exist_ok=True)
225
+
226
+ cmd = [
227
+ str(cli_path()),
228
+ "--input_folder",
229
+ str(infer_input_dir),
230
+ "--store_dir",
231
+ str(raw_dir),
232
+ "--backend",
233
+ COMPUTE_BACKEND,
234
+ ]
235
+ if COMPUTE_BACKEND == "mlx" and model == MODEL_BY_MODE["53"]:
236
+ checkpoint_path, config_path = mlx_compatible_53_stem_assets(model)
237
+ cmd.extend([
238
+ "--model_path",
239
+ str(checkpoint_path),
240
+ "--config_path",
241
+ str(config_path),
242
+ ])
243
+ else:
244
+ cmd.extend(["--model", model])
245
+
246
+ print("+", " ".join(cmd), flush=True)
247
+ tail: deque[str] = deque(maxlen=120)
248
+
249
+ process = subprocess.Popen(
250
+ cmd,
251
+ stdout=subprocess.PIPE,
252
+ stderr=subprocess.STDOUT,
253
+ text=True,
254
+ bufsize=1,
255
+ )
256
+
257
+ assert process.stdout is not None
258
+ for line in process.stdout:
259
+ line = line.rstrip()
260
+ if line:
261
+ print(line, flush=True)
262
+ tail.append(line)
263
+
264
+ return_code = process.wait()
265
+ if return_code != 0:
266
+ raise RuntimeError(
267
+ f"bs-roformer-infer exited with code {return_code}.\n\n"
268
+ + "\n".join(tail)
269
+ )
270
+
271
+
272
+ def ensure_healthy_model_assets(model: str) -> None:
273
+ checkpoint, _ = ensure_model_assets(model)
274
+ if zipfile.is_zipfile(checkpoint):
275
+ return
276
+
277
+ print(f"Cached checkpoint is corrupt; downloading a fresh copy: {checkpoint}")
278
+ checkpoint.unlink(missing_ok=True)
279
+ ensure_model_assets(model)
280
+
281
+
282
+ def mlx_compatible_53_stem_assets(model: str) -> tuple[Path, Path]:
283
+ """Return the 53-stem checkpoint and a hop-aligned job-local config."""
284
+ checkpoint, config_path = map(Path, ensure_model_assets(model))
285
+ config_text = config_path.read_text()
286
+ chunk_matches = list(re.finditer(
287
+ r"(?m)^([ \t]*chunk_size:[ \t]*)(\d+)([^\n]*)$",
288
+ config_text,
289
+ ))
290
+ hop_match = re.search(
291
+ r"(?m)^[ \t]*stft_hop_length:[ \t]*(\d+)",
292
+ config_text,
293
+ )
294
+ if not chunk_matches or hop_match is None:
295
+ raise RuntimeError("The detailed separation settings are incomplete.")
296
+
297
+ # The final chunk_size entry belongs to the inference section; the earlier
298
+ # audio entry is training metadata and must remain untouched.
299
+ chunk_match = chunk_matches[-1]
300
+ chunk_size = int(chunk_match.group(2))
301
+ hop_length = int(hop_match.group(1))
302
+ if chunk_size % hop_length == 0:
303
+ return checkpoint, config_path
304
+
305
+ aligned_chunk_size = chunk_size - (chunk_size % hop_length)
306
+ patched_text = (
307
+ config_text[:chunk_match.start(2)]
308
+ + str(aligned_chunk_size)
309
+ + config_text[chunk_match.end(2):]
310
+ )
311
+ patched_dir = WORK_ROOT / "configs"
312
+ patched_dir.mkdir(parents=True, exist_ok=True)
313
+ patched_path = patched_dir / f"{safe_stem_name(model)}.mlx.yaml"
314
+ if not patched_path.exists() or patched_path.read_text() != patched_text:
315
+ patched_path.write_text(patched_text)
316
+ return checkpoint, patched_path
317
+
318
+
319
+ def normalize_outputs(
320
+ mode: str,
321
+ stage_wav: Path,
322
+ raw_dir: Path,
323
+ job_dir: Path,
324
+ ) -> tuple[list[Path], dict[str, Path]]:
325
+ raw_wavs = sorted(raw_dir.rglob("*.wav"))
326
+ if not raw_wavs:
327
+ raise RuntimeError(f"No WAV outputs found in {raw_dir}")
328
+
329
+ previews: dict[str, Path] = {}
330
+
331
+ if mode == "2":
332
+ final_dir = job_dir / "stems"
333
+ final_dir.mkdir(parents=True, exist_ok=True)
334
+
335
+ vocals_src = best_match("vocals", raw_wavs)
336
+ if vocals_src is None:
337
+ raise RuntimeError(
338
+ "Could not identify the vocals output.\n"
339
+ + "\n".join(str(p) for p in raw_wavs)
340
+ )
341
+
342
+ vocals_dst = final_dir / "01_vocals.wav"
343
+ background_dst = final_dir / "02_background.wav"
344
+ copy_atomic(vocals_src, vocals_dst)
345
+
346
+ instrumental_src = (
347
+ best_match("instrumental", raw_wavs)
348
+ or best_match("background", raw_wavs)
349
+ )
350
+
351
+ if instrumental_src is not None:
352
+ copy_atomic(instrumental_src, background_dst)
353
+ else:
354
+ mix, sr_mix = sf.read(stage_wav, dtype="float32", always_2d=True)
355
+ vocals, sr_vocals = sf.read(vocals_src, dtype="float32", always_2d=True)
356
+ if sr_mix != sr_vocals:
357
+ raise RuntimeError(
358
+ f"Sample-rate mismatch: mixture={sr_mix}, vocals={sr_vocals}"
359
+ )
360
+ n = min(len(mix), len(vocals))
361
+ background = mix[:n] - vocals[:n]
362
+ sf.write(background_dst, background, sr_mix, subtype="FLOAT")
363
+
364
+ files = [vocals_dst, background_dst]
365
+ previews = {
366
+ "vocals": vocals_dst,
367
+ "background": background_dst,
368
+ }
369
+ return files, previews
370
+
371
+ if mode in {"4", "6"}:
372
+ final_dir = job_dir / "stems"
373
+ final_dir.mkdir(parents=True, exist_ok=True)
374
+
375
+ files = []
376
+ for index, stem in enumerate(EXPECTED_STEMS[mode], 1):
377
+ src = best_match(stem, raw_wavs)
378
+ if src is None:
379
+ raise RuntimeError(
380
+ f"Could not identify stem {stem!r}.\n"
381
+ + "\n".join(str(p) for p in raw_wavs)
382
+ )
383
+ dst = final_dir / f"{index:02d}_{stem}.wav"
384
+ copy_atomic(src, dst)
385
+ files.append(dst)
386
+ previews[stem] = dst
387
+
388
+ return files, previews
389
+
390
+ selected_dir = job_dir / "selected_stems"
391
+ selected_dir.mkdir(parents=True, exist_ok=True)
392
+
393
+ for stem in COMMON_53_STEMS:
394
+ src = best_match(stem, raw_wavs)
395
+ if src is not None:
396
+ dst = selected_dir / f"{stem}.wav"
397
+ copy_atomic(src, dst)
398
+ previews[stem] = dst
399
+
400
+ return raw_wavs, previews
401
+
402
+
403
+ def make_zip(files: list[Path], job_dir: Path) -> Path:
404
+ zip_path = job_dir / "stems.zip"
405
+ tmp_zip = job_dir / "stems.zip.tmp"
406
+ tmp_zip.unlink(missing_ok=True)
407
+
408
+ with zipfile.ZipFile(tmp_zip, "w", compression=zipfile.ZIP_STORED) as zf:
409
+ seen: set[str] = set()
410
+ for path in files:
411
+ arcname = path.name
412
+ if arcname in seen:
413
+ arcname = f"{path.parent.name}_{path.name}"
414
+ seen.add(arcname)
415
+ zf.write(path, arcname=arcname)
416
+
417
+ tmp_zip.replace(zip_path)
418
+ return zip_path
419
+
420
+
421
+ STEM_LABELS = {
422
+ "vocals": "Vocals",
423
+ "background": "Background",
424
+ "drums": "Drums",
425
+ "bass": "Bass",
426
+ "guitar": "Guitar",
427
+ "piano": "Piano",
428
+ "other": "Other",
429
+ "synth": "Synth",
430
+ }
431
+
432
+ ICON_PATHS = {
433
+ "sparkles": '<path d="m12 3-1.9 4.8L5 10l5.1 2.2L12 17l1.9-4.8L19 10l-5.1-2.2L12 3Z"/><path d="M5 3v4M3 5h4M19 17v4m-2-2h4"/>',
434
+ "play": '<path d="m8 5 11 7-11 7Z"/>',
435
+ "pause": '<path d="M9 5v14M15 5v14"/>',
436
+ "download": '<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 19h14"/>',
437
+ "vocals": '<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v4M8 21h8"/>',
438
+ "background": '<path d="m12 2 9 5-9 5-9-5 9-5Z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/>',
439
+ "drums": '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v11c0 1.7 3.6 3 8 3s8-1.3 8-3V6M8 9v10M16 9v10"/>',
440
+ "bass": '<path d="M3 12h3l2-6 4 12 3-9 2 6h4"/>',
441
+ "guitar": '<path d="m14 5 5-3 3 3-3 5M13 6l5 5M9 10c-3-1-7 2-7 6 0 3 3 6 6 6 4 0 7-4 6-7l4-4-5-5-4 4Z"/>',
442
+ "piano": '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 5v9M11 5v9M15 5v9M19 5v9M5 14h14"/>',
443
+ "other": '<circle cx="8" cy="8" r="4"/><rect x="12" y="12" width="8" height="8" rx="2"/><path d="m5 20 4-7 4 7Z"/>',
444
+ "synth": '<path d="M4 5h16M4 12h16M4 19h16"/><circle cx="9" cy="5" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="7" cy="19" r="2"/>',
445
+ }
446
+
447
+
448
+ def icon_svg(name: str, class_name: str = "ui-icon") -> str:
449
+ return (
450
+ f'<svg class="{class_name}" viewBox="0 0 24 24" fill="none" '
451
+ f'stroke="currentColor" stroke-width="1.7" stroke-linecap="round" '
452
+ f'stroke-linejoin="round" aria-hidden="true">{ICON_PATHS[name]}</svg>'
453
+ )
454
+
455
+
456
+ def mixer_html(previews: dict[str, Path]) -> str:
457
+ """Build native audio controls; Gradio's waveform player has no controllable audio src."""
458
+ controls = []
459
+ tracks = []
460
+ for stem, label in STEM_LABELS.items():
461
+ path = previews.get(stem)
462
+ if path is None:
463
+ continue
464
+ url = "/gradio_api/file=" + quote(str(path.resolve()), safe="/")
465
+ escaped_label = html.escape(label)
466
+ escaped_filename = html.escape(path.name, quote=True)
467
+ controls.append(
468
+ f'<div class="mixer-stem"><span class="stem-name">{icon_svg(stem)}{escaped_label}</span>'
469
+ f'<button class="mixer-toggle" data-stem="{stem}" aria-pressed="true">On</button></div>'
470
+ )
471
+ tracks.append(
472
+ f'<article class="stem-track" data-stem="{stem}" data-src="{html.escape(url, quote=True)}">'
473
+ f'<div class="stem-track-header"><span class="stem-track-label">{icon_svg(stem)}{escaped_label}</span>'
474
+ f'<a class="stem-download" href="{html.escape(url, quote=True)}" '
475
+ f'download="{escaped_filename}">{icon_svg("download")}Save</a></div>'
476
+ f'<div class="track-timeline" aria-hidden="true"><span class="track-playhead"></span></div>'
477
+ f'</article>'
478
+ )
479
+
480
+ if not tracks:
481
+ return '<p class="empty-mixer">No playable stems were produced.</p>'
482
+
483
+ return """
484
+ <section id="sync-mixer" aria-label="Synchronized stem mixer">
485
+ <aside id="mixer-sidebar">
486
+ <h3>Mix</h3>
487
+ <div class="transport-row">
488
+ <button class="mixer-transport" data-action="play">%s Play</button>
489
+ <button class="mixer-transport" data-action="pause">%s Pause</button>
490
+ </div>
491
+ %s
492
+ </aside>
493
+ <div id="stem-players">
494
+ <section class="shared-timeline" aria-label="Shared playback timeline">
495
+ <div class="timeline-labels"><span id="timeline-current">0:00</span><span id="timeline-total">0:00</span></div>
496
+ <input id="timeline-scrubber" type="range" min="0" max="1000" value="0" step="1" aria-label="Seek all stems">
497
+ </section>
498
+ <div id="stem-track-list">%s</div>
499
+ </div>
500
+ </section>
501
+ """ % (icon_svg("play"), icon_svg("pause"), "".join(controls), "".join(tracks))
502
+
503
+
504
+ def mode_from_choice(choice: str) -> str:
505
+ if not choice:
506
+ return "6"
507
+ return MODE_BY_CHOICE.get(choice, "6")
508
+
509
+
510
+ @spaces.GPU(duration=300)
511
+ def separate(
512
+ audio_path: str | None,
513
+ mode_choice: str,
514
+ force: bool,
515
+ progress=gr.Progress(),
516
+ ):
517
+ try:
518
+ if not audio_path:
519
+ raise gr.Error("Upload an audio file first.")
520
+
521
+ mode = mode_from_choice(mode_choice)
522
+ if mode not in MODEL_BY_MODE:
523
+ raise gr.Error(f"Unsupported mode: {mode}")
524
+
525
+ model = MODEL_BY_MODE[mode]
526
+ try:
527
+ MODEL_REGISTRY.get(model)
528
+ except KeyError as exc:
529
+ raise RuntimeError(
530
+ f"The installed bs-roformer-infer package does not provide {model!r}. "
531
+ "Use this project's environment: "
532
+ "source .venv/bin/activate && python -m pip install -r requirements.txt"
533
+ ) from exc
534
+
535
+ input_audio = Path(audio_path)
536
+ if not input_audio.exists():
537
+ raise gr.Error("The uploaded audio file is no longer available.")
538
+
539
+ progress(0.05, desc="Reading song")
540
+ input_hash = sha256_file(input_audio)
541
+ clean_name = safe_stem_name(input_audio.stem)
542
+
543
+ # The filename is only a display convenience. Look for prior results by
544
+ # content hash first, so a re-upload (or a renamed copy) reuses the same
545
+ # separation instead of needlessly running ML again.
546
+ job_dir = OUTPUT_ROOT / clean_name / f"{mode}-stem" / input_hash[:16]
547
+ job_key = {
548
+ "input_sha256": input_hash,
549
+ "model": model,
550
+ "runtime_commit": RUNTIME_COMMIT,
551
+ "backend": COMPUTE_BACKEND,
552
+ }
553
+
554
+ if not force:
555
+ for candidate in OUTPUT_ROOT.glob(f"*/{mode}-stem/{input_hash[:16]}"):
556
+ candidate_sentinel = candidate / ".complete.json"
557
+ candidate_raw = candidate / "raw"
558
+ try:
559
+ previous = json.loads(candidate_sentinel.read_text())
560
+ valid = (
561
+ all(previous.get(k) == v for k, v in job_key.items())
562
+ and any(candidate_raw.rglob("*.wav"))
563
+ )
564
+ except Exception:
565
+ valid = False
566
+ if valid:
567
+ job_dir = candidate
568
+ break
569
+
570
+ raw_dir = job_dir / "raw"
571
+ sentinel = job_dir / ".complete.json"
572
+ job_dir.mkdir(parents=True, exist_ok=True)
573
+
574
+ progress(0.15, desc="Preparing audio")
575
+ stage_wav = stage_audio(input_audio, input_hash)
576
+
577
+ cached = False
578
+ if sentinel.exists() and not force:
579
+ try:
580
+ previous = json.loads(sentinel.read_text())
581
+ cached = (
582
+ all(previous.get(k) == v for k, v in job_key.items())
583
+ and raw_dir.exists()
584
+ and any(raw_dir.rglob("*.wav"))
585
+ )
586
+ except Exception:
587
+ cached = False
588
+
589
+ if cached:
590
+ progress(0.70, desc="Opening tracks")
591
+ else:
592
+ progress(0.25, desc="Getting ready")
593
+ ensure_healthy_model_assets(model)
594
+
595
+ progress(0.40, desc="Separating")
596
+ run_inference(stage_wav, raw_dir, model)
597
+
598
+ if not any(raw_dir.rglob("*.wav")):
599
+ raise RuntimeError("Inference completed but produced no WAV files.")
600
+
601
+ sentinel.write_text(json.dumps({
602
+ **job_key,
603
+ "source_name": input_audio.name,
604
+ }, indent=2))
605
+
606
+ progress(0.82, desc="Finishing tracks")
607
+ files, previews = normalize_outputs(mode, stage_wav, raw_dir, job_dir)
608
+
609
+ progress(0.92, desc="Preparing downloads")
610
+ zip_path = make_zip(files, job_dir)
611
+
612
+ runtime_test = (
613
+ float(mx.sum(mx.array([1.0, 2.0, 3.0])).item())
614
+ if COMPUTE_BACKEND == "mlx"
615
+ else float(torch.tensor([1.0, 2.0, 3.0]).sum().item())
616
+ )
617
+ if runtime_test != 6.0:
618
+ raise RuntimeError("Audio processing runtime check failed.")
619
+
620
+ status = f"### Ready to mix\n{len(files)} tracks available."
621
+
622
+ progress(1.0, desc="Done")
623
+
624
+ return (
625
+ status,
626
+ [str(path) for path in files],
627
+ str(zip_path),
628
+ mixer_html(previews),
629
+ )
630
+
631
+ except gr.Error:
632
+ raise
633
+ except Exception as exc:
634
+ traceback.print_exc()
635
+ raise gr.Error("We couldn't split this song. Please try again.") from exc
636
+
637
+
638
+ runtime_probe = (
639
+ float(mx.sum(mx.array([1.0, 2.0, 3.0])).item())
640
+ if COMPUTE_BACKEND == "mlx"
641
+ else float(torch.tensor([1.0, 2.0, 3.0]).sum().item())
642
+ )
643
+ if runtime_probe != 6.0:
644
+ raise RuntimeError("Audio processing runtime check failed.")
645
+
646
+
647
+ MIXER_HEAD = """
648
+ <script>
649
+ (() => {
650
+ const engine = {
651
+ context: null,
652
+ mixer: null,
653
+ buffers: new Map(),
654
+ gains: new Map(),
655
+ sources: new Map(),
656
+ muted: new Set(),
657
+ duration: 0,
658
+ offset: 0,
659
+ startedAt: 0,
660
+ playing: false,
661
+ loading: false,
662
+ };
663
+
664
+ const formatTime = (seconds) => {
665
+ if (!Number.isFinite(seconds)) return "0:00";
666
+ const whole = Math.max(0, Math.floor(seconds));
667
+ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
668
+ };
669
+
670
+ const position = () => engine.playing
671
+ ? Math.min(engine.duration, engine.offset + engine.context.currentTime - engine.startedAt)
672
+ : engine.offset;
673
+
674
+ const updateTimeline = () => {
675
+ const scrubber = document.querySelector("#timeline-scrubber");
676
+ const current = document.querySelector("#timeline-current");
677
+ const total = document.querySelector("#timeline-total");
678
+ if (!scrubber || !current || !total) return;
679
+ const now = position();
680
+ scrubber.max = String(Math.max(1, Math.round(engine.duration * 1000)));
681
+ scrubber.value = String(Math.round(now * 1000));
682
+ current.textContent = formatTime(now);
683
+ total.textContent = formatTime(engine.duration);
684
+ const progress = engine.duration ? (now / engine.duration) * 100 : 0;
685
+ document.querySelectorAll("#stem-track-list .stem-track").forEach((track) => {
686
+ track.style.setProperty("--track-progress", `${progress}%`);
687
+ });
688
+ };
689
+
690
+ const stopSources = () => {
691
+ engine.sources.forEach((source) => {
692
+ try { source.stop(); } catch (_) {}
693
+ source.disconnect();
694
+ });
695
+ engine.sources.clear();
696
+ };
697
+
698
+ const resetEngine = () => {
699
+ stopSources();
700
+ engine.mixer = null;
701
+ engine.buffers.clear();
702
+ engine.gains.forEach((gain) => gain.disconnect());
703
+ engine.gains.clear();
704
+ engine.muted.clear();
705
+ engine.duration = 0;
706
+ engine.offset = 0;
707
+ engine.startedAt = 0;
708
+ engine.playing = false;
709
+ engine.loading = false;
710
+ };
711
+
712
+ const initialize = async (mixer) => {
713
+ if (engine.loading || engine.mixer === mixer) return;
714
+ resetEngine();
715
+ engine.loading = true;
716
+ engine.mixer = mixer;
717
+ engine.context ||= new AudioContext();
718
+ const tracks = [...mixer.querySelectorAll(".stem-track[data-src]")];
719
+ try {
720
+ await Promise.all(tracks.map(async (track) => {
721
+ const stem = track.dataset.stem;
722
+ const response = await fetch(track.dataset.src);
723
+ if (!response.ok) throw new Error(`Could not load ${stem}`);
724
+ const buffer = await engine.context.decodeAudioData(await response.arrayBuffer());
725
+ if (engine.mixer !== mixer) return;
726
+ const gain = engine.context.createGain();
727
+ gain.connect(engine.context.destination);
728
+ engine.buffers.set(stem, buffer);
729
+ engine.gains.set(stem, gain);
730
+ }));
731
+ if (engine.mixer !== mixer) return;
732
+ engine.duration = Math.min(...[...engine.buffers.values()].map((buffer) => buffer.duration));
733
+ mixer.dataset.audioReady = "true";
734
+ updateTimeline();
735
+ } catch (error) {
736
+ console.error("Stem mixer initialization failed", error);
737
+ mixer.dataset.audioError = "true";
738
+ } finally {
739
+ engine.loading = false;
740
+ }
741
+ };
742
+
743
+ const playFrom = async (offset) => {
744
+ if (!engine.buffers.size) return;
745
+ await engine.context.resume();
746
+ stopSources();
747
+ engine.offset = Math.max(0, Math.min(offset, engine.duration));
748
+ const when = engine.context.currentTime + 0.03;
749
+ engine.startedAt = when;
750
+ engine.playing = true;
751
+ engine.buffers.forEach((buffer, stem) => {
752
+ const source = engine.context.createBufferSource();
753
+ source.buffer = buffer;
754
+ source.connect(engine.gains.get(stem));
755
+ source.start(when, engine.offset);
756
+ engine.sources.set(stem, source);
757
+ });
758
+ };
759
+
760
+ const pause = () => {
761
+ if (!engine.playing) return;
762
+ engine.offset = position();
763
+ engine.playing = false;
764
+ stopSources();
765
+ updateTimeline();
766
+ };
767
+
768
+ const seek = async (time) => {
769
+ const wasPlaying = engine.playing;
770
+ engine.offset = Math.max(0, Math.min(time, engine.duration));
771
+ if (wasPlaying) await playFrom(engine.offset);
772
+ updateTimeline();
773
+ };
774
+
775
+ document.addEventListener("click", async (event) => {
776
+ const transport = event.target.closest(".mixer-transport");
777
+ if (transport) {
778
+ if (transport.dataset.action === "play") {
779
+ await playFrom(position() >= engine.duration ? 0 : position());
780
+ } else {
781
+ pause();
782
+ }
783
+ return;
784
+ }
785
+ const control = event.target.closest(".mixer-toggle");
786
+ if (!control) return;
787
+ const stem = control.dataset.stem;
788
+ const gain = engine.gains.get(stem);
789
+ if (!gain) return;
790
+ const muted = !engine.muted.has(stem);
791
+ if (muted) engine.muted.add(stem); else engine.muted.delete(stem);
792
+ const now = engine.context.currentTime;
793
+ if (typeof gain.gain.cancelAndHoldAtTime === "function") {
794
+ gain.gain.cancelAndHoldAtTime(now);
795
+ } else {
796
+ gain.gain.cancelScheduledValues(now);
797
+ }
798
+ gain.gain.setTargetAtTime(muted ? 0 : 1, now, 0.008);
799
+ control.setAttribute("aria-pressed", String(!muted));
800
+ control.textContent = muted ? "Muted" : "On";
801
+ });
802
+
803
+ document.addEventListener("input", async (event) => {
804
+ if (event.target.id !== "timeline-scrubber") return;
805
+ await seek(Number(event.target.value) / 1000);
806
+ });
807
+
808
+ const tick = () => {
809
+ const mixer = document.querySelector("#sync-mixer");
810
+ if (mixer && engine.mixer !== mixer && !engine.loading) initialize(mixer);
811
+ if (!mixer && engine.mixer) resetEngine();
812
+ if (engine.playing) {
813
+ if (position() >= engine.duration) {
814
+ engine.offset = engine.duration;
815
+ engine.playing = false;
816
+ stopSources();
817
+ }
818
+ updateTimeline();
819
+ }
820
+ requestAnimationFrame(tick);
821
+ };
822
+ requestAnimationFrame(tick);
823
+ })();
824
+ </script>
825
+ """
826
+
827
+
828
+ MIXER_CSS = """
829
+ :root { color-scheme: dark; --studio-accent: #a78bfa; --studio-warm: #fb7185; --studio-panel: #15131d; --studio-line: #2c2838; }
830
+ body, .gradio-container { background: #0b0a10 !important; color: #f7f4ff !important; }
831
+ .gradio-container { max-width: 1180px !important; margin: 0 auto !important; padding: 28px 24px 56px !important; }
832
+ footer { display: none !important; }
833
+ #studio-hero { position: relative; overflow: hidden; margin: 8px 0 28px; padding: 38px 40px; border: 1px solid #292435; border-radius: 28px; background: radial-gradient(circle at 82% 20%, rgba(167,139,250,.26), transparent 32%), radial-gradient(circle at 10% 100%, rgba(251,113,133,.14), transparent 36%), #111018; box-shadow: 0 30px 80px rgba(0,0,0,.28); }
834
+ #studio-hero::after { content: ""; position: absolute; width: 220px; height: 220px; right: -70px; bottom: -130px; border: 1px solid rgba(255,255,255,.12); border-radius: 50%; }
835
+ .ui-icon { width: 1.05rem; height: 1.05rem; flex: 0 0 auto; }
836
+ .hero-kicker { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: #c4b5fd; font-size: .72rem; font-weight: 800; letter-spacing: .22em; text-transform: uppercase; }
837
+ .hero-kicker .ui-icon { width: 1rem; height: 1rem; }
838
+ #studio-hero h1 { max-width: 650px; margin: 0; font-size: clamp(2.35rem, 6vw, 4.8rem); line-height: .98; letter-spacing: -.055em; }
839
+ #studio-hero p { max-width: 560px; margin: 18px 0 0; color: #aca6b9; font-size: 1.08rem; line-height: 1.55; }
840
+ #upload-panel { align-items: stretch; gap: 16px; margin-bottom: 16px; }
841
+ #song-card, #controls-card { padding: 18px !important; border: 1px solid var(--studio-line) !important; border-radius: 20px !important; background: var(--studio-panel) !important; box-shadow: 0 14px 34px rgba(0,0,0,.16); }
842
+ #song-card > div, #controls-card > div { background: transparent !important; }
843
+ #split-button { min-height: 48px; margin-top: 8px; border: 0 !important; border-radius: 14px !important; background: linear-gradient(135deg, #8b5cf6, #ec4899) !important; box-shadow: 0 10px 28px rgba(139,92,246,.28); font-weight: 750; transition: transform .16s ease, box-shadow .16s ease; }
844
+ #split-button:hover { transform: translateY(-1px); box-shadow: 0 14px 34px rgba(139,92,246,.38); }
845
+ #status-card { min-height: 0; margin: 4px 2px 14px; color: #c9c3d4; }
846
+ #status-card h3 { margin-bottom: 2px; color: #f7f4ff; }
847
+ .tabs { border: 1px solid var(--studio-line) !important; border-radius: 22px !important; background: #111018 !important; overflow: hidden; }
848
+ .tab-nav { padding: 8px 10px 0 !important; border-bottom: 1px solid var(--studio-line) !important; background: #111018 !important; }
849
+ .tab-nav button { border-radius: 10px 10px 0 0 !important; }
850
+ .tabitem { padding: 18px !important; background: #111018 !important; }
851
+ #sync-mixer { display: flex; align-items: flex-start; gap: 16px; }
852
+ #mixer-sidebar {
853
+ position: sticky;
854
+ top: 1rem;
855
+ min-width: 220px;
856
+ border: 1px solid var(--studio-line);
857
+ border-radius: 18px;
858
+ padding: 18px;
859
+ background: #181620;
860
+ }
861
+ #mixer-sidebar h3 { margin: 0 0 12px; font-size: 1.15rem; }
862
+ .transport-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
863
+ .mixer-stem { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,.055); }
864
+ .mixer-stem:last-child { border-bottom: 0; }
865
+ .mixer-transport, .mixer-toggle { border: 1px solid #373144; border-radius: 10px; background: #211e2a; color: #f7f4ff; cursor: pointer; }
866
+ .mixer-transport { display: inline-flex; align-items: center; justify-content: center; gap: 7px; }
867
+ .mixer-transport { min-height: 40px; }
868
+ .mixer-toggle { min-width: 4.5rem; padding: 6px 10px; color: #d8b4fe; }
869
+ .mixer-toggle[aria-pressed="false"] { color: #817a8d; background: #111018; }
870
+ #stem-players { flex: 1; min-width: 0; }
871
+ .shared-timeline { margin-bottom: 14px; padding: 16px 18px; border: 1px solid var(--studio-line); border-radius: 18px; background: #181620; }
872
+ .timeline-labels { display: flex; justify-content: space-between; margin-bottom: .45rem; font-variant-numeric: tabular-nums; font-size: .85rem; color: var(--body-text-color-subdued); }
873
+ #timeline-scrubber { width: 100%; accent-color: var(--studio-accent); cursor: pointer; }
874
+ #stem-track-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
875
+ .stem-track { border: 1px solid var(--studio-line); border-radius: 16px; padding: 15px 16px; background: #181620; transition: border-color .16s ease, transform .16s ease; }
876
+ .stem-track:hover { border-color: #494157; transform: translateY(-1px); }
877
+ .stem-track-header { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
878
+ .stem-name, .stem-track-label { display: inline-flex; align-items: center; gap: 9px; }
879
+ .stem-name .ui-icon, .stem-track-label .ui-icon { color: #a78bfa; }
880
+ .stem-track-label { font-weight: 600; }
881
+ .stem-download { display: inline-flex; align-items: center; gap: 6px; color: #c4b5fd !important; font-size: .82rem; text-decoration: none; }
882
+ .stem-download .ui-icon { width: .9rem; height: .9rem; }
883
+ .stem-download:hover { text-decoration: underline; }
884
+ .track-timeline { height: 7px; margin-top: 13px; border-radius: 999px; background: #292532; overflow: hidden; }
885
+ .track-playhead { display: block; width: 100%; height: 100%; transform: translateX(calc(-100% + var(--track-progress, 0%))); background: linear-gradient(90deg, var(--studio-accent), var(--studio-warm)); transition: transform .08s linear; }
886
+ .empty-mixer { padding: 60px 24px; color: #817a8d; text-align: center; }
887
+ @media (max-width: 768px) {
888
+ .gradio-container { padding: 14px 12px 36px !important; }
889
+ #studio-hero { padding: 28px 24px; border-radius: 22px; }
890
+ #sync-mixer { display: block; }
891
+ #mixer-sidebar { position: static; margin-bottom: 1rem; }
892
+ #stem-track-list { grid-template-columns: 1fr; }
893
+ }
894
+ """
895
+
896
+
897
+ APP_THEME = gr.themes.Soft(
898
+ primary_hue=gr.themes.colors.violet,
899
+ secondary_hue=gr.themes.colors.pink,
900
+ neutral_hue=gr.themes.colors.slate,
901
+ )
902
+
903
+
904
+ with gr.Blocks(title="Stem Studio") as demo:
905
+ gr.HTML(
906
+ """
907
+ <header id="studio-hero">
908
+ <div class="hero-kicker">
909
+ <svg class="ui-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 3-1.9 4.8L5 10l5.1 2.2L12 17l1.9-4.8L19 10l-5.1-2.2L12 3Z"/><path d="M5 3v4M3 5h4M19 17v4m-2-2h4"/></svg>
910
+ Stem Studio
911
+ </div>
912
+ <h1>Hear every layer.</h1>
913
+ <p>Upload a song, separate it, and shape the mix.</p>
914
+ </header>
915
+ """
916
+ )
917
+
918
+ with gr.Row(elem_id="upload-panel"):
919
+ with gr.Column(scale=2, elem_id="song-card"):
920
+ audio_input = gr.Audio(
921
+ label="Choose a song",
922
+ type="filepath",
923
+ sources=["upload"],
924
+ )
925
+ with gr.Column(scale=1, elem_id="controls-card"):
926
+ mode_input = gr.Radio(
927
+ choices=MODE_CHOICES,
928
+ value=MODE_CHOICES[2],
929
+ label="Mix detail",
930
+ )
931
+ run_button = gr.Button("Split song", variant="primary", elem_id="split-button")
932
+
933
+ force_input = gr.State(False)
934
+
935
+ status_output = gr.Markdown("", elem_id="status-card")
936
+
937
+ with gr.Tab("Mixer"):
938
+ mixer_output = gr.HTML(
939
+ "<p class='empty-mixer'>Your tracks will appear here.</p>"
940
+ )
941
+
942
+ with gr.Tab("Downloads"):
943
+ files_output = gr.File(
944
+ label="Tracks",
945
+ file_count="multiple",
946
+ )
947
+ zip_output = gr.File(
948
+ label="All tracks (.zip)",
949
+ file_count="single",
950
+ )
951
+
952
+ outputs = [
953
+ status_output,
954
+ files_output,
955
+ zip_output,
956
+ mixer_output,
957
+ ]
958
+
959
+ audio_input.change(
960
+ fn=lambda: None,
961
+ inputs=[],
962
+ outputs=[],
963
+ js="""
964
+ () => {
965
+ document.querySelectorAll("audio").forEach((player) => {
966
+ player.pause();
967
+ player.currentTime = 0;
968
+ });
969
+ }
970
+ """,
971
+ )
972
+
973
+ run_button.click(
974
+ fn=separate,
975
+ inputs=[audio_input, mode_input, force_input],
976
+ outputs=outputs,
977
+ concurrency_limit=1,
978
+ )
979
+
980
+
981
+ if __name__ == "__main__":
982
+ is_hf_space = bool(os.environ.get("SPACE_ID"))
983
+ default_server_name = "0.0.0.0" if is_hf_space else "127.0.0.1"
984
+ server_name = os.environ.get("GRADIO_SERVER_NAME", default_server_name)
985
+ server_port = int(os.environ.get("GRADIO_SERVER_PORT", "7860"))
986
+ share = os.environ.get("GRADIO_SHARE", "").lower() in {"1", "true", "yes", "on"}
987
+
988
+ print(f"bs-roformer-infer commit: {RUNTIME_COMMIT}")
989
+ print(f"Opening Gradio on http://{server_name}:{server_port}")
990
+
991
+ demo.queue(default_concurrency_limit=1, max_size=8).launch(
992
+ server_name=server_name,
993
+ server_port=server_port,
994
+ share=share,
995
+ inbrowser=not is_hf_space,
996
+ allowed_paths=[str(OUTPUT_ROOT.resolve())],
997
+ theme=APP_THEME,
998
+ css=MIXER_CSS,
999
+ head=MIXER_HEAD,
1000
+ )
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Local Apple Silicon uses MLX; Hugging Face Spaces uses Torch.
2
+ beartype>=0.22,<0.24
3
+ bs-roformer-infer[mlx] @ git+https://github.com/openmirlab/bs-roformer-infer.git@main ; sys_platform == 'darwin' and platform_machine == 'arm64'
4
+ bs-roformer-infer @ git+https://github.com/openmirlab/bs-roformer-infer.git@main ; sys_platform != 'darwin' or platform_machine != 'arm64'
5
+ gradio>=6,<7
6
+ imageio-ffmpeg>=0.6
7
+ numpy>=1.23
8
+ soundfile>=0.12