marathon-live / app.py
kalamishere's picture
prototype: Within reach tab (78-93 band dive-in)
b7e632e verified
Raw
History Blame Contribute Delete
73.6 kB
"""marathonmvp β€” one app. This week's world, any track measured against it, and
every track already measured kept ready to re-measure.
Five tabs behind one passphrase (design/ONE_APP.md):
The world what is charting everywhere this week, coloured by genre
family, with the records charting in the most markets. The
landing view: the global picture comes before anything
catalogue-relevant.
This week the weekly drop sheet, rendered natively from the same JSON
the static sheet was built from, so the app reads on a Monday
with no file to hand.
Analyse upload a track, or find one by name. The report.
Saved every analysis run so far. Open, re-run against this week,
re-score the whole list, delete.
How it works the methods and the limits, in plain language.
The three surfaces this replaces were a static encrypted Space for the sheet,
a local prototype for the trend space, and this Gradio app for the matcher.
Framing the static Space inside a tab was checked and rejected: it would ask
for the passphrase a second time inside the frame, and put two scroll
containers inside a nested iframe on a phone. design/ONE_APP.md Β§2 has the
headers that were measured.
Gated: the Space repo can be public because nothing runs without the team
passphrase, which lives only in the Space secret MARATHON_LIVE_PASSPHRASE
(same value as the drop sheet's). No passphrase set β†’ the app refuses to
analyse anything rather than silently serving an open endpoint.
The corpus (corpus.npz + corpus.json) is this week's scan exported by
`marathon.cli export-live`. It is NOT in this public repo: it lives in the
private dataset repo kalamishere/marathon-corpus and is fetched at startup
with the HF_TOKEN Space secret, so repo browsers see code but no data.
Local dev keeps working: files already present next to app.py win.
No database, no network at match time.
Look: the Gradio chrome is themed to the drop sheet's palette (paper
background, near-black accent, the same greens and oranges) so the two
surfaces read as one product. The results block is rendered by `render.py`,
which owns the drop sheet's card and chip styling.
"""
from __future__ import annotations
import hmac
import os
import re
import shutil
import tempfile
import threading
import time
import traceback
import urllib.parse
from collections import OrderedDict
from pathlib import Path
import gradio as gr
import soundfile as sf
import analysis
import clap_embed
import howitworks
import livematch
import lookup as lookupmod
import reachview
import render
import savedview
import store
import tags as tagmod
import worldmap
import worldview
HERE = Path(__file__).resolve().parent
CORPUS_DATASET = "kalamishere/marathon-corpus"
# Snippet clips are written here and served back through Gradio's own file
# route, which is what lets them sit inside the results HTML instead of in a
# detached row of players underneath it. The directory is passed to
# `launch(allowed_paths=...)`; without that the route refuses to serve them.
SNIP_ROOT = Path(tempfile.gettempdir()) / "marathon_snippets"
SNIP_TTL_S = 3600.0
def _fetch_corpus(dest: Path) -> Path:
"""Fetch corpus files from the private dataset repo unless they are
already present locally (dev checkout / staged verify dir)."""
if (dest / "corpus.npz").exists() and (dest / "corpus.json").exists():
return dest
from huggingface_hub import hf_hub_download
token = os.environ.get("HF_TOKEN")
if not token:
raise RuntimeError(
"corpus files missing and HF_TOKEN not set β€” cannot fetch "
f"{CORPUS_DATASET}")
for name in ("corpus.npz", "corpus.json"):
got = hf_hub_download(repo_id=CORPUS_DATASET, filename=name,
repo_type="dataset", token=token)
(dest / name).unlink(missing_ok=True)
(dest / name).symlink_to(got)
return dest
CORPUS = livematch.Corpus.load(_fetch_corpus(HERE))
# Singapore, Hong Kong, Malta and Cape Verde reach the corpus with their ISO
# code where their name should be β€” the export reads names out of the map's
# country file, and the map has no outline for them. Named once, here, so
# every tab reading this corpus reads names.
worldmap.name_regions(CORPUS.regions)
PASSPHRASE = os.environ.get("MARATHON_LIVE_PASSPHRASE", "")
SNIPPETS_PER_SET = 3 # two sets: closest-to-trend and strongest-hook
_state = {"embedder": None, "vocab": None, "zero_shot_ok": None}
_model_lock = threading.Lock()
_run_lock = threading.Lock()
_warm = {"done": False, "seconds": None, "error": None}
def _embedder():
"""One model, loaded once.
The load is ~90 seconds of checkpoint fetch and setup. `warm_up()` starts
it on a background thread the moment the app begins serving, so it is
normally finished before anyone uploads anything. An analysis that arrives
while that thread is still working waits on this lock rather than loading
a second two-gigabyte copy.
"""
with _model_lock:
if _state["embedder"] is None:
t0 = time.time()
emb = clap_embed.Embedder()
try:
_state["vocab"] = tagmod.TagVocab(emb)
except Exception as exc: # text tower missing β†’ group two only
print(f"[tags] text tower unavailable, zero-shot disabled: {exc}")
_state["vocab"] = None
_state["embedder"] = emb
_warm["seconds"] = round(time.time() - t0, 1)
_warm["done"] = True
print(f"[warm] model ready in {_warm['seconds']}s", flush=True)
return _state["embedder"]
def warm_up() -> threading.Thread:
"""Load the model at startup instead of on the first upload.
The thread is started just before `launch()`, so the gate and the upload
zone are on screen while the checkpoint is still coming down. A failure
here is not fatal: the embedder stays unset and the next analysis tries
the load again, reporting through the progress line instead of a log
nobody reads.
"""
def go():
try:
_embedder()
except Exception as exc:
_warm["error"] = str(exc)
print(f"[warm] load failed, the first analysis will retry: {exc}",
flush=True)
traceback.print_exc()
t = threading.Thread(target=go, name="warm-clap", daemon=True)
t.start()
return t
def _ok(passphrase: str | None) -> bool:
"""The one place the gate is decided. Every path that computes anything
goes through here first, including the named API route."""
return bool(PASSPHRASE) and hmac.compare_digest(
(passphrase or "").strip(), PASSPHRASE)
def _notice(message: str) -> str:
"""Gate and input errors in the same visual language as the results."""
return (render.CSS + '<div class="ml"><div class="card flat">'
f'{message}</div></div>')
def _snip_dir() -> Path:
"""A fresh directory for this run's clips, with anything older than an
hour swept up. The Space box is small and every analysis writes ~2.5 MB."""
SNIP_ROOT.mkdir(parents=True, exist_ok=True)
cutoff = time.time() - SNIP_TTL_S
for old in SNIP_ROOT.iterdir():
try:
if old.is_dir() and old.stat().st_mtime < cutoff:
shutil.rmtree(old, ignore_errors=True)
except OSError:
pass
return Path(tempfile.mkdtemp(prefix="run_", dir=str(SNIP_ROOT)))
def _clip(path: str, start_s: float, end_s: float, out_dir: str) -> str:
"""A real slice of the uploaded file, written out so the browser can play
it. Original sample rate and channels β€” the point is to judge the clip.
MP3 where libsndfile can write it: four 30-second stereo WAVs is ~23 MB
down a phone connection, and this is a listening check, not a master.
"""
info = sf.info(path)
sr = info.samplerate
data, _ = sf.read(path, start=int(start_s * sr), stop=int(end_s * sr),
dtype="float32")
stem = os.path.join(out_dir, f"snippet_{int(start_s)}-{int(end_s)}")
try:
out = stem + ".mp3"
sf.write(out, data, sr, format="MP3")
return out
except Exception:
out = stem + ".wav"
sf.write(out, data, sr)
return out
def _servable(paths: list) -> list:
"""Copies of stored files that Gradio's file route will actually serve.
`hf_hub_download` lands files in the HF cache, which sits outside
`allowed_paths` β€” the file route refused every one of them and reopened
reports drew players that would not play (seen live, 22 Aug). Copying
into the clips root puts them inside the allowlist; the hourly sweep
cleans them up like any run's clips.
"""
if not any(paths):
return list(paths)
d = _snip_dir()
out = []
for p in paths:
if not p:
out.append(None)
continue
try:
dest = Path(d) / Path(p).name
shutil.copy(p, dest)
out.append(str(dest))
except OSError:
out.append(None)
return out
def _clip_url(path: str) -> str:
"""Gradio 5+ serves allowed local files from /gradio_api/file=<abs path>.
Relative on purpose: the Space is embedded in an iframe, so the URL has
to resolve against whatever host is serving the app."""
return "/gradio_api/file=" + urllib.parse.quote(str(Path(path).resolve()))
def _sections(path: str) -> int:
"""How many 30-second windows this file yields on the 5-second grid.
Read off the file header so the progress line can quote a real number
rather than a guess. Same arithmetic as `Embedder.embed_windows`.
"""
try:
dur = float(sf.info(path).duration)
except Exception:
return 0
if dur < analysis.WINDOW_S:
return 0
return int((dur - analysis.WINDOW_S) // clap_embed.GRID_S) + 1
# The name of the setting is deliberately not here. Whoever reads this cannot
# act on it β€” they are a label reader, not the person who set the app up β€” and
# the deploy notes carry the name for the person who can.
NO_SECRET = ("<b>No team passphrase has been set for this app.</b> Whoever "
"set it up has to add one before anything will run.")
WRONG_PASS = ("That passphrase is not right. It is the same one the drop "
"sheet uses.")
# Shown on every tab, so it must be true on every tab β€” This week and Saved
# have no Analyse button to press.
NEED_PASS = ("Enter the team passphrase at the top to unlock this. One "
"passphrase opens every tab.")
def _analyse(audio_path: str, progress, label: str | None = None,
source: str = "upload", resolved: dict | None = None,
lead_html: str = "", snippet_note: str | None = None):
"""The work, with the progress line naming the stage it is actually in.
Four stages, each announced when the work it names begins. Nothing here
animates between them: a bar that moves while the model is thinking would
be inventing information the app does not have.
The report is yielded twice. Cutting six 30-second clips costs 8.3
seconds on the Space β€” measured, not assumed β€” and none of the markets,
the snippet times or the tags depend on it, so the whole report goes on
screen first and the players arrive on the cards afterwards. On a local
machine that gap is one second and the second yield lands almost on top
of the first, which costs nothing.
`listen` and `score` are called separately rather than through
`analyse_track`, because the archive needs the `listen` half β€” those
vectors are what make a re-score next Monday free.
"""
if not _warm["done"]:
progress(0.02, desc="warming up β€” about a minute, the first time "
"after a quiet spell")
emb = _embedder()
n = _sections(audio_path)
progress(0.20, desc=(
f"listening to the track β€” {n} sections of it against "
f"{len(CORPUS.sounds):,} charting sounds" if n
else "listening to the track"))
t0 = time.time()
heard = analysis.listen(audio_path, emb)
res = analysis.score(heard, CORPUS, vocab=_state["vocab"],
snippet_top=SNIPPETS_PER_SET)
t_analysis = time.time() - t0
label = label or Path(audio_path).stem
progress(0.85, desc="cutting the clips")
t0 = time.time()
yield lead_html + render.render(res, label, snippet_note=snippet_note)
t_first = time.time() - t0
# Each snippet card carries its own player, so the clips are cut and
# handed to the renderer as URLs. The union of both sets is at most 6
# clips; picks reference them by clip_i.
t0 = time.time()
clips: list[str | None] = []
clip_files: list[str | None] = []
if not snippet_note:
out_dir = _snip_dir()
for p in res["snippets"]:
try:
# Cut to the last bar line inside the window when there is
# one, so what the team hears finishes its phrase instead of
# stopping flat. The delivered window stays the full 30
# seconds β€” that is the field a distributor asks a label to
# fill β€” and the card prints both.
end = p.get("natural_end_s") or p["end_s"]
f = _clip(audio_path, p["start_s"], end, str(out_dir))
clip_files.append(f)
clips.append(_clip_url(f))
except Exception as exc:
print(f"[clip] {exc}")
clip_files.append(None)
clips.append(None)
t_clips = time.time() - t0
# The whole track, playable at the top of the report β€” comparing means
# hearing the analysed track next to the trending sounds it is scored
# against.
track_url = None
try:
track_copy = _servable([audio_path])[0]
track_url = _clip_url(track_copy) if track_copy else None
except Exception as exc:
print(f"[track-player] {exc}")
progress(0.98, desc="adding the players")
t0 = time.time()
html = lead_html + render.render(res, label, snippet_clips=clips,
snippet_note=snippet_note,
track_audio_url=track_url)
# Kept without being asked. A team running a list of forty tracks and
# tapping save on each one would save none of them by track ten, and the
# vectors are the whole reason a track never needs analysing twice.
# A failed save must not lose the report that is already on screen.
t_save = time.time()
try:
store.save(res, heard, label, source=source, resolved=resolved,
clip_paths=clip_files, source_audio=audio_path)
except Exception as exc:
traceback.print_exc()
html += _notice(
"This analysis could not be added to Saved. The report above is "
"complete β€” copy anything you need from it.")
# Printed so the split between the stages, and the gap the early yield
# actually buys, can be read off the Space's own log rather than guessed
# at from a stopwatch.
print(f"[timing] analysis {t_analysis:.1f}s Β· first report {t_first:.1f}s "
f"Β· clips {t_clips:.1f}s Β· players {t_save - t0:.1f}s "
f"Β· save {time.time() - t_save:.1f}s", flush=True)
yield html
def run(passphrase: str, audio_path: str, progress=gr.Progress()):
"""The named API route. Signature is (passphrase, audio) and stays that
way β€” the deploy check drives the live Space through it.
A generator, so the report can reach the screen before the clips are
cut. A client that only wants the answer, `gradio_client.predict`
included, gets the last value and never sees the difference.
"""
if not PASSPHRASE:
yield _notice(NO_SECRET)
return
if not _ok(passphrase):
yield _notice(WRONG_PASS)
return
if not audio_path:
yield _notice("Choose an audio file to analyse β€” mp3, wav or m4a.")
return
# One analysis at a time on a box this size. The queue already serialises
# the two buttons; this catches anything arriving through the API route
# alongside a browser run.
if not _run_lock.acquire(blocking=False):
yield _notice("Another analysis is already running. Give it a "
"moment, then press Analyse.")
return
try:
yield from _analyse(audio_path, progress)
except Exception as exc:
traceback.print_exc()
yield _notice("That file could not be read. Try another export of it β€” "
"mp3, wav or m4a.")
finally:
_run_lock.release()
def run_lookup(passphrase: str, artist: str, title: str,
progress=gr.Progress()):
"""Find a track by name and analyse its official 30-second preview.
For the case where the team has a name and no file. Audio is never taken
from YouTube β€” see `lookup.py` for why β€” so this resolves the same kind of
licensed preview every corpus sound is embedded from.
"""
if not PASSPHRASE:
yield _notice(NO_SECRET)
return
if not _ok(passphrase):
yield _notice(WRONG_PASS)
return
if not (title or "").strip():
yield _notice("Type a title to search for. An artist name as well "
"makes the match much more reliable.")
return
if not _run_lock.acquire(blocking=False):
yield _notice("Another analysis is already running. Give it a "
"moment, then search again.")
return
try:
progress(0.05, desc=f"looking for β€œ{artist} β€” {title}”")
try:
match = lookupmod.search(artist, title)
except lookupmod.NotFound as exc:
yield _notice(render.ESC(str(exc)))
return
except lookupmod.Unavailable as exc:
# Never reported as "your track is not there" β€” it is not the
# same fact, and the reader would go and look for the file. The
# cause goes to the log; "HTTP 503" on screen tells a label
# reader nothing they can act on.
print(f"[lookup] search unavailable: {exc}", flush=True)
yield _notice(
"The music search is not answering just now. Try again in a "
"minute, or upload the file.")
return
card = savedview.match_card(match, preview_url=match.get("preview"))
yield card # before any verdict
progress(0.15, desc="fetching the 30-second preview")
path = lookupmod.download(match["preview"])
label = lookupmod.label_for(match)
yield from _analyse(
path, progress, label=label, source="lookup",
resolved={k: match.get(k) for k in
("artist", "title", "source", "source_id", "link")},
lead_html=card,
snippet_note=savedview.PREVIEW_NO_SNIPPETS)
except Exception as exc:
traceback.print_exc()
yield _notice("That search did not finish. Try it again, or upload "
"the file.")
finally:
_run_lock.release()
def run_batch(passphrase: str, paths, progress=gr.Progress()):
"""Several files, one after another, each saved as it finishes.
A batch that dies on file nine leaves the first eight in Saved. Nothing
is held back to the end.
"""
if not _ok(passphrase):
yield _notice(NEED_PASS if PASSPHRASE else NO_SECRET)
return
files = [p for p in (paths or []) if p]
if not files:
yield _notice("Choose some audio files first.")
return
if not _run_lock.acquire(blocking=False):
yield _notice("Another analysis is already running. Give it a "
"moment, then press Run.")
return
try:
emb = _embedder()
done, failed = [], []
for n, path in enumerate(files, 1):
path = getattr(path, "name", path)
label = Path(path).stem
progress((n - 1) / len(files),
desc=f"{label} β€” {n} of {len(files)}")
try:
heard = analysis.listen(path, emb)
res = analysis.score(heard, CORPUS, vocab=_state["vocab"],
snippet_top=SNIPPETS_PER_SET)
out_dir = _snip_dir()
clip_files: list[str | None] = []
for p in res["snippets"]:
try:
# To the last bar line inside the window, exactly as
# a single upload is cut. Cutting to the flat 30
# seconds here left the card labelled "PLAYS TO THE
# BAR" over audio that did no such thing (23 Aug
# audit).
end = p.get("natural_end_s") or p["end_s"]
clip_files.append(_clip(path, p["start_s"], end,
str(out_dir)))
except Exception:
clip_files.append(None)
# And the track itself, so a report opened out of a batch
# can play the record its market cards are comparing.
# `store.save` drops anything over its own size cap.
aid = store.save(res, heard, label, clip_paths=clip_files,
source_audio=path)
row = store.meta(aid) or {}
done.append({"label": label,
"top_markets": row.get("top_markets") or []})
except Exception as exc:
traceback.print_exc()
failed.append({"label": label, "error": str(exc)})
# after every file, so a long batch shows its progress as results
yield savedview.batch_summary(done, failed)
if not done and not failed:
yield _notice("Nothing was analysed.")
except Exception as exc:
traceback.print_exc()
yield _notice("That run stopped. Anything finished before it stopped "
"is in Saved.")
finally:
_run_lock.release()
def auto_run(passphrase: str, audio_path: str, progress=gr.Progress()):
"""Upload starts the analysis on its own once the passphrase is good.
With no passphrase yet, the upload leaves the screen alone: `_pending`
has already put the "enter the passphrase" line there, and replacing it
with "that passphrase is not right" would be wrong β€” nothing was typed.
"""
if not _ok(passphrase):
yield gr.skip()
return
yield from run(passphrase, audio_path, progress)
# --------------------------------------------------------------------------
# Saved β€” the archive, and the weekly re-read that is the point of it
def _rows() -> list[dict]:
"""The index. Raises `store.StoreUnavailable` when it cannot be read.
It used to catch everything and return an empty list, so an archive
behind a bad minute at the hub was drawn as "Nothing saved yet. Every
track you analyse is kept here automatically…" β€” a team whose forty
analyses had vanished being told they had never saved one (23 Aug
audit). `store.py` states the rule it was breaking: emptiness is never
reported as failure, nor failure as emptiness.
"""
try:
return store.index()
except store.StoreUnavailable:
raise
except Exception as exc:
# Anything else is still a failure to read, not an empty archive.
raise store.StoreUnavailable(str(exc)) from exc
def saved_refresh(passphrase: str):
"""Redraw the Saved tab.
The list is a function of the archive, drawn by `@gr.render`, so a
refresh is a new nonce rather than a second copy of the list HTML. The
clock is the nonce: any new value differs from the last one, and no
caller has to know what the previous value was.
"""
return "", time.time()
# A reopened report comes out the same every time. It is drawn from the
# stored analysis and the corpus this process has loaded, and nothing about
# who is reading it enters the drawing β€” so what is kept below can be shared
# by every reader without a passphrase ever being part of the key. The gate
# is checked before any of it is reached, as it is on every other path.
#
# Drawing one costs a download of the stored result, a copy of the source
# audio and up to six clip copies into the directory the player can reach,
# and roughly 0.2 MB of HTML. The copies are most of the wait, and the Saved
# tab redraws itself on every open and every close, so a team stepping
# through a list paid that wait on each step (reported 23 Aug).
#
# Staleness is handled by the key rather than by trust: it carries the
# reading the archive holds now β€” when it was saved, when it was last
# re-read, how many times β€” so a re-score here, a re-score on the Monday
# sweep, or a re-score by another process writing the same archive all
# produce a different key and a fresh drawing. `_forget` is the belt to that
# braces, called wherever this app rewrites a stored reading itself.
_REPORTS: "OrderedDict[tuple, dict]" = OrderedDict()
_REPORTS_MAX = 32 # ~0.2 MB of HTML each, measured
_reports_lock = threading.Lock()
def _report_key(aid: str, row: dict) -> tuple:
return (aid, str(row.get("saved_at") or ""),
str(row.get("rescored_at") or ""), str(row.get("runs") or ""),
str(row.get("corpus_week") or ""))
def _remembered(key: tuple) -> str | None:
with _reports_lock:
hit = _REPORTS.get(key)
if hit is None:
return None
# The served copies are swept an hour after they are written, so a
# drawing that has outlived its files is redrawn rather than handed
# back with players that will not play.
if not all(os.path.exists(p) for p in hit["files"]):
_REPORTS.pop(key, None)
return None
_REPORTS.move_to_end(key)
return hit["html"]
def _remember(key: tuple, html: str, files: list) -> None:
with _reports_lock:
_REPORTS[key] = {"html": html, "files": [f for f in files if f]}
_REPORTS.move_to_end(key)
while len(_REPORTS) > _REPORTS_MAX:
_REPORTS.popitem(last=False)
def _forget(aid: str | None = None) -> None:
"""Drop what was drawn for one analysis, or for all of them."""
with _reports_lock:
for k in [k for k in _REPORTS if aid is None or k[0] == aid]:
_REPORTS.pop(k, None)
def saved_open(passphrase: str, aid: str, row: dict | None = None):
"""Re-render a stored report. No model, no corpus arithmetic, no audio β€”
the answer was computed once and written down.
`row` is the index row where the caller already holds it, which is what
the Saved tab hands in. The index carries everything drawn here, so
opening a track off the list does not read its meta a second time.
"""
if not _ok(passphrase):
return _notice(NEED_PASS if PASSPHRASE else NO_SECRET)
if not aid:
return _notice("Choose a saved analysis first.")
row = row or store.meta(aid)
if not row:
return _notice("That analysis could not be read back.")
key = _report_key(aid, row)
already = _remembered(key)
if already is not None:
return already
res = store.result(aid)
if not res:
return _notice("That analysis could not be read back.")
# By window, not by position: a re-score re-orders the shortlist and
# re-cuts nothing, so position is not identity (`store.clip_for`).
files = _servable(store.clips_for(aid, res))
src = _servable([store.source_path(aid)])[0]
html = (savedview.reopened_note(row, str(CORPUS.week))
+ render.render(res, row.get("label") or aid,
snippet_clips=[_clip_url(p) if p else None
for p in files],
track_audio_url=_clip_url(src) if src else None))
_remember(key, html, list(files) + [src])
return html
def saved_rerun(passphrase: str, aid: str, progress=gr.Progress()):
"""This track against the corpus loaded right now.
The stored vectors carry everything: the market ranking is a matrix
multiply, and the snippet ranking recomputes because the hook and
downbeat halves of it were never about the corpus in the first place.
"""
if not _ok(passphrase):
return _notice(NEED_PASS if PASSPHRASE else NO_SECRET)
if not aid:
return _notice("Choose a saved analysis first.")
progress(0.3, desc=f"re-reading against week {CORPUS.week}")
t0 = time.time()
try:
res = store.rescore(aid, CORPUS, vocab=_state["vocab"])
except Exception as exc:
traceback.print_exc()
return _notice("That re-run did not finish. The saved report is "
"unchanged β€” try it again.")
if res is None:
return _notice("That analysis has no saved reading to re-read. "
"Analyse the track again to bring it back.")
_forget(aid) # the stored reading it was drawn from is gone
row = store.meta(aid) or {}
print(f"[timing] re-score {row.get('label')} {time.time() - t0:.2f}s",
flush=True)
# Clips were cut for the windows the ORIGINAL run picked, and this
# re-score can promote a window that was never cut. `clips_for` matches
# them by window, so a card offers a player only where the file on it is
# the 30 seconds the card is headed with β€” the old version checked that
# the index existed and nothing more (23 Aug audit).
stored = _servable(store.clips_for(aid, res))
clips = [_clip_url(stored[p["clip_i"]])
if p.get("clip_i") is not None and p["clip_i"] < len(stored)
and stored[p["clip_i"]] else None
for p in res["snippets"]]
src = _servable([store.source_path(aid)])[0]
return (savedview.reopened_note(row, str(CORPUS.week))
+ render.render(res, row.get("label") or aid, snippet_clips=clips,
track_audio_url=_clip_url(src) if src else None))
def saved_rescore_all(passphrase: str, progress=gr.Progress()):
"""The whole list against this week.
This is the weekly mechanism. When Monday's corpus lands, the catalogue's
standing against the new world is one press away, and the same function
is callable from `marathon.cli` when the cron picks it up.
"""
if not _ok(passphrase):
return _notice(NEED_PASS if PASSPHRASE else NO_SECRET), time.time()
_forget() # every stored reading on the list is moving
try:
out = store.rescore_all(CORPUS, vocab=_state["vocab"],
progress=progress)
except Exception as exc:
traceback.print_exc()
return _notice("The re-score did not finish. Nothing on the list was "
"changed β€” try it again."), time.time()
return savedview.rescore_summary(out), time.time()
def saved_delete(passphrase: str, aid: str):
if not _ok(passphrase):
return _notice(NEED_PASS if PASSPHRASE else NO_SECRET), aid, time.time()
if not aid:
return _notice("Nothing to delete."), "", time.time()
label = (store.meta(aid) or {}).get("label", aid)
try:
store.delete(aid)
_forget(aid)
except Exception as exc:
traceback.print_exc()
return (_notice("That could not be deleted. It is still on the list."),
aid, time.time())
# Back to the list: the report that was open is no longer an analysis.
return (_notice(f"Deleted <b>{render.ESC(str(label))}</b>, with its clips."),
"", time.time())
# --------------------------------------------------------------------------
# This week β€” the drop sheet, rendered natively
def week_parts(passphrase: str) -> tuple[str, str]:
"""`(header, sheet)` β€” the tab in two blocks, with room between them.
Two rather than one because "Analyse a track β†’" belongs in the header and
only a Gradio button can switch a Gradio tab: an `<a href>` inside an
HTML component has nowhere to point, and no script runs there to help.
So the header is one component, the button is the next, and the sheet
follows.
`weekly.py` may not be present yet in a checkout mid-build, and a missing
weekly export is a normal state on a fresh Space, so neither is allowed
to take the tab down.
"""
if not _ok(passphrase):
return "", _notice(NEED_PASS if PASSPHRASE else NO_SECRET)
try:
import weekly
data = weekly.fetch()
if not data:
return "", _notice(
"No drop sheet has been published for this week yet. The "
"scan runs on Monday; the markets and the matcher are "
"available now.")
return weekly.head(data), weekly.body(data)
except Exception as exc:
print(f"[weekly] {exc}", flush=True)
traceback.print_exc()
return "", _notice("This week's sheet could not be loaded. The other "
"tabs are working β€” try this one again in a minute.")
def world_html(passphrase: str) -> str:
"""The world tab: the global trend picture, before any catalogue.
Kalam has asked three times for the global view to come before anything
catalogue-relevant, and this is it with a tab of its own. It reads the
corpus, which is always loaded, plus this week's sheet if one has been
published β€” so the tab is not empty on a week whose scan has not run, it
is simply missing the cross-market records, which is the one part of it
the database rather than the corpus knows.
Gated and error-wrapped exactly like `week_parts`: a failure here shows a
line in this tab and leaves the rest of the app standing.
"""
if not _ok(passphrase):
return _notice(NEED_PASS if PASSPHRASE else NO_SECRET)
try:
data = None
try:
import weekly
data = weekly.fetch()
except Exception as exc:
# The map and the sentences do not need the sheet. Losing it
# costs one section, not the tab.
print(f"[world] no weekly sheet: {exc}", flush=True)
return worldview.render_block(CORPUS, data)
except Exception as exc:
print(f"[world] {exc}", flush=True)
traceback.print_exc()
return _notice("The world this week could not be drawn. The other "
"tabs are working β€” try this one again in a minute.")
def week_html(passphrase: str) -> str:
"""The whole tab as one block, for any caller that wants it in one."""
head, sheet = week_parts(passphrase)
return head + sheet
# --------------------------------------------------------------------------
# Share links β€” one address that opens where it says it opens
#
# "Look at this one" between two people on a label is a link, not a set of
# directions. The app's own address plus `?tab=saved` opens on Saved, and
# `?open=<id>` opens that saved report itself. The passphrase still applies:
# a link is a shortcut through the app, never around the gate.
TAB_IDS = ("world", "week", "analyse", "saved", "how")
# What somebody writing the link by hand is likely to type instead.
TAB_ALIASES = {"analyze": "analyse", "howitworks": "how",
"how-it-works": "how", "how_it_works": "how",
"thisweek": "week", "this-week": "week",
"theworld": "world", "the-world": "world", "map": "world"}
# The shape `store.new_id` writes: a timestamp, a slug, six hex characters.
# Nothing outside it is ever used to build a path into the archive.
AID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,119}$")
def link_target(params) -> tuple[str | None, str]:
"""`(tab, saved analysis id)` for the query on a share link.
`?tab=saved` opens the Saved tab. `?open=<id>` opens one saved report,
which lives on the Saved tab, so it asks for that tab too. A tab name the
app does not have reads as no request at all, and an id that no longer
exists is dealt with where the list is drawn: the reader gets the list,
which is where they were going anyway, rather than a line telling them
off for following a link somebody sent them.
"""
try:
params = dict(params or {})
except Exception:
return None, ""
aid = str(params.get("open") or "").strip()
tab = str(params.get("tab") or "").strip().lower()
if aid:
return "saved", (aid if AID_RE.match(aid) else "")
tab = TAB_ALIASES.get(tab, tab)
return (tab if tab in TAB_IDS else None), ""
# The drop sheet's palette, applied to Gradio's own chrome. Values are the
# ones in marathon/static/signals.html; the `_dark` twins are its dark theme.
PAPER, CARD, INSET, LINE = "#fbfbf9", "#ffffff", "#f3f2ef", "#e6e4df"
INK, INK2, INK3 = "#141417", "#55534e", "#97948c"
D_PAPER, D_CARD, D_INSET, D_LINE = "#161615", "#1e1e1c", "#262624", "#343330"
D_INK, D_INK2, D_INK3 = "#f5f4f0", "#b8b5ac", "#7c7a72"
THEME = gr.themes.Base().set(
body_background_fill=PAPER,
body_background_fill_dark=D_PAPER,
body_text_color=INK,
body_text_color_dark=D_INK,
body_text_color_subdued=INK2,
body_text_color_subdued_dark=D_INK2,
background_fill_primary=PAPER,
background_fill_primary_dark=D_PAPER,
background_fill_secondary=INSET,
background_fill_secondary_dark=D_INSET,
border_color_primary=LINE,
border_color_primary_dark=D_LINE,
block_background_fill=CARD,
block_background_fill_dark=D_CARD,
block_border_color=LINE,
block_border_color_dark=D_LINE,
block_label_text_color=INK3,
block_label_text_color_dark=D_INK3,
block_title_text_color=INK2,
block_title_text_color_dark=D_INK2,
block_radius="16px",
panel_background_fill=CARD,
panel_background_fill_dark=D_CARD,
input_background_fill=INSET,
input_background_fill_dark=D_INSET,
input_border_color=LINE,
input_border_color_dark=D_LINE,
input_placeholder_color=INK3,
input_placeholder_color_dark=D_INK3,
input_radius="10px",
button_large_radius="12px",
button_small_radius="10px",
# the sheet's CTA: near-black on paper, paper on near-black
button_primary_background_fill=INK,
button_primary_background_fill_hover="#2c2c31",
button_primary_background_fill_dark=D_INK,
button_primary_background_fill_hover_dark="#e3e1da",
button_primary_border_color=INK,
button_primary_border_color_dark=D_INK,
button_primary_text_color="#ffffff",
button_primary_text_color_dark=INK,
button_secondary_background_fill=CARD,
button_secondary_background_fill_dark=D_CARD,
button_secondary_border_color=LINE,
button_secondary_border_color_dark=D_LINE,
color_accent="#0e9f6e",
color_accent_soft="#e3f5ec",
color_accent_soft_dark="#123124",
)
CSS = f"""
footer {{ display: none !important; }}
/* Gradio's default is a Google font; the sheet is on the system stack.
`!important` because the theme stylesheet loads after custom CSS. */
:root, .dark {{
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important;
/* the progress line is drawn in --font-mono, which is a Google font by
default β€” a sentence about what the app is doing should read in the
same face as the rest of the sentences */
--font-mono: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important;
}}
/* `width: 100%` is load-bearing on a phone. Gradio lays the container out as
a flex item, and with only a max-width it takes its max-content size and
overflows the viewport the moment the audio widget has a file in it: 760px
of page inside a 375px screen, the whole report sliding sideways. */
.gradio-container {{ max-width: 760px !important; width: 100% !important;
margin: 0 auto !important; }}
#intro {{ margin-bottom: 4px; }}
#intro .kick {{ font-size: 11px; font-weight: 700; letter-spacing: .18em;
text-transform: uppercase; color: {INK3}; }}
#intro h1 {{ font-size: 30px; font-weight: 800; letter-spacing: -.02em;
margin: 3px 0 8px; line-height: 1.1; }}
#intro p {{ font-size: 13.5px; line-height: 1.5; color: {INK2}; margin: 0 0 3px;
max-width: 62ch; }}
#intro .meta {{ font-size: 11.5px; font-weight: 700; letter-spacing: .1em;
text-transform: uppercase; color: {INK3}; margin-top: 8px; }}
.dark #intro .kick, .dark #intro .meta {{ color: {D_INK3}; }}
.dark #intro p {{ color: {D_INK2}; }}
/* The upload widget has two states sharing one container: the drop zone
before a file, the player after one. Every rule below is scoped to the
state it belongs to.
`#up .audio-container button {{ min-height: 104px }}` β€” unscoped β€” is what
made the loaded player look broken: it hit the transport, the volume
control and the download/share/clear icons too, so a 104px-tall toolbar
floated over the corner, the play triangle sat in a field of dead space
and the speed control showed as a bordered "1x" box. Keep the drop-zone
rules on the drop button.
Empty state. Gradio's stock copy is loose text nodes between spans, so
`display: none` on the children leaves it on screen; `font-size: 0` on
the wrapper is what actually blanks it, and our prompt comes back in
`::after` at its own size. Styling around the widget, not inside it. */
#up .audio-container {{ background: transparent; border: 0; }}
#up .audio-container > button[aria-dropeffect] {{
background: {INSET}; border: 1px dashed {LINE}; border-radius: 14px;
min-height: 104px; padding: 26px 14px 18px; }}
#up .audio-container > button[aria-dropeffect] .wrap {{
font-size: 0; min-height: 0; gap: 0; }}
#up .audio-container > button[aria-dropeffect] .wrap > * {{ display: none; }}
#up .audio-container > button[aria-dropeffect] .wrap::after {{
content: "Tap to choose an audio file β€” mp3, wav or m4a";
font-size: 14px; font-weight: 600; color: {INK2}; }}
.dark #up .audio-container > button[aria-dropeffect] {{
background: {D_INSET}; border-color: {D_LINE}; }}
.dark #up .audio-container > button[aria-dropeffect] .wrap::after {{
color: {D_INK2}; }}
/* Loaded state: one inset card β€” waveform, the times, a centred transport.
`buttons=[]` drops download and share (neither means anything for a file
you just picked); `editable=False` drops the trim tools. Volume is hidden
because the browser and the phone both already have one, and a lone
speaker icon under a centred transport reads as a stray control. */
#up .component-wrapper {{ background: {INSET}; border-radius: 14px;
padding: 10px 12px 8px; }}
#up .waveform-container {{ min-height: 0 !important; }}
#up .timestamps {{ padding: 2px 0 0 !important; }}
#up .timestamps time {{ font-size: 11.5px; color: {INK3};
font-variant-numeric: tabular-nums; }}
#up .controls {{ padding: 0 !important; margin: 0 !important;
min-height: 0 !important; flex-wrap: nowrap !important; }}
#up .controls button {{ min-height: 0 !important; height: 30px !important;
padding: 0 6px !important; }}
#up .controls .control-wrapper, #up .settings-wrapper {{
display: none !important; }}
#up .icon-button-wrapper {{ background: transparent !important;
box-shadow: none !important; }}
#up .icon-button-wrapper button {{ min-height: 0 !important;
height: 26px !important; padding: 0 5px !important; }}
.dark #up .component-wrapper {{ background: {D_INSET}; }}
.dark #up .timestamps time {{ color: {D_INK3}; }}
#go {{ font-weight: 700; letter-spacing: .01em; }}
/* The line under the gate: whether the passphrase is remembered here, and
whether the model is still loading. Small, quiet, never a box. */
#hint {{ margin: -4px 0 0; }}
#hint .hint {{ font-size: 12.5px; line-height: 1.45; color: {INK3};
margin: 0; }}
.dark #hint .hint {{ color: {D_INK3}; }}
#forget {{ font-size: 12px !important; font-weight: 600;
min-height: 0 !important; padding: 5px 11px !important;
width: auto !important; align-self: flex-start; }}
/* The stage line. Gradio draws it absolutely over the top of the results
block, which is fine while the block holds the "Analysing" notice and a
collision once the report arrives underneath it β€” two sets of words in
the same place. An opaque ground turns it into a status strip sitting on
the report instead. */
#out .wrap.center {{ background: {PAPER} !important; padding: 6px 0 10px;
border-radius: 12px; }}
.dark #out .wrap.center {{ background: {D_PAPER} !important; }}
#out .progress-level-inner {{ font-size: 12.5px !important;
letter-spacing: 0; color: {INK2}; }}
.dark #out .progress-level-inner {{ color: {D_INK2}; }}
/* Saved cards are the control. The card itself is HTML, so the thing that
can carry a click back to Python is a Button β€” it is stretched over the
whole card at zero opacity, and the card underneath draws the hover and
the chevron. A phone gets a 100%-wide, full-height tap target instead of
a picker at the foot of the tab that nobody found.
The fallback matters: if this positioning ever fails, the button is still
there, visible, reading "Open full report". Nothing becomes unreachable. */
.savedcard {{ position: relative; margin-bottom: 14px; }}
/* Gradio puts elem_classes on the <button> itself, so `.cardhit button`
matched nothing: the stretch applied, the transparency did not, and an
opaque white button sat over every card (seen live, 22 Aug β€” exactly the
failure the fallback text was written for). Style the class directly AND
the descendant, so either DOM shape gets the full treatment. */
.savedcard .cardhit {{ position: absolute; inset: 0; margin: 0 !important;
min-width: 0 !important; z-index: 2; opacity: 0;
border: 0 !important; background: transparent !important;
border-radius: 20px; cursor: pointer; }}
.savedcard .cardhit button {{ width: 100%; height: 100%; opacity: 0;
border: 0 !important; background: transparent !important;
border-radius: 20px; cursor: pointer; }}
/* A keyboard tab-stop must still be visible. */
.savedcard .cardhit button:focus-visible,
.savedcard .cardhit:focus-visible {{ opacity: 1;
outline: 2px solid {INK2}; outline-offset: -2px; background: transparent !important;
color: transparent !important; }}
/* Tabs. Gradio's stock tab row is a boxed strip; the sheet's language is
type and a rule, so the tabs are labels with an underline on the live one.
`overflow-x: auto` keeps four of them on a 343px screen without wrapping
into two rows, which is what pushed the content below the fold. */
.tab-nav, div[role="tablist"] {{ border-bottom: 1px solid {LINE} !important;
gap: 2px !important; overflow-x: auto !important; flex-wrap: nowrap !important;
scrollbar-width: none; margin-bottom: 10px !important; }}
.tab-nav::-webkit-scrollbar, div[role="tablist"]::-webkit-scrollbar {{
display: none; }}
button[role="tab"] {{ border: 0 !important; background: transparent !important;
font-size: 13.5px !important; font-weight: 700 !important;
color: {INK3} !important; padding: 9px 11px !important;
white-space: nowrap !important; border-radius: 0 !important;
border-bottom: 2px solid transparent !important; }}
button[role="tab"][aria-selected="true"] {{ color: {INK} !important;
border-bottom-color: {INK} !important; }}
.dark div[role="tablist"] {{ border-bottom-color: {D_LINE} !important; }}
.dark button[role="tab"] {{ color: {D_INK3} !important; }}
.dark button[role="tab"][aria-selected="true"] {{ color: {D_INK} !important;
border-bottom-color: {D_INK} !important; }}
/* The name lookup sits beside the upload. `or` between them is a divider
rather than a heading, because neither route is the fallback. */
#orline {{ text-align: center; font-size: 11.5px; font-weight: 800;
letter-spacing: .14em; text-transform: uppercase; color: {INK3};
margin: 14px 0 10px; }}
.dark #orline {{ color: {D_INK3}; }}
#lookrow {{ gap: 8px; }}
@media (max-width: 640px) {{
.gradio-container {{ padding: 8px !important; }}
#intro h1 {{ font-size: 25px; }}
button[role="tab"] {{ font-size: 13px !important; padding: 9px 8px !important; }}
}}
"""
# The analysis lane is serialised: one embed at a time, whichever tab asked
# for it. `PRIV` keeps an event off the public API and out of the progress
# strip. Both are read inside `@gr.render` bodies, so they are defined
# before the Blocks rather than partway down it.
LANE = dict(concurrency_id="analyse", concurrency_limit=1)
PRIV = dict(show_progress="hidden", api_visibility="private")
# Gradio 6 moved `theme` and `css` off the Blocks constructor onto launch().
with gr.Blocks(title="marathonmvp β€” live matcher") as demo:
gr.HTML(
'<div id="intro"><div class="kick">marathonmvp</div>'
'<h1>This week\'s world, and your tracks in it</h1>'
'<p>What is charting in 88 markets, measured as sound. Any track you '
'have measured against it. Everything you measure is kept and re-read '
'when the charts move.</p>'
f'<p class="meta">This week: {len(CORPUS.sounds):,} charting sounds Β· '
f'{len(CORPUS.regions)} markets Β· week {CORPUS.week}</p></div>')
# The passphrase is kept in this device's localStorage after one working
# entry, so the team types it once per phone instead of once per visit.
# `storage_key` and `secret` are fixed strings on purpose: Gradio's
# defaults are random per process, which would throw the stored value
# away on every Space restart. The encryption is obfuscation, not a
# second gate β€” anyone holding the phone holds the passphrase, which is
# the same trade the drop sheet already makes.
saved = gr.BrowserState("", storage_key="marathon_live_pass",
secret="marathon-live-matcher")
# Which passphrase the two landing blocks were last drawn for, so the
# entered-and-clicked-away pair does not draw them twice. Per browser
# tab, like every other gr.State here.
painted = gr.State(None)
# A share link that arrived before the passphrase did: held here until
# one is entered, then applied.
pending = gr.State(None)
pw = gr.Textbox(label="Team passphrase", type="password",
placeholder="the passphrase you were sent")
# The line first, then the way out of it: on a return visit the screen
# reads "Passphrase remembered on this device" and only then offers the
# button that undoes it.
hint = gr.HTML(elem_id="hint")
forget = gr.Button("Use a different passphrase", size="sm",
variant="secondary", visible=False, elem_id="forget")
# One gate, above everything. Each tab checks the passphrase again on the
# way into its own handler, so a tab is never a way round the gate β€” the
# position on screen is a courtesy, not the enforcement.
with gr.Tabs() as tabs:
# Landing. The global picture comes before anything catalogue-
# relevant β€” Kalam's ask, three times β€” so The world is the first tab
# and the view the app opens on. This week is one tap to its right,
# and the upload one further, so the app still reads on a Monday with
# no file to hand.
with gr.Tab("The world", id="world"):
world_out = gr.HTML(elem_id="worldout")
world_refresh = gr.Button("Reload the world", size="sm",
variant="secondary")
with gr.Tab("This week", id="week"):
# Header, then the one thing to do that is not on the sheet, then
# the sheet. The button sits in the header block because that is
# where the redesign puts "Analyse a track β†’" and because a
# Gradio tab can only be switched by a Gradio component.
week_head = gr.HTML(elem_id="weekhead")
to_analyse = gr.Button("Analyse a track β†’", variant="primary",
size="sm")
week_out = gr.HTML(elem_id="weekout")
week_refresh = gr.Button("Reload this week", size="sm",
variant="secondary")
with gr.Tab("Analyse", id="analyse"):
# waveform_color is a single baked-in canvas colour β€” it cannot
# follow the theme β€” so it is the sheet's mid grey, which reads on
# both the paper and the near-black inset. Progress is the sheet's
# place green.
audio = gr.Audio(label="Your track", sources=["upload"],
type="filepath", elem_id="up", editable=False,
buttons=[],
waveform_options=gr.WaveformOptions(
waveform_color="#8a887f",
waveform_progress_color="#0e9f6e",
skip_length=5))
go = gr.Button("Analyse", variant="primary", elem_id="go")
gr.HTML('<div id="orline">or, with no file to hand</div>')
with gr.Row(elem_id="lookrow"):
l_artist = gr.Textbox(label="Artist", scale=1,
placeholder="Camidoh")
l_title = gr.Textbox(label="Title", scale=1,
placeholder="Sugarcane")
find = gr.Button("Find and analyse", variant="secondary")
gr.HTML(
render.CSS + '<div class="ml"><p class="den">This looks the '
'track up and analyses its official 30-second preview β€” the '
'same kind of preview every charting sound here is measured '
'from, so the market numbers come out better calibrated than '
'a full-length upload\'s. Choosing which 30 seconds to '
'deliver needs the full track.</p></div>')
with gr.Accordion("Several tracks at once", open=False):
batch_files = gr.File(
label="Audio files", file_count="multiple",
file_types=["audio", ".mp3", ".wav", ".m4a", ".flac"])
batch_go = gr.Button("Run them", variant="secondary")
gr.HTML(
render.CSS + '<div class="ml"><p class="den">They run one '
'after another and each is saved as it finishes, so a run '
'that stops halfway keeps everything before it.</p></div>')
out = gr.HTML(elem_id="out")
with gr.Tab("Saved", id="saved"):
# Which analysis is open, and a counter the write actions bump to
# force a redraw. Both are plain state: the list below is a
# function of them, so there is no second copy of "what is saved"
# to fall out of step with the archive.
open_id = gr.State("")
saved_nonce = gr.State(0)
saved_out = gr.HTML(elem_id="savedout")
@gr.render(inputs=[open_id, saved_nonce, pw], show_progress="hidden")
def saved_body(aid, _nonce, passphrase):
"""Either the list of cards, or one open report.
Two views rather than a list with a report bolted under it:
on a phone the report is several screens long, and leaving
the list above it meant the reader lost their place in it.
"""
if not _ok(passphrase):
gr.HTML(_notice(NEED_PASS if PASSPHRASE else NO_SECRET))
return
try:
rows = _rows()
except store.StoreUnavailable as exc:
print(f"[store] index unreadable: {exc}", flush=True)
gr.HTML(savedview.unreachable())
return
if aid and any(r.get("id") == aid for r in rows):
row = next(r for r in rows if r.get("id") == aid)
back = gr.Button("← All saved", size="sm",
variant="secondary")
back.click(lambda: "", None, open_id, **PRIV)
report = gr.HTML(saved_open(passphrase, aid, row=row),
elem_id="savedreport")
with gr.Row():
rerun_btn = gr.Button(
f"Re-run against week {CORPUS.week}", size="sm",
variant="primary")
del_btn = gr.Button("Delete", size="sm",
variant="secondary")
rerun_btn.click(saved_rerun, inputs=[pw, gr.State(aid)],
outputs=report, api_visibility="private",
**LANE)
del_btn.click(saved_delete, inputs=[pw, gr.State(aid)],
outputs=[saved_out, open_id, saved_nonce],
api_visibility="private")
return
gr.HTML(savedview.header(rows, str(CORPUS.week)))
for r, html in savedview.cards(rows, str(CORPUS.week)):
with gr.Column(elem_classes=["savedcard"]):
gr.HTML(html)
hit = gr.Button("Open full report",
elem_classes=["cardhit"],
variant="secondary")
hit.click(lambda i=r["id"]: i, None, open_id, **PRIV)
if rows:
all_btn = gr.Button(f"Re-score all against week "
f"{CORPUS.week}", size="sm",
variant="secondary")
all_btn.click(saved_rescore_all, inputs=[pw],
outputs=[saved_out, saved_nonce],
api_visibility="private", **LANE)
# PROTOTYPE. The dive-in layer for the 78–93 band of one saved
# analysis: everything it draws lives in `reachview.py`, and the tab
# is two pickers and two blocks of HTML. Kept whole in one place so
# that dropping the idea is dropping this block and one import.
with gr.Tab("Within reach", id="reach"):
def reach_open(passphrase, aid):
"""A saved analysis's band. At most ten Deezer lookups."""
blank = gr.update(choices=[], value=None, visible=False)
if not _ok(passphrase):
return (_notice(NEED_PASS if PASSPHRASE else NO_SECRET),
blank, "")
if not aid:
return "", blank, ""
res = store.result(aid)
if not res:
return (_notice("That analysis could not be read back."),
blank, "")
label = (store.meta(aid) or {}).get("label") or aid
picks = reachview.market_choices(res)
return (reachview.render_band(res, label),
gr.update(choices=picks, value=None,
visible=bool(picks)), "")
def reach_market(passphrase, aid, iso):
"""One market of that band, with its own players. Three
lookups, paid only when a reader asks for them."""
if not _ok(passphrase) or not aid or not iso:
return ""
res = store.result(aid)
if not res:
return _notice("That analysis could not be read back.")
r = (res.get("regions") or {}).get(iso) or {}
ids, _ = reachview.preview_ids(
[(iso, m.get("deezer_id"))
for m in (r.get("top") or [])[:reachview.
MARKET_PREVIEW_CAP]],
reachview.MARKET_PREVIEW_CAP)
previews = render.deezer_previews(ids) if ids else {}
return reachview.market_card(res, iso, previews)
gr.HTML(reachview.opening())
@gr.render(inputs=[pw], show_progress="hidden")
def reach_body(passphrase):
if not _ok(passphrase):
gr.HTML(_notice(NEED_PASS if PASSPHRASE else NO_SECRET))
return
try:
rows = _rows()
except store.StoreUnavailable as exc:
print(f"[store] index unreadable: {exc}", flush=True)
gr.HTML(savedview.unreachable())
return
reach_pick = gr.Dropdown(
choices=reachview.saved_choices(rows), value=None,
label="Which track", interactive=True)
reach_out = gr.HTML(elem_id="reachout")
reach_one_pick = gr.Dropdown(
choices=[], value=None, visible=False, interactive=True,
label="Open one of these markets in full")
reach_one = gr.HTML(elem_id="reachone")
reach_pick.change(
reach_open, inputs=[pw, reach_pick],
outputs=[reach_out, reach_one_pick, reach_one], **PRIV)
reach_one_pick.change(
reach_market, inputs=[pw, reach_pick, reach_one_pick],
outputs=[reach_one], **PRIV)
with gr.Tab("How it works", id="how"):
gr.HTML(howitworks.render(CORPUS))
def _line(text: str) -> str:
return f'<p class="hint">{text}</p>' if text else ""
def restore(stored):
"""Page load. A passphrase that still works hides the field; one that
no longer matches is dropped and the field comes back, which is what
happens the day the passphrase is rotated."""
try:
ok = _ok(stored)
except Exception:
ok = False
bits = []
if ok:
bits.append("Passphrase remembered on this device.")
if not _warm["done"]:
bits.append("The app is still warming up, so the first analysis "
"will take about a minute longer.")
elif ok:
bits.append("Choose a file and the analysis starts on its own.")
return (gr.update(value=stored if ok else "", visible=not ok),
gr.update(visible=ok), _line(" ".join(bits)),
stored if ok else "")
def remember(passphrase):
"""A passphrase that just worked is kept for next time."""
if not _ok(passphrase):
return gr.skip(), gr.skip(), gr.skip(), gr.skip()
return (passphrase, gr.update(visible=False), gr.update(visible=True),
_line("Passphrase remembered on this device."))
def forget_it():
"""The passphrase goes, and so does everything it opened.
It used to write only to the field and the line under it, so the
world, the week and the last report stayed fully painted behind a
screen that had just gone back to asking for a passphrase (23 Aug
audit). The `.then` below repaints them; the results block is
cleared here, with the rest.
"""
return ("", gr.update(value="", visible=True), gr.update(visible=False),
_line("Forgotten on this device."), "")
def _pending(passphrase):
"""The moment a run is asked for, before any work starts.
A good passphrase clears the results area: the previous report, or a
passphrase error from an earlier attempt, must not sit there for the
whole run. Nothing is put in its place, because Gradio draws the
stage line over this same block and two messages in one spot is what
the first attempt at this looked like.
"""
if not PASSPHRASE:
return _notice(NO_SECRET), gr.update()
if not _ok(passphrase):
return _notice(NEED_PASS), gr.update()
return "", gr.update(interactive=False, value="Analysing…")
def first_paint(passphrase):
"""The landing view, on a device that already knows the passphrase.
Without this the app opens on an empty tab and the reader has to find
a Reload button to see the thing they came for. A device with no
stored passphrase gets the line asking for one, which is the correct
first screen for it.
Both of the first two tabs are painted here rather than only the one
on screen: they are the same page load, the world block is arithmetic
over labels already in memory, and painting This week lazily would
put a blank screen behind the first tap.
"""
if not _ok(passphrase):
notice = _notice(NEED_PASS if PASSPHRASE else NO_SECRET)
return notice, "", notice
# The Saved list draws itself: it is a `@gr.render` over the archive
# and the page load is one of its triggers.
return (world_html(passphrase),) + week_parts(passphrase)
def paint(passphrase, drawn):
"""`first_paint`, and not a second time for the same passphrase.
The passphrase reaches the app three ways: already stored on the
device, typed and entered, typed and then clicked away from. The last
two both fire, so that neither is a dead end, and `drawn` β€” the
passphrase the blocks on screen were drawn for β€” makes the second of
them cost nothing.
It starts as None rather than "" because an empty passphrase is a
real state with its own screen (the line asking for one), and that
screen has to be drawn once as well.
"""
key = passphrase or ""
if drawn == key:
return gr.skip(), gr.skip(), gr.skip(), gr.skip()
return first_paint(passphrase) + (key,)
def from_link(passphrase, request: gr.Request):
"""Where the address bar asks the app to open.
The gate comes first. A device that does not know the passphrase yet
opens where it always did β€” on the line asking for one β€” and the
link's request is kept until the passphrase arrives, so following a
link and typing the phrase lands on the same screen as following the
link with the phrase already stored.
"""
tab, aid = link_target(getattr(request, "query_params", None))
if not tab:
return gr.skip(), gr.skip(), None
if not _ok(passphrase):
return gr.skip(), gr.skip(), {"tab": tab, "open": aid}
return gr.Tabs(selected=tab), (aid or gr.skip()), None
def apply_pending(passphrase, want):
"""The held link request, now that the passphrase is in."""
if not want or not _ok(passphrase):
return gr.skip(), gr.skip(), want
return (gr.Tabs(selected=want.get("tab") or "saved"),
want.get("open") or gr.skip(), None)
demo.load(restore, inputs=[saved], outputs=[pw, forget, hint, saved],
show_progress="hidden", api_visibility="private").then(
paint, inputs=[saved, painted],
outputs=[world_out, week_head, week_out, painted],
show_progress="hidden", api_visibility="private").then(
from_link, inputs=[saved], outputs=[tabs, open_id, pending],
show_progress="hidden", api_visibility="private")
# Typing the passphrase has to be enough on its own. Before this, a
# first-time reader entered it and both landing tabs stayed empty until
# they found "Reload the world": the only paint ran on page load, against
# a device that did not know the passphrase yet (reported 23 Aug).
#
# Enter and clicking away are both wired, because on a phone keyboard
# neither one is certain to happen β€” and a wrong passphrase reaches the
# same two lines it was already showing, so nothing here says whether the
# phrase was close.
for entered in (pw.submit, pw.blur):
entered(remember, inputs=[pw], outputs=[saved, pw, forget, hint],
**PRIV).then(
paint, inputs=[pw, painted],
outputs=[world_out, week_head, week_out, painted], **PRIV).then(
apply_pending, inputs=[pw, pending],
outputs=[tabs, open_id, pending], **PRIV)
world_refresh.click(world_html, inputs=[pw], outputs=world_out,
show_progress="hidden", api_visibility="private")
forget.click(forget_it, inputs=None, outputs=[saved, pw, forget, hint, out],
show_progress="hidden", api_visibility="private").then(
paint, inputs=[pw, painted],
outputs=[world_out, week_head, week_out, painted], **PRIV)
# One lane for both triggers, so the upload and the button can never be
# analysing at the same time. `trigger_mode="once"` on each trigger stops
# a listener stacking on itself; the shared `concurrency_id` with a limit
# of one stops the two of them overlapping; `_run_lock` inside `run`
# catches anything arriving through the API route as well.
# Progress stays on the results block. Moving it to the button was tried
# and reverted: Gradio draws only a bar there, and the stage line β€” the
# thing that says what the app is doing β€” never appears at all.
ready = (lambda: gr.update(interactive=True, value="Analyse"), None, go)
# `run` is named so the deploy check can drive the real Space the way a
# browser does. Exposing it is safe: every path through it checks the
# passphrase first, and there is nothing to reach without one.
# One chain, ending on the Saved refresh. It used to be two: a second
# `go.click` carrying the refresh on its own, which Gradio fires as its
# own root β€” so the refresh finished in milliseconds while the analysis
# ran for a minute, and the track just analysed was missing from Saved
# until the reader pressed something else (23 Aug audit). The upload path
# below always chained it correctly, which is why only the button showed
# it.
go.click(_pending, inputs=[pw], outputs=[out, go], trigger_mode="once",
show_progress="hidden", api_visibility="private").then(
remember, inputs=[pw], outputs=[saved, pw, forget, hint],
show_progress="hidden", api_visibility="private").then(
run, inputs=[pw, audio], outputs=out, api_name="analyse", **LANE).then(
*ready, show_progress="hidden", api_visibility="private").then(
saved_refresh, inputs=[pw], outputs=[saved_out, saved_nonce],
show_progress="hidden", api_visibility="private")
# The happy path: open the page, drop a file, read the answer. The
# Analyse button stays for re-runs and for the visit where the passphrase
# is typed after the file is chosen.
audio.upload(_pending, inputs=[pw], outputs=[out, go], trigger_mode="once",
show_progress="hidden", api_visibility="private").then(
remember, inputs=[pw], outputs=[saved, pw, forget, hint],
show_progress="hidden", api_visibility="private").then(
auto_run, inputs=[pw, audio], outputs=out,
api_visibility="private", **LANE).then(
*ready, show_progress="hidden", api_visibility="private").then(
saved_refresh, inputs=[pw], outputs=[saved_out, saved_nonce],
show_progress="hidden", api_visibility="private")
# -- find by name -----------------------------------------------------
find.click(_pending, inputs=[pw], outputs=[out, go],
trigger_mode="once", **PRIV).then(
remember, inputs=[pw], outputs=[saved, pw, forget, hint], **PRIV).then(
run_lookup, inputs=[pw, l_artist, l_title], outputs=out,
api_visibility="private", **LANE).then(
*ready, **PRIV).then(
saved_refresh, inputs=[pw], outputs=[saved_out, saved_nonce], **PRIV)
# -- batch ------------------------------------------------------------
batch_go.click(_pending, inputs=[pw], outputs=[out, go],
trigger_mode="once", **PRIV).then(
remember, inputs=[pw], outputs=[saved, pw, forget, hint], **PRIV).then(
run_batch, inputs=[pw, batch_files], outputs=out,
api_visibility="private", **LANE).then(
*ready, **PRIV).then(
saved_refresh, inputs=[pw], outputs=[saved_out, saved_nonce], **PRIV)
# -- this week --------------------------------------------------------
week_refresh.click(week_parts, inputs=[pw], outputs=[week_head, week_out],
api_visibility="private")
# The inline call on the landing view. Per Kalam's standing preference an
# inline button beats a gated screen, so this moves to the tab rather
# than replacing the week with an upload form.
to_analyse.click(lambda: gr.Tabs(selected="analyse"), None, tabs, **PRIV)
if __name__ == "__main__":
warm_up()
demo.queue(max_size=8).launch(
server_name="0.0.0.0",
server_port=int(os.environ.get("PORT", "7860")),
# the snippet clips live here and are played from inside the results
allowed_paths=[str(SNIP_ROOT)],
css=CSS, theme=THEME)