Spaces:
Running
Running
| """Analysis dict → one mobile-first HTML block. | |
| The design is the weekly drop sheet's, not a second one: the palette, the | |
| card shape (20px radius, 3px verdict spine), the soft-tinted PLACE / DERIVE / | |
| PASS chip, the big verdict-coloured percentage over a 0–100 scale with the | |
| 78 / 93 cut-offs ticked, and the pill row for tags are all lifted from | |
| `marathon/static/signals.html`. A person who reads the drop sheet on | |
| their phone should recognise this screen as the same product. | |
| Two rules the drop sheet also follows and this page must not break: | |
| verdict cut-offs are always quoted next to the chip, and denominators are | |
| always shown. Nothing here is written in ML vocabulary — the readers are | |
| label and A&R people, not engineers. | |
| Dark mode follows Gradio's `.dark` body class rather than the OS setting, so | |
| the block can never end up light inside a dark page (or the reverse). | |
| Deezer previews are resolved server-side into plain <audio src> tags. The | |
| drop sheet resolves them client-side via JSONP, but scripts injected into a | |
| Gradio HTML component do not run, and the preview URLs Deezer returns expire | |
| within about a day — which is fine here, because the person listening is the | |
| person who just pressed the button. The snippet players are the same plain | |
| <audio> tag, pointed at the clip files the app cut out of the upload and | |
| serves back through Gradio's own file route. | |
| """ | |
| from __future__ import annotations | |
| import html | |
| import json | |
| import time | |
| import urllib.request | |
| from concurrent.futures import ThreadPoolExecutor | |
| import livematch | |
| import tags as tagmod | |
| import worldmap | |
| ESC = html.escape | |
| # The caption that rides next to every chip, so a number is never shown | |
| # without the rule that turned it into a verdict. Same wording as the sheet. | |
| CAPTION = { | |
| "place": "over 93 · direct fit", | |
| "derive": "78–93 · bridgeable", | |
| "pass": "under 78 · not adjacent", | |
| # Neither of these is a verdict. Both quote the rule that made them | |
| # refuse to be one, because the caption's job on this page is to carry | |
| # the cut-off next to the word. | |
| "self": "over 99.5 · the same recording", | |
| "flat": "no ranking to read", | |
| } | |
| # ONE NAME PER VERDICT. The sheet used to print PLACE on the chip, "Push what | |
| # we have" on the heading above it and "Place" in How it works — three names | |
| # for one decision, which a reader has to learn before the page means | |
| # anything. The chip carries the short word; the heading above a group keeps | |
| # the long one; nothing else invents a third. | |
| CHIP = {"place": "PUSH", "derive": "MAKE", "pass": "SKIP", | |
| "self": "SELF", "flat": "FLAT"} | |
| # The long form, used as a group heading and nowhere else. | |
| HEADING = {"place": "Push what we have", "derive": "Make these", | |
| "pass": "Skipped this week"} | |
| # The chart sources by the names they publish under. The corpus carries them | |
| # as the keys the harvest uses; a reader knows them as products. Same list | |
| # `weekly.SOURCE_LABELS` prints on the sheet. | |
| SOURCE_NAMES = {"youtube": "YouTube", "soundcloud": "SoundCloud", | |
| "beatport": "Beatport", "chartmetric": "Chartmetric"} | |
| def chip(band: str) -> str: | |
| """`<span class="chip …">WORD</span>` for any of the five states.""" | |
| b = band if band in CHIP else "flat" | |
| return f'<span class="chip {b}">{CHIP[b]}</span>' | |
| # The self-match cut-off, and the reasoning behind it, live in worldmap.py — | |
| # the opening summary has to say the same thing about the same track as the | |
| # market card does, so there is one number, not two. | |
| SAME_RECORDING = worldmap.SAME_RECORDING | |
| # --- Deezer preview lookups, inside Deezer's rate limit -------------------- | |
| # | |
| # The sheet resolves about 75 distinct track ids per render, and every tab | |
| # render resolved all of them again. Deezer allows 50 requests per 5 seconds | |
| # per IP; eight parallel workers went straight through it, and the calls that | |
| # lost returned HTTP **200** carrying `{"error": {"code": 4, "message": | |
| # "Quota limit exceeded"}}`. The old `except Exception` never saw that — a | |
| # quota refusal looked exactly like a track with no preview — so a random | |
| # quarter of the players on the page were dead on every render, and reloading | |
| # moved which ones. Reported live, 22 Aug. | |
| # | |
| # Three parts, in the order they matter: | |
| # | |
| # 1. a cache, so a re-render costs nothing. Deezer's preview URLs live | |
| # about a day, so 20 hours is a TTL that expires before the URL does. | |
| # 2. a throttle, so a first render stays inside the window it was breaking. | |
| # 3. one retry for the ids that were refused, after the window has passed. | |
| # | |
| # A refusal is never cached as "no preview". A genuine miss is cached, but | |
| # only briefly: a track can gain a preview, and re-asking once a minute is | |
| # cheap now that the hits are free. | |
| PREVIEW_TTL_S = 20 * 3600 | |
| PREVIEW_MISS_TTL_S = 60 | |
| DEEZER_WORKERS = 4 | |
| # Deezer's own limit is 50 per 5 seconds. 40 leaves room for anything else on | |
| # the same IP — the Space is one process but not the only caller of this API. | |
| DEEZER_CHUNK = 40 | |
| DEEZER_WINDOW_S = 5.5 | |
| QUOTA_BACKOFF_S = 5.5 | |
| # id -> (url or None, fetched_at). Module-level on purpose: the Space is one | |
| # long-lived process and every tab render wants the same few dozen ids. | |
| _preview_cache: dict[object, tuple[str | None, float]] = {} | |
| def _deezer_one(tid, timeout: float) -> tuple[str | None, bool]: | |
| """`(preview url or None, refused)`. | |
| `refused` is Deezer saying the quota is spent, which arrives as a 200 | |
| with an error body. It must not be read as "this track has no preview". | |
| """ | |
| try: | |
| with urllib.request.urlopen( | |
| f"https://api.deezer.com/track/{tid}", timeout=timeout) as r: | |
| body = json.load(r) or {} | |
| except Exception: | |
| return None, False | |
| err = body.get("error") or {} | |
| if err: | |
| quota = (err.get("code") == 4 | |
| or "quota" in str(err.get("message", "")).lower()) | |
| return None, bool(quota) | |
| return body.get("preview") or None, False | |
| def deezer_previews(ids: list[int], timeout: float = 6.0) -> dict[int, str]: | |
| """Resolve preview URLs, keyed exactly as they were asked for. | |
| Ids arrive as ints from a signal's own record and as strings from the | |
| tracks table's JSON, and both shapes have to find their URL, so the key | |
| is passed through untouched. | |
| """ | |
| ids = [i for i in dict.fromkeys(ids) if i] | |
| if not ids: | |
| return {} | |
| now = time.time() | |
| out: dict = {} | |
| pending = [] | |
| for i in ids: | |
| hit = _preview_cache.get(i) | |
| if hit is not None: | |
| url, at = hit | |
| if now - at < (PREVIEW_TTL_S if url else PREVIEW_MISS_TTL_S): | |
| if url: | |
| out[i] = url | |
| continue | |
| pending.append(i) | |
| for attempt in (0, 1): | |
| if not pending: | |
| break | |
| if attempt: | |
| # The whole batch was refused inside one 5-second window, so the | |
| # window has to pass before asking again. | |
| time.sleep(QUOTA_BACKOFF_S) | |
| refused = [] | |
| chunks = [pending[n:n + DEEZER_CHUNK] | |
| for n in range(0, len(pending), DEEZER_CHUNK)] | |
| for n, chunk in enumerate(chunks): | |
| if n: | |
| time.sleep(DEEZER_WINDOW_S) | |
| with ThreadPoolExecutor(max_workers=DEEZER_WORKERS) as pool: | |
| got = list(pool.map(lambda t: _deezer_one(t, timeout), chunk)) | |
| for tid, (url, quota) in zip(chunk, got): | |
| if quota: | |
| refused.append(tid) | |
| continue | |
| _preview_cache[tid] = (url, time.time()) | |
| if url: | |
| out[tid] = url | |
| pending = refused | |
| return out | |
| CSS = """ | |
| <style> | |
| .ml { color-scheme:light; | |
| --bg:#fbfbf9; --card:#fff; --inset:#f3f2ef; --line:#e6e4df; | |
| --ink:#141417; --ink2:#55534e; --ink3:#97948c; | |
| --place:#0e9f6e; --place-soft:#e3f5ec; | |
| --derive:#f2610d; --derive-soft:#fdeee3; | |
| --pass:#8a887f; --pass-soft:#eeedea; | |
| /* Neither of these two is a verdict, and neither may look like one. The | |
| self tone is cool and quiet — the record meeting its own reflection — | |
| and the flat tone is the page's own grey, so a chip that refuses to | |
| rank reads as the absence of a colour rather than a fourth colour. */ | |
| --self:#5c6b80; --self-soft:#eef1f5; | |
| --flat:#97948c; --flat-soft:#f3f2ef; | |
| /* Map fills. Softer than the chip colours because a whole country carries | |
| far more area than a chip does. `--map-derive` is paler than the | |
| others on purpose: an upload scores above the 78 line almost everywhere, | |
| so derive covers most of the world and the place greens are the finding | |
| that has to survive it. `--map-none` is deliberately barely off the | |
| card: an uncovered country should give the world its shape and nothing | |
| more. */ | |
| --map-none:#eeece6; --map-place:#5dc59c; --map-derive:#f9d0b6; | |
| --map-pass:#d7d5ce; | |
| /* The sheet's MAKE tone. `--map-derive` above is paled for the analysis | |
| map, where derive covers most of the world; on the weekly map MAKE is | |
| one or two markets and the same tint vanished (seen live, 23 Aug). */ | |
| --map-make:#f0925a; | |
| /* The flat-field tone: one step off `--map-none`, enough to show which | |
| markets were measured and not enough to look like a choice. */ | |
| --map-flat:#dcd8cd; | |
| /* The track's own chart entry. Not one of the three verdicts — a market | |
| the record is already on is not a market to place it in. */ | |
| --map-self:#a7b4c6; | |
| font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif; | |
| color:var(--ink); max-width:720px; margin:0 auto; } | |
| .ml * { box-sizing:border-box; } | |
| .ml .kicker { font-size:11px; font-weight:700; letter-spacing:.18em; | |
| color:var(--ink3); text-transform:uppercase; } | |
| .ml .hdr { font-size:26px; font-weight:800; letter-spacing:-.02em; margin:3px 0 6px; | |
| line-height:1.15; } | |
| .ml .sub { color:var(--ink2); font-size:13.5px; margin:0 0 4px; } | |
| .ml h2 { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; | |
| font-size:19px; font-weight:800; letter-spacing:-.01em; margin:28px 0 10px; | |
| color:var(--ink); } | |
| .ml h2 .tag { font-size:11.5px; font-weight:700; letter-spacing:.12em; | |
| color:var(--ink3); text-transform:uppercase; } | |
| .ml .lab { font-size:10.5px; font-weight:800; letter-spacing:.14em; | |
| color:var(--ink3); text-transform:uppercase; margin:14px 0 7px; } | |
| .ml .card { background:var(--card); border:1px solid var(--line); | |
| border-left-width:3px; border-left-color:var(--line); border-radius:20px; | |
| padding:16px; margin-bottom:14px; box-shadow:0 1px 3px rgba(0,0,0,.05); } | |
| .ml .card.place { border-left-color:var(--place); } | |
| .ml .card.derive { border-left-color:var(--derive); } | |
| .ml .card.pass { border-left-color:var(--pass); } | |
| .ml .card.self { border-left-color:var(--self); } | |
| .ml .card.flat { border-left-width:1px; } | |
| /* A saved card is a control: the whole of it opens the report. The chevron | |
| is the affordance every list on a phone uses to say so, and the card lifts | |
| under the finger. */ | |
| .ml .card.tap { margin-bottom:0; transition:border-color .12s, box-shadow .12s; } | |
| .savedcard:hover .ml .card.tap, .savedcard:focus-within .ml .card.tap { | |
| border-color:var(--ink3); box-shadow:0 2px 10px rgba(0,0,0,.08); } | |
| .savedcard:active .ml .card.tap { box-shadow:0 1px 2px rgba(0,0,0,.06); } | |
| .ml .chev { font-size:22px; line-height:1; color:var(--ink3); | |
| margin-left:2px; flex:0 0 auto; } | |
| .ml .chip { font-size:11px; font-weight:800; letter-spacing:.08em; | |
| padding:3px 9px; border-radius:6px; white-space:nowrap; } | |
| .ml .chip.place { color:var(--place); background:var(--place-soft); } | |
| .ml .chip.derive { color:var(--derive); background:var(--derive-soft); } | |
| .ml .chip.pass { color:var(--pass); background:var(--pass-soft); } | |
| .ml .chip.self { color:var(--self); background:var(--self-soft); } | |
| /* A clip's chip says what the clip is for, not what a market is worth. */ | |
| .ml .chip.deliver { color:var(--place); background:var(--place-soft); } | |
| .ml .chip.alt { color:var(--ink2); background:var(--inset); } | |
| .ml .card.deliver { border-left-color:var(--place); } | |
| .ml .card.alt { border-left-width:1px; } | |
| /* Four rows, three words, and a bar carrying the order. The floats that | |
| used to sit here were the model's own scale printed raw: nobody can act | |
| on the difference between 0.62 and 0.58, and the pair reads as precision | |
| that is not there. */ | |
| .ml .bands { display:grid; grid-template-columns:auto auto 1fr; gap:7px 10px; | |
| align-items:center; margin:12px 0 2px; } | |
| .ml .bands .bl { font-size:10.5px; font-weight:800; letter-spacing:.12em; | |
| text-transform:uppercase; color:var(--ink3); white-space:nowrap; } | |
| .ml .bands .bw { font-size:13.5px; font-weight:700; } | |
| .ml .bands .bb { position:relative; height:6px; border-radius:3px; | |
| background:var(--inset); } | |
| .ml .bands .bb i { display:block; height:100%; border-radius:3px; | |
| background:var(--ink3); } | |
| .ml .bands .bb u { position:absolute; top:-2px; bottom:-2px; width:1.5px; | |
| background:var(--line); } | |
| .ml .chip.flat { color:var(--flat); background:var(--flat-soft); } | |
| .ml .mkt { display:flex; align-items:center; gap:9px; padding-bottom:12px; | |
| border-bottom:1px solid var(--line); } | |
| .ml .mkt .name { font-size:18px; font-weight:800; line-height:1.25; min-width:0; | |
| overflow:hidden; text-overflow:ellipsis; } | |
| .ml .iso { font-size:11.5px; font-weight:700; letter-spacing:.1em; | |
| color:var(--ink3); flex:none; } | |
| .ml .mkt .chip { margin-left:auto; } | |
| .ml .sim { padding:13px 0 0; } | |
| .ml .simtop { display:flex; justify-content:space-between; align-items:flex-end; | |
| gap:6px 10px; flex-wrap:wrap; } | |
| .ml .simcaption { margin-left:auto; } | |
| .ml .simpct { font-size:30px; font-weight:800; line-height:1; | |
| font-variant-numeric:tabular-nums; } | |
| .ml .simpct small { font-size:15px; font-weight:700; } | |
| .ml .simto { font-size:15px; font-weight:700; margin-top:3px; } | |
| .ml .simcaption { font-size:12.5px; color:var(--ink3); font-weight:600; | |
| text-align:right; white-space:nowrap; } | |
| .ml .scale { position:relative; height:8px; border-radius:4px; | |
| background:var(--inset); margin:10px 0 2px; } | |
| .ml .scale .fill { position:absolute; inset:0 auto 0 0; border-radius:4px; } | |
| .ml .scale .tick { position:absolute; top:-2px; bottom:-2px; width:1.5px; | |
| background:var(--line); } | |
| .ml .den { color:var(--ink3); font-size:12.5px; margin:9px 0 2px; } | |
| .ml .row { display:flex; align-items:center; gap:10px; padding:8px 0; | |
| font-size:14.5px; border-top:1px solid var(--line); } | |
| .ml .pct { width:42px; flex:none; font-weight:700; font-size:13.5px; | |
| font-variant-numeric:tabular-nums; } | |
| .ml .lbl { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; | |
| white-space:nowrap; } | |
| .ml .rk { color:var(--ink3); font-size:11.5px; flex:none; | |
| font-variant-numeric:tabular-nums; } | |
| .ml audio { height:32px; max-width:172px; flex:none; } | |
| .ml .snip { display:flex; align-items:center; gap:9px; flex-wrap:wrap; } | |
| .ml .idx { width:22px; height:22px; border-radius:6px; background:var(--inset); | |
| color:var(--ink2); font-size:12px; font-weight:700; display:grid; | |
| place-items:center; flex:none; } | |
| .ml .time { font-size:19px; font-weight:800; letter-spacing:-.01em; | |
| font-variant-numeric:tabular-nums; } | |
| .ml .aff { margin-left:auto; font-size:19px; font-weight:800; | |
| font-variant-numeric:tabular-nums; } | |
| .ml .why { color:var(--ink2); font-size:13px; margin-top:9px; } | |
| .ml .player { display:flex; align-items:center; gap:10px; margin-top:11px; | |
| background:var(--inset); border-radius:12px; padding:8px 11px; } | |
| .ml .player .plab { font-size:10.5px; font-weight:800; letter-spacing:.14em; | |
| color:var(--ink3); flex:none; } | |
| .ml .player audio { flex:1; width:100%; max-width:100%; height:34px; } | |
| .ml .tags { display:flex; flex-wrap:wrap; gap:6px; margin:0 0 4px; } | |
| .ml .pill { display:inline-flex; align-items:center; gap:7px; | |
| border:1px solid var(--line); background:var(--bg); color:var(--ink2); | |
| border-radius:999px; padding:5px 11px; font-size:13px; font-weight:600; } | |
| .ml .pill b { color:var(--ink); font-weight:700; } | |
| .ml .pill .n { color:var(--ink3); font-weight:600; font-size:12.5px; | |
| font-variant-numeric:tabular-nums; } | |
| /* The tags linkage: one charting record per block, with the labels it | |
| carries under it. A `.row` cannot hold this — the pills need a line of | |
| their own — so the percentage and the title sit on the first line and | |
| everything the record carries hangs under them, indented to the title. */ | |
| .ml .evh { font-size:15px; font-weight:800; letter-spacing:-.01em; | |
| margin:16px 0 7px; } | |
| .ml .nrow { border-top:1px solid var(--line); padding:9px 0; } | |
| .ml .nrow:first-child { border-top:0; padding-top:0; } | |
| .ml .nrow .ntop { display:flex; align-items:baseline; gap:10px; | |
| font-size:14.5px; } | |
| .ml .nrow .lbl { font-weight:600; } | |
| .ml .nrow .nwhere { color:var(--ink3); font-size:12.5px; margin:2px 0 0 52px; } | |
| .ml .nrow .tags { margin:7px 0 0 52px; } | |
| .ml .nrow audio { margin:7px 0 0 52px; } | |
| .ml .copy { width:100%; border:1px solid var(--line); border-radius:12px; | |
| padding:10px 12px; background:var(--inset); color:var(--ink); resize:vertical; | |
| font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; } | |
| /* The report's three sections as tabs. The radios are the state, the labels | |
| are the control and `:has()` does the switching — the same machinery the | |
| weekly sheet's filters use, and for the same reason: no script runs inside | |
| a Gradio HTML component. A browser without `:has()` applies none of these | |
| rules and gets all three sections stacked, which is the page as it was. | |
| `#rt-fits` is the default tab because the map and the chips above jump to | |
| anchors inside that section. */ | |
| .ml .rt { display:none; } | |
| /* -- the segmented control --------------------------------------------- | |
| Kalam, reading the report on a phone (23 Aug): the tab row is pills on a | |
| page full of pills, and nothing about it says "this switches the view". | |
| It said the same about the sheet's Show row. So the app has one visual | |
| rule now, and it is a rule about behaviour: JOINED SEGMENTS SWITCH, | |
| SEPARATE PILLS FILTER. A market chip is additive — tap two and you see | |
| both — and it stays a pill. A view-switcher is exclusive — one of these | |
| is always on and choosing one un-chooses the rest — and it is one | |
| bordered container with the segments flush inside it, which is the | |
| control every phone OS uses for exactly that. | |
| The machinery underneath is unchanged: radios carry the state, labels | |
| are the control, `:has()` does the switching, and a browser without | |
| `:has()` gets everything stacked. */ | |
| .ml .seg { display:flex; border:1px solid var(--line); border-radius:12px; | |
| overflow:hidden; background:var(--card); } | |
| .ml .seg label { flex:1 1 0; min-width:0; cursor:pointer; text-align:center; | |
| padding:8px 7px; line-height:1.25; font-size:13px; font-weight:700; | |
| color:var(--ink); border-left:1px solid var(--line); } | |
| .ml .seg label:first-of-type { border-left:0; } | |
| /* The count drops under the word rather than sitting beside it: three | |
| segments have to share 375px, and a name plus its count on one line | |
| does not fit in a third of that. */ | |
| .ml .seg label .n { display:block; margin-top:2px; font-size:11px; | |
| font-weight:600; color:var(--ink3); font-variant-numeric:tabular-nums; } | |
| .ml .seg label .d { display:inline-block; width:8px; height:8px; | |
| border-radius:99px; margin-right:6px; vertical-align:0; } | |
| .ml .seg label .d.place { background:var(--place); } | |
| .ml .seg label .d.derive { background:var(--derive); } | |
| .ml .seg label .d.pass { background:var(--pass); } | |
| /* Sticky so the reader can change section from anywhere in a long report | |
| instead of scrolling back up (Kalam, 23 Aug). An opaque ground stops the | |
| cards showing through while it is stuck. */ | |
| .ml .rtabs { margin:20px 0 10px; position:sticky; top:0; z-index:6; | |
| background:var(--bg); padding:8px 0; } | |
| .ml:has(#rt-fits:checked) .rgrp:not(.g-fits) { display:none; } | |
| .ml:has(#rt-clips:checked) .rgrp:not(.g-clips) { display:none; } | |
| .ml:has(#rt-tags:checked) .rgrp:not(.g-tags) { display:none; } | |
| /* The selected segment is filled, not outlined: an outline is what the | |
| pills already wear, and the point of this control is to not look like | |
| them. */ | |
| .ml:has(#rt-fits:checked) label[for="rt-fits"], | |
| .ml:has(#rt-clips:checked) label[for="rt-clips"], | |
| .ml:has(#rt-tags:checked) label[for="rt-tags"] { | |
| background:var(--ink); color:var(--bg); } | |
| .ml:has(#rt-fits:checked) label[for="rt-fits"] .n, | |
| .ml:has(#rt-clips:checked) label[for="rt-clips"] .n, | |
| .ml:has(#rt-tags:checked) label[for="rt-tags"] .n { | |
| color:var(--bg); opacity:.72; } | |
| /* Disclosures. A summary is the one control this page can have that costs | |
| no script: the browser opens and closes it on its own. The triangle is | |
| drawn in CSS so it can flip, and the native marker is dropped so the row | |
| reads as one line of small caps rather than a bullet. */ | |
| .ml details { margin-top:11px; } | |
| .ml details > summary { list-style:none; cursor:pointer; font-size:10.5px; | |
| font-weight:800; letter-spacing:.14em; text-transform:uppercase; | |
| color:var(--ink2); padding:7px 13px; display:inline-block; | |
| border:1px solid var(--line); border-radius:999px; | |
| background:var(--card); } | |
| .ml details > summary::-webkit-details-marker { display:none; } | |
| .ml details > summary::after { content:" \\25be"; } | |
| .ml details[open] > summary::after { content:" \\25b4"; } | |
| .ml details > summary:hover { color:var(--ink); border-color:var(--ink3); } | |
| .ml details[open] > summary { border-color:var(--ink); background:var(--inset); } | |
| /* The line above a card's title: "#1 IN BRITAIN". */ | |
| .ml .kick { font-size:10.5px; font-weight:800; letter-spacing:.14em; | |
| text-transform:uppercase; color:var(--ink3); margin:0 0 7px; } | |
| /* The one thing to do about this card, in a sentence. */ | |
| .ml .act { font-size:14.5px; line-height:1.45; margin-top:12px; | |
| color:var(--ink); } | |
| .ml .act b { font-weight:800; } | |
| .ml .act .guard { color:var(--ink2); } | |
| .ml .note { background:var(--inset); border-radius:12px; padding:12px 14px; | |
| margin-top:12px; color:var(--ink2); font-size:13px; } | |
| .ml .note .lab { margin:0 0 4px; } | |
| .ml .note b { color:var(--ink); } | |
| .ml .caveats { border-top:1px solid var(--line); margin-top:30px; | |
| padding-top:14px; color:var(--ink2); font-size:13px; } | |
| .ml .caveats ul { margin:0; padding:0; list-style:none; display:grid; gap:11px; } | |
| .ml .caveats li { display:grid; grid-template-columns:18px 1fr; gap:8px; } | |
| .ml .caveats b { color:var(--ink); } | |
| .ml .caveats .bul { width:7px; height:7px; border-radius:99px; | |
| background:var(--line); margin-top:7px; } | |
| .ml .foot { color:var(--ink3); font-size:12px; margin-top:24px; line-height:1.55; } | |
| /* -- the opening block: summary, then the map, then the market chips ----- */ | |
| .ml .lede { background:var(--card); border:1px solid var(--line); | |
| border-radius:20px; padding:16px 16px 7px; margin:14px 0 12px; | |
| box-shadow:0 1px 3px rgba(0,0,0,.05); } | |
| .ml .lede p { margin:0 0 10px; font-size:15.5px; line-height:1.5; } | |
| .ml .lede b { font-weight:800; } | |
| /* The number-one action, closing the summary. A rule above it, because it | |
| is a different kind of sentence from the ones it follows: those describe | |
| the reading, this one says what to do about it. Same `.act` type as the | |
| copy on the market card it was hoisted from — one sentence, one look. */ | |
| .ml .lede .act { margin:0 0 12px; padding-top:12px; | |
| border-top:1px solid var(--line); } | |
| .ml .mapwrap { background:var(--card); border:1px solid var(--line); | |
| border-radius:20px; padding:10px 12px 8px; margin:0 0 14px; | |
| box-shadow:0 1px 3px rgba(0,0,0,.05); } | |
| /* width:100% + height:auto against the viewBox is what keeps the map inside | |
| 375px with no horizontal scroll: the intrinsic ratio does the sizing, and | |
| nothing in here has a pixel width. */ | |
| .ml svg.map { display:block; width:100%; height:auto; } | |
| .ml svg.map .cty { fill:var(--map-none); stroke:var(--card); stroke-width:.5; } | |
| .ml svg.map .cty.place { fill:var(--map-place); } | |
| .ml svg.map .cty.derive { fill:var(--map-derive); } | |
| /* On the weekly sheet MAKE must be seen, so its map and legend take the | |
| stronger tone; the analysis map keeps the pale one. */ | |
| .ml svg.map.wkmap .cty.derive { fill:var(--map-make); } | |
| .ml .maplegend.wk i.derive { background:var(--map-make); } | |
| .ml svg.map .cty.pass { fill:var(--map-pass); } | |
| .ml svg.map .cty.self { fill:var(--map-self); } | |
| /* A track that reads the same everywhere gets no colours. The paragraph | |
| above the map says nothing stands out; the map must not contradict it. | |
| See worldmap.FLAT_SPREAD and reports/AU_CA_CHART_CHECK.md. */ | |
| .ml svg.map.flat .cty.place, .ml svg.map.flat .cty.derive, | |
| .ml svg.map.flat .cty.pass { fill:var(--map-flat); } | |
| .ml svg.map a { cursor:pointer; } | |
| .ml .maplegend { display:flex; flex-wrap:wrap; gap:6px 14px; margin:6px 2px 2px; | |
| color:var(--ink3); font-size:11.5px; font-weight:600; } | |
| .ml .maplegend i { display:inline-block; width:10px; height:10px; | |
| border-radius:3px; margin-right:5px; vertical-align:-1px; } | |
| .ml .maplegend i.place { background:var(--map-place); } | |
| .ml .maplegend i.derive { background:var(--map-derive); } | |
| .ml .maplegend i.pass { background:var(--map-pass); } | |
| .ml .maplegend i.none { background:var(--map-none); } | |
| .ml .maplegend i.flat { background:var(--map-flat); } | |
| .ml .maplegend i.self { background:var(--map-self); } | |
| .ml .chiprow { display:flex; flex-wrap:wrap; gap:7px; margin:0 0 16px; } | |
| .ml .mchip { display:inline-flex; align-items:center; gap:7px; | |
| text-decoration:none; border:1px solid var(--line); background:var(--card); | |
| color:var(--ink); border-radius:999px; padding:8px 12px; font-size:13.5px; | |
| font-weight:700; line-height:1.2; } | |
| .ml .mchip .d { width:8px; height:8px; border-radius:99px; flex:none; } | |
| .ml .mchip .d.place { background:var(--place); } | |
| .ml .mchip .d.derive { background:var(--derive); } | |
| .ml .mchip .d.pass { background:var(--pass); } | |
| .ml .mchip .d.flat { background:var(--map-flat); } | |
| .ml .mchip .d.self { background:var(--map-self); } | |
| .ml .mchip.flat { color:var(--ink2); } | |
| .ml .mchip .n { font-variant-numeric:tabular-nums; font-weight:800; } | |
| /* An anchored card must not land flush against the top of the scroll box. */ | |
| .ml .card[id] { scroll-margin-top:14px; } | |
| /* On a phone the native audio control eats ~170px, which truncated every | |
| record to "Diamond Pla…". Give the title the full width and drop the | |
| player onto its own line. */ | |
| @media (max-width: 560px) { | |
| .ml .row { flex-wrap:wrap; padding:9px 0; } | |
| .ml .lbl { white-space:normal; overflow:visible; } | |
| .ml audio { flex:1 0 100%; max-width:100%; margin-top:6px; } | |
| .ml .hdr { font-size:22px; } | |
| .ml .card { padding:14px; border-radius:18px; } | |
| .ml .mapwrap { padding:8px 9px 6px; } | |
| /* No room for a 52px indent once the pills start wrapping. */ | |
| .ml .nrow .nwhere, .ml .nrow .tags, .ml .nrow audio { margin-left:0; } | |
| } | |
| /* Follows the Gradio theme class, never the OS: the block must not be dark | |
| inside a light page. Same dark palette as the drop sheet. */ | |
| .dark .ml { color-scheme:dark; --bg:#161615; --card:#1e1e1c; --inset:#262624; --line:#343330; | |
| --ink:#f5f4f0; --ink2:#b8b5ac; --ink3:#7c7a72; | |
| --place:#2fbf8a; --place-soft:#12312454; | |
| --derive:#ff7a2f; --derive-soft:#3a220f54; | |
| --pass:#8a887f; --pass-soft:#2a29264d; | |
| --self:#9fb0c6; --self-soft:#1e26324d; | |
| --flat:#7c7a72; --flat-soft:#2626244d; | |
| --map-none:#34342f; --map-place:#379973; --map-derive:#6b3c1c; | |
| --map-make:#d07c33; | |
| --map-pass:#454440; --map-flat:#3a3935; --map-self:#4a5566; } | |
| .dark .ml .card { box-shadow:none; } | |
| </style> | |
| """ | |
| def _pool_line(r: dict) -> str: | |
| return (f'Compared against {r["matched"]} of the {r["pool"]} sounds ' | |
| f'charting there this week') | |
| def _fmt_time_frac(t: float) -> str: | |
| """m:ss.d clock time — livematch.fmt_time rounds to whole seconds, which | |
| would swallow the sub-second nudge this format exists to show.""" | |
| tenths = round(max(0.0, float(t)) * 10) | |
| m, r = divmod(tenths, 600) | |
| return f"{m}:{r // 10:02d}.{r % 10}" | |
| # A chart this short is a short list, not a market reading. The median market | |
| # in the corpus carries about 100 charting sounds; the smallest carry 17–20, | |
| # and a best-of over twenty records is a different claim from a best-of over a | |
| # hundred. Both numbers are printed either way — this is the line at which the | |
| # card says what they mean. | |
| THIN_POOL = 25 | |
| # And the other way a pool can be thin: most of the chart could not be | |
| # measured because no preview was found for it. | |
| THIN_COVERAGE = 0.75 | |
| def _market_action(res: dict, iso: str, i: int, b: str) -> str: | |
| """One sentence saying what this market is for, built from the card's own | |
| numbers plus two honesty guards. | |
| Every element is dropped rather than invented when the data is not there: | |
| a nearest record with no chart position gets a sentence with no chart | |
| position in it. The rank and the pool sizes come from the corpus export | |
| (`livematch.rank_regions` carries `rank` on every row and `pool` / | |
| `matched` on every region), so nothing here is estimated. | |
| """ | |
| regions = res["regions"] | |
| r = regions[iso] | |
| name = ESC(r["name"]) | |
| # "charting in United States" is what a table of names gives you and | |
| # what a sentence cannot use, so a name after a preposition takes the | |
| # article form (`worldmap.in_market`). | |
| where = ESC(worldmap.in_market(iso, r.get("name"))) | |
| top = (r.get("top") or [None])[0] | |
| rank = (top or {}).get("rank") | |
| at_rank = f' at #{rank}' if rank else '' | |
| # Does an earlier market rest on the same record? That turns two markets | |
| # into one conversation, and it is the single most useful thing the page | |
| # can say about the second one. | |
| shared_with = None | |
| if top: | |
| me = (top.get("artist"), top.get("title")) | |
| for other in res.get("shown_markets", [])[:i]: | |
| o = (regions.get(other) or {}).get("top") or [None] | |
| if o[0] and (o[0].get("artist"), o[0].get("title")) == me: | |
| shared_with = regions[other]["name"] | |
| break | |
| if b == "self": | |
| return '' | |
| if b == "flat": | |
| # Said once, at the top of the list. Eight cards each saying "this | |
| # market reads level with every other market" is the same sentence | |
| # eight times, and repetition makes an absence look like a finding. | |
| if i: | |
| return '' | |
| line = (f'<b>Nothing here to act on.</b> {name} and every other ' | |
| f'market measured read level with each other, so this list ' | |
| f'is places that were checked rather than a ranking.') | |
| elif b == "place" and shared_with: | |
| line = (f'<b>The same record carries {name}{at_rank}</b> — one ' | |
| f'conversation covers both markets.') | |
| elif b == "place" and i == 0: | |
| line = (f'<b>Pitch it into {where} first.</b> The record it sits ' | |
| f'beside is{at_rank or " on the chart"} there this week.' | |
| if rank else | |
| f'<b>Pitch it into {where} first.</b> It is the closest ' | |
| f'market measured this week.') | |
| elif b == "place": | |
| # Not "same pitch, second market": the nearest record differs here, | |
| # and the record is the pitch's substance — a different record is a | |
| # different conversation. And a filtered view shows one card alone, | |
| # so every card carries a complete sentence of its own. | |
| line = (f'<b>Worth an approach in {where}.</b> The record it sits ' | |
| f'beside is{at_rank or " on the chart"} there this week.') | |
| elif b == "derive": | |
| line = (f'<b>Not a placement in {where} as it stands.</b> An edit or ' | |
| f'a remix is what closes the gap to the record it sits beside' | |
| f'{at_rank}.') | |
| else: | |
| line = (f'<b>Nothing on the chart in {where} sits near this ' | |
| f'record.</b> Spend nothing here.') | |
| guards = [] | |
| pool, matched = int(r.get("pool") or 0), int(r.get("matched") or 0) | |
| if pool and pool <= THIN_POOL: | |
| guards.append(f'Read it against a short chart: only {pool} sounds ' | |
| f'chart there and {matched} were measured.') | |
| elif pool and matched / pool < THIN_COVERAGE: | |
| guards.append(f'Only {matched} of the {pool} sounds charting there ' | |
| f'could be measured, so the comparison is against part ' | |
| f'of that chart.') | |
| if _is_outlier(res, iso): | |
| guards.append(f'Treat {name} as an outlier, not a plan — it is the ' | |
| f'only market on this list from its part of the world.') | |
| if guards: | |
| line += f' <span class="guard">{" ".join(guards)}</span>' | |
| return f'<div class="act">{line}</div>' | |
| def _is_outlier(res: dict, iso: str) -> bool: | |
| """One market sitting on its own, in a list that otherwise clusters. | |
| Kenya, Uganda and Tanzania reading close together is a finding. Romania | |
| appearing once among them is a fact about one record on one chart, and a | |
| card that reads the same for both invites a plan for Romania. | |
| """ | |
| shown = [i for i in res.get("shown_markets", []) if i in res["regions"]] | |
| if len(shown) < 4 or iso not in worldmap.CONTINENT: | |
| return False | |
| mine = worldmap.CONTINENT[iso] | |
| same = [i for i in shown if worldmap.CONTINENT.get(i) == mine] | |
| if len(same) != 1: | |
| return False | |
| # Only when the rest of the list does cluster — a list spread across six | |
| # continents has no outliers, it has no pattern. | |
| biggest = max(sum(1 for i in shown if worldmap.CONTINENT.get(i) == c) | |
| for c in set(worldmap.CONTINENT.get(i) for i in shown)) | |
| return biggest * 2 >= len(shown) | |
| # Word bands for the snippet rows. | |
| # | |
| # `hooks.relative` scales every one of these readings by the track's OWN | |
| # average and caps it at twice that, so the number arrives on a fixed scale: | |
| # 0.5 is this record's average, 1.0 is twice its average. That is what makes | |
| # a word possible at all — "strong" here means strong for this record, never | |
| # a comparison with anyone else's. | |
| # | |
| # The cut-offs: a fifth above the record's own average reads as strong, at or | |
| # above average reads as medium, below it reads as quiet. | |
| PART_STRONG, PART_MEDIUM = 0.60, 0.50 | |
| # Where the track's own average sits on the bar, as a percentage of its width. | |
| PART_AVERAGE = 50 | |
| def _pitch_prose(fields: dict, res: dict) -> str: | |
| """The Spotify description box, written as a briefing. | |
| The four-slot template from design/PITCH_GUIDE.md §4a — SOUND, | |
| HOOK/VOICE, NEAREST, CLUSTER — every clause a measurement, every slot | |
| with a drop rule. What stays human is the story, the plan and the moods; | |
| the sentence leaves room for them. | |
| A flat field gets no pitch at all. It used to drop the two market | |
| sentences and keep the sound ones, which produced "Upbeat afro-pop at | |
| 129 BPM. The hook returns through the record and the vocal sits forward | |
| in the mix; the strongest 30 seconds start at 0:30" — every word of it | |
| true, and useless in a pitch box, because an editor hears tempo, hook | |
| and vocal placement in ten seconds (Kalam, 23 Aug). The field earns its | |
| place only when it carries what the measurement knows and the editor | |
| cannot hear: the nearest charting record and where it charts. A flat | |
| field is exactly the reading with none of that, so the caller prints a | |
| note instead of an empty form. | |
| """ | |
| if worldmap.field(res)["flat"]: | |
| return "" | |
| # SOUND — "Mid-tempo afrobeats at 118 BPM." | |
| beat = res.get("beat") or {} | |
| tempo = beat.get("tempo") if beat.get("ok") else None | |
| bits = [] | |
| if tempo: | |
| bits.append(("Slow" if tempo < 95 else "Mid-tempo" if tempo < 125 | |
| else "Upbeat" if tempo < 145 else "Fast").lower()) | |
| main = (fields.get("main_genre") or {}).get("form") | |
| if main: | |
| bits.append(main.lower()) | |
| head = " ".join(bits) if bits else "A track" | |
| head = head[0].upper() + head[1:] | |
| sound = f"{head} at {tempo:.0f} BPM." if tempo else f"{head}." | |
| # HOOK/VOICE — clauses drop one by one, the sentence survives | |
| snips = res.get("snippets") or [] | |
| clauses = [] | |
| top_hook = next((x.get("hook_label") for x in snips | |
| if x.get("hook_label")), None) | |
| if top_hook == "strong": | |
| clauses.append("The hook returns through the record") | |
| elif top_hook == "medium": | |
| clauses.append("The hook holds through the record") | |
| voices = [v for v in ((x.get("hook_parts") or {}).get("voice") | |
| for x in snips) if v is not None] | |
| if voices and max(voices) >= 0.55: | |
| clauses.append(("the vocal sits forward in the mix" if clauses | |
| else "The vocal sits forward in the mix")) | |
| hook = " and ".join(clauses) | |
| # The corrected time, same as the clip card's headline — quoting the | |
| # uncorrected one would put two start times on one page. | |
| start_s = next((x.get("nearest_downbeat") if x.get("nearest_downbeat") | |
| is not None else x.get("start_s") for x in snips | |
| if x.get("start_s") is not None), None) | |
| if start_s is not None: | |
| at = livematch.fmt_time(float(start_s)) | |
| hook = (f"{hook}; the strongest 30 seconds start at {at}." if hook | |
| else f"The strongest 30 seconds start at {at}.") | |
| elif hook: | |
| hook += "." | |
| parts = [sound] + ([hook] if hook else []) | |
| # NEAREST and CLUSTER — the half of the pitch an editor cannot hear. | |
| regions = res.get("regions") or {} | |
| ranked = [i for i in (res.get("shown_markets") or []) | |
| if i in regions | |
| and float(regions[i].get("best") or 0) < worldmap.SAME_RECORDING | |
| and (regions[i].get("pool") or THIN_POOL + 1) > THIN_POOL] | |
| if ranked: | |
| lead = regions[ranked[0]] | |
| top = (lead.get("top") or [{}])[0] | |
| if top.get("artist") and top.get("title"): | |
| lead_where = worldmap.in_market(ranked[0], lead.get("name")) | |
| where = (f'at #{top["rank"]} in {lead_where}' | |
| if top.get("rank") else f'charting in {lead_where}') | |
| parts.append( | |
| f'The closest charting record to it this week is ' | |
| f'{top["artist"]} — "{top["title"]}", {where}.') | |
| others = [worldmap.in_market(i, regions[i].get("name")) | |
| for i in ranked[1:3]] | |
| if others: | |
| listed = (others[0] if len(others) == 1 | |
| else " and ".join(others)) | |
| parts.append(f"It also sits close to records charting " | |
| f"in {listed}.") | |
| return " ".join(parts) | |
| def _no_pitch_note(res: dict) -> str: | |
| """What stands where the pitch box would be on a flat reading. | |
| Not an apology and not an empty field: the measurement has nothing to | |
| add to this pitch, said plainly, with what does sell the record named so | |
| the reader knows where to go next. | |
| """ | |
| n = worldmap.field(res)["n"] | |
| across = f'all {n} markets measured' if n else 'every market measured' | |
| return (f'<div class="den" style="margin-top:16px">Nothing measured this ' | |
| f'week helps this pitch — the track reads level across {across}, ' | |
| f'so there is no chart evidence to quote. What sells this record ' | |
| f'is what only you know: the story, the plan, who it is for. ' | |
| f'Editors hear the tempo and the mood themselves.</div>') | |
| # How many charting records the Tags tab leads with. Six is what fits on a | |
| # phone screen without scrolling past the suggestions the tab is for, and it | |
| # is enough for a pattern to be visible: one record carrying a label is an | |
| # anecdote, six carrying the same one is the neighbourhood. | |
| TAG_NEIGHBOURS = 6 | |
| # `genre_source` in reader English. | |
| GENRE_SOURCE = {"itunes": "iTunes", "deezer": "Deezer"} | |
| def neighbours(res: dict, limit: int = TAG_NEIGHBOURS) -> list[dict]: | |
| """The charting records this track sits closest to, across the markets | |
| the report shows, deduped and strongest first. | |
| One record can lead several markets — Kusslove leads both Kenya and | |
| Tanzania on the real YO YO reading — and listing it twice makes one | |
| record look like two pieces of evidence. It is kept where it first | |
| appears, which is the strongest of those markets, because the shown | |
| markets arrive in rank order. | |
| A record at or above the same-recording mark is the track meeting its own | |
| chart entry, not a neighbour, so it is left out: "your own record carries | |
| these tags" is not evidence about anything. | |
| """ | |
| regions = res.get("regions") or {} | |
| rows: list[dict] = [] | |
| seen: set[tuple] = set() | |
| for iso in res.get("shown_markets") or []: | |
| r = regions.get(iso) | |
| if not r: | |
| continue | |
| for m in r.get("top") or []: | |
| key = (m.get("artist"), m.get("title")) | |
| if key in seen: | |
| continue | |
| sim = float(m.get("similarity") or 0) | |
| if sim >= SAME_RECORDING: | |
| continue | |
| seen.add(key) | |
| rows.append({**m, "iso": iso, | |
| "market": r.get("name") or iso}) | |
| rows.sort(key=lambda m: -float(m.get("similarity") or 0)) | |
| return rows[:limit] | |
| def _neighbour_tags(res: dict, flat_field: bool) -> str: | |
| """"The records this track sits closest to, and the tags they carry." | |
| Kalam's standing ask, unblocked now the corpus is labelled: the tag | |
| section used to suggest words and then, separately, list which genres | |
| happened to appear near the track. This is the linkage — each nearby | |
| record with the tags it actually carries, so a suggestion can be checked | |
| against the records it came from. | |
| An analysis saved before the labels were carried through | |
| `livematch.rank_regions` has rows with no `genres` key at all, and "no | |
| label" would be a claim this page cannot make about them. Those rows get | |
| no block; a re-run brings it back. | |
| """ | |
| rows = neighbours(res) | |
| if not rows or not any("genres" in m for m in rows): | |
| return "" | |
| out = ['<div class="evh">The records this track sits closest to, and ' | |
| 'the tags they carry</div>', '<div class="card flat">'] | |
| for m in rows: | |
| sim = float(m.get("similarity") or 0) | |
| b = "flat" if flat_field else livematch.band(sim) | |
| name = f'{m.get("artist") or "—"} — {m.get("title") or "—"}' | |
| where = ESC(str(m.get("market") or m.get("iso") or "")) | |
| if m.get("rank"): | |
| where += f' · #{int(m["rank"])}' | |
| labels = [g for g in (m.get("genres") or []) if g] | |
| src = GENRE_SOURCE.get(m.get("genre_source") or "") | |
| if labels and src: | |
| where += f' · labels from {src}' | |
| out.append(f'<div class="nrow"><div class="ntop">' | |
| f'<span class="pct" style="color:var(--{b})">' | |
| f'{livematch.pct(sim)}</span>' | |
| f'<span class="lbl">{ESC(name)}</span></div>' | |
| f'<div class="nwhere">{where}</div>') | |
| if labels: | |
| out.append('<div class="tags">' | |
| + "".join(f'<span class="pill">{ESC(g)}</span>' | |
| for g in labels) + '</div>') | |
| else: | |
| # Said, not hidden. A record with no label is a fact about the | |
| # coverage this page quotes a denominator for, and dropping it | |
| # would quietly make the labelled share look total. | |
| out.append('<div class="nwhere">no label at iTunes or ' | |
| 'Deezer</div>') | |
| out.append('</div>') | |
| c = res.get("corpus") or {} | |
| if c.get("genre_labelled") and c.get("sounds"): | |
| out.append(f'<div class="den">Labels cover ' | |
| f'{c["genre_labelled"]:,} of the {c["sounds"]:,} charting ' | |
| f'sounds this week. They are looked up on Apple\'s genre ' | |
| f'list, and on Deezer where Apple carries none — the ' | |
| f'charts publish no genre of their own.</div>') | |
| out.append('</div>') | |
| return "".join(out) | |
| def _word_band(value: float) -> str: | |
| if value >= PART_STRONG: | |
| return "strong" | |
| if value >= PART_MEDIUM: | |
| return "medium" | |
| return "quiet" | |
| def _plural(n: int, unit: str) -> str: | |
| """"1 clip", "2 clips". The tab row printed "1 fields", "1 clips" and | |
| "1 markets" — a count that says the reader cannot count (23 Aug audit). | |
| `unit` is the singular; the s is added where it belongs.""" | |
| return f'{n} {unit if n == 1 else unit + "s"}' | |
| def _tag_rows(f: dict, res: dict) -> list[tuple[str, str, str]]: | |
| """The rows the Tags section actually renders. | |
| Built here rather than inside the section, because the tab above it | |
| prints their count and the two used to disagree: the tab counted | |
| `fields["pitch"]` — the mood and production word list — which the | |
| section stopped rendering when the prose row replaced it, and did not | |
| count the prose row it does render. With the text tower up the two | |
| happened to coincide; with it unavailable, which is a documented state, | |
| they did not (23 Aug audit). | |
| """ | |
| rows: list[tuple[str, str, str]] = [] | |
| if f.get("main_genre"): | |
| mg = f["main_genre"] | |
| rows.append(("FUGA · MAIN GENRE (pick one)", mg["form"], | |
| mg.get("note") or | |
| "Apple and iTunes take the same name on the delivery " | |
| "form.")) | |
| if f.get("subgenre"): | |
| rows.append(("FUGA · SUBGENRE (free text)", f["subgenre"], | |
| "Comma-separated, in FUGA's own free-text field.")) | |
| prose = _pitch_prose(f, res) | |
| if prose: | |
| rows.append(("PITCH FORM · PROSE", prose, | |
| "One sentence for the Spotify or Apple pitch " | |
| "description, from what was measured — add mood words " | |
| "yourself if they help.")) | |
| return rows | |
| def _scale(pct: float, colour: str) -> str: | |
| """The drop sheet's 0–100 bar with the two cut-offs ticked, so the number | |
| is always read against the rule that produced the verdict. | |
| The fill is cut off at the same whole percent the number beside it | |
| prints. Rounding it drew a bar sitting exactly on the tick it was meant | |
| to be under (23 Aug audit). | |
| """ | |
| ticks = "".join(f'<span class="tick" style="left:{x}%"></span>' | |
| for x in (78, 93)) | |
| width = livematch.whole_pct(float(pct) / 100.0) | |
| return (f'<div class="scale"><span class="fill" style="width:{width}%;' | |
| f'background:var(--{colour})"></span>{ticks}</div>') | |
| def render(res: dict, track_label: str, | |
| snippet_clips: list[str | None] | None = None, | |
| snippet_note: str | None = None, | |
| track_audio_url: str | None = None) -> str: | |
| """`snippet_note` replaces the clip shortlist with one explanation. | |
| It exists for the name-lookup path. A looked-up preview is 30 seconds, | |
| which is exactly one window, so a shortlist of one would present the whole | |
| preview as a considered choice. The section says why there is nothing to | |
| choose and asks for the file instead. | |
| """ | |
| # Four markets in the corpus carry the 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 — and every line below prints `r["name"]`. | |
| # Named once here, so no sentence on the page can say "SG". | |
| regions = worldmap.name_regions(res["regions"]) | |
| ids = [m.get("deezer_id") | |
| for iso in res["shown_markets"] | |
| for m in regions[iso]["top"][:3]] | |
| ids += [p["nearest"]["deezer_id"] for p in res["snippets"] | |
| if p.get("nearest") and p["nearest"].get("deezer_id")] | |
| previews = deezer_previews(ids) | |
| clips = list(snippet_clips or []) | |
| # No list at all means the clips have not been cut yet — the first of the | |
| # two yields a fresh analysis makes. A list with a gap in it means this | |
| # window has no clip, which is a different sentence on the card. | |
| cutting = snippet_clips is None | |
| out = [CSS, '<div class="ml">'] | |
| c = res["corpus"] | |
| out.append(f'<div class="kicker">marathonmvp · chart week ' | |
| f'{ESC(str(res["week"]))}</div>') | |
| out.append(f'<div class="hdr">{ESC(track_label)}</div>') | |
| # The scale is dropped rather than printed as zeroes when the stored | |
| # result does not carry it — an older build's result, or a corpus | |
| # exported before a key existed. A number a page cannot stand behind is | |
| # worse than a shorter sentence. | |
| scale = '' | |
| if c.get("sounds") and c.get("regions"): | |
| scale = (f' · compared against {int(c["sounds"]):,} sounds charting ' | |
| f'in {int(c["regions"])} markets this week') | |
| out.append(f'<div class="sub">{res["duration_s"]:.0f} seconds long' | |
| f'{scale}.</div>') | |
| # The track itself, playable at the top — every market card below quotes | |
| # a trending sound with its own player, and comparing means hearing both. | |
| if track_audio_url: | |
| out.append(f'<div class="player"><span class="plab">THE TRACK</span>' | |
| f'<audio controls preload="none" ' | |
| f'src="{ESC(track_audio_url)}"></audio></div>') | |
| elif not cutting: | |
| # A report with no track player used to draw nothing at all, so the | |
| # reader was left comparing against a record they could not hear and | |
| # no reason for it (23 Aug audit). While the clips are still being | |
| # cut this stays quiet — the player is on its way with them. | |
| try: | |
| import store as storemod | |
| cap = f"{storemod.SOURCE_AUDIO_CAP // (1024 * 1024)} MB" | |
| except Exception: | |
| cap = "25 MB" | |
| out.append( | |
| f'<div class="note"><div class="lab">NO TRACK PLAYER</div>' | |
| f'<p class="why"><b>The whole track is not kept with this ' | |
| f'analysis</b>, so there is nothing to play here. The clip ' | |
| f'shortlist below is unaffected. A file over {cap} is never ' | |
| f'kept, which is why a WAV master reads this way — analyse an ' | |
| f'mp3 or m4a export of it to have the record playable here.' | |
| f'</p></div>') | |
| # -- markets ---------------------------------------------------------- | |
| shown = res["shown_markets"] | |
| # A flat field is a refusal to rank, and it has to reach the cards: the | |
| # opening says no market stands out, so a card underneath it must not | |
| # wear a confident verdict, a verdict colour or a filled scale. | |
| flat_field = worldmap.field(res)["flat"] | |
| # -- the opening: what this says, what to do, and where in the world --- | |
| # Kalam, twice: "open the page with high level analysis and summary plus a | |
| # world map of relevant regions highlighted ... then when you scroll down | |
| # it all slots in for the reader." Everything below this point is the | |
| # detail that block is the summary of, so it goes above "Where it fits" | |
| # and nowhere else. There is one renderer and three callers — a fresh | |
| # upload, a reopened saved analysis and a re-run all reach this line. | |
| # | |
| # PAGE_RUBRIC §1 and §2, measured on the four stored reports: the opening | |
| # read well and handed the reader nothing to do. The first actionable | |
| # line — "Pitch it into Kenya first" — was 1,300 characters, a world map, | |
| # 19 chips and a tab row further down, inside the first market card. The | |
| # number-one action is hoisted into the block that opens the report; the | |
| # card keeps its own copy, because a filtered view shows that card alone. | |
| # | |
| # `_market_action` builds it, so there is one author of the sentence and | |
| # no second wording to drift. It hands back nothing at all for a | |
| # self-match, and a flat field is excluded here: the flat summary already | |
| # ends "treat the market order as a list of places that have been | |
| # checked, not a ranking to act on", and a hoisted action would contradict | |
| # the paragraph above it. The honesty guards — a short chart, a market | |
| # standing alone — ride along, because the guard is part of the sentence. | |
| lead_action = '' | |
| if shown and not flat_field: | |
| lead = regions[shown[0]] | |
| if lead["best"] < SAME_RECORDING: | |
| lead_action = _market_action(res, shown[0], 0, | |
| livematch.band(lead["best"])) | |
| out.append(worldmap.opening(res, lead_action=lead_action)) | |
| # -- the tab row ------------------------------------------------------- | |
| # The summary and the map stay above this line on every view: they are | |
| # the thing the three sections are three views of, and tabbing away from | |
| # the map would break the chips that jump into the first tab. "Where it | |
| # fits" is checked, which is where every `#market-XX` anchor lands. | |
| picks = [] if snippet_note else res["snippets"] | |
| if not snippet_note and not picks and res.get("snippet_sets"): | |
| old = res["snippet_sets"] | |
| seen, picks = set(), [] | |
| for p in list(old.get("trend") or []) + list(old.get("hook") or []): | |
| if p["start_s"] not in seen: | |
| seen.add(p["start_s"]) | |
| picks.append(p) | |
| tf = res["tags"]["fields"] | |
| tabs = [('rt-fits', 'Where it fits', len(shown), 'market'), | |
| ('rt-clips', '30 seconds', len(picks), 'clip'), | |
| ('rt-tags', 'Tags', len(_tag_rows(tf, res)), 'field')] | |
| row = [] | |
| for tid, label, n, unit in tabs: | |
| row.append(f'<input type="radio" class="rt" name="rtab" id="{tid}"' | |
| f'{" checked" if tid == "rt-fits" else ""}>') | |
| row.append(f'<label for="{tid}">{label}' | |
| f'<span class="n">{_plural(n, unit)}</span></label>') | |
| out.append('<div class="rtabs seg">' + "".join(row) + '</div>') | |
| out.append('<section class="rgrp g-fits">') | |
| out.append(f'<h2><span>Where it fits</span>' | |
| f'<span class="tag">{_plural(len(shown), "closest market")}' | |
| f'</span></h2>') | |
| for i, iso in enumerate(shown): | |
| r = regions[iso] | |
| # The two states that are not verdicts win over the band. Self first: | |
| # a record meeting its own chart entry is not a market at any spread. | |
| is_self = r["best"] >= SAME_RECORDING | |
| b = "self" if is_self else "flat" if flat_field else livematch.band(r["best"]) | |
| pct = r["best"] * 100 | |
| top = r["top"][:3] | |
| # The id is what the map and the chips jump to. Anchor navigation is | |
| # the browser's own, so this works with no script in a component that | |
| # runs none. | |
| out.append(f'<div class="card {b}" id="market-{ESC(iso)}">') | |
| out.append( | |
| f'<div class="mkt"><span class="name">{ESC(r["name"])}</span>' | |
| f'<span class="iso">{ESC(iso)}</span>' | |
| f'{chip(b)}</div>') | |
| # A cosine this high is the record meeting itself. It happened the | |
| # first time the archive was seeded: Joshua Baraka's "What Do I Know" | |
| # read 100.0% in Uganda because it is charting in Uganda, and without | |
| # this line that reads as a spectacular fit rather than as the track | |
| # recognising its own reflection. It is a good sign that the | |
| # measurement works, and it is not a market opportunity — so the | |
| # percentage and the scale are not drawn at all here. A number the | |
| # page then spends a paragraph disowning should not be printed. | |
| if is_self: | |
| near_s = '' | |
| if top: | |
| near = f'{top[0]["artist"] or "—"} — {top[0]["title"] or "—"}' | |
| near_s = f' The chart entry it meets is <b>{ESC(near)}</b>.' | |
| out.append( | |
| '<div class="note" style="margin:12px 0 0"><p class="why">' | |
| '<b>This is the same recording.</b> The track is already ' | |
| f'charting in {ESC(worldmap.in_market(iso, r["name"]))}, so ' | |
| f'it is being compared ' | |
| f'against itself — anything over ' | |
| f'{SAME_RECORDING:.1%} is the same record, so there is no ' | |
| f'percentage to read here.{near_s} Read the markets below it ' | |
| 'for where else it sits.</p></div>') | |
| else: | |
| out.append('<div class="sim"><div class="simtop"><div>' | |
| f'<div class="simpct" style="color:var(--{b})">' | |
| f'{livematch.whole_pct(r["best"])}' | |
| f'<small>%</small></div>') | |
| if top: | |
| near = f'{top[0]["artist"] or "—"} — {top[0]["title"] or "—"}' | |
| out.append(f'<div class="simto">{ESC(near)}</div>') | |
| out.append(f'</div><div class="simcaption">{CAPTION[b]}</div>' | |
| '</div>') | |
| out.append(_scale(pct, b) + '</div>') | |
| out.append(f'<div class="den">{_pool_line(r)}. Closest three:</div>') | |
| for m in top: | |
| url = previews.get(m.get("deezer_id")) | |
| player = (f'<audio controls preload="none" src="{ESC(url)}"></audio>' | |
| if url else '') | |
| rank = f'#{m["rank"]}' if m.get("rank") else '' | |
| mb = livematch.band(m["similarity"]) | |
| out.append( | |
| f'<div class="row"><span class="pct" style="color:var(--{mb})">' | |
| f'{livematch.pct(m["similarity"])}</span>' | |
| f'<span class="lbl">{ESC(m["artist"] or "—")} — ' | |
| f'{ESC(m["title"] or "—")}</span>' | |
| f'<span class="rk">{ESC(rank)}</span>{player}</div>') | |
| # What this market is for, in one sentence, with the chart position of | |
| # the record the number was measured against and whichever honesty | |
| # guard the numbers earn — a short chart, or a market standing alone. | |
| out.append(_market_action(res, iso, i, b)) | |
| out.append('</div>') | |
| out.append('</section>') | |
| # -- snippets: ONE list ------------------------------------------------ | |
| # It used to be two, side by side — closest-to-the-trend and | |
| # strongest-hook — with a window appearing on both marked ON BOTH LISTS. | |
| # Kalam, reading it: "the organisation of two approaches with duplicates | |
| # and place, on both lists, highlighted in green looks confusing... | |
| # duplication doesn't mean anything to someone scrolling through." The | |
| # duplicate was a fact about how the list was built, and the reader had to | |
| # understand the construction before the green chip meant anything. | |
| # | |
| # Now: one list of distinct windows, strongest hook first, every card | |
| # carrying both readings side by side. That is what the two lists were | |
| # for. The best trend fit is appended with a caption when the hook order | |
| # missed it, so nothing is lost. | |
| # `picks` is built above the tab row, because the tab carries its count. | |
| # A report saved before this change still carries `snippet_sets`, and that | |
| # is flattened into the new list there rather than being drawn the old way. | |
| out.append('<section class="rgrp g-clips">') | |
| out.append('<h2><span>Which 30 seconds to deliver</span>' | |
| + (f'<span class="tag">{_plural(len(picks), "section")}</span>' | |
| if picks else '') | |
| + '</h2>') | |
| if picks: | |
| out.append('<div class="sub"><b>Nobody has listened for you.</b> ' | |
| 'Play the clip before it goes anywhere.</div>') | |
| if snippet_note: | |
| out.append(snippet_note) | |
| elif not picks: | |
| out.append( | |
| f'<div class="card flat">This file is {res["duration_s"]:.0f} ' | |
| f'seconds long — shorter than the 30-second clip a distributor ' | |
| f'asks for, so there is no window to choose. The market ranking ' | |
| f'above used the whole file.</div>') | |
| def snippet_card(p: dict, i: int) -> None: | |
| b = p["band"] | |
| # ONE time, and it is the corrected one. The card used to print the | |
| # window at the top and a second clock time inside the sentence | |
| # telling you to move it — two times, one of them wrong to use. | |
| start_s, end_s = p["start_s"], p["end_s"] | |
| db, corr = p.get("nearest_downbeat"), "" | |
| if db is not None: | |
| delta = db - start_s | |
| if abs(delta) < 0.06: | |
| corr = "Already starts on the first beat of a bar." | |
| else: | |
| start_s, end_s = db, db + (end_s - p["start_s"]) | |
| corr = (f'Moved {abs(delta):.2f}s ' | |
| f'{"later" if delta > 0 else "earlier"} to start on ' | |
| f'the first beat of a bar.') | |
| t0, t1 = _fmt_time_frac(start_s), _fmt_time_frac(end_s) | |
| # The chip says what this clip is FOR. It used to say PLACE, which is | |
| # a verdict about a market and means nothing about a 30-second window. | |
| role = "deliver" if i == 1 else "alt" | |
| word = "DELIVER" if i == 1 else "SECOND CHOICE" | |
| out.append(f'<div class="card {role}"><div class="snip">' | |
| f'<span class="idx">{i}</span>' | |
| f'<span class="time">{t0} – {t1}</span>' | |
| f'<span class="chip {role}">{word}</span>' | |
| f'<span class="aff" style="color:var(--{b})">' | |
| f'{livematch.pct(p["affinity"])}</span></div>') | |
| out.append(f'<div class="den" style="margin:7px 0 0">' | |
| f'{CAPTION[b]} against what is charting' | |
| + (f' in {ESC(worldmap.in_market(p.get("market"), p["market_name"]))}' | |
| if p.get("market_name") | |
| else '') | |
| + (f'. {corr}' if corr else '.') + '</div>') | |
| # Four rows, three words. The floats that were here — 0.62, 0.41 — | |
| # were the model's own scale printed raw, and nobody can act on the | |
| # difference between 0.62 and 0.58. The bar carries the order. | |
| rows = [] | |
| if p.get("hook_label") and p.get("hook") is not None: | |
| rows.append(("Hook signal", p["hook_label"], | |
| float(p["hook"]))) | |
| parts = p.get("hook_parts") or {} | |
| for key, label_ in (("repeats", "Comes back"), | |
| ("voice", "Voice up front"), | |
| ("lift", "Energy")): | |
| if key in parts: | |
| v = float(parts[key]) | |
| rows.append((label_, _word_band(v), v)) | |
| if rows: | |
| cells = [] | |
| for label_, word_, v in rows: | |
| cells.append( | |
| f'<span class="bl">{ESC(label_)}</span>' | |
| f'<span class="bw">{ESC(word_)}</span>' | |
| f'<span class="bb"><i style="width:' | |
| f'{max(4.0, min(100.0, v * 100)):.0f}%"></i>' | |
| f'<u style="left:{PART_AVERAGE}%"></u></span>') | |
| out.append('<div class="bands">' + "".join(cells) + '</div>') | |
| out.append('<div class="den" style="margin:6px 0 0">The mark on ' | |
| 'each bar is this track\'s own average. Every word ' | |
| 'here is read against the record itself, never ' | |
| 'against anyone else\'s.</div>') | |
| # One sentence: what to do with this clip. | |
| near = p.get("nearest") | |
| near_s = (f' The closest charting record to this part is ' | |
| f'<b>{ESC(near["artist"] or "—")} — ' | |
| f'{ESC(near["title"] or "—")}</b>.' if near else '') | |
| if i == 1: | |
| act = (f'<b>Send this one.</b> It is the strongest 30 seconds of ' | |
| f'the record by the hook reading, and it sits at ' | |
| f'{livematch.pct(p["affinity"])} to what is charting' | |
| + (f' in {ESC(worldmap.in_market(p.get("market"), p["market_name"]))}' | |
| if p.get("market_name") | |
| else '') + f'.{near_s}') | |
| elif p.get("pick_reason") == "trend": | |
| act = (f'<b>Keep this as the alternate.</b> It is here because it ' | |
| f'is the closest section to the trend, not the strongest ' | |
| f'hook.{near_s}') | |
| else: | |
| act = (f'<b>Keep this as the alternate.</b> Same record, a ' | |
| f'different section — use it if the first one starts ' | |
| f'mid-word.{near_s}') | |
| out.append(f'<div class="act">{act}</div>') | |
| ci = p.get("clip_i") | |
| clip = clips[ci] if ci is not None and ci < len(clips) else None | |
| if clip: | |
| # The played clip may stop at the last bar line inside the window | |
| # so that what the team hears finishes its phrase. The DELIVERED | |
| # window is still the full 30 seconds printed at the top of the | |
| # card, and the label under the player says which is which. | |
| nat = p.get("natural_end_s") | |
| if nat and nat < p["end_s"] - 0.2: | |
| lab = "PLAYS TO THE BAR" | |
| foot = ('This clip stops on the last bar line inside the ' | |
| 'window so the phrase finishes. The 30 seconds you ' | |
| 'deliver are the times above.') | |
| else: | |
| lab, foot = "THIS 30s", "" | |
| out.append(f'<div class="player"><span class="plab">{lab}</span>' | |
| # metadata, not none: the duration should be on screen | |
| # before anyone presses play | |
| f'<audio controls preload="metadata" src="{ESC(clip)}">' | |
| '</audio></div>') | |
| if foot: | |
| out.append(f'<div class="den" style="margin:7px 0 0">' | |
| f'{foot}</div>') | |
| elif cutting: | |
| # The report goes on screen before the clips are cut — that gap | |
| # is 8 seconds on the Space and is why the numbers arrive first. | |
| # Every card spent it saying the clips had expired, on a report | |
| # computed two seconds earlier (23 Aug audit). The players arrive | |
| # on their own when the cutting finishes. | |
| out.append('<div class="player"><span class="plab">CUTTING</span>' | |
| '<span class="den" style="margin:0">The audio for ' | |
| 'this window is still being cut. It arrives here on ' | |
| 'its own in a few seconds — the times above are ' | |
| 'already final.</span></div>') | |
| else: | |
| # The message belongs here, where the player is missing, rather | |
| # than in a footer under four cards: this is the card the reader | |
| # is looking at when they wonder where the audio went. It says | |
| # "analyse the file again" and not "re-run", because a re-run | |
| # reads the saved track against this week's charts and cuts no | |
| # audio at all — only the file can do that. | |
| out.append('<div class="player"><span class="plab">NO CLIP</span>' | |
| '<span class="den" style="margin:0">No clip for this ' | |
| 'window. Analyse the file again to cut one.</span>' | |
| '</div>') | |
| out.append('</div>') | |
| for i, p in enumerate(picks, 1): | |
| snippet_card(p, i) | |
| if picks and res.get("hook", {}).get("used"): | |
| out.append( | |
| f'<div class="note"><div class="lab">How these were picked</div>' | |
| f'{res["window_count"]} sections of the track were compared, ' | |
| f'moving in 5-second steps. They are ordered by the hook ' | |
| f'reading: how often the section comes back elsewhere in the ' | |
| f'record, how much sits in the centre of the stereo image where ' | |
| f'a lead vocal usually is, and how loud and busy it is against ' | |
| f'the track\'s own average. Each card also shows how close that ' | |
| f'section sits to the charting music above, so both readings are ' | |
| f'in front of you when you choose.' | |
| f'<br><br>Sections are placed so a musical phrase ends where the ' | |
| f'clip ends, rather than stopping flat at 30 seconds. Where that ' | |
| f'lands before the mark, the player here stops on the bar and ' | |
| f'the card says so; the 30 seconds you hand a distributor are ' | |
| f'the times printed on the card.' | |
| f'<br><br>The centre-of-image measurement cannot tell a voice ' | |
| f'from a lead instrument, and nothing here separates the vocal ' | |
| f'out — a section can score well and still start mid-word. ' | |
| f'Listen to each one before you send it.</div>') | |
| elif picks: | |
| out.append( | |
| f'<div class="note"><div class="lab">How these were picked</div>' | |
| f'{res["window_count"]} sections of the track were compared, ' | |
| f'moving in 5-second steps, ranked by how close each sounds to ' | |
| f'the charting music above. The hook reading could not be taken ' | |
| f'on this file. Listen to each one before you send it.</div>') | |
| out.append('</section>') | |
| # -- tags: platform suggestions lead, evidence follows --------------- | |
| out.append('<section class="rgrp g-tags">') | |
| out.append('<h2><span>Tags for the platforms</span>' | |
| '<span class="tag">suggested</span></h2>') | |
| t = res["tags"] | |
| f = t["fields"] | |
| # Three destinations, three rows, each with the exact value that goes in | |
| # it. The section used to be organised by platform — Apple, then FUGA, | |
| # then Spotify — which meant the same genre name was printed twice and | |
| # the reader had to work out which of the two to paste where. | |
| # | |
| # Two on a flat reading: the pitch row is replaced by a note saying the | |
| # measurement has nothing to put in that box. See `_pitch_prose`. | |
| out.append('<div class="card flat">') | |
| rows = _tag_rows(f, res) | |
| for label_, value, note in rows: | |
| out.append(f'<div class="lab">{ESC(label_)}</div>' | |
| f'<textarea class="copy" rows="{2 if len(value) > 46 else 1}"' | |
| f' readonly>{ESC(value)}</textarea>' | |
| f'<div class="den">{ESC(note)}</div>') | |
| if rows: | |
| # No Copy button. One would need JavaScript, which does not run in a | |
| # Gradio HTML component, and a button that does nothing is worse than | |
| # no button. The fields are selectable; the affordance is deferred. | |
| # The line says what to do, not why the button is missing — a label | |
| # manager has no use for the reason. | |
| out.append('<div class="den" style="margin-top:12px">Tap a field to ' | |
| 'select it, then copy.</div>') | |
| if flat_field and not any(label_ == "PITCH FORM · PROSE" | |
| for label_, _v, _n in rows): | |
| # After the copy line, because that line belongs to the fields above | |
| # it and this note is about the field that is not there. | |
| out.append(_no_pitch_note(res)) | |
| if f.get("main_genre") and f.get("show_african_list"): | |
| afro = "".join(f'<span class="pill">{ESC(n)}</span>' | |
| for n in tagmod.APPLE_AFRICAN) | |
| out.append(f'<details><summary>Apple lists ' | |
| f'{len(tagmod.APPLE_AFRICAN)} African genres — the pick ' | |
| f'is a human call</summary>' | |
| f'<div class="den">The reading narrows to a family. ' | |
| f'Naming which of these it is takes a person who knows ' | |
| f'the record.</div>' | |
| f'<div class="tags">{afro}</div></details>') | |
| out.append('<div class="lab">TikTok</div>' | |
| '<div class="den">Genre goes in on the distribution form ' | |
| '(SoundOn or your distributor\'s TikTok delivery). Whether ' | |
| 'that field is a fixed list or free text is not yet verified ' | |
| '— use the main genre above until it is.</div>') | |
| out.append('<div class="lab">YouTube</div>' | |
| '<div class="den">No tag field worth filling: YouTube Music ' | |
| 'takes genre through the distributor delivery and shows none ' | |
| 'of it, and video keywords carry almost no weight in ' | |
| 'discovery.</div>') | |
| out.append('</div>') | |
| # -- the linkage: nearby records and the tags they carry --------------- | |
| # The suggestions above are what to paste into a form. This is the | |
| # evidence they are checked against, and it comes before the model's own | |
| # reading of the family because it is the half made of facts: real | |
| # records, on real charts, carrying labels somebody else assigned. | |
| out.append(_neighbour_tags(res, flat_field)) | |
| # -- how the family reads --------------------------------------------- | |
| # Order, not strengths. The scores were printed to two decimals next to | |
| # each word, which invites a reader to compare 0.31 with 0.28 — a | |
| # difference this reading cannot support. What it can support is which | |
| # word came first. | |
| if t.get("model"): | |
| genres = (t["model"]["by_group"].get("genre") or [])[:4] | |
| if genres: | |
| out.append('<div class="lab">How the family reads</div>') | |
| out.append('<div class="card flat"><div class="bands">') | |
| for n, r in enumerate(genres): | |
| place = ("1st", "2nd", "3rd", "4th")[n] | |
| out.append( | |
| f'<span class="bl">{place}</span>' | |
| f'<span class="bw">{ESC(r["tag"])}</span>' | |
| f'<span class="bb"><i style="width:' | |
| f'{100 - n * 22}%"></i></span>') | |
| out.append('</div>') | |
| out.append('<div class="den" style="margin-top:10px">' | |
| '<b>Naming the family is reliable; naming the ' | |
| 'sub-genre is not.</b> The bars carry the order these ' | |
| 'words came in and nothing else — on our own test ' | |
| 'records this reading put "latin pop" next to ' | |
| 'afrobeats, and missed gospel on a gospel track. ' | |
| 'How it works has the detail.</div>') | |
| other = [(g, (t["model"]["by_group"].get(g) or [])) | |
| for g in ("mood", "production")] | |
| other = [(g, rs) for g, rs in other if rs] | |
| if other: | |
| out.append('<details><summary>What else the sound reads as' | |
| '</summary>') | |
| for group, rs in other: | |
| out.append(f'<div class="lab">{ESC(group)}</div>' | |
| '<div class="tags">' | |
| + "".join(f'<span class="pill">{ESC(r["tag"])}' | |
| f'</span>' for r in rs) | |
| + '</div>') | |
| out.append('</details>') | |
| out.append('</div>') | |
| else: | |
| out.append('<div class="card flat"><div class="den">Tag suggestions ' | |
| 'are switched off on this run: on the reference check the ' | |
| 'reading did not tell the genre words apart well enough ' | |
| 'to be worth pasting into a delivery form.</div></div>') | |
| tr = t["trend"] | |
| out.append('<div class="card flat"><div class="den">Taken from this week\'s ' | |
| 'chart data.</div>') | |
| # Country names, not ISO codes — "BO" tells a label reader nothing, and | |
| # this was the one row in the app still speaking in codes (Kalam, 23 Aug). | |
| regions_by_iso = res.get("regions") or {} | |
| # A market the track already charts in prints no percentage here either. | |
| # This row read "Australia 100% · Chile 100% · Spain 100%" while the card | |
| # above it wore a SELF chip with no number and spent a paragraph on why | |
| # there is no percentage to read (23 Aug audit) — the same rule, three | |
| # inches apart, stated two different ways. | |
| chips = "".join( | |
| f'<span class="pill"><b>' | |
| f'{ESC((regions_by_iso.get(m["iso"]) or {}).get("name") or m["iso"])}' | |
| f'</b><span class="n">' | |
| f'{CHIP["self"] if float(m["best"]) >= SAME_RECORDING else livematch.pct(m["best"])}' | |
| f'</span></span>' | |
| for m in tr["markets"][:8]) | |
| out.append(f'<div class="lab">markets it sits nearest</div>' | |
| f'<div class="tags">{chips}</div>') | |
| if tr["surfaces"]: | |
| out.append('<div class="lab">charts the nearest records appear on</div>' | |
| '<div class="tags">' | |
| + "".join(f'<span class="pill">{ESC(s)}</span>' | |
| for s in tr["surfaces"]) + '</div>') | |
| if tr["genres"]: | |
| out.append('<div class="lab">genre, per Apple and Deezer</div>' | |
| '<div class="tags">' | |
| + "".join(f'<span class="pill">{ESC(g)}</span>' | |
| for g in tr["genres"]) + '</div>') | |
| elif not c.get("genre_labelled"): | |
| # The charts publish no genre of their own. Labels are looked up | |
| # afterwards against Apple's taxonomy, with Deezer as the fallback, | |
| # so a corpus exported before that pass has run carries none. | |
| # The denominator is dropped rather than guessed at when the stored | |
| # result does not carry it. | |
| of = (f'the {int(c["sounds"]):,} charting sounds' if c.get("sounds") | |
| else 'the charting sounds') | |
| out.append(f'<div class="note">No genre label this week: none of ' | |
| f'{of} carry one yet. Labels ' | |
| f'are looked up on Apple\'s genre list, and on Deezer ' | |
| f'where Apple carries none — the charts publish no genre ' | |
| f'of their own.</div>') | |
| else: | |
| of = (f'the {int(c["sounds"]):,} charting sounds this week' | |
| if c.get("sounds") else 'the charting sounds this week') | |
| out.append(f'<div class="note">The records nearest yours carry no ' | |
| f'genre label. {int(c["genre_labelled"]):,} of {of} do; ' | |
| f'these are among the rest.</div>') | |
| out.append('</div>') | |
| out.append('</section>') | |
| # -- caveats ----------------------------------------------------------- | |
| # Outside the tabs on purpose. Everything here is a limit that changes | |
| # what a number means, and a limit behind a tab is a limit somebody does | |
| # not read. | |
| tempo = res["beat"].get("tempo") | |
| items = [ | |
| '<b>A high percentage means two recordings sound alike.</b> Use it to ' | |
| 'decide where a track fits. It carries no information about how a ' | |
| 'record will perform.', | |
| f'<b>The cut-offs are a first pass.</b> Above ' | |
| f'{livematch.PLACE_THRESHOLD:.0%} reads as a direct fit, ' | |
| f'{livematch.DERIVE_THRESHOLD:.0%}–{livematch.PLACE_THRESHOLD:.0%} as ' | |
| f'remix or edit territory, and below that as no real connection. They ' | |
| f'were set on US and global chart data and have not been re-checked ' | |
| f'for African markets.', | |
| '<b>This is one week of charts.</b> A single week cannot tell a rising ' | |
| 'sound from a fading one, so nothing here says which way anything is ' | |
| 'moving.', | |
| '<b>Charting records are compared using their official 30-second ' | |
| 'preview. Your upload is the whole track.</b> A whole track covers ' | |
| 'more music, which lifts its score against everything. Compare ' | |
| 'tracks by where they sit in this list, and read the percentages as ' | |
| 'approximate.', | |
| '<b>Only the sound is heard.</b> Nothing here knows the lyrics, the ' | |
| 'language, who is on the record, or whether a market would take it.', | |
| ] | |
| items.append( | |
| f'<b>Tempo read as {tempo:.0f} BPM.</b> The beat reading was told to ' | |
| f'expect something near 110 BPM, which suits Afrobeats. If the tempo ' | |
| f'is wrong, only the start-time suggestions above are affected. The ' | |
| f'market ranking does not use tempo.' if tempo else | |
| '<b>The beat could not be read on this file</b>, so the clips were ' | |
| 'ranked on how they sound alone, with nothing lined up to the bar.') | |
| out.append('<div class="caveats"><div class="lab">Read this with the ' | |
| 'numbers</div><ul>' | |
| + "".join(f'<li><span class="bul"></span><span>{i}</span></li>' | |
| for i in items) | |
| + '</ul></div>') | |
| # The chart sources by the names they publish under, and the pull as a | |
| # date. It used to print "from soundcloud, youtube · pulled | |
| # 2026-08-22T00:31:47+00:00" — two machine strings on a line whose whole | |
| # job is telling a label reader how old the numbers are. | |
| # Every field is read with `.get`: a stored result from an older build, | |
| # or a corpus exported before a key existed, must print a shorter line | |
| # rather than the word None or an error (23 Aug audit). | |
| src = ", ".join(SOURCE_NAMES.get(s, s) for s in (c.get("sources") or [])) | |
| pulled = str(c.get("generated_at") or "").split("T")[0] | |
| bits = [f'Charts used: {ESC(str(c.get("week") or "—"))}'] | |
| if c.get("sounds"): | |
| bits.append(f'{int(c["sounds"]):,} sounds') | |
| if c.get("regions"): | |
| bits.append(f'{int(c["regions"])} markets') | |
| if src: | |
| bits.append(f'from {ESC(src)}') | |
| if pulled: | |
| bits.append(f'read on {ESC(pulled)}') | |
| out.append(f'<div class="foot">{" · ".join(bits)}</div>') | |
| out.append('</div>') | |
| return "".join(out) | |