Commit dc8af085 by PLN (Algolia)

tide-table: load-once viz (embed source/audio), usability, #56 take-filter, #59 pattern n-grams

- triangle viz loads once: .tidal source embedded per-row in catalog_view.json
  (no per-file fetch / relative-path fragility); take audio resolved at build
  time so a player only renders when a proxy exists; data-load failure banner
- usability: full-width search, fixed colgroup widths, ellipsized sound-lists,
  tooltip'd empty states (corner-C/B gap, not a disagreement)
- tide.py serve: serves the tide-table dir load-once, prints the triangle URL
- #56: drop empty/sketch take-types from candidate links (Take63/64 noise)
- #59 pattern_ngrams.py + PatternRegistry: mini-notation phrases as 'things'
  (unique/repeated/shared), track clustering by sound-token n-gram Jaccard
  (df-filtered + min-shingle guard). 73 tracks -> 1325 phrases, 193 shared,
  33 repeated; surfaces the gMask/gMute boolean idioms + euclid figures
- tests: 43 green (new guards: no empty/sketch candidates, source embedded,
  audio paths resolve; full UT+IT for the pattern registry)
parent 975f9150
......@@ -49,6 +49,8 @@ FUZZY_DAYS = 3
# ingredient `type`s that are not a loaded sound source
NON_SOUND_TYPES = {"moment", "effect"}
# take `type`s that aren't a real audition candidate (no usable audio) — #56
NON_CANDIDATE_TYPES = {"empty", "sketch"}
# parse "d4 — bass" / "d10" style orbit hints from an ingredient description/code
DN_HINT = re.compile(r"\bd(\d{1,2})\b")
......@@ -140,6 +142,19 @@ def eda_index():
return have
def proxy_index():
"""Audition proxies present on disk, by take id → relative path. Lets the
drawer render an <audio> ONLY when a proxy actually exists (no broken players).
Matches `proxy_take89.mp3` / `proxy_take87.flac` style under any subdir."""
have = {}
for ext in ("mp3", "flac", "m4a", "ogg", "wav"):
for f in glob.glob(str(HERE / f"**/proxy_take*.{ext}"), recursive=True):
m = re.search(r"proxy_(take\d+)", Path(f).name.lower())
if m:
have.setdefault("Take" + m.group(1)[4:], Path(f).relative_to(HERE).as_posix())
return have
# ── agreement ─────────────────────────────────────────────────────────────────
def agree(score_sounds, claimed_sounds):
"""A↔C sound agreement, with a taxonomy that never blames the metadata for a
......@@ -195,6 +210,7 @@ def build():
vocab, kind = sound_vocab()
gigs, takes = load_gigs(), load_takes()
eda = eda_index()
proxies = proxy_index()
# invert: track .tidal path → its appearances across gigs
tracks: dict[str, dict] = {}
......@@ -214,6 +230,14 @@ def build():
a_present = path.exists()
score = ts.orbit_sounds(path, vocab, kind) if a_present else {}
score_sounds = [d["sound"] for d in score.values()]
# embed the .tidal text so the drawer's ground-truth source view is load-once
# (no per-file fetch, no relative-path fragility — #58)
source = None
if a_present:
try:
source = path.read_text(encoding="utf-8", errors="replace")
except Exception:
source = None
# corner C: union claimed sounds + representative metadata + raw ingredients
claimed, metas, gig_slugs, ingredients = [], [], [], []
......@@ -227,17 +251,22 @@ def build():
"description": ing.get("description", ""), "gig": slug})
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.
# Drop empty/sketch take-types — they have no usable audio and only pollute
# the candidate list (Take63 empty / Take64 sketch date-noise). (#56)
cand_takes, edad = {}, 0
for slug, gdate, _tr in rec["appearances"]:
for tk, method in takes_for_gig(gdate, takes):
key = tk["take"]
if tk["type"].lower() in NON_CANDIDATE_TYPES:
continue
if key not in cand_takes:
has_eda = key in eda
cand_takes[key] = {
"take": key, "type": tk["type"], "via": slug,
"method": method, "is_set": tk["type"].upper() == "SET",
"eda": eda.get(key),
"audio": proxies.get(key),
}
if has_eda:
edad += 1
......@@ -254,6 +283,7 @@ def build():
"n_orbits": len(score),
"score": {f"d{o}": score[o]["sound"] for o in sorted(score)},
"score_sounds": sorted(set(score_sounds)),
"source": source,
# corner C
"claimed_sounds": claimed,
"ingredients": ingredients, # raw code+description, for ground-truth view
......
......@@ -732,8 +732,6 @@
"takes": [
"Take18",
"Take19",
"Take63",
"Take64",
"Take65",
"Take66"
],
......@@ -949,8 +947,6 @@
"takes": [
"Take18",
"Take19",
"Take63",
"Take64",
"Take65",
"Take66"
],
......@@ -1195,8 +1191,6 @@
"takes": [
"Take18",
"Take19",
"Take63",
"Take64",
"Take65",
"Take66",
"Take70"
......@@ -1653,8 +1647,6 @@
"Take19",
"Take20",
"Take21",
"Take63",
"Take64",
"Take65",
"Take66",
"Take80"
......@@ -1791,8 +1783,6 @@
"takes": [
"Take18",
"Take19",
"Take63",
"Take64",
"Take65",
"Take66"
],
......@@ -1831,8 +1821,6 @@
"2025/la-french-stack"
],
"takes": [
"Take63",
"Take64",
"Take65",
"Take66",
"Take70"
......@@ -2178,8 +2166,6 @@
"takes": [
"Take18",
"Take19",
"Take63",
"Take64",
"Take65",
"Take66",
"Take70",
......@@ -2224,8 +2210,6 @@
"takes": [
"Take18",
"Take19",
"Take63",
"Take64",
"Take65",
"Take66",
"Take70"
......@@ -2390,8 +2374,6 @@
"Take19",
"Take20",
"Take21",
"Take63",
"Take64",
"Take65",
"Take66"
],
......@@ -2439,8 +2421,6 @@
"Take19",
"Take20",
"Take21",
"Take63",
"Take64",
"Take65",
"Take66"
],
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -271,6 +271,7 @@ class TakeRef(BaseModel):
method: str # date-exact | date±Nd
is_set: bool
eda: Optional[str] = None # path to the take's EDA artifact, if grounded
audio: Optional[str] = None # relative path to an audition proxy, if one exists on disk
class TrackRow(BaseModel):
......@@ -285,6 +286,7 @@ class TrackRow(BaseModel):
n_orbits: int
score: dict[str, str] = Field(default_factory=dict) # {"d1":"kick",…}
score_sounds: list[str] = Field(default_factory=list)
source: Optional[str] = None # embedded .tidal text → ground-truth view works load-once (no per-file fetch)
# corner C — metadata
claimed_sounds: list[str] = Field(default_factory=list)
ingredients: list[Ingredient] = Field(default_factory=list)
......@@ -359,3 +361,59 @@ class Catalog(BaseModel):
n_tracks: int
n_authored: int
tracks: list[CatalogEntry] = Field(default_factory=list)
# ── pattern registry — phrases as things + n-gram clustering (#59) ─────────────
# Lifts the catalog from flat sound-token SETS to mini-notation PHRASES: each
# normalized phrase is "a thing" with a stable id; tracks cluster by shared
# sound-token n-grams. Generated by pattern_ngrams.build, validated on emit.
class PatternScope(str, Enum):
unique = "unique" # one occurrence, one track
repeated = "repeated" # ≥2× in ONE track → a duplicated section
shared = "shared" # in ≥2 tracks → a shared idiom
class PatternOccurrence(BaseModel):
track: str # canonical .tidal path
name: str = ""
orbit: Optional[int] = None # dN it sits on, if inside a block
count: int = 1 # times the phrase appears in that track
raw: str = "" # a representative verbatim form (pre-normalize)
class PatternEntry(BaseModel):
"""One normalized mini-notation phrase — 'a thing' we can name and track."""
id: str # stable: 'p'+sha1(norm)[:6]
norm: str # normalized phrase (indices/whitespace canonical)
n_tokens: int
n_tracks: int
n_total: int
scope: PatternScope
occurrences: list[PatternOccurrence] = Field(default_factory=list)
class TrackNeighbor(BaseModel):
track: str
name: str = ""
similarity: float # Jaccard of significant sound-token n-gram sets
shared_patterns: list[str] = Field(default_factory=list) # shared PatternEntry ids
class TrackPatternSig(BaseModel):
track: str
name: str = ""
n_phrases: int # distinct phrases (≥MIN_PHRASE_TOKENS) in this track
n_shared: int # how many of them are shared with another track
neighbors: list[TrackNeighbor] = Field(default_factory=list)
class PatternRegistry(BaseModel):
model_config = ConfigDict(populate_by_name=True)
schema_: str = Field(alias="schema")
as_of: str
n_tracks: int
n_patterns: int
n_shared: int
n_repeated: int
patterns: list[PatternEntry] = Field(default_factory=list)
tracks: list[TrackPatternSig] = Field(default_factory=list)
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -29,3 +29,10 @@ def view():
"""The real pipeline output, built once (IT-on-real-data)."""
import build_catalog_view as bcv
return bcv.build()
@pytest.fixture(scope="session")
def registry():
"""The real pattern registry, built once (IT-on-real-data)."""
import pattern_ngrams as pn
return pn.build()
......@@ -37,6 +37,36 @@ def test_candidate_takes_are_real(view):
assert re.fullmatch(r"Take\d+", t["take"]), t["take"]
def test_no_empty_or_sketch_candidate_takes(view):
"""#56: empty/sketch take-types have no usable audio → never a candidate."""
for r in view["tracks"]:
for t in r["takes"]:
assert t["type"].lower() not in ("empty", "sketch"), f"{r['name']}: {t['take']}"
def test_source_is_embedded_for_every_parsed_track(view):
"""#58 load-once: the .tidal text is embedded so the drawer's ground-truth
source view needs no per-file fetch (no relative-path fragility)."""
for r in view["tracks"]:
if r["score_present"]:
assert r.get("source"), f"{r['name']} missing embedded source"
# at least one parsed base sound should appear in the embedded text
# (orbits may be addressed via `# orbit X` comments == d(X+1), so the
# literal dN need not appear — but the sound names do)
if r["score_sounds"]:
assert any(s in r["source"] for s in r["score_sounds"]), r["name"]
def test_take_audio_paths_resolve_when_present(view):
"""An embedded audio path must point at a real file under HERE (no broken players)."""
from pathlib import Path
here = Path(__file__).resolve().parent.parent
for r in view["tracks"]:
for t in r["takes"]:
if t.get("audio"):
assert (here / t["audio"]).is_file(), t["audio"]
def test_eda_coverage_is_honest(view):
"""The whole point of surfacing the gap: EDA is sparse and we don't hide it."""
s = view["stats"]
......
"""Pattern n-gram registry — UT on the pure transforms + IT-on-real-data with
invariant + coverage guards (parsers-over-copy: the registry is generated, tested
mechanically, validated against the model on emit)."""
import pattern_ngrams as pn
# ── UT: normalization makes surface-different phrases the SAME thing ──────────
def test_normalize_drops_indices_and_collapses_space():
assert pn.normalize("kick:4 ~ kick:4") == pn.normalize("kick:2 ~ kick:2")
assert pn.normalize(' BD:4 SN ') == "bd sn"
def test_pid_is_stable_and_norm_keyed():
assert pn.pid("bd sn") == pn.pid("bd sn")
assert pn.pid("bd sn") != pn.pid("sn bd")
assert pn.pid("x").startswith("p")
def test_is_phrase_rejects_bare_labels_keeps_structure():
assert not pn.is_phrase("moog") # a synth name, not a phrase
assert not pn.is_phrase("")
assert pn.is_phrase("bd sn") # ≥2 steps
assert pn.is_phrase("bd*2") # operator → structure
assert pn.is_phrase("<0 1 2 3>") # note pattern
def test_shingles_are_contiguous_ngrams():
sh = pn.shingles(["a", "b", "c"])
assert "a b" in sh and "b c" in sh and "a b c" in sh
assert "a c" not in sh # not contiguous
def test_phrase_tokens_keep_sounds_and_numbers():
assert pn.phrase_tokens("808bd ~ 0 sn") == ["808bd", "0", "sn"]
# ── IT: structural invariants on the real corpus ─────────────────────────────
def test_registry_validates_against_model(registry):
from models import PatternRegistry
r = PatternRegistry.model_validate(registry)
assert r.n_patterns == len(r.patterns)
def test_scope_is_consistent_with_occurrences(registry):
for p in registry["patterns"]:
n_tracks = len({o["track"] for o in p["occurrences"]})
assert p["n_tracks"] == n_tracks
if n_tracks >= 2:
assert p["scope"] == "shared"
elif any(o["count"] >= 2 for o in p["occurrences"]):
assert p["scope"] == "repeated"
else:
assert p["scope"] == "unique"
def test_every_registered_phrase_has_enough_structure(registry):
"""A 'thing' must carry structure — never a bare single token."""
for p in registry["patterns"]:
assert p["n_tokens"] >= pn.MIN_PHRASE_TOKENS or any(
ch in pn._OP for ch in p["norm"]), p["norm"]
def test_similarity_is_bounded_and_symmetric_floor(registry):
"""Jaccard ∈ (floor,1]; and we never emit a degenerate self-ish 1.0 flood:
at most a handful of pairs may hit 1.0 (true duplicates), not dozens."""
ones = 0
for t in registry["tracks"]:
for nb in t["neighbors"]:
assert pn.NEIGHBOR_SIM <= nb["similarity"] <= 1.0
if nb["similarity"] == 1.0:
ones += 1
assert ones <= 6, f"{ones} perfect matches — boilerplate likely dominating clustering"
def test_shared_things_actually_exist(registry):
"""The whole point: the catalog reuses phrases across tracks."""
assert registry["n_shared"] >= 50 # was 193 — alarm if phrase-reuse craters
assert registry["n_patterns"] >= 800 # was 1325
......@@ -9,6 +9,7 @@ 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 serve # serve the triangle viz (load-once, Ctrl-C to stop)
python3 tide.py list # show the pipeline
Each step is importable and pure-ish (reads the corpus, writes one artifact), so
......@@ -42,6 +43,12 @@ def _catalog():
return m.OUT
def _patterns():
import pattern_ngrams as m
m.main() # validates against PatternRegistry on emit
return m.OUT
def _ts_types():
sys.path.insert(0, str(ROOT / "tools"))
import gen_ts_types as g
......@@ -54,6 +61,7 @@ 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),
("patterns", "pattern registry · phrases-as-things + n-gram clustering", _patterns),
("ts_types", "pydantic models → generated TypeScript (DRY)", _ts_types),
]
NAMES = [n for n, _, _ in STEPS]
......@@ -85,11 +93,28 @@ def cmd_test():
sys.exit(r.returncode)
def cmd_serve(args):
"""Serve the tide-table dir so the triangle viz loads with NO path fragility.
Source is embedded in catalog_view.json (load-once); only audition proxies need
the server, and they live under HERE, so serving HERE resolves everything."""
port = 8731
if args and args[0].isdigit():
port = int(args[0])
serve_py = HERE.parent / "serve.py" # armada/serve.py (Range-capable)
url = f"http://127.0.0.1:{port}/triangle.html"
print(f"⛵ tide serve — open the triangle at:\n {url}\n (Ctrl-C to stop)\n")
try:
subprocess.run([sys.executable, str(serve_py), "--dir", str(HERE), "--port", str(port)])
except KeyboardInterrupt:
print("\n✓ stopped")
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")
print(" serve [port] serve the triangle viz (default :8731)")
def main():
......@@ -99,10 +124,12 @@ def main():
cmd_build(args[1:])
elif cmd == "test":
cmd_test()
elif cmd == "serve":
cmd_serve(args[1:])
elif cmd in ("list", "ls"):
cmd_list()
else:
sys.exit(f"usage: tide.py [build [step…] | test | list]")
sys.exit(f"usage: tide.py [build [step…] | test | serve [port] | list]")
if __name__ == "__main__":
......
......@@ -28,22 +28,36 @@ h1 b{color:var(--magenta)}
.stack i{display:block;height:100%}
/* toolbar */
.bar{padding:8px 20px;display:flex;gap:8px;align-items:center;border-bottom:1px solid var(--hairline);flex-wrap:wrap}
input#q{background:var(--overlay);border:1px solid var(--hairline);color:var(--ink);border-radius:8px;
padding:6px 10px;font:inherit;width:230px}
.bar{padding:10px 20px;display:flex;gap:10px;align-items:center;border-bottom:1px solid var(--hairline);flex-wrap:wrap}
.search{position:relative;flex:1 1 360px;max-width:620px;display:flex;align-items:center}
.search::before{content:"⌕";position:absolute;left:12px;color:var(--faint);font-size:16px;pointer-events:none}
input#q{background:var(--overlay);border:1px solid var(--hairline);color:var(--ink);border-radius:9px;
padding:9px 12px 9px 32px;font:inherit;font-size:14px;width:100%}
input#q:focus{outline:none;border-color:var(--melodic)}
.f{background:transparent;border:1px solid var(--hairline);color:var(--mute);border-radius:99px;
padding:4px 11px;cursor:pointer;font:inherit;font-size:12px;display:flex;gap:6px;align-items:center}
.f:hover{color:var(--ink)}.f.on{color:#000;font-weight:600}
.f .d{width:8px;height:8px;border-radius:50%}
.grow{flex:1}.count{color:var(--faint);font-size:12px}
.count{color:var(--faint);font-size:12px;white-space:nowrap}
/* data-load banner */
.banner{margin:16px 20px;padding:14px 16px;border:1px solid var(--conflict);border-radius:10px;
background:#ff52520f;color:var(--ink);font-size:13px;line-height:1.6}
.banner b{color:var(--conflict)}
.banner code{background:#000;border:1px solid var(--hairline);border-radius:5px;padding:1px 6px;
font-family:"Geist Mono",monospace;font-size:12px;color:#7fe3b0}
/* table */
.wrap{overflow:auto}
table{width:100%;border-collapse:collapse;font-size:13px}
table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed}
col.c-track{width:25%}col.c-score{width:24%}col.c-meta{width:24%}
col.c-ac{width:120px}col.c-rec{width:135px}col.c-gigs{width:56px}
thead th{position:sticky;top:0;background:var(--surface);text-align:left;padding:8px 12px;
font-size:10px;letter-spacing:.07em;text-transform:uppercase;color:var(--faint);
border-bottom:1px solid var(--hairline);z-index:2;white-space:nowrap}
tbody td{padding:7px 12px;border-bottom:1px solid #ffffff10;vertical-align:middle}
tbody td{padding:7px 12px;border-bottom:1px solid #ffffff10;vertical-align:middle;
overflow:hidden;text-overflow:ellipsis}
tbody td .snd,tbody td .path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
tbody tr{cursor:pointer}tbody tr:hover{background:#ffffff08}
tbody tr.sel{background:#ffffff12;outline:1px solid var(--hairline)}
.tname{font-weight:500}
......@@ -103,13 +117,16 @@ audio{width:100%;height:30px;margin-top:5px}
<div class="chips" id="chips"></div>
</header>
<div class="bar">
<input id="q" placeholder="search track / sound / gig…" oninput="render()">
<span class="search"><input id="q" placeholder="search track, sound, sample, gig…" oninput="render()"></span>
<span id="filters"></span>
<span class="grow"></span>
<span class="count" id="count"></span>
</div>
<div class="wrap">
<table>
<colgroup>
<col class="c-track"><col class="c-score"><col class="c-meta">
<col class="c-ac"><col class="c-rec"><col class="c-gigs">
</colgroup>
<thead><tr>
<th>Track</th><th>A · Score</th><th>C · Metadata</th>
<th>A↔C</th><th>B · Recording</th><th class="num">Gigs</th>
......@@ -129,7 +146,18 @@ let DATA=null, FILTER='all', SEL=null;
const FILTERS=[['all','All'],['agree','Agree'],['partial','Partial'],['conflict','Conflict'],
['divergent','Divergent'],['no-claim','No-claim'],['unrecorded','Unrecorded'],['eda','EDA-grounded']];
fetch('catalog_view.json').then(r=>r.json()).then(d=>{DATA=d;boot()});
fetch('catalog_view.json').then(r=>{if(!r.ok)throw new Error(r.status);return r.json()})
.then(d=>{DATA=d;boot()})
.catch(e=>{
document.getElementById('sub').textContent='data not loaded';
document.querySelector('.wrap').innerHTML=`<div class="banner">
<b>⚠ catalog_view.json didn't load</b> (${esc(String(e.message||e))}).<br>
This page needs a server (file:// blocks fetch). Run it load-once from the repo:<br>
<code>cd armada/tide-table && python3 tide.py serve</code><br>
then open <code>http://127.0.0.1:8731/triangle.html</code>.<br>
Or regenerate the data first: <code>python3 tide.py build catalog_view</code>.
</div>`;
});
function boot(){
const s=DATA.stats;
......@@ -182,11 +210,11 @@ function render(){
const meta=r.metas[0]||{};
const metaCell=r.claimed_sounds.length
? `<span class="snd">${r.claimed_sounds.slice(0,4).join(' · ')}${r.claimed_sounds.length>4?'…':''}</span>`
: `<span class="muted">— no ingredients —</span>`;
: `<span class="muted" title="this track's gig tracklist lists no ingredient sounds — a corner-C metadata gap, not a disagreement (A↔C = no-claim)">— none listed —</span>`;
const bpm=meta.bpm?`<span class="mono">${meta.bpm}</span> `:'';
const recCell=r.recorded
? `${r.n_takes} take${r.n_takes>1?'s':''} <span class="${r.n_takes_eda?'eda-yes':'eda-no'}" title="EDA-grounded takes">${r.n_takes_eda?'◉ EDA':'○ no EDA'}</span>`
: `<span class="muted">— unrecorded —</span>`;
: `<span class="muted" title="no Ardour take date-joins to this track's gig(s) yet — corner B unlinked">— unrecorded —</span>`;
return `<tr data-i="${idx}" class="${idx===SEL?'sel':''}">
<td><div class="tname">${esc(r.name)}${sib}</div><div class="path mono">${esc(r.track)}</div></td>
<td><span class="mono">${r.n_orbits}d</span> <span class="snd">${r.score_sounds.slice(0,4).join(' · ')}${r.score_sounds.length>4?'…':''}</span></td>
......@@ -208,26 +236,26 @@ function hl(src){
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);
// source is EMBEDDED in catalog_view.json (load-once) — no per-file fetch, no path fragility
function toggleSrc(i){const el=document.getElementById('src-'+i);if(!el)return;
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)}}
el.style.display='block';el.dataset.open='1';
if(!el.dataset.loaded){el.dataset.loaded='1';
const src=DATA.tracks[i].source;
el.innerHTML=src!=null?hl(src):'<span class="empty">no .tidal source embedded (file not found at build time)</span>';
}}
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]);
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>';
// B · recordings — with audio when a take proxy exists
// B · recordings — render an <audio> ONLY when a proxy actually exists on disk
// (t.audio, resolved at build time) so there are no broken/empty players
const takes=r.takes.length?r.takes.map(t=>`<div class="tk"><span class="mono">${t.take}</span>
<span class="muted" style="flex:1">${t.type} · via ${esc(t.via)} · ${t.method}</span>
<span class="${t.eda?'eda-yes':'eda-no'}" title="EDA-grounded">${t.eda?'◉ EDA':'○'}</span></div>
<audio controls preload="none" src="punkachien/proxy_${t.take.toLowerCase()}.mp3"
onerror="this.style.display='none'"></audio>`).join('')
<span class="${t.eda?'eda-yes':'eda-no'}" title="EDA-grounded">${t.eda?'◉ EDA':'○'}</span></div>`
+(t.audio?`<audio controls preload="none" src="${esc(t.audio)}"></audio>`
:`<div class="empty" style="margin:-2px 0 7px">no audition proxy on disk yet</div>`)).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)||{};
......@@ -245,7 +273,7 @@ function open(i){SEL=i;render();const r=DATA.tracks[i];const lv=r.ac.level,c=css
<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><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>
&nbsp;<span class="toggle" onclick="toggleSrc(${i})">▾ 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>
......
......@@ -3,6 +3,8 @@
export type AgreeLevel = 'empty' | 'unparsed' | 'no-claim' | 'agree' | 'partial' | 'conflict' | 'divergent'
export type PatternScope = 'unique' | 'repeated' | 'shared'
export type Variant = 'stream' | 'club'
/** A↔C: how well the site's claimed ingredients match the actual score. */
......@@ -66,6 +68,25 @@ export interface OrbitActivity {
activity: number[]
}
/** One normalized mini-notation phrase — 'a thing' we can name and track. */
export interface PatternEntry {
id: string
norm: string
n_tokens: number
n_tracks: number
n_total: number
scope: PatternScope
occurrences?: PatternOccurrence[]
}
export interface PatternOccurrence {
track: string
name?: string
orbit?: number | null
count?: number
raw?: string
}
export interface RoleGroup {
key: string
label: string
......@@ -94,6 +115,7 @@ export interface TakeRef {
method: string
is_set: boolean
eda?: string | null
audio?: string | null
}
/** Corner C facts for one gig appearance (site tracklist, a pointer). */
......@@ -105,6 +127,21 @@ export interface TrackMeta {
dur?: number | null
}
export interface TrackNeighbor {
track: string
name?: string
similarity: number
shared_patterns?: string[]
}
export interface TrackPatternSig {
track: string
name?: string
n_phrases: number
n_shared: number
neighbors?: TrackNeighbor[]
}
/** One track (identity = its .tidal path), reconciled across the three corners. */
export interface TrackRow {
track: string
......@@ -116,6 +153,7 @@ export interface TrackRow {
n_orbits: number
score?: Record<string, string>
score_sounds?: string[]
source?: string | null
claimed_sounds?: string[]
ingredients?: Ingredient[]
ac: AgreeResult
......@@ -149,3 +187,14 @@ export interface CatalogView {
stats: CatalogStats
tracks?: TrackRow[]
}
export interface PatternRegistry {
schema: string
as_of: string
n_tracks: number
n_patterns: number
n_shared: number
n_repeated: number
patterns?: PatternEntry[]
tracks?: TrackPatternSig[]
}
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