Commit 975f9150 by PLN (Algolia)

pipeline driver + generated catalog + ground-truth drawer

tide.py: one command regenerates every downstream artifact in dependency order
(track_recording_map → catalog_view → catalog → ts_types) + `tide.py test`.

Demote catalog.yaml (hand-state, no valid-as-of) → generated artifact:
- catalog.authored.yaml: the ONLY hand facts (license/collab/inspiration/status/
  notes), each with provenance; keyed by .tidal path
- build_catalog.py: catalog_view ⊕ overlay → catalog.generated.json (73 tracks,
  7 authored), validated against models.Catalog on emit
- catalog.yaml marked DEPRECATED

Triangle drawer = ground-truth validator (per corner):
- A: scrollable, syntax-highlighted .tidal source (fetched live) + parsed orbits
- B: audio player when a take proxy exists
- C: per-gig bpm/style/dur + external gig link + raw ingredient list (site's claim)
- search now spans ingredients/samples too
- enrich catalog_view rows with raw ingredients (+ Ingredient/Catalog models, TS)

Serve from the REPO ROOT so source/audio resolve:
  python3 armada/serve.py --dir . --port 8731
  → /armada/tide-table/triangle.html

30 pytest green.
parent 2704f92c
#!/usr/bin/env python3
"""build_catalog — the GENERATED catalog (replaces hand-maintained catalog.yaml).
catalog.yaml had no knowable "valid as of". The catalog is now a downstream
artifact: derived facts from `catalog_view.json` (sounds, gigs, takes, recording
status, A↔C level) ⊕ the authored overlay `catalog.authored.yaml` (license,
inspiration, collab, curated status, notes — each with provenance). Validated
against models.Catalog on emit. Run via `tide.py build catalog` (after catalog_view).
python3 build_catalog.py
"""
from __future__ import annotations
import json
from pathlib import Path
import yaml
HERE = Path(__file__).parent
CATALOG_VIEW = HERE / "catalog_view.json"
AUTHORED = HERE / "catalog.authored.yaml"
OUT = HERE / "catalog.generated.json"
def build():
view = json.loads(CATALOG_VIEW.read_text())
authored = (yaml.safe_load(AUTHORED.read_text()) or {}).get("tracks", {}) or {}
tracks = []
for r in view["tracks"]:
a = authored.get(r["track"], {})
tracks.append({
"id": r["track"],
"title": a.get("title") or r["name"],
"aka": sorted(set(r["names"]) | set(a.get("aka", []))),
# derived — do not hand-edit
"sounds": r["score_sounds"],
"orbits": r["n_orbits"],
"gigs": r["gigs"],
"takes": [t["take"] for t in r["takes"]],
"recorded": r["recorded"],
"eda_grounded": r["n_takes_eda"] > 0,
"ac_level": r["ac"]["level"],
# authored overlay
"license": a.get("license"),
"inspiration": a.get("inspiration", []),
"collab": a.get("collab", []),
"status": a.get("status"),
"notes": a.get("notes"),
"authored_prov": a.get("prov"),
})
return {
"schema": "catalog v1 (GENERATED — catalog_view ⊕ catalog.authored.yaml; "
"do not hand-edit; edit the overlay or the source)",
"as_of": view["as_of"],
"n_tracks": len(tracks),
"n_authored": len(authored),
"tracks": tracks,
}
def main():
out = build()
from models import Catalog # DRY contract: validate before writing
Catalog.model_validate(out)
OUT.write_text(json.dumps(out, ensure_ascii=False, indent=1))
print(f"✓ {OUT} ({out['n_tracks']} tracks · {out['n_authored']} authored overlays)")
if __name__ == "__main__":
main()
...@@ -215,13 +215,16 @@ def build(): ...@@ -215,13 +215,16 @@ def build():
score = ts.orbit_sounds(path, vocab, kind) if a_present else {} score = ts.orbit_sounds(path, vocab, kind) if a_present else {}
score_sounds = [d["sound"] for d in score.values()] score_sounds = [d["sound"] for d in score.values()]
# corner C: union claimed sounds + representative metadata # corner C: union claimed sounds + representative metadata + raw ingredients
claimed, metas, gig_slugs = [], [], [] claimed, metas, gig_slugs, ingredients = [], [], [], []
for slug, gdate, tr in rec["appearances"]: for slug, gdate, tr in rec["appearances"]:
gig_slugs.append(slug) gig_slugs.append(slug)
claimed += ingredient_sounds(tr, vocab) claimed += ingredient_sounds(tr, vocab)
metas.append({"gig": slug, "date": gdate, "bpm": tr.get("bpm"), metas.append({"gig": slug, "date": gdate, "bpm": tr.get("bpm"),
"style": tr.get("style"), "dur": tr.get("duration")}) "style": tr.get("style"), "dur": tr.get("duration")})
for ing in tr.get("ingredients", []): # ground-truth for the drawer
ingredients.append({"type": ing.get("type", ""), "code": ing.get("code", ""),
"description": ing.get("description", ""), "gig": slug})
claimed = list(dict.fromkeys(claimed)) # de-dup, keep order claimed = list(dict.fromkeys(claimed)) # de-dup, keep order
# corner B: candidate takes via date-join across this track's gigs # corner B: candidate takes via date-join across this track's gigs
...@@ -253,6 +256,7 @@ def build(): ...@@ -253,6 +256,7 @@ def build():
"score_sounds": sorted(set(score_sounds)), "score_sounds": sorted(set(score_sounds)),
# corner C # corner C
"claimed_sounds": claimed, "claimed_sounds": claimed,
"ingredients": ingredients, # raw code+description, for ground-truth view
# A↔C # A↔C
"ac": ac, "ac": ac,
# corner B # corner B
......
# tide-table · AUTHORED overlay — the ONLY hand-maintained catalog facts.
#
# Everything DERIVABLE (sounds, gigs, takes, bpm, style, recording/EDA status,
# A↔C agreement) is GENERATED into catalog.generated.json by `tide.py build` from
# the parsers — never duplicate it here. This file holds ONLY what no parser can
# know: licensing, inspiration, human collab credits, curated lifecycle, prose
# notes. Each entry carries provenance (feedback_metadata_provenance).
#
# Keyed by canonical track id = the .tidal path (merges FR⇄EN aliases — the same
# file is "L'Or Bleu" and "Blue Gold"). Fields (all optional):
# title canonical display name (overrides the gig name)
# aka[] extra aliases beyond those derived from gig naming
# license e.g. "CC BY-SA"
# inspiration[] artists/works that inspired it
# collab[] human collaborators (ontology keys: rhadamanthe, raph, …)
# status idea | demo | performed | recorded | mastered | released
# notes prose a parser can't infer (sample-pack provenance, story)
# prov {source: user|ear|file|web|derived, locator, as_of}
#
# This started as a SEED migrated from catalog.yaml (now deprecated). Grow it as
# facts are verified — and prefer correcting at the source (.tidal / site) when
# the truth is actually derivable.
tracks:
live/collab/mousquetaires/blue_gold.tidal:
title: "L'Or Bleu"
aka: ["Blue Gold", "Blue Gold 🌇"]
license: "CC BY-SA"
inspiration: ["Leifur James"]
status: released
notes: "suns_keys/suns_guitar/suns_voice originals. FR release title EN stage name."
prov: {source: user, locator: "catalog.yaml seed", as_of: "2026-06-06"}
live/midi/nova/lounge/suns_of_gold.tidal:
title: "L'Or Bleu suns of gold (ébauche)"
aka: ["L'Or Bleu", "Blue Gold"]
inspiration: ["Leifur James"]
status: demo
notes: "Earlier ébauche of the same L'Or Bleu remix idea (per PLN, 2026-06-06)."
prov: {source: user, locator: "PLN 2026-06-06", as_of: "2026-06-06"}
live/collab/baba/sept1.tidal:
title: "Premier Septembre"
aka: ["Sept1", "Sept 1"]
collab: ["rhadamanthe"]
status: released
notes: "rhadamanthe_melo/vocal + cpluck."
prov: {source: user, locator: "catalog.yaml seed", as_of: "2026-06-06"}
live/midi/nova/breaks/lady_perplexity.tidal:
title: "Lady Perplexity"
collab: ["rhadamanthe"]
status: released
prov: {source: user, locator: "catalog.yaml seed", as_of: "2026-06-06"}
live/collab/raph/jeudrill.tidal:
title: "Jeudi Drill"
aka: ["JeuDrill"]
collab: ["raph"]
status: released
notes: "bogdan_grime 'I'm from Cardiff!' vocals; marimba1 melody."
prov: {source: user, locator: "catalog.yaml seed", as_of: "2026-06-06"}
live/midi/nova/techno/ete_a_mauerpark.tidal:
title: "L'été à Mauerpark"
aka: ["Mauerpark"]
status: released
notes: "moogBass Berlin warmth, Leslie/chorus."
prov: {source: user, locator: "catalog.yaml seed", as_of: "2026-06-06"}
live/midi/nova/dnb/venons_ensemble.tidal:
title: "Venons Ensemble"
status: released
notes: "come_bass/come_eguitar/come_voice 3-part band pack."
prov: {source: user, locator: "catalog.yaml seed", as_of: "2026-06-06"}
# ⚠️ DEPRECATED (2026-06-06) — hand-state with no knowable "valid as of".
# The catalog is now GENERATED: `tide.py build catalog` →
# • catalog.generated.json — derived facts (catalog_view) ⊕ authored overlay
# • catalog.authored.yaml — the ONLY hand-maintained facts (license, collab,
# inspiration, status, notes), each with provenance
# Kept for reference only. DO NOT edit as a source of truth — edit the overlay or
# fix the truth at its source (.tidal / site tracks.json), then re-run the build.
#
# tide-table · Table des Vagues — catalog (SEED / worked example) # tide-table · Table des Vagues — catalog (SEED / worked example)
# First real, reconciled data: CosmicFest v0, charted across site + Bandcamp + SoundCloud. # First real, reconciled data: CosmicFest v0, charted across site + Bandcamp + SoundCloud.
# Validates the schema & the performed-vs-released / FR⇄EN aliasing problem. # Validates the schema & the performed-vs-released / FR⇄EN aliasing problem.
......
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -255,6 +255,14 @@ class TrackMeta(BaseModel): ...@@ -255,6 +255,14 @@ class TrackMeta(BaseModel):
dur: Optional[float] = None dur: Optional[float] = None
class Ingredient(BaseModel):
"""A raw corner-C ingredient (the site's claim) — ground truth for the drawer."""
type: str = ""
code: str = ""
description: str = ""
gig: str = ""
class TakeRef(BaseModel): class TakeRef(BaseModel):
"""Corner B candidate: a take this track plausibly lives in (date-join, L0).""" """Corner B candidate: a take this track plausibly lives in (date-join, L0)."""
take: str take: str
...@@ -279,6 +287,7 @@ class TrackRow(BaseModel): ...@@ -279,6 +287,7 @@ class TrackRow(BaseModel):
score_sounds: list[str] = Field(default_factory=list) score_sounds: list[str] = Field(default_factory=list)
# corner C — metadata # corner C — metadata
claimed_sounds: list[str] = Field(default_factory=list) claimed_sounds: list[str] = Field(default_factory=list)
ingredients: list[Ingredient] = Field(default_factory=list)
# A↔C # A↔C
ac: AgreeResult ac: AgreeResult
# corner B — recording # corner B — recording
...@@ -316,3 +325,37 @@ class CatalogView(BaseModel): ...@@ -316,3 +325,37 @@ class CatalogView(BaseModel):
as_of: str as_of: str
stats: CatalogStats stats: CatalogStats
tracks: list[TrackRow] = Field(default_factory=list) tracks: list[TrackRow] = Field(default_factory=list)
# ── catalog — GENERATED rollup: derived facts ⊕ authored overlay (#51) ────────
# catalog.yaml was hand-state with no "valid as of". The catalog is now generated
# (catalog.generated.json) from catalog_view (derived) ⊕ catalog.authored.yaml
# (the only hand-maintained facts), every entry stamped. parsers-over-copy.
class CatalogEntry(BaseModel):
id: str # canonical = .tidal path
title: str
aka: list[str] = Field(default_factory=list)
# derived (from catalog_view — do not hand-edit)
sounds: list[str] = Field(default_factory=list)
orbits: int = 0
gigs: list[str] = Field(default_factory=list)
takes: list[str] = Field(default_factory=list)
recorded: bool = False
eda_grounded: bool = False
ac_level: AgreeLevel
# authored (from catalog.authored.yaml overlay — may be absent)
license: Optional[str] = None
inspiration: list[str] = Field(default_factory=list)
collab: list[str] = Field(default_factory=list)
status: Optional[str] = None
notes: Optional[str] = None
authored_prov: Optional[Provenance] = None
class Catalog(BaseModel):
model_config = ConfigDict(populate_by_name=True)
schema_: str = Field(alias="schema")
as_of: str
n_tracks: int
n_authored: int
tracks: list[CatalogEntry] = Field(default_factory=list)
"""The GENERATED catalog: derived facts ⊕ authored overlay, validated + merged."""
import build_catalog as bc
def test_catalog_validates_and_merges_overlay():
out = bc.build()
from models import Catalog
cat = Catalog.model_validate(out) # DRY contract
assert cat.n_tracks == len(cat.tracks)
# the authored overlay must actually merge onto the derived rows
by_id = {t.id: t for t in cat.tracks}
sept = by_id.get("live/collab/baba/sept1.tidal")
assert sept and "rhadamanthe" in sept.collab # authored collab credit
assert sept.sounds # derived sounds still present
assert sept.authored_prov is not None # provenance carried through
def test_no_authored_entry_still_yields_a_row():
out = bc.build()
# a track with no overlay must still appear, just without authored fields
rows = [t for t in out["tracks"] if not t["authored_prov"]]
assert rows and all(r["title"] for r in rows)
#!/usr/bin/env python3
"""tide — the tide-table pipeline driver.
ONE reproducible command regenerates every DOWNSTREAM ARTIFACT from its parsers,
in dependency order. The catalog is not hand-maintained state with an unknowable
"valid as of" — it is generated, every artifact stamped (parsers-over-copy). Run
under system python3 (numpy + pydantic).
python3 tide.py build # regenerate all artifacts
python3 tide.py build catalog_view # just one step
python3 tide.py test # run the pytest suite (the mechanical gate)
python3 tide.py list # show the pipeline
Each step is importable and pure-ish (reads the corpus, writes one artifact), so
the same functions back the test suite. Add a step → add one STEPS entry.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent # …/Sound/Tidal
sys.path.insert(0, str(HERE))
def _track_recording_map():
import build_track_recording_map as m
m.main()
return m.OUT
def _catalog_view():
import build_catalog_view as m
m.main() # validates against CatalogView on emit
return m.OUT
def _catalog():
import build_catalog as m
m.main() # validates against Catalog on emit
return m.OUT
def _ts_types():
sys.path.insert(0, str(ROOT / "tools"))
import gen_ts_types as g
g.main()
return g.OUT
# name → (one-line description, fn → artifact Path). Dependency order top-to-bottom.
STEPS = [
("track_recording_map", "L0 metadata map · site tracklist × take_gig_map", _track_recording_map),
("catalog_view", "triangle · A=score ⋈ C=metadata ⋈ B=recording (validated)", _catalog_view),
("catalog", "GENERATED catalog · catalog_view ⊕ authored overlay", _catalog),
("ts_types", "pydantic models → generated TypeScript (DRY)", _ts_types),
]
NAMES = [n for n, _, _ in STEPS]
def cmd_build(which):
todo = which or NAMES
bad = [w for w in todo if w not in NAMES]
if bad:
sys.exit(f"unknown step(s): {', '.join(bad)}\n available: {', '.join(NAMES)}")
print(f"⛵ tide build — {len(todo)} step(s)\n")
for name, desc, fn in STEPS:
if name not in todo:
continue
print(f"▸ {name} ({desc})")
try:
out = fn()
rel = Path(out).relative_to(ROOT) if Path(out).is_absolute() else out
print(f" ✓ {rel}\n")
except Exception as e: # surface, don't swallow — a broken parser must fail loud
print(f" ✗ FAILED: {type(e).__name__}: {e}\n")
raise
print("✓ tide build complete")
def cmd_test():
print("⛵ tide test — pytest\n")
r = subprocess.run([sys.executable, "-m", "pytest", str(HERE / "tests"), "-q"])
sys.exit(r.returncode)
def cmd_list():
print("⛵ tide pipeline (dependency order):\n")
for name, desc, _ in STEPS:
print(f" {name:<22} {desc}")
print("\n test run the pytest suite")
def main():
args = sys.argv[1:]
cmd = args[0] if args else "build"
if cmd == "build":
cmd_build(args[1:])
elif cmd == "test":
cmd_test()
elif cmd in ("list", "ls"):
cmd_list()
else:
sys.exit(f"usage: tide.py [build [step…] | test | list]")
if __name__ == "__main__":
main()
...@@ -72,9 +72,29 @@ tbody tr.sel{background:#ffffff12;outline:1px solid var(--hairline)} ...@@ -72,9 +72,29 @@ tbody tr.sel{background:#ffffff12;outline:1px solid var(--hairline)}
.d-shared{background:#5bc09122;color:var(--agree);border:1px solid #5bc09155} .d-shared{background:#5bc09122;color:var(--agree);border:1px solid #5bc09155}
.d-score{background:#8a93a622;color:var(--atmos);border:1px solid #8a93a655} .d-score{background:#8a93a622;color:var(--atmos);border:1px solid #8a93a655}
.d-meta{background:#ff525222;color:var(--conflict);border:1px solid #ff525255} .d-meta{background:#ff525222;color:var(--conflict);border:1px solid #ff525255}
.tk{display:flex;justify-content:space-between;border:1px solid var(--hairline);border-radius:7px;padding:6px 9px;margin-bottom:5px;font-size:12px} .tk{display:flex;justify-content:space-between;align-items:center;gap:8px;border:1px solid var(--hairline);border-radius:7px;padding:6px 9px;margin-bottom:5px;font-size:12px}
.empty{color:var(--faint);font-style:italic;font-size:12px} .empty{color:var(--faint);font-style:italic;font-size:12px}
a.src{color:var(--melodic);text-decoration:none;font-size:11px}a.src:hover{text-decoration:underline} a.lnk{color:var(--melodic);text-decoration:none}a.lnk:hover{text-decoration:underline}
/* corner badges */
.corner{display:inline-block;width:15px;height:15px;border-radius:4px;text-align:center;
line-height:15px;font-size:10px;font-weight:700;color:#000;margin-right:6px}
.cA{background:var(--melodic)}.cB{background:var(--percs)}.cC{background:var(--tops)}
/* source code view */
.code{background:#000;border:1px solid var(--hairline);border-radius:8px;padding:9px 11px;
max-height:300px;overflow:auto;font-family:"Geist Mono",ui-monospace,monospace;font-size:11px;
line-height:1.5;white-space:pre;color:#cdd6dd;margin-top:6px}
.code .c-com{color:#5a6b73;font-style:italic}
.code .c-str{color:#7fe3b0}
.code .c-dn{color:var(--magenta);font-weight:700}
.toggle{cursor:pointer;color:var(--melodic);font-size:11px;user-select:none}
.toggle:hover{text-decoration:underline}
/* ingredients */
.ing{border:1px solid var(--hairline);border-radius:6px;padding:5px 8px;margin-bottom:4px}
.ing code{color:#7fe3b0;font-size:11px;font-family:"Geist Mono",monospace}
.ing .ds{color:var(--mute);font-size:11px;display:block;margin-top:1px}
.ing .ty{font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:var(--faint)}
audio{width:100%;height:30px;margin-top:5px}
.gigrow{display:flex;justify-content:space-between;border:1px solid var(--hairline);border-radius:6px;padding:5px 8px;margin-bottom:4px;font-size:12px;align-items:center}
</style></head><body> </style></head><body>
<div class="app"> <div class="app">
<header> <header>
...@@ -142,7 +162,8 @@ function boot(){ ...@@ -142,7 +162,8 @@ function boot(){
function match(r){ function match(r){
const q=document.getElementById('q').value.toLowerCase().trim(); const q=document.getElementById('q').value.toLowerCase().trim();
if(q){const hay=(r.name+' '+r.track+' '+r.score_sounds.join(' ')+' '+r.claimed_sounds.join(' ')+' '+r.gigs.join(' ')).toLowerCase(); if(q){const ing=(r.ingredients||[]).map(g=>g.code+' '+g.description).join(' ');
const hay=(r.name+' '+r.track+' '+r.score_sounds.join(' ')+' '+r.claimed_sounds.join(' ')+' '+r.gigs.join(' ')+' '+ing).toLowerCase();
if(!hay.includes(q))return false} if(!hay.includes(q))return false}
if(FILTER==='all')return true; if(FILTER==='all')return true;
if(FILTER==='unrecorded')return !r.recorded; if(FILTER==='unrecorded')return !r.recorded;
...@@ -177,27 +198,67 @@ function render(){ ...@@ -177,27 +198,67 @@ function render(){
document.querySelectorAll('#rows tr').forEach(tr=>tr.onclick=()=>open(+tr.dataset.i)); document.querySelectorAll('#rows tr').forEach(tr=>tr.onclick=()=>open(+tr.dataset.i));
} }
// safe, line-based Tidal highlighter: split comment in RAW, escape, then color
function hl(src){
return src.split('\n').map(line=>{
let raw=line,com='';const k=raw.indexOf('--');
if(k>=0){com='<span class="c-com">'+esc(raw.slice(k))+'</span>';raw=raw.slice(0,k)}
let e=esc(raw).replace(/&quot;([^&]*)&quot;/g,'<span class="c-str">&quot;$1&quot;</span>')
.replace(/^(\s*)(d\d{1,2}|p\d{1,2})\b/,'$1<span class="c-dn">$2</span>');
return e+com;
}).join('\n');
}
function loadSrc(i,track){
fetch('../../'+track).then(x=>x.ok?x.text():null).then(t=>{
const el=document.getElementById('src-'+i);if(!el)return;
el.innerHTML=t!=null?hl(t):'<span class="empty">source not reachable — run serve.py with --dir at the repo root</span>';
}).catch(()=>{const el=document.getElementById('src-'+i);if(el)el.innerHTML='<span class="empty">source fetch failed</span>'});
}
function toggleSrc(i,track){const el=document.getElementById('src-'+i);
if(el.dataset.open==='1'){el.style.display='none';el.dataset.open='0';return}
el.style.display='block';el.dataset.open='1';if(!el.dataset.loaded){el.dataset.loaded='1';loadSrc(i,track)}}
const gigURL=slug=>'https://me.nech.pl/parvagues/live/'+slug.split('/').pop();
function open(i){SEL=i;render();const r=DATA.tracks[i];const lv=r.ac.level,c=css(C[lv]); function open(i){SEL=i;render();const r=DATA.tracks[i];const lv=r.ac.level,c=css(C[lv]);
const orb=Object.entries(r.score).map(([d,snd])=>`<div class="o mono">${d}</div><div class="mono">${esc(snd)}</div>`).join(''); const orb=Object.entries(r.score).map(([d,snd])=>`<div class="o mono">${d}</div><div class="mono">${esc(snd)}</div>`).join('');
const diff=g=>(r.ac[g]||[]).map(s=>`<span class="d-${g==='metadata_only'?'meta':g==='score_only'?'score':'shared'}">${esc(s)}</span>`).join('')||'<span class="empty">none</span>'; const diff=g=>(r.ac[g]||[]).map(s=>`<span class="d-${g==='metadata_only'?'meta':g==='score_only'?'score':'shared'}">${esc(s)}</span>`).join('')||'<span class="empty">none</span>';
// B · recordings — with audio when a take proxy exists
const takes=r.takes.length?r.takes.map(t=>`<div class="tk"><span class="mono">${t.take}</span> const takes=r.takes.length?r.takes.map(t=>`<div class="tk"><span class="mono">${t.take}</span>
<span class="muted">${t.type} · via ${esc(t.via)} · ${t.method}</span> <span class="muted" style="flex:1">${t.type} · via ${esc(t.via)} · ${t.method}</span>
<span class="${t.eda?'eda-yes':'eda-no'}">${t.eda?'◉':'○'}</span></div>`).join(''):'<div class="empty">no candidate take (date-join found nothing)</div>'; <span class="${t.eda?'eda-yes':'eda-no'}" title="EDA-grounded">${t.eda?'◉ EDA':'○'}</span></div>
const gigs=r.gigs.map(g=>`<span class="pill">${esc(g)}</span>`).join(''); <audio controls preload="none" src="punkachien/proxy_${t.take.toLowerCase()}.mp3"
onerror="this.style.display='none'"></audio>`).join('')
:'<div class="empty">no candidate take (date-join found nothing) — unrecorded</div>';
// C · gigs + raw ingredients (the site's claim = ground truth for corner C)
const gigs=r.gigs.map(g=>{const m=(r.metas||[]).find(x=>x.gig===g)||{};
return `<div class="gigrow"><a class="lnk" href="${gigURL(g)}" target="_blank">${esc(g)} ↗</a>
<span class="muted mono">${m.bpm?m.bpm+'bpm ':''}${m.style||''}${m.dur?' · '+Math.round(m.dur)+'s':''}</span></div>`}).join('');
const ings=(r.ingredients||[]).length?r.ingredients.map(g=>`<div class="ing">
<span class="ty">${esc(g.type||'?')}</span> <code>${esc(g.code||'')}</code>
${g.description?`<span class="ds">${esc(g.description)}</span>`:''}</div>`).join('')
:'<div class="empty">no ingredients listed in metadata</div>';
const alias=r.alias_siblings.length?`<div class="sec"><h3>⚠ name shared with other files</h3> const alias=r.alias_siblings.length?`<div class="sec"><h3>⚠ name shared with other files</h3>
${r.alias_siblings.map(s=>`<div class="path mono">${esc(s)}</div>`).join('')} ${r.alias_siblings.map(s=>`<div class="path mono">${esc(s)}</div>`).join('')}
<div class="empty">versions of one track, or a wrong link — ear-verify</div></div>`:''; <div class="empty">versions of one track, or a wrong link — ear-verify</div></div>`:'';
document.getElementById('drawer').innerHTML=`<span class="x" onclick="close_()">✕</span> document.getElementById('drawer').innerHTML=`<span class="x" onclick="close_()">✕</span>
<h2>${esc(r.name)}</h2><div class="path mono">${esc(r.track)}</div> <h2>${esc(r.name)}</h2><div class="path mono">${esc(r.track)}</div>
<div style="margin-top:9px"><span class="tag lvl" style="background:${c}">${lv}${r.ac.precision!=null?' · precision '+Math.round(r.ac.precision*100)+'%':''}</span></div> <div style="margin-top:9px"><span class="tag lvl" style="background:${c}">${lv}${r.ac.precision!=null?' · precision '+Math.round(r.ac.precision*100)+'%':''}</span></div>
<div class="sec"><h3>A · score (${r.n_orbits} orbits)</h3><div class="kv">${orb||'<span class="empty">unparsed</span>'}</div></div>
<div class="sec"><h3><span class="corner cA">A</span>score — ${r.n_orbits} orbits
&nbsp;<span class="toggle" onclick="toggleSrc(${i},'${r.track}')">▾ view .tidal source</span></h3>
<div class="kv">${orb||'<span class="empty">unparsed</span>'}</div>
<pre class="code" id="src-${i}" style="display:none" data-open="0"></pre></div>
<div class="sec"><h3>A↔C diff</h3> <div class="sec"><h3>A↔C diff</h3>
<div class="diff"><div><b class="muted" style="font-size:11px">shared</b><br>${diff('shared')}</div> <div class="diff"><div><b class="muted" style="font-size:11px">shared</b><br>${diff('shared')}</div>
<div style="margin-top:6px"><b class="muted" style="font-size:11px">score-only (often drums C omits)</b><br>${diff('score_only')}</div> <div style="margin-top:6px"><b class="muted" style="font-size:11px">score-only (often drums C omits)</b><br>${diff('score_only')}</div>
<div style="margin-top:6px"><b class="muted" style="font-size:11px">metadata-only (claimed ∉ score)</b><br>${diff('metadata_only')}</div></div></div> <div style="margin-top:6px"><b class="muted" style="font-size:11px">metadata-only (claimed ∉ score)</b><br>${diff('metadata_only')}</div></div></div>
${alias} ${alias}
<div class="sec"><h3>B · candidate recordings</h3>${takes}</div>
<div class="sec"><h3>gigs (${r.gigs.length})</h3>${gigs}</div>`; <div class="sec"><h3><span class="corner cB">B</span>recordings — ${r.n_takes} candidate${r.n_takes!==1?'s':''}</h3>${takes}</div>
<div class="sec"><h3><span class="corner cC">C</span>gigs — ${r.gigs.length}</h3>${gigs}</div>
<div class="sec"><h3>ingredients (site's claim · ${(r.ingredients||[]).length})</h3>${ings}</div>`;
document.getElementById('drawer').classList.add('open'); document.getElementById('drawer').classList.add('open');
} }
function close_(){document.getElementById('drawer').classList.remove('open');SEL=null;render()} function close_(){document.getElementById('drawer').classList.remove('open');SEL=null;render()}
......
...@@ -36,6 +36,14 @@ export interface CatalogStats { ...@@ -36,6 +36,14 @@ export interface CatalogStats {
gigs_total: number gigs_total: number
} }
/** A raw corner-C ingredient (the site's claim) — ground truth for the drawer. */
export interface Ingredient {
type?: string
code?: string
description?: string
gig?: string
}
export interface LoudTrace { export interface LoudTrace {
trace: number[] trace: number[]
stepS: number stepS: number
...@@ -109,6 +117,7 @@ export interface TrackRow { ...@@ -109,6 +117,7 @@ export interface TrackRow {
score?: Record<string, string> score?: Record<string, string>
score_sounds?: string[] score_sounds?: string[]
claimed_sounds?: string[] claimed_sounds?: string[]
ingredients?: Ingredient[]
ac: AgreeResult ac: AgreeResult
takes?: TakeRef[] takes?: TakeRef[]
n_takes: number n_takes: number
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment