Spaces:
Running
Running
Commit ·
4e92959
0
Parent(s):
squash: purge data files from history
Browse files- .gitattributes +35 -0
- README.md +39 -0
- analysis.py +109 -0
- app.py +182 -0
- boundaries.py +135 -0
- clap_embed.py +156 -0
- livematch.py +198 -0
- packages.txt +1 -0
- render.py +283 -0
- requirements.txt +18 -0
- tags.py +176 -0
- verify.py +98 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Marathon Live Matcher
|
| 3 |
+
emoji: 🌍
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.19.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
short_description: Market fit, 30s snippet picks and metadata tags
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Marathon — live matcher
|
| 14 |
+
|
| 15 |
+
Upload a track and get, on one screen:
|
| 16 |
+
|
| 17 |
+
1. **Where it fits.** The track's audio fingerprint against every sound
|
| 18 |
+
charting in each market this week — nearest records named, with
|
| 19 |
+
place / derive / pass verdicts and the denominators behind them.
|
| 20 |
+
2. **Snippet recommendations.** A 30-second window slid across the track at a
|
| 21 |
+
5-second hop, ranked by how close each window sits to the trend pools the
|
| 22 |
+
track already fits, blended with a musical-boundary score. This is the
|
| 23 |
+
official clip a label must pick when delivering to TikTok / YouTube /
|
| 24 |
+
Meta through a distributor.
|
| 25 |
+
3. **Suggested tags.** CLAP zero-shot descriptors plus trend-derived context,
|
| 26 |
+
as a copy-paste line for distributor metadata forms.
|
| 27 |
+
|
| 28 |
+
**Gated.** Nothing runs without the team passphrase
|
| 29 |
+
(`MARATHON_LIVE_PASSPHRASE`, a Space secret). Uploads are analysed in memory
|
| 30 |
+
and never stored or published.
|
| 31 |
+
|
| 32 |
+
Everything here is descriptive. Similarity measures how close two recordings
|
| 33 |
+
sound; it is not a prediction that a record will perform. Cutoffs are
|
| 34 |
+
first-pass and calibrated on US/global data. The corpus is one week of
|
| 35 |
+
charts — no trajectory, no momentum claims.
|
| 36 |
+
|
| 37 |
+
Built from `marathon/live_space/` and deployed by
|
| 38 |
+
`python -m marathon.cli deploy-live`. The corpus files are written by
|
| 39 |
+
`python -m marathon.cli export-live`.
|
analysis.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One upload → the whole answer. No UI, no Gradio: importable and runnable
|
| 2 |
+
headless, which is how the end-to-end verification runs before deploy.
|
| 3 |
+
|
| 4 |
+
Order matters for RAM on a small box: the audio is loaded once at 48 kHz for
|
| 5 |
+
CLAP, the beat analysis reloads it at 22.05 kHz (librosa's own cache-free
|
| 6 |
+
path), and the CLAP tile cache is dropped as soon as the vectors exist.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
import boundaries
|
| 14 |
+
import clap_embed
|
| 15 |
+
import livematch
|
| 16 |
+
import tags as tagmod
|
| 17 |
+
|
| 18 |
+
WINDOW_S = 30.0 # the official clip length a distributor asks for
|
| 19 |
+
HOP_S = clap_embed.GRID_S
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def analyse_track(path: str, embedder, corpus: livematch.Corpus,
|
| 23 |
+
vocab=None, top_markets: int = 8, focus_markets: int = 3,
|
| 24 |
+
snippet_top: int = 4) -> dict:
|
| 25 |
+
audio = clap_embed.load_audio(path)
|
| 26 |
+
duration = len(audio) / clap_embed.CLAP_SR
|
| 27 |
+
|
| 28 |
+
track_vec = embedder.embed_track(audio)
|
| 29 |
+
scan = livematch.rank_regions(corpus, track_vec, top=5)
|
| 30 |
+
ranked_isos = sorted(scan["regions"], key=lambda k: -scan["regions"][k]["best"])
|
| 31 |
+
shown = ranked_isos[:top_markets]
|
| 32 |
+
focus = ranked_isos[:focus_markets]
|
| 33 |
+
|
| 34 |
+
# -- snippets ---------------------------------------------------------
|
| 35 |
+
starts, wvecs = embedder.embed_windows(audio, WINDOW_S, HOP_S)
|
| 36 |
+
embedder.reset_cache()
|
| 37 |
+
|
| 38 |
+
per_market = {}
|
| 39 |
+
if starts and focus:
|
| 40 |
+
for iso in focus:
|
| 41 |
+
idx = np.asarray(corpus.regions[iso]["idx"], dtype=np.int64)
|
| 42 |
+
sims = wvecs @ corpus.emb[idx].T # (windows, pool)
|
| 43 |
+
best = sims.argmax(axis=1)
|
| 44 |
+
per_market[iso] = {
|
| 45 |
+
"best_sim": sims.max(axis=1),
|
| 46 |
+
"best_sound": [int(idx[b]) for b in best],
|
| 47 |
+
}
|
| 48 |
+
if per_market:
|
| 49 |
+
stack = np.stack([per_market[i]["best_sim"] for i in focus])
|
| 50 |
+
win_best_market = [focus[int(j)] for j in stack.argmax(axis=0)]
|
| 51 |
+
affinity = stack.max(axis=0).tolist()
|
| 52 |
+
else:
|
| 53 |
+
win_best_market = [None] * len(starts)
|
| 54 |
+
affinity = [0.0] * len(starts)
|
| 55 |
+
|
| 56 |
+
beat = boundaries.analyse(path)
|
| 57 |
+
wscores = boundaries.window_scores(beat, starts, WINDOW_S)
|
| 58 |
+
picks = livematch.rank_windows(affinity, wscores["boundary"], starts,
|
| 59 |
+
top=snippet_top)
|
| 60 |
+
by_start = {round(s, 2): i for i, s in enumerate(starts)}
|
| 61 |
+
for p in picks:
|
| 62 |
+
i = by_start[p["start_s"]]
|
| 63 |
+
iso = win_best_market[i]
|
| 64 |
+
p["end_s"] = round(p["start_s"] + WINDOW_S, 2)
|
| 65 |
+
p["market"] = iso
|
| 66 |
+
p["market_name"] = corpus.regions[iso]["name"] if iso else None
|
| 67 |
+
p["on_downbeat"] = wscores["on_downbeat"][i]
|
| 68 |
+
p["nearest_downbeat"] = wscores["nearest_downbeat"][i]
|
| 69 |
+
if iso:
|
| 70 |
+
s = corpus.sounds[per_market[iso]["best_sound"][i]]
|
| 71 |
+
p["nearest"] = {"artist": s["artist"], "title": s["title"],
|
| 72 |
+
"deezer_id": s.get("deezer_id")}
|
| 73 |
+
else:
|
| 74 |
+
p["nearest"] = None
|
| 75 |
+
|
| 76 |
+
# -- tags -------------------------------------------------------------
|
| 77 |
+
# nearest neighbours by index, straight from the focus pools
|
| 78 |
+
neighbour_idx: list[int] = []
|
| 79 |
+
if focus:
|
| 80 |
+
pool = livematch.pool_index(corpus, focus)
|
| 81 |
+
sims = corpus.emb[pool] @ track_vec
|
| 82 |
+
neighbour_idx = [int(pool[i]) for i in np.argsort(-sims)[:10]]
|
| 83 |
+
trend = tagmod.trend_tags(scan["regions"], shown, corpus.sounds, neighbour_idx)
|
| 84 |
+
model_tags, copy = None, None
|
| 85 |
+
if vocab is not None:
|
| 86 |
+
model_tags = vocab.top(track_vec)
|
| 87 |
+
copy = tagmod.copy_line(model_tags, trend)
|
| 88 |
+
else:
|
| 89 |
+
copy = ", ".join((trend.get("genres") or [])
|
| 90 |
+
+ [m["iso"] for m in trend["markets"][:5]])
|
| 91 |
+
|
| 92 |
+
return {
|
| 93 |
+
"duration_s": round(duration, 1),
|
| 94 |
+
"week": scan["week"],
|
| 95 |
+
"shown_markets": shown,
|
| 96 |
+
"focus_markets": focus,
|
| 97 |
+
"regions": scan["regions"],
|
| 98 |
+
"track_vec": track_vec,
|
| 99 |
+
"snippets": picks,
|
| 100 |
+
"window_count": len(starts),
|
| 101 |
+
"beat": {"ok": beat.get("ok"), "tempo": beat.get("tempo"),
|
| 102 |
+
"error": beat.get("error")},
|
| 103 |
+
"tags": {"model": model_tags, "trend": trend, "copy_line": copy},
|
| 104 |
+
"corpus": {"week": corpus.week, "sounds": len(corpus.sounds),
|
| 105 |
+
"regions": len(corpus.regions),
|
| 106 |
+
"generated_at": corpus.meta.get("generated_at"),
|
| 107 |
+
"sources": corpus.meta.get("sources", []),
|
| 108 |
+
"genre_labelled": corpus.meta.get("genre_labelled_sounds", 0)},
|
| 109 |
+
}
|
app.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Marathon — live matcher. Upload a track, get the world scan, the 30-second
|
| 2 |
+
snippet shortlist and metadata tags, in one screen.
|
| 3 |
+
|
| 4 |
+
Gated: the Space repo can be public because nothing runs without the team
|
| 5 |
+
passphrase, which lives only in the Space secret MARATHON_LIVE_PASSPHRASE
|
| 6 |
+
(same value as the drop sheet's). No passphrase set → the app refuses to
|
| 7 |
+
analyse anything rather than silently serving an open endpoint.
|
| 8 |
+
|
| 9 |
+
The corpus (corpus.npz + corpus.json) is this week's scan exported by
|
| 10 |
+
`marathon.cli export-live`. It is NOT in this public repo: it lives in the
|
| 11 |
+
private dataset repo kalamishere/marathon-corpus and is fetched at startup
|
| 12 |
+
with the HF_TOKEN Space secret, so repo browsers see code but no data.
|
| 13 |
+
Local dev keeps working: files already present next to app.py win.
|
| 14 |
+
No database, no network at match time.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import hmac
|
| 20 |
+
import os
|
| 21 |
+
import tempfile
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import gradio as gr
|
| 25 |
+
import soundfile as sf
|
| 26 |
+
|
| 27 |
+
import analysis
|
| 28 |
+
import clap_embed
|
| 29 |
+
import livematch
|
| 30 |
+
import render
|
| 31 |
+
import tags as tagmod
|
| 32 |
+
|
| 33 |
+
HERE = Path(__file__).resolve().parent
|
| 34 |
+
CORPUS_DATASET = "kalamishere/marathon-corpus"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _fetch_corpus(dest: Path) -> Path:
|
| 38 |
+
"""Fetch corpus files from the private dataset repo unless they are
|
| 39 |
+
already present locally (dev checkout / staged verify dir)."""
|
| 40 |
+
if (dest / "corpus.npz").exists() and (dest / "corpus.json").exists():
|
| 41 |
+
return dest
|
| 42 |
+
from huggingface_hub import hf_hub_download
|
| 43 |
+
|
| 44 |
+
token = os.environ.get("HF_TOKEN")
|
| 45 |
+
if not token:
|
| 46 |
+
raise RuntimeError(
|
| 47 |
+
"corpus files missing and HF_TOKEN not set — cannot fetch "
|
| 48 |
+
f"{CORPUS_DATASET}")
|
| 49 |
+
for name in ("corpus.npz", "corpus.json"):
|
| 50 |
+
got = hf_hub_download(repo_id=CORPUS_DATASET, filename=name,
|
| 51 |
+
repo_type="dataset", token=token)
|
| 52 |
+
(dest / name).unlink(missing_ok=True)
|
| 53 |
+
(dest / name).symlink_to(got)
|
| 54 |
+
return dest
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
CORPUS = livematch.Corpus.load(_fetch_corpus(HERE))
|
| 58 |
+
PASSPHRASE = os.environ.get("MARATHON_LIVE_PASSPHRASE", "")
|
| 59 |
+
MAX_SNIPPETS = 4
|
| 60 |
+
|
| 61 |
+
_state = {"embedder": None, "vocab": None, "zero_shot_ok": None}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _embedder():
|
| 65 |
+
"""Loaded on first analysis, not at import: the Space should show its gate
|
| 66 |
+
immediately rather than after a two-gigabyte checkpoint download."""
|
| 67 |
+
if _state["embedder"] is None:
|
| 68 |
+
emb = clap_embed.Embedder()
|
| 69 |
+
_state["embedder"] = emb
|
| 70 |
+
try:
|
| 71 |
+
_state["vocab"] = tagmod.TagVocab(emb)
|
| 72 |
+
except Exception as exc: # text tower missing → group two only
|
| 73 |
+
print(f"[tags] text tower unavailable, zero-shot disabled: {exc}")
|
| 74 |
+
_state["vocab"] = None
|
| 75 |
+
return _state["embedder"]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _clip(path: str, start_s: float, end_s: float, out_dir: str) -> str:
|
| 79 |
+
"""A real slice of the uploaded file, written out so the browser can play
|
| 80 |
+
it. Original sample rate and channels — the point is to judge the clip.
|
| 81 |
+
|
| 82 |
+
MP3 where libsndfile can write it: four 30-second stereo WAVs is ~23 MB
|
| 83 |
+
down a phone connection, and this is a listening check, not a master.
|
| 84 |
+
"""
|
| 85 |
+
info = sf.info(path)
|
| 86 |
+
sr = info.samplerate
|
| 87 |
+
data, _ = sf.read(path, start=int(start_s * sr), stop=int(end_s * sr),
|
| 88 |
+
dtype="float32")
|
| 89 |
+
stem = os.path.join(out_dir, f"snippet_{int(start_s)}-{int(end_s)}")
|
| 90 |
+
try:
|
| 91 |
+
out = stem + ".mp3"
|
| 92 |
+
sf.write(out, data, sr, format="MP3")
|
| 93 |
+
return out
|
| 94 |
+
except Exception:
|
| 95 |
+
out = stem + ".wav"
|
| 96 |
+
sf.write(out, data, sr)
|
| 97 |
+
return out
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def run(passphrase: str, audio_path: str, progress=gr.Progress()):
|
| 101 |
+
blanks = [gr.update(value=None, visible=False)] * MAX_SNIPPETS
|
| 102 |
+
if not PASSPHRASE:
|
| 103 |
+
return ("<p><b>This Space has no passphrase configured.</b> Set the "
|
| 104 |
+
"MARATHON_LIVE_PASSPHRASE secret before use.</p>", *blanks)
|
| 105 |
+
if not hmac.compare_digest((passphrase or "").strip(), PASSPHRASE):
|
| 106 |
+
return ("<p>Wrong passphrase.</p>", *blanks)
|
| 107 |
+
if not audio_path:
|
| 108 |
+
return ("<p>Choose an audio file (mp3, wav, m4a) to analyse.</p>", *blanks)
|
| 109 |
+
|
| 110 |
+
progress(0.05, desc="loading the model")
|
| 111 |
+
emb = _embedder()
|
| 112 |
+
progress(0.25, desc="listening to the track")
|
| 113 |
+
res = analysis.analyse_track(audio_path, emb, CORPUS, vocab=_state["vocab"],
|
| 114 |
+
snippet_top=MAX_SNIPPETS)
|
| 115 |
+
progress(0.85, desc="cutting snippets")
|
| 116 |
+
label = Path(audio_path).stem
|
| 117 |
+
html = render.render(res, label)
|
| 118 |
+
|
| 119 |
+
tmp = tempfile.mkdtemp(prefix="marathon_snips_")
|
| 120 |
+
players = []
|
| 121 |
+
for i in range(MAX_SNIPPETS):
|
| 122 |
+
if i < len(res["snippets"]):
|
| 123 |
+
p = res["snippets"][i]
|
| 124 |
+
try:
|
| 125 |
+
clip = _clip(audio_path, p["start_s"], p["end_s"], tmp)
|
| 126 |
+
except Exception as exc:
|
| 127 |
+
print(f"[clip] {exc}")
|
| 128 |
+
players.append(gr.update(value=None, visible=False))
|
| 129 |
+
continue
|
| 130 |
+
mk = f' · closest to {p["market"]}' if p["market"] else ""
|
| 131 |
+
players.append(gr.update(
|
| 132 |
+
value=clip, visible=True,
|
| 133 |
+
label=f'{i + 1} · {livematch.fmt_time(p["start_s"])}–'
|
| 134 |
+
f'{livematch.fmt_time(p["end_s"])}{mk} · '
|
| 135 |
+
f'{p["affinity"] * 100:.0f}%'))
|
| 136 |
+
else:
|
| 137 |
+
players.append(gr.update(value=None, visible=False))
|
| 138 |
+
return (html, *players)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
CSS = """
|
| 142 |
+
footer { display: none !important; }
|
| 143 |
+
#intro h1 { font-size: 21px; margin: 0 0 4px; letter-spacing: -.01em; }
|
| 144 |
+
#intro p { font-size: 13.5px; color: #55534e; margin: 0 0 2px; }
|
| 145 |
+
/* Gradio's upload widget renders differently across versions; rather than
|
| 146 |
+
fight its internals we hide the stock copy and write our own prompt. */
|
| 147 |
+
#up .wrap, #up .source-selection { min-height: 0; }
|
| 148 |
+
#up .wrap span, #up .wrap p { display: none; }
|
| 149 |
+
#up .wrap::after { content: "Tap to choose an audio file — mp3, wav or m4a";
|
| 150 |
+
font-size: 14px; color: #55534e; }
|
| 151 |
+
@media (max-width: 640px) { .gradio-container { padding: 8px !important; } }
|
| 152 |
+
"""
|
| 153 |
+
|
| 154 |
+
# Gradio 6 moved `theme` and `css` off the Blocks constructor onto launch().
|
| 155 |
+
with gr.Blocks(title="Marathon — live matcher") as demo:
|
| 156 |
+
gr.HTML(
|
| 157 |
+
'<div id="intro"><h1>Marathon — live matcher</h1>'
|
| 158 |
+
'<p>Upload a track: where it sits against what is charting in each '
|
| 159 |
+
'market this week, which 30 seconds to deliver as the official clip, '
|
| 160 |
+
'and tags for the metadata form.</p>'
|
| 161 |
+
f'<p>Corpus: {CORPUS.week} · {len(CORPUS.sounds):,} charting sounds · '
|
| 162 |
+
f'{len(CORPUS.regions)} markets.</p></div>')
|
| 163 |
+
pw = gr.Textbox(label="Team passphrase", type="password",
|
| 164 |
+
placeholder="the passphrase you were sent")
|
| 165 |
+
audio = gr.Audio(label="Track", sources=["upload"], type="filepath",
|
| 166 |
+
elem_id="up")
|
| 167 |
+
go = gr.Button("Analyse", variant="primary")
|
| 168 |
+
out = gr.HTML()
|
| 169 |
+
snips = [gr.Audio(label=f"Snippet {i + 1}", visible=False,
|
| 170 |
+
interactive=False)
|
| 171 |
+
for i in range(MAX_SNIPPETS)]
|
| 172 |
+
# Named so the deploy check can drive the real Space the way a browser
|
| 173 |
+
# does. Exposing it is safe: every path through `run` checks the
|
| 174 |
+
# passphrase first, and there is nothing to reach without one.
|
| 175 |
+
go.click(run, inputs=[pw, audio], outputs=[out, *snips],
|
| 176 |
+
api_name="analyse")
|
| 177 |
+
|
| 178 |
+
if __name__ == "__main__":
|
| 179 |
+
demo.queue(max_size=8).launch(
|
| 180 |
+
server_name="0.0.0.0",
|
| 181 |
+
server_port=int(os.environ.get("PORT", "7860")),
|
| 182 |
+
css=CSS, theme=gr.themes.Soft())
|
boundaries.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Where a 30-second clip should START — the musical half of the snippet pick.
|
| 2 |
+
|
| 3 |
+
Adapted from audio-brief's `cutpoints.py` (v1, language-agnostic, no ASR),
|
| 4 |
+
© the same project, vendored here rather than imported because audio-brief is
|
| 5 |
+
a separate deployed Space and is not modified by this build.
|
| 6 |
+
|
| 7 |
+
Two things are kept from cutpoints and one is dropped:
|
| 8 |
+
|
| 9 |
+
kept · downbeat phase picked by LOW-BAND (kick) onset energy, so "starts on
|
| 10 |
+
a downbeat" means the real 4/4 phase, not every 4th beat from an
|
| 11 |
+
arbitrary offset.
|
| 12 |
+
kept · per-beat hook score from recurrence COUNT (position-neutral, so the
|
| 13 |
+
first chorus is not penalised for arriving early) plus RMS energy.
|
| 14 |
+
drop · the demucs vocal stem. cutpoints weights it at 0.35, but demucs is a
|
| 15 |
+
second model and several hundred MB of RAM; on a free CPU Space that
|
| 16 |
+
is the difference between working and not. Its weight is folded into
|
| 17 |
+
repetition and energy, and the UI reports the omission.
|
| 18 |
+
|
| 19 |
+
Weights are HAND-SET AND UNFIT — the same status cutpoints reports for its
|
| 20 |
+
own. They order candidate windows; they are not a measurement of anything.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
SR = 22050
|
| 28 |
+
HOP = 512
|
| 29 |
+
W_REP, W_ENE = 0.65, 0.35 # hand-set, unfit; cutpoints' vocal weight folded in
|
| 30 |
+
|
| 31 |
+
# The roster this Space serves is Afrobeats-leaning, where librosa's 120 BPM
|
| 32 |
+
# default prior tends to lock onto a half-time or triplet reading. 110 is a
|
| 33 |
+
# genre-matched prior, not a measurement — the detected tempo is reported so a
|
| 34 |
+
# wrong lock is visible rather than silent.
|
| 35 |
+
START_BPM = 110.0
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _norm(x: np.ndarray) -> np.ndarray:
|
| 39 |
+
x = np.asarray(x, dtype=np.float64)
|
| 40 |
+
lo, hi = float(np.min(x)), float(np.max(x))
|
| 41 |
+
return (x - lo) / (hi - lo) if hi - lo > 1e-9 else np.zeros_like(x)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _norm_rows(m):
|
| 45 |
+
m = np.asarray(m, dtype=np.float64)
|
| 46 |
+
return (m - m.mean(axis=1, keepdims=True)) / (m.std(axis=1, keepdims=True) + 1e-9)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def analyse(path: str) -> dict:
|
| 50 |
+
"""-> {tempo, beat_times, downbeat_times, score (per beat), ok}.
|
| 51 |
+
|
| 52 |
+
Never raises: a track the beat tracker cannot read still gets snippets,
|
| 53 |
+
ranked on trend affinity alone, with `ok: False` so the UI can say so.
|
| 54 |
+
"""
|
| 55 |
+
try:
|
| 56 |
+
return _analyse(path)
|
| 57 |
+
except Exception as exc: # noqa: BLE001 — degradation is the feature
|
| 58 |
+
return {"ok": False, "error": f"{type(exc).__name__}: {exc}",
|
| 59 |
+
"tempo": None, "beat_times": [], "downbeat_times": [],
|
| 60 |
+
"score": []}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _analyse(path: str) -> dict:
|
| 64 |
+
import librosa
|
| 65 |
+
|
| 66 |
+
y, sr = librosa.load(path, sr=SR, mono=True)
|
| 67 |
+
tempo, beat_frames = librosa.beat.beat_track(
|
| 68 |
+
y=y, sr=sr, hop_length=HOP, trim=False, start_bpm=START_BPM)
|
| 69 |
+
beat_times = librosa.frames_to_time(beat_frames, sr=sr, hop_length=HOP)
|
| 70 |
+
if len(beat_times) < 8:
|
| 71 |
+
raise ValueError("too few beats")
|
| 72 |
+
|
| 73 |
+
# 4/4 phase from kick-band onset energy (cutpoints._downbeat_phase)
|
| 74 |
+
mel = librosa.feature.melspectrogram(y=y, sr=sr, hop_length=HOP,
|
| 75 |
+
n_mels=16, fmax=200)
|
| 76 |
+
onset_low = librosa.onset.onset_strength(
|
| 77 |
+
S=librosa.power_to_db(mel), sr=sr, hop_length=HOP)
|
| 78 |
+
bf = np.clip(beat_frames, 0, len(onset_low) - 1)
|
| 79 |
+
beat_onset = onset_low[bf]
|
| 80 |
+
phase = int(np.argmax([
|
| 81 |
+
float(np.mean(beat_onset[p::4])) if len(beat_onset[p::4]) else 0.0
|
| 82 |
+
for p in range(4)]))
|
| 83 |
+
downbeat_times = [float(beat_times[i])
|
| 84 |
+
for i in range(phase, len(beat_times), 4)]
|
| 85 |
+
|
| 86 |
+
# hook score: recurrence count + energy, smoothed over a bar
|
| 87 |
+
chroma = librosa.feature.chroma_cqt(y=y, sr=sr, hop_length=HOP)
|
| 88 |
+
mfcc = librosa.feature.mfcc(y=y, sr=sr, hop_length=HOP, n_mfcc=13)
|
| 89 |
+
feat = np.vstack([
|
| 90 |
+
_norm_rows(librosa.util.sync(chroma, beat_frames, aggregate=np.median)),
|
| 91 |
+
_norm_rows(librosa.util.sync(mfcc, beat_frames, aggregate=np.mean))])
|
| 92 |
+
aff = librosa.segment.recurrence_matrix(feat, mode="affinity", sym=True, width=9)
|
| 93 |
+
nz = aff[aff > 0]
|
| 94 |
+
rep = (np.zeros(aff.shape[0]) if nz.size == 0
|
| 95 |
+
else (aff > float(np.percentile(nz, 75))).astype(np.float64).sum(axis=1))
|
| 96 |
+
rms = librosa.feature.rms(y=y, hop_length=HOP)[0]
|
| 97 |
+
ene = librosa.util.sync(rms[np.newaxis, :], beat_frames, aggregate=np.mean)[0]
|
| 98 |
+
n = min(len(rep), len(ene), len(beat_times))
|
| 99 |
+
score = W_REP * _norm(rep[:n]) + W_ENE * _norm(ene[:n])
|
| 100 |
+
score = np.convolve(score, np.ones(4) / 4, mode="same")
|
| 101 |
+
|
| 102 |
+
return {"ok": True, "tempo": round(float(np.atleast_1d(tempo)[0]), 1),
|
| 103 |
+
"beat_times": [float(t) for t in beat_times[:n]],
|
| 104 |
+
"downbeat_times": downbeat_times,
|
| 105 |
+
"score": [float(x) for x in score]}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def window_scores(an: dict, starts: list[float], window_s: float = 30.0) -> dict:
|
| 109 |
+
"""-> {boundary: [...0-1...], on_downbeat: [...], nearest_downbeat: [...]}.
|
| 110 |
+
|
| 111 |
+
boundary = half "does it start on the downbeat grid", half "is the window
|
| 112 |
+
inside the recurring, high-energy part of the record". With no beat track
|
| 113 |
+
every window scores 0.5 — neutral, so affinity alone decides.
|
| 114 |
+
"""
|
| 115 |
+
if not an.get("ok") or not an.get("downbeat_times"):
|
| 116 |
+
return {"boundary": [0.5] * len(starts),
|
| 117 |
+
"on_downbeat": [None] * len(starts),
|
| 118 |
+
"nearest_downbeat": [None] * len(starts)}
|
| 119 |
+
db = np.asarray(an["downbeat_times"], dtype=np.float64)
|
| 120 |
+
bt = np.asarray(an["beat_times"], dtype=np.float64)
|
| 121 |
+
sc = np.asarray(an["score"], dtype=np.float64)
|
| 122 |
+
sec_per_beat = float(np.median(np.diff(bt))) if len(bt) > 1 else 0.5
|
| 123 |
+
tol = 2.0 * sec_per_beat # within half a bar counts as aligned
|
| 124 |
+
|
| 125 |
+
boundary, on_db, near = [], [], []
|
| 126 |
+
for s in starts:
|
| 127 |
+
d = float(np.min(np.abs(db - s)))
|
| 128 |
+
align = max(0.0, 1.0 - d / tol) if tol > 0 else 0.0
|
| 129 |
+
sel = (bt >= s) & (bt < s + window_s)
|
| 130 |
+
hook = float(np.mean(sc[sel])) if sel.any() else 0.0
|
| 131 |
+
boundary.append(round(0.5 * align + 0.5 * hook, 4))
|
| 132 |
+
on_db.append(round(align, 3))
|
| 133 |
+
near.append(round(float(db[int(np.argmin(np.abs(db - s)))]), 2))
|
| 134 |
+
return {"boundary": boundary, "on_downbeat": on_db,
|
| 135 |
+
"nearest_downbeat": near}
|
clap_embed.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLAP audio + text embedding for the live matcher Space.
|
| 2 |
+
|
| 3 |
+
The tiling here is a deliberate, line-by-line reimplementation of
|
| 4 |
+
audio-brief's `clap_worker.py::_embed` (checkpoint `default-630k`,
|
| 5 |
+
HTSAT-tiny, enable_fusion=False, librosa.load(sr=48000, mono=True), 10 s
|
| 6 |
+
consecutive tiles, mean, L2). That worker produced every vector in the
|
| 7 |
+
exported corpus. If this file drifts from it, every similarity in the app
|
| 8 |
+
becomes quietly wrong while still looking plausible — so `cli verify-live`
|
| 9 |
+
re-embeds a known corpus track through this code and compares against its
|
| 10 |
+
stored database vector before anything ships.
|
| 11 |
+
|
| 12 |
+
Two things beyond the worker:
|
| 13 |
+
|
| 14 |
+
* **A tile cache.** A 30 s window starting on a 5 s grid decomposes into
|
| 15 |
+
exactly three 10 s tiles that also lie on that grid, so the whole snippet
|
| 16 |
+
sweep reuses the tiles the full-track vector already paid for. A 3-minute
|
| 17 |
+
track costs ~37 tile embeddings for the full track *and* ~31 windows,
|
| 18 |
+
instead of ~130. Same numbers, a third of the CPU — which is what makes
|
| 19 |
+
this affordable on a free CPU Space.
|
| 20 |
+
* **The text tower.** `get_text_embedding` rides in the same checkpoint, so
|
| 21 |
+
descriptor tags are scored in the same 512-dim space as the audio, with no
|
| 22 |
+
extra model.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
|
| 29 |
+
CLAP_SR = 48_000 # CLAP is trained at 48 kHz
|
| 30 |
+
CLAP_WINDOW = CLAP_SR * 10 # laion-clap's fixed input length (non-fusion)
|
| 31 |
+
GRID_S = 5 # snippet hop; also the tile-cache grid
|
| 32 |
+
DIM = 512
|
| 33 |
+
CKPT_ID = "default-630k"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def l2(v: np.ndarray) -> np.ndarray:
|
| 37 |
+
n = float(np.linalg.norm(v))
|
| 38 |
+
return v / n if n > 1e-12 else v
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def load_audio(path: str) -> np.ndarray:
|
| 42 |
+
import librosa
|
| 43 |
+
|
| 44 |
+
audio, _ = librosa.load(path, sr=CLAP_SR, mono=True)
|
| 45 |
+
if audio.size == 0:
|
| 46 |
+
raise ValueError("empty audio")
|
| 47 |
+
return audio
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class Embedder:
|
| 51 |
+
"""One loaded CLAP model. Holds the tile cache for the current track."""
|
| 52 |
+
|
| 53 |
+
ckpt = CKPT_ID
|
| 54 |
+
|
| 55 |
+
def __init__(self):
|
| 56 |
+
import laion_clap
|
| 57 |
+
|
| 58 |
+
# HTSAT-tiny is the arch of the auto-downloaded 630k-audioset
|
| 59 |
+
# checkpoint. $LAION_CLAP_MUSIC_CKPT is NOT honoured here: that
|
| 60 |
+
# variable switches to the HTSAT-base music checkpoint, a different
|
| 61 |
+
# vector space, and would silently invalidate every score.
|
| 62 |
+
self._model = laion_clap.CLAP_Module(enable_fusion=False,
|
| 63 |
+
amodel="HTSAT-tiny")
|
| 64 |
+
self._model.load_ckpt(ckpt=_local_ckpt())
|
| 65 |
+
try:
|
| 66 |
+
self._model.model.eval() # no dropout — reproducibility
|
| 67 |
+
except Exception:
|
| 68 |
+
pass
|
| 69 |
+
self._cache: dict[tuple[int, int], np.ndarray] = {}
|
| 70 |
+
|
| 71 |
+
# -- raw calls ---------------------------------------------------------
|
| 72 |
+
def _raw(self, audio: np.ndarray) -> np.ndarray:
|
| 73 |
+
x = np.ascontiguousarray(audio[None, :], dtype=np.float32)
|
| 74 |
+
emb = self._model.get_audio_embedding_from_data(x=x, use_tensor=False)[0]
|
| 75 |
+
return np.asarray(emb, dtype=np.float32)
|
| 76 |
+
|
| 77 |
+
def _tile(self, audio: np.ndarray, s: int, e: int) -> np.ndarray:
|
| 78 |
+
key = (s, e - s)
|
| 79 |
+
hit = self._cache.get(key)
|
| 80 |
+
if hit is not None:
|
| 81 |
+
return hit
|
| 82 |
+
w = audio[s:e]
|
| 83 |
+
if len(w) < CLAP_WINDOW: # pad the last kept tile, as the worker does
|
| 84 |
+
w = np.pad(w, (0, CLAP_WINDOW - len(w)))
|
| 85 |
+
out = self._tile_uncached(w)
|
| 86 |
+
self._cache[key] = out
|
| 87 |
+
return out
|
| 88 |
+
|
| 89 |
+
def _tile_uncached(self, w: np.ndarray) -> np.ndarray:
|
| 90 |
+
return self._raw(w)
|
| 91 |
+
|
| 92 |
+
# -- clap_worker._embed, over an arbitrary span ------------------------
|
| 93 |
+
def embed_span(self, audio: np.ndarray, lo: int, hi: int) -> np.ndarray:
|
| 94 |
+
"""L2-normed vector for audio[lo:hi], identical to what clap_worker
|
| 95 |
+
would return for that span written out as its own file."""
|
| 96 |
+
W = CLAP_WINDOW
|
| 97 |
+
n = hi - lo
|
| 98 |
+
if n <= 0:
|
| 99 |
+
raise ValueError("empty span")
|
| 100 |
+
if n <= W:
|
| 101 |
+
# Short input is never randomly truncated → embed as-is,
|
| 102 |
+
# unpadded, exactly as the worker does.
|
| 103 |
+
return l2(self._raw(audio[lo:hi]))
|
| 104 |
+
tiles = []
|
| 105 |
+
for s in range(lo, hi, W):
|
| 106 |
+
e = min(s + W, hi)
|
| 107 |
+
if e - s < W // 2: # drop a remainder under half a window
|
| 108 |
+
break
|
| 109 |
+
tiles.append(self._tile(audio, s, e))
|
| 110 |
+
return l2(np.mean(tiles, axis=0).astype(np.float32))
|
| 111 |
+
|
| 112 |
+
def embed_track(self, audio: np.ndarray) -> np.ndarray:
|
| 113 |
+
return self.embed_span(audio, 0, len(audio))
|
| 114 |
+
|
| 115 |
+
def embed_windows(self, audio: np.ndarray, window_s: float = 30.0,
|
| 116 |
+
hop_s: float = GRID_S):
|
| 117 |
+
"""-> (starts_seconds, matrix). Only windows that fit entirely inside
|
| 118 |
+
the track are emitted: a short tail window would be embedded under
|
| 119 |
+
different padding rules than the corpus, and a snippet you cannot
|
| 120 |
+
actually deliver is not a recommendation. A track shorter than the
|
| 121 |
+
window yields none at all — claiming a 0:00-0:30 clip of a
|
| 122 |
+
20-second file would be a straightforwardly false recommendation."""
|
| 123 |
+
n = len(audio)
|
| 124 |
+
win = int(round(window_s * CLAP_SR))
|
| 125 |
+
hop = int(round(hop_s * CLAP_SR))
|
| 126 |
+
if n < win:
|
| 127 |
+
return [], np.zeros((0, DIM), dtype=np.float32)
|
| 128 |
+
starts = list(range(0, n - win + 1, hop))
|
| 129 |
+
vecs = [self.embed_span(audio, s, s + win) for s in starts]
|
| 130 |
+
return [s / CLAP_SR for s in starts], np.asarray(vecs, dtype=np.float32)
|
| 131 |
+
|
| 132 |
+
def reset_cache(self) -> None:
|
| 133 |
+
self._cache.clear()
|
| 134 |
+
|
| 135 |
+
# -- text tower --------------------------------------------------------
|
| 136 |
+
def embed_texts(self, texts: list[str]) -> np.ndarray:
|
| 137 |
+
vecs = self._model.get_text_embedding(texts, use_tensor=False)
|
| 138 |
+
arr = np.asarray(vecs, dtype=np.float32)
|
| 139 |
+
return np.stack([l2(v) for v in arr])
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _local_ckpt():
|
| 143 |
+
"""Fetch 630k-audioset-best.pt from the Hub once and cache it.
|
| 144 |
+
|
| 145 |
+
laion_clap's own `load_ckpt()` wgets into its site-packages directory,
|
| 146 |
+
which a Space container discards on every rebuild. hf_hub_download puts
|
| 147 |
+
it in HF_HOME instead, where it survives restarts. Returns None (i.e.
|
| 148 |
+
let laion_clap do its default download) if the Hub is unreachable, so a
|
| 149 |
+
local run without network still works off an already-downloaded copy.
|
| 150 |
+
"""
|
| 151 |
+
try:
|
| 152 |
+
from huggingface_hub import hf_hub_download
|
| 153 |
+
|
| 154 |
+
return hf_hub_download("lukewys/laion_clap", "630k-audioset-best.pt")
|
| 155 |
+
except Exception:
|
| 156 |
+
return None
|
livematch.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Corpus matching without a database — the offline half of the live matcher.
|
| 2 |
+
|
| 3 |
+
`trackmap.rank_against_regions` does this against Postgres + pgvector. The
|
| 4 |
+
hosted Space has no Postgres, only the weekly export (`liveexport.py`): an
|
| 5 |
+
embedding matrix plus per-region index lists. This module runs the *same*
|
| 6 |
+
maths on that export, so the Space and the CLI answer identically.
|
| 7 |
+
|
| 8 |
+
Deliberately dependency-light — numpy only, and **no imports from the
|
| 9 |
+
`marathon` package**. `cli deploy-live` copies this file verbatim into the
|
| 10 |
+
Space repo, where it sits flat next to `app.py` and is imported as
|
| 11 |
+
`livematch`. Anything it imports from a sibling module would break there.
|
| 12 |
+
|
| 13 |
+
Thresholds are duplicated from `exploit.py` rather than imported (that module
|
| 14 |
+
pulls in psycopg). `tests/test_livematch.py` asserts the two never drift.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
|
| 24 |
+
# Keep in lockstep with exploit.PLACE_THRESHOLD / DERIVE_THRESHOLD.
|
| 25 |
+
# First-pass, calibrated on US/global trend data — see exploit.py's docstring.
|
| 26 |
+
PLACE_THRESHOLD = 0.93
|
| 27 |
+
DERIVE_THRESHOLD = 0.78
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def band(sim: float) -> str:
|
| 31 |
+
if sim >= PLACE_THRESHOLD:
|
| 32 |
+
return "place"
|
| 33 |
+
if sim >= DERIVE_THRESHOLD:
|
| 34 |
+
return "derive"
|
| 35 |
+
return "pass"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class Corpus:
|
| 39 |
+
"""The exported week: `emb` (N x 512, L2-normed float32), `sounds`
|
| 40 |
+
(index-aligned metadata) and `regions` (ISO2 -> pool/matched/idx/ranks).
|
| 41 |
+
|
| 42 |
+
Cosine is a plain dot product because every stored vector is L2-normed —
|
| 43 |
+
the same invariant `embed.py` maintains for the database.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
def __init__(self, emb: np.ndarray, meta: dict):
|
| 47 |
+
self.emb = np.ascontiguousarray(emb, dtype=np.float32)
|
| 48 |
+
self.meta = meta
|
| 49 |
+
self.sounds = meta["sounds"]
|
| 50 |
+
self.regions = meta["regions"]
|
| 51 |
+
self.week = meta.get("week")
|
| 52 |
+
self.ckpt = meta.get("ckpt")
|
| 53 |
+
if self.emb.shape[0] != len(self.sounds):
|
| 54 |
+
raise ValueError(
|
| 55 |
+
f"corpus mismatch: {self.emb.shape[0]} vectors, "
|
| 56 |
+
f"{len(self.sounds)} metadata rows")
|
| 57 |
+
|
| 58 |
+
@classmethod
|
| 59 |
+
def load(cls, directory: str | Path) -> "Corpus":
|
| 60 |
+
d = Path(directory)
|
| 61 |
+
with np.load(d / "corpus.npz") as z:
|
| 62 |
+
emb = z["emb"]
|
| 63 |
+
meta = json.loads((d / "corpus.json").read_text())
|
| 64 |
+
return cls(emb, meta)
|
| 65 |
+
|
| 66 |
+
def sims(self, vec) -> np.ndarray:
|
| 67 |
+
"""Cosine of one query vector against every sound. The query is
|
| 68 |
+
re-normalised defensively: an un-normed vector silently rescales
|
| 69 |
+
every score, which is the failure mode that makes a whole run wrong
|
| 70 |
+
without erroring."""
|
| 71 |
+
v = np.asarray(vec, dtype=np.float32).ravel()
|
| 72 |
+
if v.shape[0] != self.emb.shape[1]:
|
| 73 |
+
raise ValueError(
|
| 74 |
+
f"query is {v.shape[0]}-dim, corpus is {self.emb.shape[1]}-dim")
|
| 75 |
+
n = float(np.linalg.norm(v))
|
| 76 |
+
if n <= 1e-12:
|
| 77 |
+
raise ValueError("zero vector cannot be compared")
|
| 78 |
+
return self.emb @ (v / n)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def rank_regions(corpus: Corpus, vec, top: int = 5) -> dict:
|
| 82 |
+
"""-> {week, regions: {ISO2: {name, pool, matched, best, top: [...]}}}.
|
| 83 |
+
|
| 84 |
+
Mirrors `trackmap.rank_against_regions`, including its honesty
|
| 85 |
+
denominators: `pool` is how many distinct sounds chart in that country
|
| 86 |
+
this cycle, `matched` how many of those carry an embedding. A 95% best
|
| 87 |
+
score over 4 of 20 sounds is a different claim than over 20 of 20.
|
| 88 |
+
"""
|
| 89 |
+
sims = corpus.sims(vec)
|
| 90 |
+
out = {}
|
| 91 |
+
for iso, r in corpus.regions.items():
|
| 92 |
+
idx = np.asarray(r["idx"], dtype=np.int64)
|
| 93 |
+
if idx.size == 0:
|
| 94 |
+
continue
|
| 95 |
+
local = sims[idx]
|
| 96 |
+
order = np.argsort(-local)[:top]
|
| 97 |
+
ranks = r.get("ranks") or [None] * len(r["idx"])
|
| 98 |
+
rows = []
|
| 99 |
+
for o in order:
|
| 100 |
+
s = corpus.sounds[int(idx[o])]
|
| 101 |
+
rows.append({
|
| 102 |
+
"artist": s["artist"], "title": s["title"],
|
| 103 |
+
"similarity": round(float(local[o]), 4),
|
| 104 |
+
"rank": ranks[int(o)],
|
| 105 |
+
"deezer_id": s.get("deezer_id"),
|
| 106 |
+
"source": s.get("source"), "chart": s.get("chart"),
|
| 107 |
+
})
|
| 108 |
+
out[iso] = {
|
| 109 |
+
"name": r.get("name", iso),
|
| 110 |
+
"pool": r["pool"], "matched": r["matched"],
|
| 111 |
+
"best": round(float(local[order[0]]), 4),
|
| 112 |
+
"top": rows,
|
| 113 |
+
}
|
| 114 |
+
return {"week": corpus.week, "regions": out}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def pool_index(corpus: Corpus, isos: list[str]) -> np.ndarray:
|
| 118 |
+
"""Union of the sound indices charting in any of `isos`."""
|
| 119 |
+
seen: set[int] = set()
|
| 120 |
+
for iso in isos:
|
| 121 |
+
r = corpus.regions.get(iso)
|
| 122 |
+
if r:
|
| 123 |
+
seen.update(int(i) for i in r["idx"])
|
| 124 |
+
return np.asarray(sorted(seen), dtype=np.int64)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# --- snippet windows -------------------------------------------------------
|
| 128 |
+
#
|
| 129 |
+
# A distributor asks for ONE 30-second official clip per track. Two things
|
| 130 |
+
# make a window a good answer, and they are not the same thing:
|
| 131 |
+
#
|
| 132 |
+
# affinity — how close that 30 seconds sits to the trend pool the track
|
| 133 |
+
# already fits best. Measured, and the reason to prefer one
|
| 134 |
+
# window over another at all.
|
| 135 |
+
# boundary — whether it starts somewhere musical (on a downbeat, inside
|
| 136 |
+
# the repeating, high-energy part of the record) rather than
|
| 137 |
+
# mid-phrase. Derived from audio-brief's cutpoints signals.
|
| 138 |
+
#
|
| 139 |
+
# The blend weights below are HAND-SET AND UNFIT, exactly as cutpoints' own
|
| 140 |
+
# weights are, and the UI says so.
|
| 141 |
+
#
|
| 142 |
+
# Affinities across one track's windows sit in a narrow band — measured on a
|
| 143 |
+
# real master, 35 windows spanned 0.943 to 0.954. Min-max normalising that
|
| 144 |
+
# range was the first attempt and it is wrong: it stretches an 0.001
|
| 145 |
+
# difference, which is noise, into a decisive one, so a window starting
|
| 146 |
+
# mid-phrase beats a downbeat-aligned window for no real reason. Instead the
|
| 147 |
+
# gap to the best window is measured against a FIXED span. 0.03 is a fifth of
|
| 148 |
+
# the 0.15-wide derive→place band, i.e. roughly the smallest similarity
|
| 149 |
+
# difference worth overriding musical sense for; anything further behind than
|
| 150 |
+
# that scores zero on affinity and has to win on boundary alone.
|
| 151 |
+
AFFINITY_SPAN = 0.03
|
| 152 |
+
W_AFFINITY, W_BOUNDARY = 0.70, 0.30
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def rank_windows(
|
| 156 |
+
affinity: list[float],
|
| 157 |
+
boundary: list[float],
|
| 158 |
+
starts: list[float],
|
| 159 |
+
top: int = 4,
|
| 160 |
+
min_gap_s: float = 15.0,
|
| 161 |
+
w_affinity: float = W_AFFINITY,
|
| 162 |
+
w_boundary: float = W_BOUNDARY,
|
| 163 |
+
span: float = AFFINITY_SPAN,
|
| 164 |
+
) -> list[dict]:
|
| 165 |
+
"""Rank 30s window candidates, keeping the picks distinct.
|
| 166 |
+
|
| 167 |
+
Greedy: take the best combined score, then suppress everything starting
|
| 168 |
+
within `min_gap_s` of it, repeat. Five near-identical windows around one
|
| 169 |
+
peak is not a shortlist.
|
| 170 |
+
"""
|
| 171 |
+
if not affinity:
|
| 172 |
+
return []
|
| 173 |
+
a = np.asarray(affinity, dtype=np.float64)
|
| 174 |
+
b = np.asarray(boundary, dtype=np.float64)
|
| 175 |
+
a_norm = np.clip(1.0 - (float(a.max()) - a) / max(span, 1e-9), 0.0, 1.0)
|
| 176 |
+
combined = w_affinity * a_norm + w_boundary * b
|
| 177 |
+
order = np.argsort(-combined)
|
| 178 |
+
picked: list[dict] = []
|
| 179 |
+
for i in order:
|
| 180 |
+
s = float(starts[i])
|
| 181 |
+
if any(abs(s - p["start_s"]) < min_gap_s for p in picked):
|
| 182 |
+
continue
|
| 183 |
+
picked.append({
|
| 184 |
+
"start_s": round(s, 2),
|
| 185 |
+
"affinity": round(float(a[i]), 4),
|
| 186 |
+
"affinity_rel": round(float(a_norm[i]), 3),
|
| 187 |
+
"boundary": round(float(b[i]), 3),
|
| 188 |
+
"score": round(float(combined[i]), 3),
|
| 189 |
+
"band": band(float(a[i])),
|
| 190 |
+
})
|
| 191 |
+
if len(picked) >= top:
|
| 192 |
+
break
|
| 193 |
+
return picked
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def fmt_time(t: float) -> str:
|
| 197 |
+
t = max(0.0, float(t))
|
| 198 |
+
return f"{int(t // 60)}:{int(round(t % 60)):02d}"
|
packages.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
ffmpeg
|
render.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Analysis dict → one mobile-first HTML block.
|
| 2 |
+
|
| 3 |
+
Same visual language and the same honesty labels as the weekly drop sheet
|
| 4 |
+
(`marathon/static/signals.html`) and the trend map (`trackmap.render_map`):
|
| 5 |
+
green = place, orange = derive, grey = pass, verdict cutoffs always carried
|
| 6 |
+
next to the chip, denominators always shown.
|
| 7 |
+
|
| 8 |
+
Deezer previews are resolved server-side into plain <audio src> tags. The
|
| 9 |
+
drop sheet resolves them client-side via JSONP, but scripts injected into a
|
| 10 |
+
Gradio HTML component do not run, and the preview URLs Deezer returns expire
|
| 11 |
+
within about a day — which is fine here, because the person listening is the
|
| 12 |
+
person who just pressed the button.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import html
|
| 18 |
+
import json
|
| 19 |
+
import urllib.request
|
| 20 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 21 |
+
|
| 22 |
+
import livematch
|
| 23 |
+
|
| 24 |
+
ESC = html.escape
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def deezer_previews(ids: list[int], timeout: float = 6.0) -> dict[int, str]:
|
| 28 |
+
ids = [i for i in dict.fromkeys(ids) if i]
|
| 29 |
+
if not ids:
|
| 30 |
+
return {}
|
| 31 |
+
|
| 32 |
+
def one(tid):
|
| 33 |
+
try:
|
| 34 |
+
with urllib.request.urlopen(
|
| 35 |
+
f"https://api.deezer.com/track/{tid}", timeout=timeout) as r:
|
| 36 |
+
return tid, (json.load(r) or {}).get("preview") or None
|
| 37 |
+
except Exception:
|
| 38 |
+
return tid, None
|
| 39 |
+
|
| 40 |
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
| 41 |
+
return {t: u for t, u in pool.map(one, ids) if u}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
CSS = """
|
| 45 |
+
<style>
|
| 46 |
+
.ml { --bg:#fbfbf9; --card:#fff; --ink:#141417; --muted:#55534e;
|
| 47 |
+
--line:#e5e3dc; --place:#0e9f6e; --derive:#f2610d; --pass:#8d8a82;
|
| 48 |
+
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
|
| 49 |
+
color:var(--ink); max-width:760px; margin:0 auto; }
|
| 50 |
+
.ml * { box-sizing:border-box; }
|
| 51 |
+
.ml h2 { font-size:13px; letter-spacing:.14em; text-transform:uppercase;
|
| 52 |
+
color:#97948c; margin:26px 0 10px; font-weight:700; }
|
| 53 |
+
.ml .hdr { font-size:20px; font-weight:700; margin:0 0 2px; letter-spacing:-.01em; }
|
| 54 |
+
.ml .sub { color:var(--muted); font-size:13px; margin-bottom:6px; }
|
| 55 |
+
.ml .card { background:var(--card); border:1px solid var(--line);
|
| 56 |
+
border-radius:14px; padding:13px 14px; margin-bottom:9px; }
|
| 57 |
+
.ml .mkt { display:flex; align-items:center; gap:8px; font-weight:650; font-size:15px; }
|
| 58 |
+
.ml .best { margin-left:auto; font-variant-numeric:tabular-nums; font-weight:700; }
|
| 59 |
+
.ml .chip { font-size:10.5px; font-weight:800; letter-spacing:.08em;
|
| 60 |
+
padding:2px 7px; border-radius:999px; color:#fff; }
|
| 61 |
+
.ml .chip.place { background:var(--place); } .ml .chip.derive { background:var(--derive); }
|
| 62 |
+
.ml .chip.pass { background:var(--pass); }
|
| 63 |
+
.ml .den { color:var(--muted); font-size:11.5px; margin:3px 0 8px; }
|
| 64 |
+
.ml .row { display:flex; align-items:center; gap:8px; padding:4px 0;
|
| 65 |
+
font-size:13px; border-top:1px solid var(--line); }
|
| 66 |
+
.ml .row:first-of-type { border-top:0; }
|
| 67 |
+
.ml .pct { width:38px; font-variant-numeric:tabular-nums; color:var(--muted); flex:none; }
|
| 68 |
+
.ml .lbl { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
| 69 |
+
.ml .rk { color:var(--muted); font-size:11.5px; flex:none; }
|
| 70 |
+
.ml audio { height:30px; max-width:150px; flex:none; }
|
| 71 |
+
.ml .snip { display:flex; align-items:baseline; gap:9px; flex-wrap:wrap; }
|
| 72 |
+
.ml .time { font-size:17px; font-weight:700; font-variant-numeric:tabular-nums; }
|
| 73 |
+
.ml .why { color:var(--muted); font-size:12.5px; margin-top:6px; }
|
| 74 |
+
.ml .tags { display:flex; flex-wrap:wrap; gap:6px; margin:4px 0 2px; }
|
| 75 |
+
.ml .tag { border:1px solid var(--line); border-radius:999px; padding:3px 9px;
|
| 76 |
+
font-size:12.5px; background:#f6f5f1; }
|
| 77 |
+
.ml .tag b { font-weight:650; } .ml .tag i { font-style:normal; color:var(--muted);
|
| 78 |
+
font-variant-numeric:tabular-nums; font-size:11px; margin-left:5px; }
|
| 79 |
+
.ml .copy { width:100%; font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
|
| 80 |
+
border:1px solid var(--line); border-radius:10px; padding:9px 10px;
|
| 81 |
+
background:#f6f5f1; color:var(--ink); resize:vertical; }
|
| 82 |
+
.ml .note { color:var(--muted); font-size:12.5px; margin-top:8px; }
|
| 83 |
+
.ml .caveats { border-top:1px solid var(--line); margin-top:24px; padding-top:12px;
|
| 84 |
+
color:var(--muted); font-size:12.5px; }
|
| 85 |
+
.ml .caveats li { margin-bottom:5px; }
|
| 86 |
+
.ml .foot { color:#97948c; font-size:11.5px; margin-top:14px; }
|
| 87 |
+
/* On a phone the native audio control eats ~150px, which truncated every
|
| 88 |
+
record to "Diamond Pla…". Give the title the full width and drop the
|
| 89 |
+
player onto its own line. */
|
| 90 |
+
@media (max-width: 560px) {
|
| 91 |
+
.ml .row { flex-wrap:wrap; padding:7px 0; }
|
| 92 |
+
.ml .lbl { white-space:normal; overflow:visible; }
|
| 93 |
+
.ml audio { flex:1 0 100%; max-width:100%; margin-top:5px; }
|
| 94 |
+
}
|
| 95 |
+
@media (prefers-color-scheme: dark) {
|
| 96 |
+
.ml { --bg:#161615; --card:#1d1d1b; --ink:#f5f4f0; --muted:#b0ada4;
|
| 97 |
+
--line:#333230; --pass:#7c7a73; }
|
| 98 |
+
.ml .tag, .ml .copy { background:#232321; }
|
| 99 |
+
.ml h2 { color:#8a877f; }
|
| 100 |
+
}
|
| 101 |
+
</style>
|
| 102 |
+
"""
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _pool_line(r: dict) -> str:
|
| 106 |
+
return (f'{r["matched"]} of {r["pool"]} sounds charting there this cycle '
|
| 107 |
+
f'were compared')
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def render(res: dict, track_label: str) -> str:
|
| 111 |
+
regions = res["regions"]
|
| 112 |
+
ids = [m.get("deezer_id")
|
| 113 |
+
for iso in res["shown_markets"]
|
| 114 |
+
for m in regions[iso]["top"][:3]]
|
| 115 |
+
ids += [p["nearest"]["deezer_id"] for p in res["snippets"]
|
| 116 |
+
if p.get("nearest") and p["nearest"].get("deezer_id")]
|
| 117 |
+
previews = deezer_previews(ids)
|
| 118 |
+
|
| 119 |
+
out = [CSS, '<div class="ml">']
|
| 120 |
+
out.append(f'<div class="hdr">{ESC(track_label)}</div>')
|
| 121 |
+
c = res["corpus"]
|
| 122 |
+
out.append(
|
| 123 |
+
f'<div class="sub">{res["duration_s"]:.0f}s · compared against '
|
| 124 |
+
f'{c["sounds"]:,} charting sounds in {c["regions"]} markets · '
|
| 125 |
+
f'chart week {ESC(str(res["week"]))}</div>')
|
| 126 |
+
|
| 127 |
+
# -- markets ----------------------------------------------------------
|
| 128 |
+
out.append('<h2>Where it fits</h2>')
|
| 129 |
+
for iso in res["shown_markets"]:
|
| 130 |
+
r = regions[iso]
|
| 131 |
+
b = livematch.band(r["best"])
|
| 132 |
+
out.append('<div class="card">')
|
| 133 |
+
out.append(
|
| 134 |
+
f'<div class="mkt"><span>{ESC(r["name"])} <span class="rk">{ESC(iso)}</span></span>'
|
| 135 |
+
f'<span class="chip {b}">{b.upper()}</span>'
|
| 136 |
+
f'<span class="best">{r["best"]*100:.0f}%</span></div>')
|
| 137 |
+
out.append(f'<div class="den">{_pool_line(r)}</div>')
|
| 138 |
+
for m in r["top"][:3]:
|
| 139 |
+
url = previews.get(m.get("deezer_id"))
|
| 140 |
+
player = (f'<audio controls preload="none" src="{ESC(url)}"></audio>'
|
| 141 |
+
if url else '')
|
| 142 |
+
rank = f'#{m["rank"]}' if m.get("rank") else ''
|
| 143 |
+
out.append(
|
| 144 |
+
f'<div class="row"><span class="pct">{m["similarity"]*100:.0f}%</span>'
|
| 145 |
+
f'<span class="lbl">{ESC(m["artist"] or "—")} — {ESC(m["title"] or "—")}</span>'
|
| 146 |
+
f'<span class="rk">{ESC(rank)}</span>{player}</div>')
|
| 147 |
+
out.append('</div>')
|
| 148 |
+
|
| 149 |
+
# -- snippets ---------------------------------------------------------
|
| 150 |
+
out.append('<h2>Snippet recommendations · 30 seconds</h2>')
|
| 151 |
+
if not res["snippets"]:
|
| 152 |
+
out.append(
|
| 153 |
+
f'<div class="card">This file is {res["duration_s"]:.0f} seconds '
|
| 154 |
+
f'long — shorter than the 30-second clip a distributor asks for, '
|
| 155 |
+
f'so there is no window to choose. The market ranking above used '
|
| 156 |
+
f'the whole file.</div>')
|
| 157 |
+
for i, p in enumerate(res["snippets"], 1):
|
| 158 |
+
t0, t1 = livematch.fmt_time(p["start_s"]), livematch.fmt_time(p["end_s"])
|
| 159 |
+
mk = f'{ESC(p["market_name"])} ({ESC(p["market"])})' if p["market"] else '—'
|
| 160 |
+
out.append('<div class="card"><div class="snip">'
|
| 161 |
+
f'<span class="rk">{i}</span>'
|
| 162 |
+
f'<span class="time">{t0}–{t1}</span>'
|
| 163 |
+
f'<span class="chip {p["band"]}">{p["band"].upper()}</span>'
|
| 164 |
+
f'<span class="best">{p["affinity"]*100:.0f}%</span></div>')
|
| 165 |
+
near = p.get("nearest")
|
| 166 |
+
near_s = (f' Closest charting sound in that window: '
|
| 167 |
+
f'{ESC(near["artist"] or "—")} — {ESC(near["title"] or "—")}.'
|
| 168 |
+
if near else '')
|
| 169 |
+
dbeat = ''
|
| 170 |
+
if p.get("nearest_downbeat") is not None:
|
| 171 |
+
# Windows land on a 5-second grid, so the nearest downbeat is
|
| 172 |
+
# usually a fraction of a second away — quote the offset, not a
|
| 173 |
+
# rounded clock time that reads as the start you already have.
|
| 174 |
+
delta = p["nearest_downbeat"] - p["start_s"]
|
| 175 |
+
if abs(delta) < 0.06:
|
| 176 |
+
dbeat = ' Starts on the downbeat.'
|
| 177 |
+
else:
|
| 178 |
+
dbeat = (f' Nudge the start {abs(delta):.2f}s '
|
| 179 |
+
f'{"later" if delta > 0 else "earlier"} '
|
| 180 |
+
f'(to {p["nearest_downbeat"]:.2f}s) to land on the bar.')
|
| 181 |
+
out.append(
|
| 182 |
+
f'<div class="why">Closest to the {mk} trend pool at '
|
| 183 |
+
f'{p["affinity"]*100:.0f}%.{near_s}{dbeat}</div></div>')
|
| 184 |
+
if res["snippets"]:
|
| 185 |
+
out.append(
|
| 186 |
+
f'<div class="note">{res["window_count"]} windows were scanned at a '
|
| 187 |
+
f'5-second hop. Ranking blends how close a window sits to the trend '
|
| 188 |
+
f'pools above (70%) with a musical-boundary score — downbeat '
|
| 189 |
+
f'alignment plus how repeating and energetic that part of the record '
|
| 190 |
+
f'is (30%). Those weights are hand-set and unfitted. The vocal-stem '
|
| 191 |
+
f'signal audio-brief\'s cutpoints uses is not computed here.</div>')
|
| 192 |
+
|
| 193 |
+
# -- tags -------------------------------------------------------------
|
| 194 |
+
out.append('<h2>Suggested tags</h2>')
|
| 195 |
+
t = res["tags"]
|
| 196 |
+
if t.get("model"):
|
| 197 |
+
out.append('<div class="card"><div class="den">Model-suggested — CLAP '
|
| 198 |
+
'zero-shot on the audio. Ordering is the signal; the numbers '
|
| 199 |
+
'are cosine similarities, not probabilities. Check these '
|
| 200 |
+
'before pasting: the model separates broad families well '
|
| 201 |
+
'(an Afrobeats record sits far from techno or ambient) but '
|
| 202 |
+
'is unreliable at naming a specific genre — on our own test '
|
| 203 |
+
'records it ranked "latin pop" alongside afrobeats, and '
|
| 204 |
+
'missed gospel entirely on a gospel track.</div>')
|
| 205 |
+
for group in ("genre", "mood", "production"):
|
| 206 |
+
rows = t["model"]["by_group"].get(group) or []
|
| 207 |
+
if not rows:
|
| 208 |
+
continue
|
| 209 |
+
chips = "".join(
|
| 210 |
+
f'<span class="tag"><b>{ESC(r["tag"])}</b><i>{r["score"]:.2f}</i></span>'
|
| 211 |
+
for r in rows)
|
| 212 |
+
out.append(f'<div class="den" style="margin-bottom:2px">{group}</div>'
|
| 213 |
+
f'<div class="tags">{chips}</div>')
|
| 214 |
+
out.append('</div>')
|
| 215 |
+
else:
|
| 216 |
+
out.append('<div class="card"><div class="den">Model-suggested tags are '
|
| 217 |
+
'switched off — the zero-shot text tower did not separate the '
|
| 218 |
+
'vocabulary well enough on the reference check to be worth '
|
| 219 |
+
'pasting into metadata.</div></div>')
|
| 220 |
+
|
| 221 |
+
tr = t["trend"]
|
| 222 |
+
out.append('<div class="card"><div class="den">Trend-derived — read off '
|
| 223 |
+
'this week\'s corpus, not guessed from the audio.</div>')
|
| 224 |
+
chips = "".join(
|
| 225 |
+
f'<span class="tag"><b>{ESC(m["iso"])}</b><i>{m["best"]*100:.0f}%</i></span>'
|
| 226 |
+
for m in tr["markets"][:8])
|
| 227 |
+
out.append(f'<div class="den" style="margin-bottom:2px">markets it sits '
|
| 228 |
+
f'nearest</div><div class="tags">{chips}</div>')
|
| 229 |
+
if tr["surfaces"]:
|
| 230 |
+
out.append('<div class="den" style="margin-bottom:2px">chart surfaces '
|
| 231 |
+
'the nearest sounds trend on</div><div class="tags">'
|
| 232 |
+
+ "".join(f'<span class="tag">{ESC(s)}</span>'
|
| 233 |
+
for s in tr["surfaces"]) + '</div>')
|
| 234 |
+
if tr["genres"]:
|
| 235 |
+
out.append('<div class="den" style="margin-bottom:2px">genre slugs from '
|
| 236 |
+
'the charts</div><div class="tags">'
|
| 237 |
+
+ "".join(f'<span class="tag">{ESC(g)}</span>'
|
| 238 |
+
for g in tr["genres"]) + '</div>')
|
| 239 |
+
else:
|
| 240 |
+
out.append(f'<div class="note">No genre tag is derived from the charts: '
|
| 241 |
+
f'{c["genre_labelled"]} of {c["sounds"]:,} sounds in this '
|
| 242 |
+
f'week\'s corpus carry a genre slug — the YouTube country '
|
| 243 |
+
f'charts publish none.</div>')
|
| 244 |
+
out.append('</div>')
|
| 245 |
+
|
| 246 |
+
out.append('<div class="card"><div class="den">Copy line for the metadata '
|
| 247 |
+
'form</div>'
|
| 248 |
+
f'<textarea class="copy" rows="3" readonly>{ESC(t["copy_line"])}'
|
| 249 |
+
'</textarea></div>')
|
| 250 |
+
|
| 251 |
+
# -- caveats ----------------------------------------------------------
|
| 252 |
+
tempo = res["beat"].get("tempo")
|
| 253 |
+
out.append(
|
| 254 |
+
'<div class="caveats"><b>Read this with the numbers.</b><ul>'
|
| 255 |
+
'<li><b>Similarity is not a hit prediction.</b> It measures how close '
|
| 256 |
+
'two recordings sound in texture. It says nothing about whether a '
|
| 257 |
+
'record will perform.</li>'
|
| 258 |
+
f'<li><b>Cutoffs are first-pass and US-calibrated.</b> Over '
|
| 259 |
+
f'{livematch.PLACE_THRESHOLD:.0%} reads as a direct fit, '
|
| 260 |
+
f'{livematch.DERIVE_THRESHOLD:.0%}–{livematch.PLACE_THRESHOLD:.0%} as '
|
| 261 |
+
f'remix or edit territory, below that as no adjacency. They were fitted '
|
| 262 |
+
f'on US and global trend data and have not been re-fitted for African '
|
| 263 |
+
f'markets.</li>'
|
| 264 |
+
'<li><b>One week of charts.</b> A single snapshot cannot tell a rising '
|
| 265 |
+
'sound from a fading one, so there are no trend arrows here.</li>'
|
| 266 |
+
'<li><b>Chart sounds are 30-second previews; your upload is the full '
|
| 267 |
+
'master.</b> A full-length vector averages over more of the record and '
|
| 268 |
+
'scores higher against everything, so read the ordering rather than '
|
| 269 |
+
'comparing one track\'s absolute numbers with another\'s.</li>'
|
| 270 |
+
'<li><b>The model hears texture only</b> — not lyrics, language, who is '
|
| 271 |
+
'on the record, or whether a market would accept it.</li>'
|
| 272 |
+
+ (f'<li><b>Tempo read as {tempo:.0f} BPM</b> with an Afrobeats-leaning '
|
| 273 |
+
f'110 BPM prior; a wrong read only shifts the downbeat suggestions, '
|
| 274 |
+
f'not the market ranking.</li>' if tempo else
|
| 275 |
+
'<li><b>The beat tracker could not read this file</b>, so snippet '
|
| 276 |
+
'ranking used trend affinity alone.</li>')
|
| 277 |
+
+ '</ul></div>')
|
| 278 |
+
out.append(
|
| 279 |
+
f'<div class="foot">corpus: {ESC(str(c["week"]))} · {c["sounds"]:,} sounds · '
|
| 280 |
+
f'{c["regions"]} markets · sources: {ESC(", ".join(c["sources"]) or "—")} · '
|
| 281 |
+
f'exported {ESC(str(c["generated_at"]))}</div>')
|
| 282 |
+
out.append('</div>')
|
| 283 |
+
return "".join(out)
|
requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Marathon live matcher — Space runtime.
|
| 2 |
+
#
|
| 3 |
+
# The pins that matter, learned from audio-brief's own build:
|
| 4 |
+
# * laion-clap imports torchvision at runtime but does NOT declare it, so
|
| 5 |
+
# it must be listed explicitly or the first embed fails at call time.
|
| 6 |
+
# * laion-clap constrains numpy<2 (resolves to 1.26.x). librosa is fine
|
| 7 |
+
# with that; pinning it here makes the resolution explicit rather than
|
| 8 |
+
# a surprise from a transitive constraint.
|
| 9 |
+
# The checkpoint itself is not vendored — clap_embed pulls
|
| 10 |
+
# lukewys/laion_clap:630k-audioset-best.pt into HF_HOME on first use.
|
| 11 |
+
|
| 12 |
+
gradio==6.19.0
|
| 13 |
+
laion-clap
|
| 14 |
+
torchvision
|
| 15 |
+
librosa>=0.10.1
|
| 16 |
+
soundfile>=0.12
|
| 17 |
+
numpy>=1.24,<2
|
| 18 |
+
huggingface_hub
|
tags.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Suggested tags for distributor metadata forms.
|
| 2 |
+
|
| 3 |
+
Two groups, kept visually and factually apart because they are different
|
| 4 |
+
kinds of claim:
|
| 5 |
+
|
| 6 |
+
model-suggested — CLAP zero-shot. The checkpoint's text tower embeds a
|
| 7 |
+
curated vocabulary once at startup; the uploaded track's audio vector is
|
| 8 |
+
scored against it by cosine, in the same 512-dim space. This is the
|
| 9 |
+
model's opinion about texture. It has no idea what language the vocal is
|
| 10 |
+
in, who is on the record, or what a market calls the style.
|
| 11 |
+
|
| 12 |
+
trend-derived — read straight off the corpus: which markets the track's
|
| 13 |
+
nearest charting neighbours are trending in, on which chart surfaces,
|
| 14 |
+
and their genre slugs where the source publishes any. Grounded in this
|
| 15 |
+
week's data, with the same limits as the rest of the scan.
|
| 16 |
+
|
| 17 |
+
CLAP audio↔text cosines sit in a narrow band (roughly 0.15–0.45) and are NOT
|
| 18 |
+
probabilities. The ordering is the signal; the number is shown so the gap
|
| 19 |
+
between #1 and #10 is visible, not so anyone reads 0.31 as 31% confidence.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
|
| 26 |
+
# CLAP's text tower was trained on captions, so a sentence beats a bare word,
|
| 27 |
+
# and the sentence itself moves the answer more than is comfortable. Four
|
| 28 |
+
# templates were scored against known-genre probes from the corpus
|
| 29 |
+
# (2026-08-21, checkpoint default-630k, genre vocabulary only, top-6 shown):
|
| 30 |
+
#
|
| 31 |
+
# "a {} song" YO YO → afrobeats .52 FIRST; Davido "Guide"
|
| 32 |
+
# → afrobeats tied first .53; KATSEYE → pop,
|
| 33 |
+
# k-pop 2nd
|
| 34 |
+
# "This is a {} music track." YO YO → latin pop .474 first, afrobeats 4th;
|
| 35 |
+
# Davido → latin pop first
|
| 36 |
+
# "This audio is {} music." YO YO → afrobeats first; Davido → afrobeats
|
| 37 |
+
# first; but gospel probe → indie pop, pop
|
| 38 |
+
# "{}" worst everywhere; reggae and bollywood
|
| 39 |
+
# surface on Afrobeats records
|
| 40 |
+
#
|
| 41 |
+
# "a {} song" wins on the probes and is what ships. The measurement that
|
| 42 |
+
# matters more is what it did NOT fix: on Bad Bunny the top tag is "afro
|
| 43 |
+
# house", and on a live gospel record "gospel" and "worship" appear nowhere
|
| 44 |
+
# in the top six under any template. Family-level separation is real
|
| 45 |
+
# (afro-family vs techno/ambient clears by ~0.09 cosine); specific genre
|
| 46 |
+
# naming is not reliable, and the UI says so rather than implying otherwise.
|
| 47 |
+
TEMPLATE = "a {} song"
|
| 48 |
+
|
| 49 |
+
VOCAB: dict[str, list[str]] = {
|
| 50 |
+
"genre": [
|
| 51 |
+
"afrobeats", "afro pop", "afro house", "amapiano", "afro swing",
|
| 52 |
+
"gengetone", "bongo flava", "genge", "kwaito", "gqom", "azonto",
|
| 53 |
+
"highlife", "afro fusion", "afro soul", "dancehall", "reggae",
|
| 54 |
+
"soca", "kompa", "coupé-décalé", "reggaeton", "latin pop",
|
| 55 |
+
"hip hop", "trap", "drill", "uk drill", "grime", "r&b",
|
| 56 |
+
"contemporary r&b", "pop", "dance pop", "house", "deep house",
|
| 57 |
+
"tech house", "techno", "electro", "drum and bass", "jungle",
|
| 58 |
+
"dubstep", "garage", "uk funky", "gospel", "worship", "soul",
|
| 59 |
+
"funk", "jazz", "rock", "indie pop", "country", "k-pop",
|
| 60 |
+
"bollywood", "arabic pop", "lo-fi hip hop", "ambient",
|
| 61 |
+
],
|
| 62 |
+
"mood": [
|
| 63 |
+
"uplifting", "euphoric", "energetic", "confident", "playful",
|
| 64 |
+
"romantic", "sensual", "melancholic", "sad", "nostalgic",
|
| 65 |
+
"laid-back", "hypnotic", "dark", "aggressive", "triumphant",
|
| 66 |
+
"spiritual", "carefree", "moody", "dreamy", "tense",
|
| 67 |
+
],
|
| 68 |
+
"production": [
|
| 69 |
+
"vocal-led", "instrumental", "male vocal", "female vocal",
|
| 70 |
+
"group vocal", "call and response", "acoustic", "electronic",
|
| 71 |
+
"percussive", "log drum bassline", "808 bass", "guitar-led",
|
| 72 |
+
"piano-led", "synth-led", "horn section", "auto-tuned vocal",
|
| 73 |
+
"sparse arrangement", "dense arrangement", "live drums",
|
| 74 |
+
"programmed drums", "sped-up", "slowed and reverbed",
|
| 75 |
+
"lo-fi production", "polished production", "danceable",
|
| 76 |
+
"mid-tempo", "up-tempo", "down-tempo",
|
| 77 |
+
],
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
# Groups whose members are mutually exclusive enough that a softmax-style
|
| 81 |
+
# read would mislead; we never softmax, but we do cap how many of each group
|
| 82 |
+
# reaches the copy line.
|
| 83 |
+
TOP_PER_GROUP = {"genre": 4, "mood": 3, "production": 4}
|
| 84 |
+
|
| 85 |
+
# Within a group, drop anything trailing the group's leader by more than this.
|
| 86 |
+
# Measured reason: on YO YO the production group returned "danceable" at 0.34
|
| 87 |
+
# and then "electronic" 0.15, "percussive" 0.15, "acoustic" 0.14 — a flat tail
|
| 88 |
+
# that includes two contradictory tags. Filling a quota with a flat tail puts
|
| 89 |
+
# noise into someone's metadata form. 0.10 is hand-set, like everything else
|
| 90 |
+
# here; the effect is only ever to show fewer tags, never different ones.
|
| 91 |
+
TAG_MARGIN = 0.10
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class TagVocab:
|
| 95 |
+
"""Text-tower vectors for the vocabulary, embedded once."""
|
| 96 |
+
|
| 97 |
+
def __init__(self, embedder):
|
| 98 |
+
self.terms: list[tuple[str, str]] = [
|
| 99 |
+
(group, term) for group, terms in VOCAB.items() for term in terms]
|
| 100 |
+
prompts = [TEMPLATE.format(t) for _, t in self.terms]
|
| 101 |
+
self.vectors = embedder.embed_texts(prompts) # (T, 512), L2-normed
|
| 102 |
+
|
| 103 |
+
def score(self, audio_vec) -> list[dict]:
|
| 104 |
+
v = np.asarray(audio_vec, dtype=np.float32).ravel()
|
| 105 |
+
n = float(np.linalg.norm(v))
|
| 106 |
+
if n <= 1e-12:
|
| 107 |
+
raise ValueError("zero vector cannot be scored")
|
| 108 |
+
sims = self.vectors @ (v / n)
|
| 109 |
+
out = [{"group": g, "tag": t, "score": round(float(s), 4)}
|
| 110 |
+
for (g, t), s in zip(self.terms, sims)]
|
| 111 |
+
out.sort(key=lambda r: -r["score"])
|
| 112 |
+
return out
|
| 113 |
+
|
| 114 |
+
def top(self, audio_vec, per_group: dict | None = None,
|
| 115 |
+
margin: float = TAG_MARGIN) -> dict:
|
| 116 |
+
per_group = per_group or TOP_PER_GROUP
|
| 117 |
+
ranked = self.score(audio_vec)
|
| 118 |
+
picked: dict[str, list[dict]] = {g: [] for g in VOCAB}
|
| 119 |
+
for r in ranked:
|
| 120 |
+
bucket = picked[r["group"]]
|
| 121 |
+
if len(bucket) >= per_group.get(r["group"], 3):
|
| 122 |
+
continue
|
| 123 |
+
if bucket and r["score"] < bucket[0]["score"] - margin:
|
| 124 |
+
continue # flat tail — see TAG_MARGIN
|
| 125 |
+
bucket.append(r)
|
| 126 |
+
return {"by_group": picked, "ranked": ranked}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def discrimination_check(vocab: TagVocab, audio_vec,
|
| 130 |
+
expect_above: list[str],
|
| 131 |
+
expect_below: list[str]) -> dict:
|
| 132 |
+
"""Does the text tower actually separate anything on this vector?
|
| 133 |
+
|
| 134 |
+
Used by `cli verify-live` on a known Afrobeats reference: if the
|
| 135 |
+
afrobeats family does not outrank e.g. techno, zero-shot tagging is noise
|
| 136 |
+
on this checkpoint and the honest move is to ship group two only.
|
| 137 |
+
"""
|
| 138 |
+
ranked = {r["tag"]: r["score"] for r in vocab.score(audio_vec)}
|
| 139 |
+
above = {t: ranked.get(t) for t in expect_above}
|
| 140 |
+
below = {t: ranked.get(t) for t in expect_below}
|
| 141 |
+
lo_above = min(v for v in above.values() if v is not None)
|
| 142 |
+
hi_below = max(v for v in below.values() if v is not None)
|
| 143 |
+
return {"above": above, "below": below,
|
| 144 |
+
"margin": round(lo_above - hi_below, 4),
|
| 145 |
+
"passed": lo_above > hi_below}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def trend_tags(regions: dict, top_isos: list[str], corpus_sounds: list[dict],
|
| 149 |
+
neighbour_idx: list[int]) -> dict:
|
| 150 |
+
"""Tags read off the corpus rather than guessed from audio."""
|
| 151 |
+
markets = [{"iso": iso, "name": regions[iso]["name"],
|
| 152 |
+
"best": regions[iso]["best"]}
|
| 153 |
+
for iso in top_isos if iso in regions]
|
| 154 |
+
surfaces, genres = [], []
|
| 155 |
+
for i in neighbour_idx:
|
| 156 |
+
s = corpus_sounds[i]
|
| 157 |
+
if s.get("chart") and s["chart"] not in surfaces:
|
| 158 |
+
surfaces.append(s["chart"])
|
| 159 |
+
for g in s.get("genres") or []:
|
| 160 |
+
if g not in genres:
|
| 161 |
+
genres.append(g)
|
| 162 |
+
return {"markets": markets, "surfaces": surfaces, "genres": genres}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def copy_line(model_tags: dict, trend: dict) -> str:
|
| 166 |
+
"""One comma-separated line, ready to paste into a metadata field."""
|
| 167 |
+
parts = [r["tag"] for g in ("genre", "mood", "production")
|
| 168 |
+
for r in model_tags["by_group"].get(g, [])]
|
| 169 |
+
parts += trend.get("genres") or []
|
| 170 |
+
parts += [m["iso"] for m in trend.get("markets", [])[:5]]
|
| 171 |
+
seen, out = set(), []
|
| 172 |
+
for p in parts:
|
| 173 |
+
if p not in seen:
|
| 174 |
+
seen.add(p)
|
| 175 |
+
out.append(p)
|
| 176 |
+
return ", ".join(out)
|
verify.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prove the Space's embeddings match the corpus before anything ships.
|
| 2 |
+
|
| 3 |
+
Run inside an environment that has laion_clap (audio-brief's venv locally,
|
| 4 |
+
the Space's own env when run there), from a *staged* directory — i.e. the
|
| 5 |
+
exact set of files that gets uploaded:
|
| 6 |
+
|
| 7 |
+
python verify.py --stage DIR --parity AUDIO --parity-ref VECTOR.npy \
|
| 8 |
+
[--analyse AUDIO] [--out RESULT.json]
|
| 9 |
+
|
| 10 |
+
`--parity` re-embeds a track whose vector is already in the corpus (the
|
| 11 |
+
canonical path being audio-brief's clap_worker via WorkerBackend) and reports
|
| 12 |
+
the cosine against the stored vector. Anything below 0.9999 means this code
|
| 13 |
+
has drifted from the worker and every similarity the app shows would be
|
| 14 |
+
quietly wrong.
|
| 15 |
+
|
| 16 |
+
`--analyse` runs a full headless analysis so the market ranking, snippets and
|
| 17 |
+
tags can be eyeballed without a browser.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import argparse
|
| 23 |
+
import json
|
| 24 |
+
import sys
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
|
| 29 |
+
PARITY_FLOOR = 0.9999
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main() -> int:
|
| 33 |
+
ap = argparse.ArgumentParser()
|
| 34 |
+
ap.add_argument("--stage", required=True)
|
| 35 |
+
ap.add_argument("--parity", default=None)
|
| 36 |
+
ap.add_argument("--parity-ref", default=None)
|
| 37 |
+
ap.add_argument("--analyse", default=None)
|
| 38 |
+
ap.add_argument("--out", default=None)
|
| 39 |
+
args = ap.parse_args()
|
| 40 |
+
|
| 41 |
+
sys.path.insert(0, str(Path(args.stage).resolve()))
|
| 42 |
+
import analysis # noqa: E402
|
| 43 |
+
import clap_embed # noqa: E402
|
| 44 |
+
import livematch # noqa: E402
|
| 45 |
+
import tags as tagmod # noqa: E402
|
| 46 |
+
|
| 47 |
+
corpus = livematch.Corpus.load(args.stage)
|
| 48 |
+
emb = clap_embed.Embedder()
|
| 49 |
+
result: dict = {"corpus": {"week": corpus.week, "ckpt": corpus.ckpt,
|
| 50 |
+
"sounds": len(corpus.sounds),
|
| 51 |
+
"regions": len(corpus.regions)}}
|
| 52 |
+
|
| 53 |
+
if args.parity:
|
| 54 |
+
v = emb.embed_track(clap_embed.load_audio(args.parity))
|
| 55 |
+
ref = np.load(args.parity_ref).astype(np.float32).ravel()
|
| 56 |
+
ref = ref / np.linalg.norm(ref)
|
| 57 |
+
cos = float(np.dot(v, ref))
|
| 58 |
+
result["parity"] = {"file": args.parity, "cosine": round(cos, 7),
|
| 59 |
+
"floor": PARITY_FLOOR, "passed": cos >= PARITY_FLOOR}
|
| 60 |
+
|
| 61 |
+
vocab = tagmod.TagVocab(emb)
|
| 62 |
+
if args.analyse:
|
| 63 |
+
res = analysis.analyse_track(args.analyse, emb, corpus, vocab=vocab)
|
| 64 |
+
result["zero_shot_check"] = tagmod.discrimination_check(
|
| 65 |
+
vocab, res["track_vec"],
|
| 66 |
+
expect_above=["afrobeats", "afro pop", "afro fusion"],
|
| 67 |
+
expect_below=["techno", "country", "k-pop", "ambient"])
|
| 68 |
+
res.pop("track_vec", None)
|
| 69 |
+
result["analysis"] = {
|
| 70 |
+
"duration_s": res["duration_s"], "week": res["week"],
|
| 71 |
+
"beat": res["beat"], "window_count": res["window_count"],
|
| 72 |
+
"markets": [{"iso": i, "name": res["regions"][i]["name"],
|
| 73 |
+
"best": res["regions"][i]["best"],
|
| 74 |
+
"band": livematch.band(res["regions"][i]["best"]),
|
| 75 |
+
"matched": res["regions"][i]["matched"],
|
| 76 |
+
"pool": res["regions"][i]["pool"],
|
| 77 |
+
"top": res["regions"][i]["top"][:3]}
|
| 78 |
+
for i in res["shown_markets"]],
|
| 79 |
+
"snippets": res["snippets"],
|
| 80 |
+
"tags": {
|
| 81 |
+
"by_group": {g: rows for g, rows
|
| 82 |
+
in (res["tags"]["model"]["by_group"].items()
|
| 83 |
+
if res["tags"]["model"] else [])},
|
| 84 |
+
"trend": res["tags"]["trend"],
|
| 85 |
+
"copy_line": res["tags"]["copy_line"],
|
| 86 |
+
},
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
text = json.dumps(result, indent=2, default=str)
|
| 90 |
+
if args.out:
|
| 91 |
+
Path(args.out).write_text(text)
|
| 92 |
+
print(text)
|
| 93 |
+
parity = result.get("parity")
|
| 94 |
+
return 0 if (parity is None or parity["passed"]) else 4
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
sys.exit(main())
|