Commit 33d49591 by PLN (Algolia)

feat(corpus-viz): 'by the numbers' scrollytelling dataviz (task #64)

Phase-2 infodesign of the tide_eda corpus EDA: a standalone, scroll-driven
data essay (corpus.html) on the Ship's Bridge dark instrument language.

Six curated stories, vanilla SVG (zero npm deps), measured-width responsive:
1. the slow climb — studio vs club tempo, dual median lines + track cloud +
   histogram + felt-vs-written 2x inset; studio<->club lens is first-class
2. 2024 breakout — vocab burst + gig cadence on a shared time axis, magenta
   reserved for the one earned 2024 accent
3. palette — 12 sample families as a proportion bar, fleet colors + glyphs
4. the accent — signature gMask/gMute idioms (f*16 in 62/73)
5. collab fingerprint — per-collab bpm range + distinctive samples (raph fast)
6. set-staples — recurrence, Cafe trilogy highlighted

- tide_eda: add stage_tempo_by_year (gig-date x track-tempo) for the club lens
- build_corpus.py + 'tide.py corpus': inline-bundle to one self-contained file
  for deploy to me.nech.pl/parvagues/viz (dist/ gitignored)
- IntersectionObserver reveal/lazy-draw, reduced-motion + mobile reflow,
  load-fail banner; lens auto-disables on single-lens sections (honest)
- 59 tests green
parent 830d2dd1
#!/usr/bin/env python3
"""Inline-bundle corpus.html → a single portable file for the public deploy.
The dev/serve version of corpus.html fetches eda_report.json + tokens.json over
HTTP. For deploy (me.nech.pl/parvagues/viz) we want ONE self-contained file with
no fetches and no relative-asset fragility, so we embed the two JSONs into the
empty <script id="eda-data"> / <script id="tok-data"> blocks the page already
reads first (it only falls back to fetch when those are empty).
python3 build_corpus.py # → dist/viz.html
python3 build_corpus.py -o out.html
parsers-over-copy: the source of truth stays corpus.html + the generated JSONs;
this is a pure mechanical bundle step, re-runnable after any `tide_eda.py` run.
"""
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
def _embed(html: str, script_id: str, payload: str) -> str:
"""Replace the (empty) <script id=...></script> body with JSON payload."""
open_tag = f'<script id="{script_id}" type="application/json">'
i = html.find(open_tag)
if i < 0:
raise SystemExit(f"corpus.html missing <script id={script_id!r}> block")
j = html.find("</script>", i)
# </ inside JSON would break the parser; escape the only dangerous sequence.
safe = payload.replace("</", "<\\/")
return html[: i + len(open_tag)] + safe + html[j:]
def build(out: Path) -> Path:
html = (HERE / "corpus.html").read_text()
eda = (HERE / "eda_report.json").read_text()
tok_path = HERE / "tokens.json"
tok = tok_path.read_text() if tok_path.exists() else "null"
# minify the JSON a touch (re-dump without the indent)
eda = json.dumps(json.loads(eda), ensure_ascii=False, separators=(",", ":"))
tok = json.dumps(json.loads(tok), ensure_ascii=False, separators=(",", ":"))
html = _embed(html, "eda-data", eda)
html = _embed(html, "tok-data", tok)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(html)
return out
def main():
out = HERE / "dist" / "viz.html"
args = sys.argv[1:]
if args and args[0] in ("-o", "--out"):
out = Path(args[1])
p = build(out)
kb = p.stat().st_size / 1024
print(f"⛵ bundled → {p} ({kb:.0f} KB, self-contained, no fetch)")
print(f" deploy: copy to me.nech.pl/parvagues/viz/index.html")
if __name__ == "__main__":
main()
<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ParVagues, by the numbers</title>
<meta name="description" content="Five years of livecoded TidalCycles sets, read back from the source. Tempo, samples and idioms parsed straight from the .tidal files.">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
/* ───────────────────────────────────────────────────────────────────────────
ParVagues · by the numbers a scroll-driven data essay on the corpus.
Ship's Bridge language (armada/DESIGN.md): dark instrument surface, Geist +
Geist Mono, never hue alone, brand magenta reserved for ONE earned accent
(the 2024 breakout). Fleet sample-family colors carry the charts and are
overridden at runtime from tokens.json. Self-contained: zero npm deps; data
is fetched, or read from inlined <script> blocks for the static deploy build.
─────────────────────────────────────────────────────────────────────────── */
:root{
--surface:#0a0a0a; --raised:#111113; --overlay:#16161a; --hairline:#ffffff1c;
--ink:#e8e8ea; --mute:#9a9aa2; --faint:#67676e;
--magenta:#d900ff; /* reserved — the breakout beat only */
--studio:#7db3ff; /* "in the studio" tempo line */
--club:#ffb454; /* "in the club" tempo line */
--grid:#ffffff10;
--maxw:1080px; --col:880px;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
@media (prefers-reduced-motion: reduce){html{scroll-behavior:auto}}
body{margin:0;background:var(--surface);color:var(--ink);
font:400 16px/1.6 Geist,Inter,system-ui,-apple-system,sans-serif;
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
overflow-x:hidden}
.mono{font-family:"Geist Mono",ui-monospace,SFMono-Regular,monospace;
font-variant-numeric:tabular-nums}
a{color:var(--studio);text-decoration:none}a:hover{text-decoration:underline}
code{font-family:"Geist Mono",ui-monospace,monospace;font-size:.86em;
background:#ffffff0d;border:1px solid var(--hairline);border-radius:5px;
padding:1px 6px;color:#9fd0ff}
/* ── layout shell ─────────────────────────────────────────────────────────── */
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
section{padding:13vh 0;position:relative}
.col{max-width:var(--col);margin:0 auto}
h1,h2,h3{margin:0;line-height:1.04;letter-spacing:-0.03em;font-weight:600}
h2{font-size:clamp(1.9rem,4.4vw,3rem);text-wrap:balance;margin-bottom:.5rem}
.kicker{font:500 13px/1 "Geist Mono",monospace;letter-spacing:.04em;
color:var(--mute);margin-bottom:1.1rem;display:flex;gap:.6rem;align-items:center}
.kicker .n{color:var(--ink)}
.kicker .rule{height:1px;flex:1;background:linear-gradient(90deg,var(--hairline),transparent)}
.dek{font-size:clamp(1.05rem,1.6vw,1.3rem);color:var(--mute);max-width:62ch;
text-wrap:pretty;margin:0 0 2.2rem}
.dek b{color:var(--ink);font-weight:500}
.note{font-size:.82rem;color:var(--faint);max-width:64ch;margin-top:1.1rem;line-height:1.55}
.note code{font-size:.8em}
/* ── masthead ─────────────────────────────────────────────────────────────── */
.hero{min-height:100svh;display:flex;flex-direction:column;justify-content:center;
padding:8vh 0 10vh}
.hero .eyebrow{font:500 14px/1 "Geist Mono",monospace;letter-spacing:.18em;
text-transform:uppercase;color:var(--magenta);margin-bottom:1.6rem}
.hero h1{font-size:clamp(2.7rem,9vw,5.6rem);font-weight:700;letter-spacing:-0.04em;
text-wrap:balance;margin-bottom:1.4rem}
.hero h1 em{font-style:normal;color:var(--mute)}
.hero .lede{font-size:clamp(1.1rem,2vw,1.45rem);color:var(--mute);max-width:54ch;
line-height:1.5;text-wrap:pretty}
.hero .lede b{color:var(--ink);font-weight:500}
.stats{display:flex;flex-wrap:wrap;gap:0;margin-top:3rem;
border-top:1px solid var(--hairline);border-bottom:1px solid var(--hairline)}
.stat{padding:1.1rem 1.6rem 1.1rem 0;margin-right:1.6rem;
border-right:1px solid var(--hairline)}
.stat:last-child{border-right:0;margin-right:0}
.stat .v{font:600 1.9rem/1 "Geist Mono",monospace;color:var(--ink);letter-spacing:-0.02em}
.stat .l{font-size:.78rem;color:var(--faint);margin-top:.35rem;letter-spacing:.02em}
.scrollcue{margin-top:3.4rem;font:500 12px/1 "Geist Mono",monospace;
color:var(--faint);letter-spacing:.1em;display:flex;align-items:center;gap:.7rem}
.scrollcue .arr{animation:bob 2.4s ease-in-out infinite}
@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(4px)}}
@media (prefers-reduced-motion: reduce){.scrollcue .arr{animation:none}}
/* ── lens toggle (studio ⇄ club) ──────────────────────────────────────────── */
.lens{position:fixed;top:16px;right:16px;z-index:50;display:flex;align-items:center;
gap:0;background:#0c0c0fd9;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
border:1px solid var(--hairline);border-radius:11px;padding:4px;
box-shadow:0 6px 24px #000a;transition:opacity .25s ease}
.lens.off{opacity:.42}
.lens .lbl{font:500 10px/1 "Geist Mono",monospace;color:var(--faint);
letter-spacing:.05em;padding:0 9px;text-transform:uppercase}
.lens button{appearance:none;border:0;background:transparent;cursor:pointer;
font:500 12.5px/1 Geist,sans-serif;color:var(--mute);padding:7px 13px;
border-radius:8px;display:flex;align-items:center;gap:6px;transition:.18s ease}
.lens button .d{width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.5}
.lens button.on{color:var(--ink);background:#ffffff12}
.lens button.on .d{opacity:1}
.lens button:hover:not(.on){color:var(--ink)}
.lens.off button{cursor:default;pointer-events:none}
.lens[data-lens=studio] button.studio{color:var(--studio)}
.lens[data-lens=club] button.club{color:var(--club)}
/* ── progress rail ────────────────────────────────────────────────────────── */
.rail{position:fixed;right:22px;top:50%;transform:translateY(-50%);z-index:40;
display:flex;flex-direction:column;gap:13px}
.rail a{width:9px;height:9px;border-radius:50%;border:1.5px solid var(--faint);
background:transparent;transition:.22s ease;position:relative}
.rail a.on{border-color:var(--ink);background:var(--ink);transform:scale(1.15)}
.rail a:hover::after{content:attr(data-t);position:absolute;right:18px;top:50%;
transform:translateY(-50%);white-space:nowrap;font:500 11px/1 "Geist Mono",monospace;
color:var(--mute);background:#0c0c0fe6;border:1px solid var(--hairline);
padding:5px 8px;border-radius:6px}
/* ── chart frame ──────────────────────────────────────────────────────────── */
.chart{width:100%;margin:.5rem 0 0}
.chart svg{display:block;width:100%;height:auto;overflow:visible}
.legend{display:flex;flex-wrap:wrap;gap:.5rem 1rem;margin-top:1.1rem}
.lg{display:inline-flex;align-items:center;gap:7px;font-size:.82rem;color:var(--mute)}
.lg .g{font-size:1rem;line-height:1}
.lg b{color:var(--ink);font-weight:500;font-family:"Geist Mono",monospace}
.sub{display:grid;grid-template-columns:1.2fr 1fr;gap:24px;margin-top:1.8rem}
@media(max-width:680px){.sub{grid-template-columns:1fr}}
.panel{border:1px solid var(--hairline);border-radius:12px;padding:18px 20px;
background:linear-gradient(180deg,#ffffff05,transparent)}
.panel h3{font-size:.95rem;font-weight:600;margin-bottom:.2rem}
.panel .ph{font-size:.78rem;color:var(--faint);margin-bottom:1rem}
.twox{display:flex;flex-direction:column;gap:.85rem}
.twox .row{display:flex;align-items:baseline;justify-content:space-between;gap:12px;
padding-bottom:.7rem;border-bottom:1px solid var(--hairline)}
.twox .row:last-child{border-bottom:0;padding-bottom:0}
.twox .nm{font-size:.9rem;color:var(--ink)}
.twox .vs{font-family:"Geist Mono",monospace;font-size:.84rem;color:var(--mute);
white-space:nowrap}
.twox .vs .a{color:var(--studio)}.twox .vs .c{color:var(--club)}
.twox .x{color:var(--faint);font-size:.78rem}
/* SVG primitives */
.ax{stroke:var(--hairline);stroke-width:1}
.gl{stroke:var(--grid);stroke-width:1}
.atxt{font-family:"Geist Mono",monospace;font-size:11px;fill:var(--faint)}
.atxt.b{fill:var(--mute)}
.dlabel{font-family:"Geist Mono",monospace;font-size:12px;font-weight:500}
.barlabel{font-family:"Geist Mono",monospace;font-size:12px;fill:var(--ink)}
.barv{font-family:"Geist Mono",monospace;font-size:12px;fill:var(--mute)}
/* reveal */
.reveal{opacity:0;transform:translateY(22px);
transition:opacity .7s cubic-bezier(.16,1,.3,1),transform .7s cubic-bezier(.16,1,.3,1)}
.reveal.in{opacity:1;transform:none}
@media (prefers-reduced-motion: reduce){.reveal{opacity:1;transform:none;transition:none}}
/* footer + banner */
footer{padding:9vh 0 12vh;border-top:1px solid var(--hairline);margin-top:6vh}
footer .meta{font-size:.85rem;color:var(--faint);max-width:66ch;line-height:1.7}
footer .sig{margin-top:1.6rem;font:600 1.1rem/1 Geist,sans-serif;color:var(--ink)}
footer .sig span{color:var(--magenta)}
.banner{margin:24px;padding:18px 20px;border:1px solid #ff5252;border-radius:12px;
background:#ff52520f;color:var(--ink);font-size:14px;line-height:1.7}
.banner b{color:#ff7a7a}
@media(max-width:760px){.rail{display:none}.lens{top:auto;bottom:14px;right:14px}
section{padding:9vh 0}}
</style></head>
<body>
<!-- studio ⇄ club lens (sticky). Disabled+dimmed on sections that are score-derived
and therefore single-lens — honest per Principle 1 (never fake a dimension). -->
<div class="lens off" id="lens" data-lens="studio" title="">
<span class="lbl" id="lensLbl">one lens</span>
<button class="studio on" data-l="studio"><span class="d"></span>in the studio</button>
<button class="club" data-l="club"><span class="d"></span>in the club</button>
</div>
<nav class="rail" id="rail" aria-label="sections"></nav>
<div id="root">
<!-- masthead -->
<header class="hero wrap">
<div class="col">
<div class="eyebrow">ParVagues</div>
<h1>By the numbers<em>.</em></h1>
<p class="lede">Five years of livecoded sets, <b>read straight back from the source</b>.
Every tempo, sample and phrase below is parsed from the <code>.tidal</code> files.
Nothing here was typed by hand.</p>
<div class="stats" id="stats"></div>
<div class="scrollcue"><span class="arr"></span> scroll to read</div>
</div>
</header>
<main id="stories"></main>
<footer class="wrap"><div class="col reveal">
<p class="meta" id="footmeta"></p>
<div class="sig">Par<span>Vagues</span></div>
</div></footer>
</div>
<!-- inlined data for the static deploy build (filled by build_corpus.py --inline) -->
<script id="eda-data" type="application/json"></script>
<script id="tok-data" type="application/json"></script>
<script>
"use strict";
const NS="http://www.w3.org/2000/svg";
const RM=matchMedia("(prefers-reduced-motion: reduce)").matches;
const E=(t,a={})=>{const e=document.createElementNS(NS,t);for(const k in a)e.setAttribute(k,a[k]);return e;};
const TX=(x,y,s,cls,anchor)=>{const e=E("text",{x,y});if(cls)e.setAttribute("class",cls);if(anchor)e.setAttribute("text-anchor",anchor);e.textContent=s;return e;};
const esc=s=>String(s).replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
const css=v=>getComputedStyle(document.documentElement).getPropertyValue(v).trim();
let EDA=null, TOK=null, FAM={}, LENS="studio";
const CHARTS=[]; // [{el, draw}] for responsive re-render
/* ── data load: inlined block first (deploy), else fetch (dev/serve) ──────── */
function embedded(id){const e=document.getElementById(id);
if(e&&e.textContent.trim()){try{return JSON.parse(e.textContent)}catch(_){}}return null;}
async function load(){
EDA=embedded("eda-data");
TOK=embedded("tok-data");
if(!EDA)EDA=await fetch("eda_report.json").then(r=>{if(!r.ok)throw new Error("eda_report.json "+r.status);return r.json();});
if(!TOK)TOK=await fetch("tokens.json").then(r=>r.ok?r.json():null).catch(()=>null);
}
/* fleet palette → CSS vars + family lookup keyed by family KEY */
function applyTokens(){
if(!TOK)return;
const root=document.documentElement.style;
for(const[k,v]of Object.entries(TOK.neutrals||{}))root.setProperty("--"+k,v);
if(TOK.brand&&TOK.brand.magenta)root.setProperty("--magenta",TOK.brand.magenta);
(TOK.sample||[]).forEach(f=>FAM[f.key]=f);
}
/* classify a raw sound token → family (sample-world identity, lexical) */
function sampleFamily(name){
const s=String(name).toLowerCase();
for(const f of (TOK&&TOK.sample||[])){
if((f.match||[]).some(m=>s===m||s.startsWith(m)))return f;
}
return null;
}
/* ── tiny responsive chart kit ────────────────────────────────────────────── */
function mountChart(el,draw){
CHARTS.push({el,draw,done:false});
}
function drawAll(){for(const c of CHARTS)redraw(c);}
function redraw(c){
const w=c.el.clientWidth; if(!w)return;
c.el.innerHTML="";
const animate=!RM && c.el.dataset.live==="1";
c.draw(c.el,w,animate);
}
function animBars(g,delayBase=0){ // scaleX/scaleY rects with transform-origin set
if(RM)return;
[...g.querySelectorAll("[data-anim=barx]")].forEach((r,i)=>{
r.style.transformOrigin=r.dataset.ox+"px "+r.dataset.oy+"px";
r.style.transform="scaleX(0)";r.style.transition="transform .8s cubic-bezier(.16,1,.3,1)";
r.style.transitionDelay=(delayBase+i*40)+"ms";
requestAnimationFrame(()=>requestAnimationFrame(()=>{r.style.transform="scaleX(1)";}));
});
[...g.querySelectorAll("[data-anim=bary]")].forEach((r,i)=>{
r.style.transformOrigin=r.dataset.ox+"px "+r.dataset.oy+"px";
r.style.transform="scaleY(0)";r.style.transition="transform .7s cubic-bezier(.16,1,.3,1)";
r.style.transitionDelay=(delayBase+i*45)+"ms";
requestAnimationFrame(()=>requestAnimationFrame(()=>{r.style.transform="scaleY(1)";}));
});
}
function animLine(path,dur=1100,delay=0){
if(RM)return;
path.setAttribute("pathLength","1");
path.style.strokeDasharray="1";path.style.strokeDashoffset="1";
path.style.transition=`stroke-dashoffset ${dur}ms cubic-bezier(.5,0,.2,1) ${delay}ms`;
requestAnimationFrame(()=>requestAnimationFrame(()=>{path.style.strokeDashoffset="0";}));
}
function animFade(els,base=0,step=30){
if(RM)return;
els.forEach((e,i)=>{e.style.opacity="0";e.style.transition=`opacity .5s ease ${base+i*step}ms`;
requestAnimationFrame(()=>requestAnimationFrame(()=>{e.style.opacity="";}));});
}
/* ══════════════════════════════════════════════════════════════════════════
STORY 1 — The slow climb (studio ⇄ club tempo)
══════════════════════════════════════════════════════════════════════════ */
function chartTempo(el,W,animate){
const t=EDA.tempo, H=Math.max(300,Math.min(420,W*0.52));
const m={l:46,r:96,t:24,b:40};
const iw=W-m.l-m.r, ih=H-m.t-m.b;
const studio=t.creation_tempo_by_year, stage=t.stage_tempo_by_year;
const years=[...new Set([...Object.keys(studio),...Object.keys(stage)])].map(Number).sort();
const y0=Math.min(...years), y1=Math.max(...years);
const pts=t.by_creation.filter(p=>p.created);
const bpmAll=[...Object.values(studio),...Object.values(stage),...pts.map(p=>p.bpm)];
const lo=Math.min(95,Math.min(...bpmAll))-6, hi=Math.max(...bpmAll)+6;
const X=y=>m.l+(y-y0)/Math.max(1,(y1-y0))*iw;
const Y=b=>m.t+(1-(b-lo)/(hi-lo))*ih;
const Xd=iso=>{const d=new Date(iso);const fy=d.getUTCFullYear()+(d.getUTCMonth())/12;return X(Math.max(y0,Math.min(y1,fy)));};
const svg=E("svg",{viewBox:`0 0 ${W} ${H}`,role:"img"});
svg.appendChild(TX(0,0,"ParVagues tempo by year, studio vs club")).setAttribute("opacity","0");
// gridlines + y labels (BPM)
for(let b=Math.ceil(lo/20)*20;b<=hi;b+=20){
svg.appendChild(E("line",{class:"gl",x1:m.l,x2:m.l+iw,y1:Y(b),y2:Y(b)}));
svg.appendChild(TX(m.l-9,Y(b)+4,b,"atxt","end"));
}
svg.appendChild(TX(m.l-9,m.t-9,"BPM","atxt","end"));
// x labels (years)
for(const y of years){svg.appendChild(TX(X(y),H-m.b+20,y,"atxt b","middle"));}
// scatter — each track by creation date (the studio cloud)
const cloud=E("g");
const dim = LENS==="club";
pts.forEach(p=>{cloud.appendChild(E("circle",{cx:Xd(p.created),cy:Y(p.bpm),r:3.4,
fill:"#ffffff",opacity:dim?0.10:0.22}));});
svg.appendChild(cloud);
// the two median lines
function line(map,color,key){
const ys=Object.keys(map).map(Number).sort();
if(ys.length<2)return null;
const d=ys.map((y,i)=>(i?"L":"M")+X(y)+" "+Y(map[y])).join(" ");
const active = LENS===key;
const p=E("path",{d,fill:"none",stroke:color,"stroke-width":active?3.2:1.6,
"stroke-linecap":"round","stroke-linejoin":"round",opacity:active?1:0.4});
return {p,ys,map,color,active};
}
const ls=line(stage,css("--club")||"#ffb454","club");
const lt=line(studio,css("--studio")||"#7db3ff","studio");
[ls,lt].forEach(L=>{if(!L)return;
svg.appendChild(L.p);
L.ys.forEach(y=>svg.appendChild(E("circle",{cx:X(y),cy:Y(L.map[y]),r:L.active?4:3,
fill:L.color,opacity:L.active?1:0.45})));
// end label
const ly=L.ys[L.ys.length-1];
const lab=TX(X(ly)+10,Y(L.map[ly])+4,L.color===(css("--club")||"#ffb454")?"in the club":"in the studio","dlabel");
lab.setAttribute("fill",L.color);lab.setAttribute("opacity",L.active?1:0.5);
svg.appendChild(lab);
});
el.appendChild(svg);
if(animate){if(lt)animLine(lt.p,1000,0);if(ls)animLine(ls.p,1000,120);
animFade([...cloud.querySelectorAll("circle")],300,8);}
}
/* ══════════════════════════════════════════════════════════════════════════
STORY 1b — tempo histogram
══════════════════════════════════════════════════════════════════════════ */
function chartHist(el,W,animate){
const h=EDA.tempo.histogram, H=180, m={l:8,r:8,t:14,b:26};
const keys=Object.keys(h).map(Number).sort((a,b)=>a-b);
const mx=Math.max(...Object.values(h));
const iw=W-m.l-m.r, ih=H-m.t-m.b;
const bw=iw/keys.length;
const svg=E("svg",{viewBox:`0 0 ${W} ${H}`});
const g=E("g");
keys.forEach((k,i)=>{
const bh=Math.max(2,(h[k]/mx)*ih), x=m.l+i*bw, y=m.t+ih-bh;
const hot = k===120;
const r=E("rect",{x:x+2,y,width:bw-4,height:bh,rx:3,
fill:hot?"var(--ink)":"#ffffff2e","data-anim":"bary","data-ox":x,"data-oy":m.t+ih});
g.appendChild(r);
g.appendChild(TX(x+bw/2,m.t+ih+17,k,"atxt","middle"));
g.appendChild(TX(x+bw/2,y-5,h[k],"barv","middle")).setAttribute("font-size","10");
});
svg.appendChild(g);el.appendChild(svg);
if(animate)animBars(g);
}
/* ══════════════════════════════════════════════════════════════════════════
STORY 2 — 2024, everything at once (vocab burst + gig cadence)
══════════════════════════════════════════════════════════════════════════ */
function chartBreakout(el,W,animate){
const voc=EDA.vocabulary_growth, cad=EDA.cadence;
const H=Math.max(340,Math.min(460,W*0.5));
const m={l:44,r:20,t:26,b:42};
const iw=W-m.l-m.r;
const gapLane=18, topH=(H-m.t-m.b-gapLane)*0.42, botH=(H-m.t-m.b-gapLane)*0.58;
const topY=m.t, botY=m.t+topH+gapLane;
// shared x = months from 2022-01 .. last vocab/gig
const vmonths=Object.keys(voc);
const years=Object.keys(cad).map(Number);
const minY=Math.min(...years), maxY=Math.max(...years);
const mi=ym=>{const[y,mo]=ym.split("-").map(Number);return (y-minY)*12+(mo-1);};
const lastM=Math.max(...vmonths.map(mi),(maxY-minY)*12+11);
const totM=lastM+1;
const Xm=idx=>m.l+(idx+0.5)/totM*iw;
const Xy=y=>m.l+((y-minY)*12)/totM*iw;
const svg=E("svg",{viewBox:`0 0 ${W} ${H}`});
// magenta breakout band over 2024
const bx=Xy(2024), bw=12/totM*iw;
svg.appendChild(E("rect",{x:bx,y:m.t-6,width:bw,height:H-m.t-m.b+12,
fill:"var(--magenta)",opacity:0.07}));
svg.appendChild(TX(bx+bw/2,m.t-12,"the breakout","dlabel","middle")).setAttribute("fill","var(--magenta)");
// top lane: gigs per year (columns)
const gmx=Math.max(...Object.values(cad));
const gTop=E("g");
for(const[y,n]of Object.entries(cad)){
const x0=Xy(Number(y)), x1=Xy(Number(y)+1), cw=(x1-x0)*0.62, cx=x0+(x1-x0-cw)/2;
const bh=Math.max(2,(n/gmx)*topH), yy=topY+topH-bh;
const hot=Number(y)===2024;
gTop.appendChild(E("rect",{x:cx,y:yy,width:cw,height:bh,rx:4,
fill:hot?"var(--magenta)":"#ffffff24","data-anim":"bary","data-ox":cx,"data-oy":topY+topH}));
gTop.appendChild(TX(x0+(x1-x0)/2,yy-6,n,"barv","middle"));
}
svg.appendChild(gTop);
svg.appendChild(TX(m.l,topY-10,"GIGS / year","atxt b"));
// bottom lane: monthly sample-folder imports (columns)
const vmx=Math.max(...Object.values(voc));
const cw=Math.max(2,iw/totM*0.7);
const gBot=E("g");
for(const[ym,n]of Object.entries(voc)){
const x=Xm(mi(ym)), bh=Math.max(1.5,(n/vmx)*botH), yy=botY+botH-bh;
const hot=ym.startsWith("2024-07");
const r=E("rect",{x:x-cw/2,y:yy,width:cw,height:bh,rx:2,
fill:hot?"var(--magenta)":"#ffffff2e","data-anim":"bary","data-ox":x,"data-oy":botY+botH});
gBot.appendChild(r);
if(n>=80)gBot.appendChild(TX(x,yy-6,n,"barv","middle"));
}
svg.appendChild(gBot);
svg.appendChild(TX(m.l,botY-8,"SAMPLE FOLDERS imported / month","atxt b"));
// baseline + year ticks
svg.appendChild(E("line",{class:"ax",x1:m.l,x2:m.l+iw,y1:botY+botH,y2:botY+botH}));
for(let y=minY;y<=maxY;y++)svg.appendChild(TX(Xy(y)+ (6/totM*iw),H-m.b+20,y,"atxt b","middle"));
el.appendChild(svg);
if(animate){animBars(gTop);animBars(gBot,200);}
}
/* ══════════════════════════════════════════════════════════════════════════
STORY 3 — What it's made of (sample-family palette)
══════════════════════════════════════════════════════════════════════════ */
function chartFamilies(el,W,animate){
const fam=EDA.families;
const uncl=Object.values(EDA.unclassified_top).reduce((a,b)=>a+b,0);
const entries=Object.entries(fam).sort((a,b)=>b[1]-a[1]);
const total=entries.reduce((a,[,n])=>a+n,0)+uncl;
const H=92, m={l:0,r:0};
const iw=W;
const svg=E("svg",{viewBox:`0 0 ${W} ${H}`});
const barY=18, barH=46;
let x=0; const g=E("g");
const seg=(key,label,n,color,glyph)=>{
const w=Math.max(1,(n/total)*iw);
const r=E("rect",{x,y:barY,width:w,height:barH,fill:color,
"data-anim":"barx","data-ox":x,"data-oy":barY});
g.appendChild(r);
if(w>34){ // glyph + count inside if room
g.appendChild(TX(x+w/2,barY+barH/2+1,glyph,"","middle")).setAttribute("style",
"font-size:15px;fill:#0a0a0a;opacity:.85");
g.appendChild(TX(x+w/2,barY+barH+16,n,"barv","middle"));
}
x+=w;
};
entries.forEach(([k,n])=>{const f=FAM[k]||{};seg(k,f.label||k,n,f.base||"#666",f.glyph||"");});
if(uncl)seg("_u","other",uncl,"#2a2a2e","·");
svg.appendChild(g);
el.appendChild(svg);
if(animate)animBars(g);
}
/* ══════════════════════════════════════════════════════════════════════════
STORY 4 — The accent (signature idioms)
══════════════════════════════════════════════════════════════════════════ */
function chartIdioms(el,W,animate){
const rows=EDA.idioms_top.slice(0,9);
const rh=46, m={l:0,r:54,t:8,b:8};
const H=m.t+rows.length*rh+m.b;
const labelW=Math.min(290,Math.max(150,W*0.42));
const iw=W-labelW-m.r;
const mx=rows[0].n_tracks;
const svg=E("svg",{viewBox:`0 0 ${W} ${H}`});
const g=E("g");
rows.forEach((row,i)=>{
const y=m.t+i*rh, cy=y+rh/2;
const bw=Math.max(2,(row.n_tracks/mx)*iw);
g.appendChild(E("rect",{x:labelW,y:y+7,width:bw,height:rh-20,rx:4,
fill:i===0?"var(--ink)":"#ffffff26","data-anim":"barx","data-ox":labelW,"data-oy":y}));
// mini-notation label (mono), right-aligned to bar start
const lab=TX(labelW-12,cy+4,row.norm,"barlabel","end");
lab.setAttribute("fill",i===0?"var(--ink)":"var(--mute)");
g.appendChild(lab);
g.appendChild(TX(labelW+bw+9,cy+4,row.n_tracks,"barv"));
});
svg.appendChild(g);el.appendChild(svg);
if(animate)animBars(g);
}
/* ══════════════════════════════════════════════════════════════════════════
STORY 5 — Who I play with (collab fingerprint, small multiples)
══════════════════════════════════════════════════════════════════════════ */
function chartCollab(el,W,animate){
const fp=EDA.collab_fingerprint;
// order: solo first (anchor), then by track count desc
const rows=Object.entries(fp).sort((a,b)=>{
if(/solo/.test(a[0]))return -1; if(/solo/.test(b[0]))return 1;
return b[1].n-a[1].n;});
const lo=80,hi=170;
const rh=78, m={l:128,r:18,t:30,b:22};
const H=m.t+rows.length*rh+m.b;
const iw=W-m.l-m.r;
const X=b=>m.l+(b-lo)/(hi-lo)*iw;
const svg=E("svg",{viewBox:`0 0 ${W} ${H}`});
// bpm axis ticks
for(let b=80;b<=170;b+=30){
svg.appendChild(E("line",{class:"gl",x1:X(b),x2:X(b),y1:m.t-6,y2:H-m.b}));
svg.appendChild(TX(X(b),m.t-12,b,"atxt","middle"));
}
svg.appendChild(TX(m.l,m.t-12,"BPM →","atxt b","start")).setAttribute("opacity","0");
const g=E("g");
rows.forEach((row,i)=>{
const[who,d]=row, y=m.t+i*rh, cy=y+24;
const solo=/solo/.test(who);
const c=solo?"#ffffff":(css("--club")||"#ffb454");
// name + count
const nm=TX(m.l-14,cy+1,who.replace("(solo)"," ·solo"),"dlabel","end");
nm.setAttribute("fill","var(--ink)");nm.setAttribute("font-size","14");
g.appendChild(nm);
g.appendChild(TX(m.l-14,cy+17,d.n+" track"+(d.n>1?"s":""),"atxt","end"));
// range strip min..max
g.appendChild(E("line",{x1:X(d.bpm_min),x2:X(d.bpm_max),y1:cy,y2:cy,
stroke:c,"stroke-width":3,"stroke-linecap":"round",opacity:0.32}));
g.appendChild(E("circle",{cx:X(d.bpm_min),cy,r:3,fill:c,opacity:0.5}));
g.appendChild(E("circle",{cx:X(d.bpm_max),cy,r:3,fill:c,opacity:0.5}));
// median marker
g.appendChild(E("circle",{cx:X(d.bpm_median),cy,r:6,fill:c}));
g.appendChild(TX(X(d.bpm_median),cy-12,d.bpm_median,"barv","middle")).setAttribute("fill",c);
// distinctive sample chips (tell)
const tells=(d.distinctive_samples||[]).slice(0,3);
let tx=m.l;
tells.forEach(s=>{
const f=sampleFamily(s.sound);const col=f?f.base:"#8a8a90";
const txt=(f?f.glyph+" ":"")+s.sound;
const t=TX(tx,cy+30,txt,"");t.setAttribute("font-family","Geist Mono, monospace");
t.setAttribute("font-size","11.5");t.setAttribute("fill",col);
g.appendChild(t);
tx+=txt.length*7.0+18;
});
});
svg.appendChild(g);el.appendChild(svg);
if(animate)animFade([...g.querySelectorAll("circle,line,text")],100,14);
}
/* ══════════════════════════════════════════════════════════════════════════
STORY 6 — The set-staples (recurrence)
══════════════════════════════════════════════════════════════════════════ */
function chartStaples(el,W,animate){
const rows=EDA.recurrence_top.filter(r=>r.gigs>=4).slice(0,13);
const cafe=/^Caf[ée]/i;
const rh=40, m={t:6,b:6,r:46};
const H=m.t+rows.length*rh+m.b;
const labelW=Math.min(260,Math.max(150,W*0.38));
const iw=W-labelW-m.r;
const mx=rows[0].gigs;
const svg=E("svg",{viewBox:`0 0 ${W} ${H}`});
const g=E("g");
const cafeC=FAM.keys?FAM.keys.base:"#00abbd";
rows.forEach((row,i)=>{
const y=m.t+i*rh, cy=y+rh/2;
const bw=Math.max(2,(row.gigs/mx)*iw);
const isCafe=cafe.test(row.name);
g.appendChild(E("rect",{x:labelW,y:y+6,width:bw,height:rh-16,rx:4,
fill:i===0?"var(--ink)":(isCafe?cafeC:"#ffffff24"),
opacity:isCafe&&i!==0?0.8:1,"data-anim":"barx","data-ox":labelW,"data-oy":y}));
const lab=TX(labelW-12,cy+4,row.name,"barlabel","end");
lab.setAttribute("fill",i===0?"var(--ink)":"var(--mute)");lab.setAttribute("font-family","Geist, sans-serif");
g.appendChild(lab);
g.appendChild(TX(labelW+bw+9,cy+4,row.gigs,"barv"));
});
svg.appendChild(g);el.appendChild(svg);
if(animate)animBars(g);
}
/* ── story scaffolding ────────────────────────────────────────────────────── */
const STORIES=[
{id:"creep",n:"01",t:"The slow climb",lens:true,
dek:`My tempo crept from <b>110 to 126 BPM</b> over five years. Not a decision, a drift.
The cloud is every track by when it was written; the lines are the yearly medians
<b>in the studio</b> (when I made it) and <b>in the club</b> (when I played it).`,
build:s=>{
const c=chartBox(s,chartTempo,true);
legend(s,[["in the studio","var(--studio)","—"],["in the club","var(--club)","—"],
["each track","#ffffff66","●"]]);
const sub=div(s,"sub");
const p1=panel(sub,"Where the tracks sit","73 tracks by tempo · the floor is 80, the ceiling 170");
chartBox(p1,chartHist,true);
const p2=panel(sub,"Felt vs written","tracks tagged at double or half the score tempo — half-time notation, not error");
twoX(p2,EDA.tempo.ac_delta.filter(d=>Math.abs(Math.abs(d.delta)-d.score_bpm)<1.5||Math.abs(d.delta)>=55).slice(0,4));
note(s,`Tempo is parsed from each track's <code>setcps</code>. A few sit at exactly half or
double their tagged BPM: that's half-time feel written one way and counted another, kept honest here.`);
}},
{id:"breakout",n:"02",t:"2024, everything at once",lens:false,
dek:`One summer the whole project changed gear. In <b>July 2024</b> I imported
<b>334 sample folders</b> in a single burst; the same year the gigs <b>tripled to 14</b>.`,
build:s=>{
chartBox(s,chartBreakout,true);
legend(s,[["sample folders / month","#ffffff66","▏"],["gigs / year","#ffffff66","▏"],
["2024","var(--magenta)","■"]]);
note(s,`Sample-folder dates come from the Dirt-Samples symlink mtimes, reliable only from
mid-2024 on, so the import timeline starts there. The gig count spans the whole run and
is the steadier signal: 2 → 4 → <b>14</b> → 14 → 3-so-far.`);
}},
{id:"palette",n:"03",t:"What it's made of",lens:false,
dek:`<b>Breaks lead</b>, then synths and snares. Across every track's score, the sound palette
is exactly the jazz-meets-jungle hybrid the sets feel like.`,
build:s=>{
chartBox(s,chartFamilies,true);
familyLegend(s);
note(s,`Each sound token in every <code>.tidal</code> is classified into one of twelve
families by a shared lexical rule (the same one that colors the fleet). <b>"other"</b> is the
honest unclassified tail: rare one-off samples the rule doesn't claim.`);
}},
{id:"accent",n:"04",t:"The accent",lens:false,
dek:`Strip the tracks down to their phrases and one shows up everywhere: <code>f*16</code>
appears in <b>62 of 73</b> tracks. It's the gMask/gMute boolean rhythm, my reflex move.`,
build:s=>{
chartBox(s,chartIdioms,true);
note(s,`From <b>${EDA.idioms_counts.total.toLocaleString()}</b> distinct phrases mined across the corpus,
<b>${EDA.idioms_counts.shared}</b> recur in two or more tracks. These nine are the most shared:
boolean masks (<code>f*16</code>, <code>t . &lt;f t f…&gt;</code>), euclids (<code>t(4,8,1)</code>)
and stacked drum hits. The signature isn't a sound, it's a way of writing rhythm.`);
}},
{id:"collab",n:"05",t:"Who I play with",lens:false,
dek:`Solo, I sit around <b>117 BPM</b>. Every collaborator pulls somewhere else, and
<b>raph is the fast one</b>: a median of 138, reaching all the way to 170.`,
build:s=>{
chartBox(s,chartCollab,true);
note(s,`Each row is a collaborator's tempo range across our shared tracks, with their most
<i>distinctive</i> samples (the ones lifted far above corpus baseline). raph brings the club
kick and claps; solo work spreads widest because it's the largest, oldest body of tracks.`);
}},
{id:"staples",n:"06",t:"The set-staples",lens:false,
dek:`Some tracks I keep coming back to. <b>Sunny Side Up</b> has opened or closed
<b>11 sets</b>; the Café trilogy (Tiède, Glacé, Bouillant) is a recurring suite of its own.`,
build:s=>{
chartBox(s,chartStaples,true);
legend(s,[["most-played","var(--ink)","■"],["the Café trilogy","var(--keys,#00abbd)","■"]]);
note(s,`Counted from the gig setlists: how many distinct sets each track has appeared in.
The long tail (one or two gigs) is most of the catalog. These are the ones that earned a
permanent slot.`);
}},
];
/* DOM helpers for story sections */
function div(parent,cls){const d=document.createElement("div");if(cls)d.className=cls;parent.appendChild(d);return d;}
function chartBox(parent,fn,live){const c=div(parent,"chart reveal");if(live)c.dataset.live="1";
mountChart(c,fn);return c;}
function legend(parent,items){const l=div(parent,"legend reveal");
items.forEach(([lab,col,g])=>{const s=document.createElement("span");s.className="lg";
s.innerHTML=`<span class="g" style="color:${col}">${g}</span>${esc(lab)}`;l.appendChild(s);});}
function familyLegend(parent){const l=div(parent,"legend reveal");
const fam=EDA.families;
Object.entries(fam).forEach(([k,n])=>{const f=FAM[k]||{};const s=document.createElement("span");
s.className="lg";s.innerHTML=`<span class="g" style="color:${f.base||'#888'}">${f.glyph||'•'}</span>${esc(f.label||k)} <b>${n}</b>`;
l.appendChild(s);});}
function note(parent,html){const p=document.createElement("p");p.className="note reveal";p.innerHTML=html;parent.appendChild(p);}
function panel(parent,title,ph){const p=div(parent,"panel reveal");
const h=document.createElement("h3");h.textContent=title;p.appendChild(h);
const d=document.createElement("div");d.className="ph";d.textContent=ph;p.appendChild(d);return p;}
function twoX(parent,rows){const w=div(parent,"twox");
rows.forEach(r=>{const row=div(w,"row");const x=Math.abs(r.delta)>=r.score_bpm?2:0.5;
row.innerHTML=`<span class="nm">${esc(r.track)}</span>
<span class="vs"><span class="a">${r.score_bpm}</span> · <span class="c">${r.meta_bpm}</span>
<span class="x">×${x}</span></span>`;});}
/* ── build the page ───────────────────────────────────────────────────────── */
function buildStats(){
const c=EDA.coverage, host=document.getElementById("stats");
const nSamples=Object.values(EDA.families).reduce((a,b)=>a+b,0);
const items=[[c.tracks,"tracks"],[c.canonical_gigs,"gigs played"],
["2021–26","five years"],[EDA.tempo.median,"median BPM"]];
items.forEach(([v,l])=>{const s=div(host,"stat");
s.innerHTML=`<div class="v">${v}</div><div class="l">${l}</div>`;});
}
function buildStories(){
const host=document.getElementById("stories");
STORIES.forEach(st=>{
const sec=document.createElement("section");sec.id="s-"+st.id;sec.dataset.lens=st.lens?"1":"0";
const col=div(sec,"col");
const k=div(col,"kicker reveal");
k.innerHTML=`<span class="n">${st.n}</span> / 06 <span class="rule"></span>`;
const h=document.createElement("h2");h.className="reveal";h.textContent=st.t;col.appendChild(h);
const dek=document.createElement("p");dek.className="dek reveal";dek.innerHTML=st.dek;col.appendChild(dek);
st.build(col);
host.appendChild(sec);
});
document.getElementById("footmeta").innerHTML=
`Built from the L'Armada catalog: ${EDA.coverage.tracks} tracks reconciled across their
<code>.tidal</code> score, site metadata and Ardour takes, then read for tempo, sample
families and recurring phrases. Generated ${EDA.as_of||""}. No values were typed by hand;
every chart traces back to a parser over the source.`;
}
function buildRail(){
const rail=document.getElementById("rail");
STORIES.forEach(st=>{const a=document.createElement("a");a.href="#s-"+st.id;
a.dataset.t=st.t;a.dataset.id=st.id;rail.appendChild(a);});
}
/* lens toggle wiring */
function setLens(l){
LENS=l;
const w=document.getElementById("lens");w.dataset.lens=l;
w.querySelectorAll("button").forEach(b=>b.classList.toggle("on",b.dataset.l===l));
// re-render only the lens-aware charts (Story 1's tempo line)
CHARTS.forEach(c=>{if(c.draw===chartTempo)redraw(c);});
}
document.getElementById("lens").querySelectorAll("button").forEach(b=>{
b.addEventListener("click",()=>{if(document.getElementById("lens").classList.contains("off"))return;
setLens(b.dataset.l);});
});
/* observers: reveal, lazy chart draw, rail + lens state by active section */
function observe(){
const revs=document.querySelectorAll(".reveal");
const io=new IntersectionObserver((es)=>{es.forEach(e=>{if(e.isIntersecting){
e.target.classList.add("in");
const c=CHARTS.find(c=>c.el===e.target);
if(c&&!c.done){c.done=true;redraw(c);}
io.unobserve(e.target);}});},{rootMargin:"0px 0px -8% 0px",threshold:0.12});
revs.forEach(r=>io.observe(r));
// active section → rail dot + lens enable/disable
const secs=[...document.querySelectorAll("section[id^=s-]")];
const lensEl=document.getElementById("lens"), lensLbl=document.getElementById("lensLbl");
const so=new IntersectionObserver((es)=>{es.forEach(e=>{if(e.isIntersecting){
const id=e.target.id.replace("s-","");
document.querySelectorAll(".rail a").forEach(a=>a.classList.toggle("on",a.dataset.id===id));
const aware=e.target.dataset.lens==="1";
lensEl.classList.toggle("off",!aware);
lensEl.title=aware?"toggle the tempo lens":"this chart is score-derived — one lens only";
lensLbl.textContent=aware?"lens":"one lens";
}});},{threshold:0.4});
secs.forEach(s=>so.observe(s));
}
/* responsive redraw (debounced) */
let rT;
addEventListener("resize",()=>{clearTimeout(rT);rT=setTimeout(()=>{
CHARTS.forEach(c=>{if(c.done){c.el.dataset.live="0";redraw(c);}});},160);});
/* go */
load().then(()=>{
applyTokens();
buildStats();buildRail();buildStories();
observe();
// first viewport charts may already be in view
requestAnimationFrame(()=>CHARTS.forEach(c=>{const r=c.el.getBoundingClientRect();
if(r.top<innerHeight&&!c.done){c.done=true;c.el.classList.add("in");redraw(c);}}));
}).catch(e=>{
document.getElementById("root").innerHTML=`<div class="banner">
<b>⚠ data didn't load</b> (${esc(String(e.message||e))}).<br>
This page needs a server (file:// blocks fetch). From the repo:<br>
<code>cd armada/tide-table &amp;&amp; python3 tide.py serve</code><br>
then open <code>http://127.0.0.1:8731/corpus.html</code>.<br>
Or regenerate the data: <code>python3 tide_eda.py</code>.</div>`;
});
</script>
</body></html>
......@@ -475,6 +475,13 @@
"2025": 122,
"2026": 126
},
"stage_tempo_by_year": {
"2022": 115,
"2023": 112,
"2024": 124,
"2025": 120,
"2026": 124
},
"by_creation": [
{
"name": "CBOW",
......@@ -945,12 +952,12 @@
"bpm_max": 142.0,
"distinctive_samples": [
{
"sound": "moogBass",
"sound": "risers",
"lift": 4.6,
"n": 2
},
{
"sound": "risers",
"sound": "moogBass",
"lift": 4.6,
"n": 2
},
......
......@@ -109,20 +109,28 @@ def cmd_serve(args):
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")
base = f"http://127.0.0.1:{port}"
print(f"⛵ tide serve — open:\n {base}/triangle.html (catalog)"
f"\n {base}/corpus.html (by-the-numbers dataviz)\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_corpus(args):
"""Bundle the by-the-numbers dataviz into one self-contained file for deploy."""
r = subprocess.run([sys.executable, str(HERE / "build_corpus.py"), *args])
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")
print(" serve [port] serve the triangle viz (default :8731)")
print(" serve [port] serve the triangle + corpus viz (default :8731)")
print(" corpus [-o out.html] bundle corpus.html → self-contained deploy file")
def main():
......@@ -134,6 +142,8 @@ def main():
cmd_test()
elif cmd == "serve":
cmd_serve(args[1:])
elif cmd == "corpus":
cmd_corpus(args[1:])
elif cmd in ("list", "ls"):
cmd_list()
else:
......
......@@ -264,6 +264,21 @@ def build():
creation_year_bpm[x["created"][:4]].append(x["bpm"])
creation_tempo = {y: round(st.median(v)) for y, v in sorted(creation_year_bpm.items())}
# stage-BPM story: tempo of what was actually PERFORMED each year (club lens).
# Attribute a track's score-tempo to every gig-year it appears in (one vote per
# performance), then take the year median. The "in the club" counterpart to the
# studio (creation) line above — same tracks, weighted by when they hit a stage.
stage_year_bpm = defaultdict(list)
for t in T:
tp = tempo.get(t["track"])
if not tp:
continue
for g in t.get("gigs", []):
gdate = gigs.get(g)
if gdate:
stage_year_bpm[gdate[:4]].append(tp["bpm"])
stage_tempo = {y: round(st.median(v)) for y, v in sorted(stage_year_bpm.items())}
# palette + families
snd = Counter()
for t in T:
......@@ -308,6 +323,7 @@ def build():
"histogram": dict(sorted(Counter(int(b // 10) * 10 for b in bpms).items())),
"ac_delta": sorted(ac_delta, key=lambda x: -abs(x["delta"])),
"creation_tempo_by_year": creation_tempo,
"stage_tempo_by_year": stage_tempo,
"by_creation": by_creation,
},
"collab_fingerprint": collab_fingerprint(T, tempo),
......
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