Spaces:
Sleeping
Sleeping
Commit Β·
8e11dec
1
Parent(s): e8871cc
Heavy ML on HF; variant match % via librosa; Validate vs Gemini button on Analysis
Browse files- app.py +124 -10
- requirements.txt +9 -8
- 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(
|
| 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
|
| 800 |
-
#
|
| 801 |
-
#
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
# ββ
|
| 33 |
-
#
|
| 34 |
-
#
|
| 35 |
-
#
|
| 36 |
-
#
|
| 37 |
-
#
|
| 38 |
-
|
| 39 |
-
|
|
|
|
| 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;
|