kalamishere commited on
Commit
8e11dec
Β·
1 Parent(s): e8871cc

Heavy ML on HF; variant match % via librosa; Validate vs Gemini button on Analysis

Browse files
Files changed (3) hide show
  1. app.py +124 -10
  2. requirements.txt +9 -8
  3. theme.py +28 -0
app.py CHANGED
@@ -779,15 +779,20 @@ def regenerate_variants(prompt_text: str, last_run_state, api_key: str = ""):
779
  # filepath strings in a streaming generator was leaving v1..v5 with
780
  # paths the browser couldn't fetch (Audio rendered the slot but no
781
  # file URL was wired up).
782
- def _slot(p: str | None):
783
- if p:
784
- return gr.update(value=p, visible=True)
785
- return gr.update(value=None, visible=True)
786
-
787
  paths: list[str | None] = [None, None, None, None, None]
 
 
 
 
 
 
 
 
 
 
788
 
789
  def _emit(status: str):
790
- return (*(_slot(p) for p in paths), status)
791
 
792
  if not (prompt_text or "").strip():
793
  yield _emit("_❌ Enter a prompt to regenerate from._")
@@ -796,16 +801,25 @@ def regenerate_variants(prompt_text: str, last_run_state, api_key: str = ""):
796
  yield _emit("_❌ Connect a Pollinations wallet first (click the pollen pill, top right)._")
797
  return
798
 
799
- # Try to thread parent lineage from last_run state (set when the user
800
- # routed in via "Use for analysis" β€” `audio_path` is the source tile's
801
- # audio_path, and we can resolve it back to its crate tile.id).
 
802
  parent_id: str | None = None
 
 
803
  if isinstance(last_run_state, dict):
804
  src_path = last_run_state.get("audio_path", "")
805
  for t in crate.list_tiles():
806
  if t.audio_path == src_path:
807
  parent_id = t.id
808
  break
 
 
 
 
 
 
809
 
810
  duration = 15 # fixed for v1 β€” variants are short clips
811
  model = sa3.DEFAULT_MODEL
@@ -842,7 +856,11 @@ def regenerate_variants(prompt_text: str, last_run_state, api_key: str = ""):
842
  except Exception:
843
  pass
844
  paths[i] = tile.audio_path
845
- print(f"[regen v{i+1}] tile={tile.id} bytes={info.get('bytes')} path={tile.audio_path}",
 
 
 
 
846
  file=_sys.stderr, flush=True)
847
 
848
  parent_note = ""
@@ -912,6 +930,56 @@ def delete_tile(tile_id: str | None):
912
  _crate_header_html())
913
 
914
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
915
  _BPM_RE = __import__("re").compile(r"(\d{2,3})\s*BPM", __import__("re").IGNORECASE)
916
 
917
 
@@ -1180,6 +1248,15 @@ def build_ui() -> gr.Blocks:
1180
  # confirms what the LLM heard.
1181
  with gr.Tab("Analysis"):
1182
  caption = gr.Markdown()
 
 
 
 
 
 
 
 
 
1183
  # 6-up metric tile grid: BPM / KEY / LUFS / TRUE PK / LRA / LENGTH.
1184
  # Empty placeholder shown until the pipeline fills it in.
1185
  metrics_html = gr.HTML(_metrics_html(None))
@@ -1347,6 +1424,43 @@ def build_ui() -> gr.Blocks:
1347
  api_name="compare",
1348
  )
