D-r-e commited on
Commit
bbfd93e
·
verified ·
1 Parent(s): 5fc14a5

Update stem mixer and separation logic

Browse files
Files changed (1) hide show
  1. app.py +242 -73
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" 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),
@@ -279,8 +272,8 @@ def ensure_healthy_model_assets(model: str) -> None:
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(
@@ -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
- # 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:
@@ -387,15 +380,13 @@ def normalize_outputs(
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
 
@@ -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 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">
@@ -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), "".join(tracks))
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
- 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) => {
@@ -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-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; }
@@ -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
- #stem-track-list { grid-template-columns: 1fr; }
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