Spaces:
Running on Zero
Running on Zero
Update stem mixer and separation logic
Browse files
app.py
CHANGED
|
@@ -14,7 +14,7 @@ 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 |
|
|
@@ -36,23 +36,11 @@ EXPECTED_STEMS = {
|
|
| 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"]))
|
|
@@ -114,6 +102,11 @@ 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 = []
|
|
@@ -232,8 +225,8 @@ def run_inference(stage_wav: Path, raw_dir: Path, model: str) -> None:
|
|
| 232 |
"--backend",
|
| 233 |
COMPUTE_BACKEND,
|
| 234 |
]
|
| 235 |
-
if COMPUTE_BACKEND == "mlx"
|
| 236 |
-
checkpoint_path, config_path =
|
| 237 |
cmd.extend([
|
| 238 |
"--model_path",
|
| 239 |
str(checkpoint_path),
|
|
@@ -279,8 +272,8 @@ def ensure_healthy_model_assets(model: str) -> None:
|
|
| 279 |
ensure_model_assets(model)
|
| 280 |
|
| 281 |
|
| 282 |
-
def
|
| 283 |
-
"""Return
|
| 284 |
checkpoint, config_path = map(Path, ensure_model_assets(model))
|
| 285 |
config_text = config_path.read_text()
|
| 286 |
chunk_matches = list(re.finditer(
|
|
@@ -294,9 +287,9 @@ def mlx_compatible_53_stem_assets(model: str) -> tuple[Path, Path]:
|
|
| 294 |
if not chunk_matches or hop_match is None:
|
| 295 |
raise RuntimeError("The detailed separation settings are incomplete.")
|
| 296 |
|
| 297 |
-
#
|
| 298 |
-
#
|
| 299 |
-
chunk_match = chunk_matches[
|
| 300 |
chunk_size = int(chunk_match.group(2))
|
| 301 |
hop_length = int(hop_match.group(1))
|
| 302 |
if chunk_size % hop_length == 0:
|
|
@@ -387,15 +380,13 @@ def normalize_outputs(
|
|
| 387 |
|
| 388 |
return files, previews
|
| 389 |
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
copy_atomic(src, dst)
|
| 398 |
-
previews[stem] = dst
|
| 399 |
|
| 400 |
return raw_wavs, previews
|
| 401 |
|
|
@@ -429,11 +420,37 @@ STEM_LABELS = {
|
|
| 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"/>',
|
|
@@ -453,32 +470,97 @@ def icon_svg(name: str, class_name: str = "ui-icon") -> str:
|
|
| 453 |
)
|
| 454 |
|
| 455 |
|
| 456 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
"""Build native audio controls; Gradio's waveform player has no controllable audio src."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
controls = []
|
| 459 |
tracks = []
|
| 460 |
-
for
|
| 461 |
-
|
| 462 |
-
if
|
| 463 |
continue
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
|
| 480 |
-
|
| 481 |
-
|
|
|
|
|
|
|
|
|
|
| 482 |
|
| 483 |
return """
|
| 484 |
<section id="sync-mixer" aria-label="Synchronized stem mixer">
|
|
@@ -498,7 +580,7 @@ def mixer_html(previews: dict[str, Path]) -> str:
|
|
| 498 |
<div id="stem-track-list">%s</div>
|
| 499 |
</div>
|
| 500 |
</section>
|
| 501 |
-
""" % (icon_svg("play"), icon_svg("pause"), "".join(controls),
|
| 502 |
|
| 503 |
|
| 504 |
def mode_from_choice(choice: str) -> str:
|
|
@@ -625,7 +707,7 @@ def separate(
|
|
| 625 |
status,
|
| 626 |
[str(path) for path in files],
|
| 627 |
str(zip_path),
|
| 628 |
-
mixer_html(previews),
|
| 629 |
)
|
| 630 |
|
| 631 |
except gr.Error:
|
|
@@ -667,6 +749,47 @@ MIXER_HEAD = """
|
|
| 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;
|
|
@@ -727,6 +850,10 @@ MIXER_HEAD = """
|
|
| 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));
|
|
@@ -782,22 +909,40 @@ MIXER_HEAD = """
|
|
| 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 |
-
|
| 792 |
-
const
|
| 793 |
-
if (
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
}
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
control.textContent = muted ? "Muted" : "On";
|
| 801 |
});
|
| 802 |
|
| 803 |
document.addEventListener("input", async (event) => {
|
|
@@ -805,6 +950,11 @@ MIXER_HEAD = """
|
|
| 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);
|
|
@@ -830,13 +980,17 @@ MIXER_CSS = """
|
|
| 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; }
|
|
@@ -862,22 +1016,37 @@ footer { display: none !important; }
|
|
| 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-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
| 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;
|
| 875 |
-
.stem-track { border: 1px solid var(--studio-line); border-radius: 16px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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; }
|
|
@@ -889,7 +1058,7 @@ footer { display: none !important; }
|
|
| 889 |
#studio-hero { padding: 28px 24px; border-radius: 22px; }
|
| 890 |
#sync-mixer { display: block; }
|
| 891 |
#mixer-sidebar { position: static; margin-bottom: 1rem; }
|
| 892 |
-
|
| 893 |
}
|
| 894 |
"""
|
| 895 |
|
|
|
|
| 14 |
import sysconfig
|
| 15 |
import traceback
|
| 16 |
import zipfile
|
| 17 |
+
from collections import defaultdict, deque
|
| 18 |
from pathlib import Path
|
| 19 |
from urllib.parse import quote
|
| 20 |
|
|
|
|
| 36 |
"6": ["vocals", "drums", "bass", "guitar", "piano", "other"],
|
| 37 |
}
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
MODE_CHOICES = [
|
| 40 |
+
"Voice + music — 2 tracks",
|
| 41 |
+
"Core instruments — 4 tracks",
|
| 42 |
+
"Full band — 6 tracks",
|
| 43 |
+
"Every detail — 53 tracks",
|
| 44 |
]
|
| 45 |
|
| 46 |
MODE_BY_CHOICE = dict(zip(MODE_CHOICES, ["2", "4", "6", "53"]))
|
|
|
|
| 102 |
return re.sub(r"[^a-z0-9]+", "_", path.stem.lower()).strip("_")
|
| 103 |
|
| 104 |
|
| 105 |
+
def output_stem_name(path: Path) -> str:
|
| 106 |
+
"""Derive the model's stem label from its rendered WAV filename."""
|
| 107 |
+
return re.sub(r"^input[_ -]", "", path.stem.lower())
|
| 108 |
+
|
| 109 |
+
|
| 110 |
def best_match(stem_name: str, paths: list[Path]) -> Path | None:
|
| 111 |
stem = stem_name.lower()
|
| 112 |
matches = []
|
|
|
|
| 225 |
"--backend",
|
| 226 |
COMPUTE_BACKEND,
|
| 227 |
]
|
| 228 |
+
if COMPUTE_BACKEND == "mlx":
|
| 229 |
+
checkpoint_path, config_path = mlx_compatible_assets(model)
|
| 230 |
cmd.extend([
|
| 231 |
"--model_path",
|
| 232 |
str(checkpoint_path),
|
|
|
|
| 272 |
ensure_model_assets(model)
|
| 273 |
|
| 274 |
|
| 275 |
+
def mlx_compatible_assets(model: str) -> tuple[Path, Path]:
|
| 276 |
+
"""Return model assets, patching MLX-incompatible chunk sizes when needed."""
|
| 277 |
checkpoint, config_path = map(Path, ensure_model_assets(model))
|
| 278 |
config_text = config_path.read_text()
|
| 279 |
chunk_matches = list(re.finditer(
|
|
|
|
| 287 |
if not chunk_matches or hop_match is None:
|
| 288 |
raise RuntimeError("The detailed separation settings are incomplete.")
|
| 289 |
|
| 290 |
+
# Published configs keep the chunked-inference setting in the audio section.
|
| 291 |
+
# Use the first entry instead of the model's unrelated training metadata.
|
| 292 |
+
chunk_match = chunk_matches[0]
|
| 293 |
chunk_size = int(chunk_match.group(2))
|
| 294 |
hop_length = int(hop_match.group(1))
|
| 295 |
if chunk_size % hop_length == 0:
|
|
|
|
| 380 |
|
| 381 |
return files, previews
|
| 382 |
|
| 383 |
+
# The detailed model emits a distinct WAV for every one of its 53 stems.
|
| 384 |
+
# Keep every source in the mixer instead of showing only a small preview.
|
| 385 |
+
for src in raw_wavs:
|
| 386 |
+
stem = output_stem_name(src)
|
| 387 |
+
if stem in previews:
|
| 388 |
+
stem = src.stem.lower()
|
| 389 |
+
previews[stem] = src
|
|
|
|
|
|
|
| 390 |
|
| 391 |
return raw_wavs, previews
|
| 392 |
|
|
|
|
| 420 |
"synth": "Synth",
|
| 421 |
}
|
| 422 |
|
| 423 |
+
GROUP_LABELS = {
|
| 424 |
+
"vocals": "Vocals",
|
| 425 |
+
"rhythm": "Rhythm",
|
| 426 |
+
"guitars_keys": "Guitars & keys",
|
| 427 |
+
"orchestral_winds": "Orchestral & winds",
|
| 428 |
+
"other": "Other instruments",
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
STEM_GROUPS = {
|
| 432 |
+
"vocals": {"vocal", "vocals", "lead-vocal", "back-vocal"},
|
| 433 |
+
"rhythm": {"drums", "kick", "snare", "toms", "hh", "percussion", "congas", "tambourine", "timpani", "triangle", "bass", "double-bass"},
|
| 434 |
+
"guitars_keys": {"acoustic-guitar", "electric-guitar", "guitar", "banjo", "dobro", "mandolin", "ukulele", "sitar", "harp", "piano", "digital-piano", "keys", "organ", "harpsichord", "accordion", "synth", "marimba", "glockenspiel", "bells"},
|
| 435 |
+
"orchestral_winds": {"bowed_strings", "strings", "cello", "viola", "violin", "brass", "trumpet", "trombone", "tuba", "french-horn", "flute", "clarinet", "oboe", "saxophone", "bassoon", "harmonica", "wind", "wind-chimes", "woodwind"},
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
# These broad predictions overlap the more specific instrument predictions in
|
| 439 |
+
# the same group. They are a convenient mix layer, not an extra source to add.
|
| 440 |
+
AGGREGATE_STEMS = {
|
| 441 |
+
"vocal", "vocals",
|
| 442 |
+
"drums", "percussion",
|
| 443 |
+
"guitar", "keys",
|
| 444 |
+
"bowed_strings", "strings", "brass", "wind", "woodwind",
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
ICON_PATHS = {
|
| 448 |
"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"/>',
|
| 449 |
"play": '<path d="m8 5 11 7-11 7Z"/>',
|
| 450 |
"pause": '<path d="M9 5v14M15 5v14"/>',
|
| 451 |
"download": '<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 19h14"/>',
|
| 452 |
+
"speaker": '<path d="M11 5 6 9H3v6h3l5 4Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18 6a8.5 8.5 0 0 1 0 12"/>',
|
| 453 |
+
"speaker-muted": '<path d="M11 5 6 9H3v6h3l5 4Z"/><path d="m16 9 5 5m0-5-5 5"/>',
|
| 454 |
"vocals": '<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v4M8 21h8"/>',
|
| 455 |
"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"/>',
|
| 456 |
"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"/>',
|
|
|
|
| 470 |
)
|
| 471 |
|
| 472 |
|
| 473 |
+
def stem_group(stem: str) -> str:
|
| 474 |
+
for group, stems in STEM_GROUPS.items():
|
| 475 |
+
if stem in stems:
|
| 476 |
+
return group
|
| 477 |
+
return "other"
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def stem_label(stem: str) -> str:
|
| 481 |
+
return STEM_LABELS.get(stem, stem.replace("-", " ").replace("_", " ").title())
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def mixer_html(previews: dict[str, Path], mode: str) -> str:
|
| 485 |
"""Build native audio controls; Gradio's waveform player has no controllable audio src."""
|
| 486 |
+
detailed_mode = mode == "53"
|
| 487 |
+
flat_mode = mode == "6"
|
| 488 |
+
grouped: dict[str, list[tuple[str, Path]]] = defaultdict(list)
|
| 489 |
+
for stem, path in previews.items():
|
| 490 |
+
grouped[stem_group(stem)].append((stem, path))
|
| 491 |
+
|
| 492 |
+
if not grouped:
|
| 493 |
+
return '<p class="empty-mixer">No playable stems were produced.</p>'
|
| 494 |
+
|
| 495 |
controls = []
|
| 496 |
tracks = []
|
| 497 |
+
for group in GROUP_LABELS:
|
| 498 |
+
group_stems = grouped.get(group)
|
| 499 |
+
if not group_stems:
|
| 500 |
continue
|
| 501 |
+
group_label = GROUP_LABELS[group]
|
| 502 |
+
escaped_group_label = html.escape(group_label)
|
| 503 |
+
group_controls = []
|
| 504 |
+
group_tracks = []
|
| 505 |
+
for stem, path in sorted(group_stems, key=lambda item: stem_label(item[0])):
|
| 506 |
+
label = stem_label(stem)
|
| 507 |
+
url = "/gradio_api/file=" + quote(str(path.resolve()), safe="/")
|
| 508 |
+
escaped_label = html.escape(label)
|
| 509 |
+
escaped_filename = html.escape(path.name, quote=True)
|
| 510 |
+
icon_name = stem if stem in ICON_PATHS else "other"
|
| 511 |
+
aggregate = detailed_mode and stem in AGGREGATE_STEMS
|
| 512 |
+
state = "false" if aggregate else "true"
|
| 513 |
+
action = "Unmute" if aggregate else "Mute"
|
| 514 |
+
button_icon = "speaker-muted" if aggregate else "speaker"
|
| 515 |
+
button_text = "Muted" if aggregate else "On"
|
| 516 |
+
tier = "aggregate" if aggregate else "individual"
|
| 517 |
+
group_controls.append(
|
| 518 |
+
f'<div class="mixer-stem" data-tier="{tier}"><span class="stem-name">{icon_svg(icon_name)}{escaped_label}</span>'
|
| 519 |
+
f'<button class="mixer-toggle" data-stem="{stem}" data-label="{escaped_label}" data-tier="{tier}" '
|
| 520 |
+
f'aria-pressed="{state}" aria-label="{action} {escaped_label}" title="{action} {escaped_label}">'
|
| 521 |
+
f'{icon_svg(button_icon)}<span>{button_text}</span></button></div>'
|
| 522 |
+
)
|
| 523 |
+
group_tracks.append(
|
| 524 |
+
f'<article class="stem-track" data-stem="{stem}" data-group="{group}" data-tier="{tier}" '
|
| 525 |
+
f'data-initial-muted="{str(aggregate).lower()}" data-src="{html.escape(url, quote=True)}">'
|
| 526 |
+
f'<div class="stem-track-header"><span class="stem-track-label">{icon_svg(icon_name)}{escaped_label}</span>'
|
| 527 |
+
f'<a class="stem-download" href="{html.escape(url, quote=True)}" '
|
| 528 |
+
f'download="{escaped_filename}">{icon_svg("download")}Save</a></div>'
|
| 529 |
+
f'<div class="track-timeline" aria-hidden="true"><span class="track-playhead"></span></div>'
|
| 530 |
+
f'</article>'
|
| 531 |
+
)
|
| 532 |
+
group_header = (
|
| 533 |
+
f'<section class="mixer-group"><div class="mixer-group-heading"><h4>{escaped_group_label}</h4>'
|
| 534 |
+
f'<button class="mixer-group-toggle" data-group="{group}" data-label="{escaped_group_label}" '
|
| 535 |
+
f'data-detailed="{str(detailed_mode).lower()}" '
|
| 536 |
+
f'aria-pressed="true" aria-label="Mute all {escaped_group_label}" title="Mute all {escaped_group_label}">'
|
| 537 |
+
f'{icon_svg("speaker")}<span>Mute all</span></button></div>'
|
| 538 |
)
|
| 539 |
+
if flat_mode:
|
| 540 |
+
controls.extend(group_controls)
|
| 541 |
+
tracks.extend(group_tracks)
|
| 542 |
+
elif detailed_mode:
|
| 543 |
+
controls.append(
|
| 544 |
+
group_header + f'<details><summary>{len(group_stems)} tracks</summary>{"".join(group_controls)}</details></section>'
|
| 545 |
+
)
|
| 546 |
+
tracks.append(
|
| 547 |
+
f'<details class="stem-track-group"{" open" if group == "vocals" else ""}>'
|
| 548 |
+
f'<summary><span>{escaped_group_label}</span><span>{len(group_stems)} tracks</span></summary>'
|
| 549 |
+
f'<div class="stem-track-grid">{"".join(group_tracks)}</div></details>'
|
| 550 |
+
)
|
| 551 |
+
else:
|
| 552 |
+
controls.append(group_header + "".join(group_controls) + "</section>")
|
| 553 |
+
tracks.append(
|
| 554 |
+
f'<section class="stem-track-group is-expanded"><div class="stem-track-group-title">'
|
| 555 |
+
f'<span>{escaped_group_label}</span><span>{len(group_stems)} tracks</span></div>'
|
| 556 |
+
f'<div class="stem-track-grid">{"".join(group_tracks)}</div></section>'
|
| 557 |
+
)
|
| 558 |
|
| 559 |
+
track_markup = (
|
| 560 |
+
f'<div class="stem-track-grid full-grid">{"".join(tracks)}</div>'
|
| 561 |
+
if flat_mode
|
| 562 |
+
else "".join(tracks)
|
| 563 |
+
)
|
| 564 |
|
| 565 |
return """
|
| 566 |
<section id="sync-mixer" aria-label="Synchronized stem mixer">
|
|
|
|
| 580 |
<div id="stem-track-list">%s</div>
|
| 581 |
</div>
|
| 582 |
</section>
|
| 583 |
+
""" % (icon_svg("play"), icon_svg("pause"), "".join(controls), track_markup)
|
| 584 |
|
| 585 |
|
| 586 |
def mode_from_choice(choice: str) -> str:
|
|
|
|
| 707 |
status,
|
| 708 |
[str(path) for path in files],
|
| 709 |
str(zip_path),
|
| 710 |
+
mixer_html(previews, mode),
|
| 711 |
)
|
| 712 |
|
| 713 |
except gr.Error:
|
|
|
|
| 749 |
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
|
| 750 |
};
|
| 751 |
|
| 752 |
+
const speakerIcon = (muted) => `<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">${muted
|
| 753 |
+
? '<path d="M11 5 6 9H3v6h3l5 4Z"/><path d="m16 9 5 5m0-5-5 5"/>'
|
| 754 |
+
: '<path d="M11 5 6 9H3v6h3l5 4Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18 6a8.5 8.5 0 0 1 0 12"/>'
|
| 755 |
+
}</svg>`;
|
| 756 |
+
|
| 757 |
+
const setStemMuted = (stem, muted) => {
|
| 758 |
+
const gain = engine.gains.get(stem);
|
| 759 |
+
if (!gain || !engine.mixer) return;
|
| 760 |
+
if (muted) engine.muted.add(stem); else engine.muted.delete(stem);
|
| 761 |
+
const now = engine.context.currentTime;
|
| 762 |
+
if (typeof gain.gain.cancelAndHoldAtTime === "function") {
|
| 763 |
+
gain.gain.cancelAndHoldAtTime(now);
|
| 764 |
+
} else {
|
| 765 |
+
gain.gain.cancelScheduledValues(now);
|
| 766 |
+
}
|
| 767 |
+
gain.gain.setTargetAtTime(muted ? 0 : 1, now, 0.008);
|
| 768 |
+
const control = [...engine.mixer.querySelectorAll(".mixer-toggle")]
|
| 769 |
+
.find((button) => button.dataset.stem === stem);
|
| 770 |
+
if (!control) return;
|
| 771 |
+
const action = muted ? "Unmute" : "Mute";
|
| 772 |
+
const label = control.dataset.label || stem;
|
| 773 |
+
control.setAttribute("aria-pressed", String(!muted));
|
| 774 |
+
control.setAttribute("aria-label", `${action} ${label}`);
|
| 775 |
+
control.title = `${action} ${label}`;
|
| 776 |
+
control.innerHTML = `${speakerIcon(muted)}<span>${muted ? "Muted" : "On"}</span>`;
|
| 777 |
+
};
|
| 778 |
+
|
| 779 |
+
const updateGroupControl = (group) => {
|
| 780 |
+
if (!engine.mixer) return;
|
| 781 |
+
const control = engine.mixer.querySelector(`.mixer-group-toggle[data-group="${group}"]`);
|
| 782 |
+
const stems = [...engine.mixer.querySelectorAll(`.stem-track[data-group="${group}"]`)]
|
| 783 |
+
.map((track) => track.dataset.stem);
|
| 784 |
+
if (!control || !stems.length) return;
|
| 785 |
+
const allMuted = stems.every((stem) => engine.muted.has(stem));
|
| 786 |
+
const label = control.dataset.label || group;
|
| 787 |
+
control.setAttribute("aria-pressed", String(!allMuted));
|
| 788 |
+
control.setAttribute("aria-label", `${allMuted ? "Unmute" : "Mute"} all ${label}`);
|
| 789 |
+
control.title = `${allMuted ? "Unmute" : "Mute"} all ${label}`;
|
| 790 |
+
control.innerHTML = `${speakerIcon(allMuted)}<span>${allMuted ? "Unmute all" : "Mute all"}</span>`;
|
| 791 |
+
};
|
| 792 |
+
|
| 793 |
const position = () => engine.playing
|
| 794 |
? Math.min(engine.duration, engine.offset + engine.context.currentTime - engine.startedAt)
|
| 795 |
: engine.offset;
|
|
|
|
| 850 |
gain.connect(engine.context.destination);
|
| 851 |
engine.buffers.set(stem, buffer);
|
| 852 |
engine.gains.set(stem, gain);
|
| 853 |
+
if (track.dataset.initialMuted === "true") {
|
| 854 |
+
gain.gain.value = 0;
|
| 855 |
+
engine.muted.add(stem);
|
| 856 |
+
}
|
| 857 |
}));
|
| 858 |
if (engine.mixer !== mixer) return;
|
| 859 |
engine.duration = Math.min(...[...engine.buffers.values()].map((buffer) => buffer.duration));
|
|
|
|
| 909 |
}
|
| 910 |
return;
|
| 911 |
}
|
| 912 |
+
if (!engine.mixer) return;
|
| 913 |
+
const groupControl = event.target.closest(".mixer-group-toggle");
|
| 914 |
+
if (groupControl) {
|
| 915 |
+
const group = groupControl.dataset.group;
|
| 916 |
+
const stems = [...engine.mixer.querySelectorAll(`.stem-track[data-group="${group}"]`)]
|
| 917 |
+
.map((track) => track.dataset.stem);
|
| 918 |
+
const shouldMute = stems.some((stem) => !engine.muted.has(stem));
|
| 919 |
+
if (shouldMute) {
|
| 920 |
+
stems.forEach((stem) => setStemMuted(stem, true));
|
| 921 |
+
} else if (groupControl.dataset.detailed === "true") {
|
| 922 |
+
// Returning a detailed group to audible state selects its individual
|
| 923 |
+
// sources, keeping broad aggregate predictions muted.
|
| 924 |
+
engine.mixer.querySelectorAll(`.stem-track[data-group="${group}"]`).forEach((track) => {
|
| 925 |
+
setStemMuted(track.dataset.stem, track.dataset.tier === "aggregate");
|
| 926 |
+
});
|
| 927 |
+
} else {
|
| 928 |
+
stems.forEach((stem) => setStemMuted(stem, false));
|
| 929 |
+
}
|
| 930 |
+
updateGroupControl(group);
|
| 931 |
+
return;
|
| 932 |
+
}
|
| 933 |
const control = event.target.closest(".mixer-toggle");
|
| 934 |
if (!control) return;
|
| 935 |
const stem = control.dataset.stem;
|
|
|
|
|
|
|
| 936 |
const muted = !engine.muted.has(stem);
|
| 937 |
+
const track = engine.mixer.querySelector(`.stem-track[data-stem="${stem}"]`);
|
| 938 |
+
const group = track?.dataset.group;
|
| 939 |
+
if (!muted && group && control.dataset.tier) {
|
| 940 |
+
const oppositeTier = control.dataset.tier === "aggregate" ? "individual" : "aggregate";
|
| 941 |
+
engine.mixer.querySelectorAll(`.stem-track[data-group="${group}"][data-tier="${oppositeTier}"]`)
|
| 942 |
+
.forEach((other) => setStemMuted(other.dataset.stem, true));
|
| 943 |
}
|
| 944 |
+
setStemMuted(stem, muted);
|
| 945 |
+
if (group) updateGroupControl(group);
|
|
|
|
| 946 |
});
|
| 947 |
|
| 948 |
document.addEventListener("input", async (event) => {
|
|
|
|
| 950 |
await seek(Number(event.target.value) / 1000);
|
| 951 |
});
|
| 952 |
|
| 953 |
+
document.addEventListener("click", (event) => {
|
| 954 |
+
if (!event.target.closest("#split-button")) return;
|
| 955 |
+
document.querySelector("#studio-hero")?.classList.add("is-compact");
|
| 956 |
+
});
|
| 957 |
+
|
| 958 |
const tick = () => {
|
| 959 |
const mixer = document.querySelector("#sync-mixer");
|
| 960 |
if (mixer && engine.mixer !== mixer && !engine.loading) initialize(mixer);
|
|
|
|
| 980 |
body, .gradio-container { background: #0b0a10 !important; color: #f7f4ff !important; }
|
| 981 |
.gradio-container { max-width: 1180px !important; margin: 0 auto !important; padding: 28px 24px 56px !important; }
|
| 982 |
footer { display: none !important; }
|
| 983 |
+
#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); transition: margin .25s ease, padding .25s ease, border-radius .25s ease; }
|
| 984 |
#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%; }
|
| 985 |
.ui-icon { width: 1.05rem; height: 1.05rem; flex: 0 0 auto; }
|
| 986 |
.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; }
|
| 987 |
.hero-kicker .ui-icon { width: 1rem; height: 1rem; }
|
| 988 |
#studio-hero h1 { max-width: 650px; margin: 0; font-size: clamp(2.35rem, 6vw, 4.8rem); line-height: .98; letter-spacing: -.055em; }
|
| 989 |
#studio-hero p { max-width: 560px; margin: 18px 0 0; color: #aca6b9; font-size: 1.08rem; line-height: 1.55; }
|
| 990 |
+
#studio-hero.is-compact { position: sticky; top: 8px; z-index: 10; margin: 8px 0 16px; padding: 13px 18px; border-radius: 16px; background: rgba(17,16,24,.94); box-shadow: 0 10px 28px rgba(0,0,0,.3); backdrop-filter: blur(14px); }
|
| 991 |
+
#studio-hero.is-compact::after, #studio-hero.is-compact h1, #studio-hero.is-compact p { display: none; }
|
| 992 |
+
#studio-hero.is-compact .hero-kicker { margin: 0; font-size: .78rem; }
|
| 993 |
+
#studio-hero.is-compact .hero-kicker::after { content: "Separation workspace"; margin-left: 8px; padding-left: 10px; border-left: 1px solid #494157; color: #aca6b9; font-weight: 600; letter-spacing: .08em; }
|
| 994 |
#upload-panel { align-items: stretch; gap: 16px; margin-bottom: 16px; }
|
| 995 |
#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); }
|
| 996 |
#song-card > div, #controls-card > div { background: transparent !important; }
|
|
|
|
| 1016 |
.transport-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
|
| 1017 |
.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); }
|
| 1018 |
.mixer-stem:last-child { border-bottom: 0; }
|
| 1019 |
+
.mixer-group { margin-top: 14px; }
|
| 1020 |
+
.mixer-group-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-bottom: 5px; border-bottom: 1px solid rgba(255,255,255,.1); }
|
| 1021 |
+
.mixer-group-heading h4 { margin: 0; color: #c9c3d4; font-size: .76rem; letter-spacing: .08em; text-transform: uppercase; }
|
| 1022 |
+
.mixer-group details summary { padding: 7px 0 1px; color: #817a8d; cursor: pointer; font-size: .78rem; }
|
| 1023 |
+
.mixer-group details summary:hover { color: #c9c3d4; }
|
| 1024 |
+
.mixer-transport, .mixer-toggle, .mixer-group-toggle { border: 1px solid #373144; border-radius: 10px; background: #211e2a; color: #f7f4ff; cursor: pointer; }
|
| 1025 |
.mixer-transport { display: inline-flex; align-items: center; justify-content: center; gap: 7px; }
|
| 1026 |
.mixer-transport { min-height: 40px; }
|
| 1027 |
+
.mixer-toggle { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 5.4rem; padding: 6px 10px; color: #d8b4fe; }
|
| 1028 |
+
.mixer-group-toggle { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 5px 7px; color: #d8b4fe; font-size: .72rem; white-space: nowrap; }
|
| 1029 |
+
.mixer-group-toggle .ui-icon { width: .9rem; height: .9rem; }
|
| 1030 |
.mixer-toggle[aria-pressed="false"] { color: #817a8d; background: #111018; }
|
| 1031 |
+
.mixer-group-toggle[aria-pressed="false"] { color: #817a8d; background: #111018; }
|
| 1032 |
#stem-players { flex: 1; min-width: 0; }
|
| 1033 |
.shared-timeline { margin-bottom: 14px; padding: 16px 18px; border: 1px solid var(--studio-line); border-radius: 18px; background: #181620; }
|
| 1034 |
.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); }
|
| 1035 |
#timeline-scrubber { width: 100%; accent-color: var(--studio-accent); cursor: pointer; }
|
| 1036 |
+
#stem-track-list { display: grid; gap: 1rem; }
|
| 1037 |
+
.stem-track-group { border: 1px solid var(--studio-line); border-radius: 16px; background: #15131c; overflow: hidden; }
|
| 1038 |
+
.stem-track-group > summary { display: flex; align-items: center; justify-content: space-between; padding: 13px 16px; color: #c9c3d4; cursor: pointer; font-size: .8rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
| 1039 |
+
.stem-track-group > summary span:last-child { color: #817a8d; font-size: .72rem; font-weight: 600; letter-spacing: .04em; }
|
| 1040 |
+
.stem-track-group[open] > summary { border-bottom: 1px solid var(--studio-line); }
|
| 1041 |
+
.stem-track-group-title { display: flex; align-items: center; justify-content: space-between; padding: 13px 16px; border-bottom: 1px solid var(--studio-line); color: #c9c3d4; font-size: .8rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
| 1042 |
+
.stem-track-group-title span:last-child { color: #817a8d; font-size: .72rem; font-weight: 600; letter-spacing: .04em; }
|
| 1043 |
+
.stem-track-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; padding: 10px; }
|
| 1044 |
+
.stem-track { border: 1px solid rgba(255,255,255,.07); border-radius: 11px; padding: 10px 11px; background: #181620; transition: border-color .16s ease, transform .16s ease; }
|
| 1045 |
.stem-track:hover { border-color: #494157; transform: translateY(-1px); }
|
| 1046 |
.stem-track-header { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
| 1047 |
.stem-name, .stem-track-label { display: inline-flex; align-items: center; gap: 9px; }
|
| 1048 |
.stem-name .ui-icon, .stem-track-label .ui-icon { color: #a78bfa; }
|
| 1049 |
+
.stem-track-label { font-size: .88rem; font-weight: 600; }
|
| 1050 |
.stem-download { display: inline-flex; align-items: center; gap: 6px; color: #c4b5fd !important; font-size: .82rem; text-decoration: none; }
|
| 1051 |
.stem-download .ui-icon { width: .9rem; height: .9rem; }
|
| 1052 |
.stem-download:hover { text-decoration: underline; }
|
|
|
|
| 1058 |
#studio-hero { padding: 28px 24px; border-radius: 22px; }
|
| 1059 |
#sync-mixer { display: block; }
|
| 1060 |
#mixer-sidebar { position: static; margin-bottom: 1rem; }
|
| 1061 |
+
.stem-track-grid { grid-template-columns: 1fr; }
|
| 1062 |
}
|
| 1063 |
"""
|
| 1064 |
|