1349
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1350
  scorecard_btn.click(
1351
  fn=export_scorecard,
1352
  inputs=[compare_state],
 
779
  # filepath strings in a streaming generator was leaving v1..v5 with
780
  # paths the browser couldn't fetch (Audio rendered the slot but no
781
  # file URL was wired up).
 
 
 
 
 
782
  paths: list[str | None] = [None, None, None, None, None]
783
+ matches: list[int | None] = [None, None, None, None, None]
784
+
785
+ def _slot(idx: int):
786
+ """Build the gr.update for slot idx. Label encodes match % when
787
+ we've scored the variant against the anchor."""
788
+ m = matches[idx]
789
+ label = f"v{idx+1}" if m is None else f"V{idx+1} Β· {m}% match"
790
+ if paths[idx]:
791
+ return gr.update(value=paths[idx], visible=True, label=label)
792
+ return gr.update(value=None, visible=True, label=label)
793
 
794
  def _emit(status: str):
795
+ return (*(_slot(i) for i in range(5)), status)
796
 
797
  if not (prompt_text or "").strip():
798
  yield _emit("_❌ Enter a prompt to regenerate from._")
 
801
  yield _emit("_❌ Connect a Pollinations wallet first (click the pollen pill, top right)._")
802
  return
803
 
804
+ # Try to thread parent lineage + anchor measurements from last_run.
805
+ # parent_id sets the crate-tile lineage; anchor_bpm/anchor_key power
806
+ # the per-variant match % readout. last_run_state is populated by
807
+ # run_brief on analysis-complete.
808
  parent_id: str | None = None
809
+ anchor_bpm: float | None = None
810
+ anchor_key: str | None = None
811
  if isinstance(last_run_state, dict):
812
  src_path = last_run_state.get("audio_path", "")
813
  for t in crate.list_tiles():
814
  if t.audio_path == src_path:
815
  parent_id = t.id
816
  break
817
+ an = last_run_state.get("analysis")
818
+ if an is not None:
819
+ anchor_bpm = getattr(an, "bpm", None)
820
+ # Compose "F minor" style key string to match anchor_key format.
821
+ k_root = getattr(an, "key", None)
822
+ anchor_key = k_root if k_root else None
823
 
824
  duration = 15 # fixed for v1 β€” variants are short clips
825
  model = sa3.DEFAULT_MODEL
 
856
  except Exception:
857
  pass
858
  paths[i] = tile.audio_path
859
+ # Quick match β€” librosa-only BPM+key on the variant vs anchor.
860
+ # Adds ~1-2s per variant; total regen wall time stays ~25s for 5.
861
+ matches[i] = _quick_match(tile.audio_path, anchor_bpm, anchor_key)
862
+ print(f"[regen v{i+1}] tile={tile.id} bytes={info.get('bytes')} "
863
+ f"match={matches[i]} path={tile.audio_path}",
864
  file=_sys.stderr, flush=True)
865
 
866
  parent_note = ""
 
930
  _crate_header_html())
931
 
932
 
933
+ def _quick_match(variant_path: str, anchor_bpm: float | None, anchor_key: str | None) -> int | None:
934
+ """Fast match score (0-100) for a regenerated variant against the
935
+ analysed anchor. BPM weight 60% Β· key weight 40%.
936
+
937
+ Uses librosa directly β€” skips demucs/basic-pitch/pyloudnorm so it adds
938
+ only ~1-2s per variant on top of the gen call. Returns None if the
939
+ anchor's BPM/key are missing (uploaded-without-analysis case) or if
940
+ librosa fails (bad audio file)."""
941
+ if not (anchor_bpm and anchor_key):
942
+ return None
943
+ try:
944
+ import librosa
945
+ import numpy as np
946
+ y, sr = librosa.load(variant_path, sr=22050, mono=True, duration=15.0)
947
+ # BPM with anchor as the prior so dnb half-tempo lock can't lie.
948
+ tempo, _ = librosa.beat.beat_track(
949
+ y=y, sr=sr, start_bpm=float(anchor_bpm), tightness=100,
950
+ )
951
+ v_bpm = float(np.asarray(tempo).item())
952
+ # Krumhansl-Schmuckler key (same procedure as pipeline._stage_bpm_key).
953
+ chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
954
+ chroma_mean = np.mean(chroma, axis=1)
955
+ KEY_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
956
+ major = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
957
+ minor = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
958
+ best_corr, v_key = -1.0, "C"
959
+ for i in range(12):
960
+ for prof in (major, minor):
961
+ rolled = np.roll(prof, i)
962
+ corr = float(np.corrcoef(chroma_mean, rolled)[0, 1])
963
+ if corr > best_corr:
964
+ best_corr, v_key = corr, KEY_NAMES[i]
965
+ # Score: BPM within Β±2% β†’ 100, Β±10% β†’ 50, Β±25% β†’ 0 (linear-ish).
966
+ bpm_ratio = abs(v_bpm - float(anchor_bpm)) / max(float(anchor_bpm), 1.0)
967
+ bpm_score = max(0.0, 100.0 - bpm_ratio * 500.0)
968
+ # Key: exact match β†’ 100, semitone away β†’ 67, whole-tone β†’ 33, else 0.
969
+ if anchor_key in KEY_NAMES:
970
+ a_idx = KEY_NAMES.index(anchor_key)
971
+ v_idx = KEY_NAMES.index(v_key)
972
+ delta = min((a_idx - v_idx) % 12, (v_idx - a_idx) % 12)
973
+ key_score = max(0.0, 100.0 - delta * 33.0)
974
+ else:
975
+ key_score = 0.0
976
+ return int(round(bpm_score * 0.6 + key_score * 0.4))
977
+ except Exception as e:
978
+ print(f"[_quick_match] {type(e).__name__}: {e}",
979
+ file=sys.stderr, flush=True)
980
+ return None
981
+
982
+
983
  _BPM_RE = __import__("re").compile(r"(\d{2,3})\s*BPM", __import__("re").IGNORECASE)
