Spaces:
Running
Running
| """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") | |
| 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()) | |
| 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) | |