kalamishere commited on
Commit
4e92959
·
0 Parent(s):

squash: purge data files from history

Browse files
Files changed (12) hide show
  1. .gitattributes +35 -0
  2. README.md +39 -0
  3. analysis.py +109 -0
  4. app.py +182 -0
  5. boundaries.py +135 -0
  6. clap_embed.py +156 -0
  7. livematch.py +198 -0
  8. packages.txt +1 -0
  9. render.py +283 -0
  10. requirements.txt +18 -0
  11. tags.py +176 -0
  12. 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())