984
 
985
 
 
1248
  # confirms what the LLM heard.
1249
  with gr.Tab("Analysis"):
1250
  caption = gr.Markdown()
1251
+ # Validate-vs-Gemini button β€” promoted from the Compare tab so
1252
+ # the wedge demo is one click away after analysis lands. Hidden
1253
+ # until last_run flips to a populated dict; on click it
1254
+ # programmatically switches to Compare and fires run_compare.
1255
+ validate_btn = gr.Button(
1256
+ "Validate vs Gemini β–Έ",
1257
+ size="sm", interactive=False,
1258
+ elem_classes=["dc-validate-btn"],
1259
+ )
1260
  # 6-up metric tile grid: BPM / KEY / LUFS / TRUE PK / LRA / LENGTH.
1261
  # Empty placeholder shown until the pipeline fills it in.
1262
  metrics_html = gr.HTML(_metrics_html(None))
 
1424
  api_name="compare",
1425
  )
1426
 
1427
+ # Enable Validate-vs-Gemini button only after analysis lands.
1428
+ last_run.change(
1429
+ fn=lambda lr: gr.update(interactive=isinstance(lr, dict)),
1430
+ inputs=[last_run],
1431
+ outputs=[validate_btn],
1432
+ )
1433
+
1434
+ # Validate button on Analysis: switch to Compare tab (via JS) and
1435
+ # fire run_compare with the current analysis state. Same outputs
1436
+ # as compare_btn β€” populates the Compare tab's columns directly.
1437
+ validate_btn.click(
1438
+ fn=run_compare,
1439
+ inputs=[last_run, model_a_dd, model_b_dd, model_c_dd, api_key_state],
1440
+ outputs=[
1441
+ a_header, a_brief,
1442
+ b_header, b_brief,
1443
+ c_header, c_brief,
1444
+ comparison_table,
1445
+ a_timing, b_timing, c_timing,
1446
+ compare_state,
1447
+ ],
1448
+ js="""
1449
+ () => {
1450
+ // Find and click the "Compare with Gemini" tab button so
1451
+ // the user lands on the side-by-side as the comparison runs.
1452
+ const tabs = document.querySelectorAll('[role="tab"]');
1453
+ for (const t of tabs) {
1454
+ if ((t.innerText || '').trim().startsWith('Compare')) {
1455
+ t.click();
1456
+ break;
1457
+ }
1458
+ }
1459
+ return []; // no input mutation
1460
+ }
1461
+ """,
1462
+ )
1463
+
1464
  scorecard_btn.click(
1465
  fn=export_scorecard,
1466
  inputs=[compare_state],
requirements.txt CHANGED
@@ -29,14 +29,15 @@ pyloudnorm>=0.1.1
29
  # LLM narrative + SA3 audio gen β€” both via Pollinations HTTP API.
30
  requests>=2.31
31
 
32
- # ── OPTIONAL HEAVY ML ──────────────────────────────────────────────────
33
- # Uncomment to enable stem split + bass-MIDI. Adds ~2 GB and ~8 min to
34
- # the build on HF Spaces free CPU; basic-pitch's latest PyPI version is
35
- # 0.4.0 (NOT 0.5 β€” that was an internal aspirational pin).
36
- #
37
- # demucs>=4.0
38
- # basic-pitch[onnx]>=0.4,<0.5
39
- # torchcodec>=0.14 # demucs uses torchaudio which moved file I/O here
 
40
 
41
  # ── DEFERRED v2 ────────────────────────────────────────────────────────
42
  # essentia-tensorflow # tagging (stage 7) β€” Linux wheels only
 
29
  # LLM narrative + SA3 audio gen β€” both via Pollinations HTTP API.
30
  requests>=2.31
31
 
32
+ # ── HEAVY ML β€” stem split + bass-MIDI ─────────────────────────────────
33
+ # These pull torch + torchaudio + onnxruntime β€” together ~2 GB and a
34
+ # first-build cost of 8-12 min on HF Spaces free CPU. Subsequent builds
35
+ # reuse the layer cache. The pipeline's per-stage try/except means a
36
+ # failed install here still leaves Generate + Analysis + Regenerate
37
+ # working on librosa-only stages.
38
+ demucs>=4.0
39
+ basic-pitch[onnx]>=0.4,<0.5 # 0.4.0 is latest on PyPI; 0.5+ doesn't exist
40
+ torchcodec>=0.14 # demucs uses torchaudio's new file IO
41
 
42
  # ── DEFERRED v2 ────────────────────────────────────────────────────────
43
  # essentia-tensorflow # tagging (stage 7) β€” Linux wheels only
theme.py CHANGED
@@ -335,6 +335,34 @@ h3 { font-size: 13px; color: #C9CDD4; }
335
  background: #14171D;
336
  }
337
  .dc-var-hold .chips { display: flex; gap: 6px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  .dc-var-hold .chip {
339
  flex: 1;
340
  text-align: center;
 
335
  background: #14171D;
336
  }
337
  .dc-var-hold .chips { display: flex; gap: 6px; }
338
+ /* ---- Validate-vs-Gemini button (Analysis tab) β€” outline style so it
339
+ reads as a secondary CTA next to the brief, not a primary action.
340
+ Mint border = measured-grounding promise; flips to coral on hover. */
341
+ .dc-validate-btn button, button.dc-validate-btn {
342
+ background: transparent !important;
343
+ border: 1px solid rgba(91,224,200,0.45) !important;
344
+ color: #5BE0C8 !important;
345
+ font-family: 'Space Grotesk', sans-serif !important;
346
+ font-weight: 600 !important;
347
+ font-size: 12px !important;
348
+ padding: 6px 14px !important;
349
+ border-radius: 8px !important;
350
+ height: auto !important;
351
+ min-height: 32px !important;
352
+ width: fit-content !important;
353
+ max-width: 220px !important;
354
+ margin: 4px 0 12px !important;
355
+ }
356
+ .dc-validate-btn button:hover, button.dc-validate-btn:hover {
357
+ background: rgba(255,106,61,0.08) !important;
358
+ border-color: #FF8C5A !important;
359
+ color: #FF8C5A !important;
360
+ }
361
+ .dc-validate-btn button:disabled, button.dc-validate-btn:disabled {
362
+ opacity: 0.35 !important;
363
+ cursor: not-allowed !important;
364
+ }
365
+
366
  .dc-var-hold .chip {
367
  flex: 1;
368
  text-align: center;