Commit 91a9ac89 by PLN (Algolia)

judge: derive orbit labels from score, graded activation, DRY types, waveform zoom/loop

#40 Judge annotations were stale+wrong: labels came from a hardcoded ROLE_MAP
(orbit-03 'hats' though punkachien.tidal plays 'dr'; d6/meth_bass missing) and a
hard -38dB on/off gate hid audible orbits that dip within a 2s bin (PLN saw only
d3 OR d8 while hearing both).
- tidal_score.py: parse per-orbit sound map from any .tidal (reuses tfidf vocab;
  also accepts the bare $ "sample" source form). dr/meth_bass/jungle_breaks now correct.
- audio_lens: classify_family() (breaks->tops, drums->percs by identity, register
  by measured centroid), profile peak_db. Fix orbit_files to try padded+unpadded
  channel names (Take89 uses 'Tidal 01-1' -> centroids were silently None).
- build_player_data: labels from score, family validated by centroid, two
  thresholds (litFloorDb -52 visible / activeDb -38 driving), emits validated PlayerData.
- OrbitRail: graded activation (dim->vivid), no orbit vanishes when quiet.

#42 DRY: pydantic models in models.py are the single source of truth; gen_ts_types.py
generates ui/src/types.gen.ts (types.ts is now a re-export). No more hand-synced shapes.

#41 WaveformPlayer: Audacity-style zoom (+/-/ctrl-wheel) + scroll, drag-select a span
and loop it (Regions plugin), keyboard L=loop. tsc + vite build green.
parent 9089a988
......@@ -43,9 +43,15 @@ def decode(path, ac=1, ss=None, t=None):
def orbit_files(take, o):
L = IX / f"{take}_Tidal {o}-1%L.wav"
R = IX / f"{take}_Tidal {o}-1%R.wav"
return (L if L.exists() else None), (R if R.exists() else None)
"""Interchange stem L/R for orbit `o` (== dN). Ardour exports vary between
padded ("Tidal 01-1") and unpadded ("Tidal 1-1") channel names across takes,
so try both — a silent mismatch here once produced None centroids."""
for tag in (f"{o:02d}", str(o)):
L = IX / f"{take}_Tidal {tag}-1%L.wav"
R = IX / f"{take}_Tidal {tag}-1%R.wav"
if L.exists():
return L, (R if R.exists() else None)
return None, None
# ── the lens: spectral profile ────────────────────────────────────────────────
......@@ -59,9 +65,47 @@ def profile(sig):
tot = S.sum() + 1e-20
bands = {name: 100 * S[(f >= lo) & (f < hi)].sum() / tot for name, lo, hi in BANDS}
return {"rms_db": 20*np.log10(np.sqrt(np.mean(sig**2))+1e-9),
"peak_db": 20*np.log10(np.abs(sig).max()+1e-9),
"centroid": float((f*S).sum()/tot), "bands": bands}
# ── role family from the SOUND + its measured spectrum (Judge UI #40) ─────────
# Families = percs | bass | melodic | tops | atmos (DESIGN.md role-family colors).
# Identity decides the things spectrum can't (a kick and a sub are both <150 Hz;
# a break is broadband) — breaks→tops, drums→percs. Register (bass vs melodic)
# is then decided by the MEASURED centroid, not the name (feedback_mastering_eda:
# meth_bass is a wobble, but its register IS bass — centroid keeps us honest).
_BREAK_HINTS = ("break", "jungle", "amen", "org_jungle")
# exact-match perc names (short/ambiguous: "cp","dr","sn" must NOT match cpluck,
# dropbass, snippet…), plus a few safe prefixes that won't collide.
_PERC_EXACT = {"kick", "bd", "snare", "sn", "sd", "dr", "drum", "clap", "cp",
"rim", "rs", "hh", "hat", "hats", "cym", "cy", "tom", "perc",
"crash", "ride", "shaker", "conga", "bongo"}
_PERC_PREFIX = ("kick", "snare", "clap", "hat", "perc", "rample", "h2ogm",
"reverbkick", "808kick", "909")
_BASS_HINTS = ("bass", "sub", "808", "reese", "moog", "fbass", "acid", "wobble")
def classify_family(sound, prof=None):
"""(family_key, centroid|None) for a sound, measurement-aware."""
s = (sound or "").lower()
cen = prof["centroid"] if prof else None
if any(h in s for h in _BREAK_HINTS):
return "tops", cen # breakbeats → tops lane
if s in _PERC_EXACT or any(s.startswith(h) for h in _PERC_PREFIX):
return "percs", cen # drums/hats → percs
bass_name = any(h in s for h in _BASS_HINTS)
if cen is None: # no audio → fall back to name
return ("bass" if bass_name else "melodic"), None
if cen < 180:
return "bass", cen # sub register
if cen < 1400:
return ("bass" if bass_name else "melodic"), cen
if cen < 3500:
return "melodic", cen
return "atmos", cen
def is_broadband(p):
"""A real musical mix has mid+high energy and a centroid above pure-sub.
Pure kick/sub ≈ centroid <130Hz and <20% above 150Hz → FAIL."""
......
......@@ -150,3 +150,73 @@ class TfidfReport(BaseModel):
idf: dict[str, float]
kinds: dict[str, str] = Field(default_factory=dict) # sound -> sample|synth
tracks: dict[str, TrackSignature]
# ── Judge UI player data (armada/ui) — derived from the score + measured ──────
# Single source of truth: ui/src/types.gen.ts is generated from these via
# `tools/gen_types.sh` (model_json_schema → json-schema-to-typescript). Don't
# hand-edit the TS shapes; edit here and regenerate. (#42, DRY)
class Variant(str, Enum):
stream = "stream" # -14 LUFS streaming master
club = "club" # louder club master
class RoleGroup(BaseModel):
key: str # percs | bass | melodic | tops | atmos
label: str
color: str # hex; role-family color (DESIGN.md), never hue-alone
glyph: str
class OrbitActivity(BaseModel):
"""One orbit's lane in a take: label DERIVED from the .tidal sound, family
VALIDATED by audio (centroid), activity = RMS dB per bin. (#40)"""
orbit: str # "03" == d3 == stemmap orbit-03
label: str # the ACTUAL sound on this orbit ("dr", not "hats")
group: str # role-family key (RoleGroup.key)
sound: str = "" # base sound name parsed from the score
centroid: Optional[float] = None # measured spectral centroid (Hz) — family evidence
activity: list[float] # RMS dB per binS bin, aligned to take t0
class MasterInfo(BaseModel):
file: str
I: Optional[float] = None # integrated LUFS
LRA: Optional[float] = None
TP: Optional[float] = None # true peak dBFS
class LoudTrace(BaseModel):
trace: list[float] # momentary LUFS per stepS
stepS: float
class Take(BaseModel):
id: str
label: str
gig: str
date: str
tidal: Optional[str] = None # the score this take's labels were derived from
dur: float
binS: float
masters: dict[Variant, MasterInfo]
loud: dict[Variant, LoudTrace]
orbits: list[OrbitActivity]
class Note(BaseModel):
id: str
takeId: str
t: float # seconds
text: str
class PlayerData(BaseModel):
track: str
calibration: str
activeDb: float # "core/driving" threshold (lane is strongly on)
litFloorDb: float = -52.0 # audible floor: orbit shows (dim) above this, so a
# quiet-but-playing orbit never vanishes (#40 activation)
roleGroups: list[RoleGroup]
note: str = ""
takes: list[Take]
......@@ -2,37 +2,54 @@
"""Precompute the Judge UI's player data → armada/ui/public/punkachien.json.
The katana produces data; React consumes it. Per take we emit:
- orbit activity over time (RMS dB, 2 s bins), grouped by measured role family
- orbit activity over time (RMS dB, 2 s bins), labelled from the TRACK'S SCORE
(tidal_score → the actual sound on each dN, e.g. "dr" not a generic "hats"),
grouped into role families VALIDATED by measured spectral centroid (audio_lens)
- a momentary-LUFS trace per master (ffmpeg ebur128) + integrated LUFS / LRA / TP
Montreuil orbit activity comes from the windowed Take89 stem-map; Hamburg's is
computed from the already-windowed hamburg_stems. Calibration: stem ≈ master − 3.
Output is a VALIDATED models.PlayerData (single source of truth; ui/src/types.gen.ts
is generated from the same models — #42). Two activation thresholds: `litFloorDb`
(orbit shows, dimly, when audible) and `activeDb` (strongly on). The old single
hard −38 gate hid orbits that dip below it though clearly audible (#40 activation).
Montreuil orbit activity comes from the windowed Take89 stem-map; centroid is
measured from the Take89 interchange stems at each orbit's loudest moment.
Calibration: stem ≈ master − 3.
Run under system python3 (has numpy + pydantic): python3 build_player_data.py
"""
import json, re, subprocess, sys
import json
import re
import subprocess
import sys
from pathlib import Path
import numpy as np
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE.parent)) # armada/tide-table
from audio_lens import classify_family, load_window, orbit_files, profile # noqa: E402
from models import (LoudTrace, MasterInfo, OrbitActivity, PlayerData, # noqa: E402
RoleGroup, Take, Variant)
from tidal_score import orbit_sounds # noqa: E402
OUT = HERE.parent.parent / "ui" / "public" / "punkachien.json"
TIDAL = Path("/home/pln/Work/Sound/Tidal/live/collab/raph/punkachien.tidal")
TAKE = "Take89" # interchange take backing the Montreuil master
DUR = 287.74
BIN_S = 2.0
ACTIVE_DB = -38.0 # lane lights above this
MONTREUIL_WIN = (2309.1, 2596.8)
ACTIVE_DB = -38.0 # "strongly on / driving"
LIT_FLOOR = -52.0 # audible floor — orbit shows (dim) above this
MONTREUIL_WIN = (2309.1, 2596.8) # PunkAChien within the Take89 set (stem time)
# role families (DESIGN.md). vox folds into melodic's stack.
# role families (DESIGN.md). Colors reinforce label + glyph + lane, never hue alone.
ROLE_GROUPS = [
{"key": "percs", "label": "Percs", "color": "#ff8c00", "glyph": "▰"},
{"key": "bass", "label": "Bass", "color": "#7c5cff", "glyph": "▂"},
{"key": "melodic", "label": "Melodic", "color": "#36c5f0", "glyph": "♪"},
{"key": "tops", "label": "Tops", "color": "#2dd4bf", "glyph": "≈"},
{"key": "atmos", "label": "Atmos", "color": "#8a93a6", "glyph": "◌"},
RoleGroup(key="percs", label="Percs", color="#ff8c00", glyph="▰"),
RoleGroup(key="bass", label="Bass", color="#7c5cff", glyph="▂"),
RoleGroup(key="melodic", label="Melodic", color="#36c5f0", glyph="♪"),
RoleGroup(key="tops", label="Tops", color="#2dd4bf", glyph="≈"),
RoleGroup(key="atmos", label="Atmos", color="#8a93a6", glyph="◌"),
]
# measured PunkAChien arrangement (orbit -> label, family)
ROLE_MAP = {
"01": ("kick", "percs"), "02": ("snare", "percs"), "03": ("hats", "percs"),
"04": ("acid", "bass"), "05": ("cpluck", "bass"), "09": ("moog sub", "bass"),
"08": ("break", "tops"), "11": ("atmos", "atmos"),
}
def ff(args):
......@@ -57,7 +74,6 @@ def loudness(path):
pts = re.findall(r"t:\s*([\d.]+).*?\sM:\s*(-?[\d.]+|-inf)", txt)
grid, cur, step = [], 0.0, 0.5
bucket = []
# frames ~ every 0.1s; bucket to 0.5s mean (ignoring -inf/silence floor)
for t, m in pts:
t = float(t)
val = None if m == "-inf" else float(m)
......@@ -68,105 +84,97 @@ def loudness(path):
cur += step
bucket.append(val)
summ = txt.split("Summary:")[-1]
def g(pat, d):
m = re.search(pat, summ)
return round(float(m.group(1)), 1) if m else d
return {
"trace": grid, "stepS": step,
return {"trace": grid, "stepS": step,
"I": g(r"I:\s*(-?[\d.]+)\s*LUFS", None),
"LRA": g(r"LRA:\s*(-?[\d.]+)\s*LU", None),
"TP": g(r"Peak:\s*(-?[\d.]+)\s*dBFS", None),
}
"TP": g(r"Peak:\s*(-?[\d.]+)\s*dBFS", None)}
def measure_centroid(orbit, t_peak):
"""Spectral profile of one orbit's interchange stem around its loudest moment.
Returns a profile dict (centroid/bands) or None if the stem is unavailable."""
L, _ = orbit_files(TAKE, orbit)
if L is None:
return None
t0 = max(MONTREUIL_WIN[0], t_peak - 8)
t1 = min(MONTREUIL_WIN[1], t_peak + 8)
try:
return profile(load_window(L, t0, t1))
except Exception:
return None
def montreuil_orbits(dur):
"""Per-orbit lanes for the Montreuil take, labelled+grouped from the score."""
sounds = orbit_sounds(TIDAL) # {orbit:int -> {sound,...}}
sm = json.loads((HERE / "stemmap_take89.json").read_text())
rms = sm["rms_db"]
sb = int(round(MONTREUIL_WIN[0] / BIN_S))
n = int(round(dur / BIN_S))
orbits = []
for i, name in enumerate(sm["orbits"]):
o = name.split("-")[1]
if o not in ROLE_MAP:
o = int(name.split("-")[1])
if o not in sounds: # not triggered in this score
continue
act = [round(float(x), 1) for x in rms[i][sb:sb + n]]
if max(act) <= ACTIVE_DB:
continue
label, group = ROLE_MAP[o]
orbits.append({"orbit": o, "label": label, "group": group, "activity": act})
return orbits
def hamburg_orbits(dur):
stems = HERE / "hamburg_stems"
n = int(round(dur / BIN_S))
orbits = []
for o in (f"{i:02d}" for i in range(1, 13)):
if o not in ROLE_MAP:
continue
f = stems / f"orbit-{o}.flac"
if not f.exists():
continue
# decode mono @ 4kHz f32, RMS per 2s bin
r = subprocess.run(["ffmpeg", "-v", "error", "-i", str(f), "-ac", "1",
"-ar", "4000", "-f", "f32le", "-"], capture_output=True)
sig = np.frombuffer(r.stdout, dtype=np.float32)
sr = 4000
per = int(BIN_S * sr)
act = []
for b in range(n):
seg = sig[b * per:(b + 1) * per]
rms = float(np.sqrt(np.mean(seg**2))) if seg.size else 0.0
act.append(round(20 * np.log10(rms) if rms > 1e-7 else -240.0, 1))
if max(act) <= ACTIVE_DB:
if not act or max(act) <= LIT_FLOOR: # never audible in the window
continue
label, group = ROLE_MAP[o]
orbits.append({"orbit": o, "label": label, "group": group, "activity": act})
sound = sounds[o]["sound"]
peak_bin = int(np.argmax(act))
prof = measure_centroid(o, MONTREUIL_WIN[0] + peak_bin * BIN_S)
group, cen = classify_family(sound, prof)
orbits.append(OrbitActivity(
orbit=f"{o:02d}", label=sound, sound=sound, group=group,
centroid=round(cen) if cen else None, activity=act))
orbits.sort(key=lambda o: o.orbit)
return orbits
def take(tid, label, gig, date, stream_f, club_f, orbits_fn):
def build_take(tid, label, gig, date, stream_f, club_f):
dur = round(ffprobe_dur(HERE / stream_f), 2)
masters = {}
loud = {}
for k, fn in (("stream", stream_f), ("club", club_f)):
path = HERE / fn
ld = loudness(path)
masters[k] = {"file": fn, "I": ld["I"], "LRA": ld["LRA"], "TP": ld["TP"]}
loud[k] = {"trace": ld["trace"], "stepS": ld["stepS"]}
print(f" {tid}/{k}: I={ld['I']} LRA={ld['LRA']} TP={ld['TP']} "
masters, loud = {}, {}
for v, fn in ((Variant.stream, stream_f), (Variant.club, club_f)):
ld = loudness(HERE / fn)
masters[v] = MasterInfo(file=fn, I=ld["I"], LRA=ld["LRA"], TP=ld["TP"])
loud[v] = LoudTrace(trace=ld["trace"], stepS=ld["stepS"])
print(f" {tid}/{v.value}: I={ld['I']} LRA={ld['LRA']} TP={ld['TP']} "
f"({len(ld['trace'])} pts)", flush=True)
return {"id": tid, "label": label, "gig": gig, "date": date,
"dur": dur, "binS": BIN_S, "masters": masters, "loud": loud,
"orbits": orbits_fn(dur)}
return Take(id=tid, label=label, gig=gig, date=date, tidal=str(TIDAL),
dur=dur, binS=BIN_S, masters=masters, loud=loud,
orbits=montreuil_orbits(dur))
def main():
print("building player data…", flush=True)
data = {
"track": "PunkAChien",
"calibration": "stem ≈ master − 3 s (verified by cross-correlation, z=29)",
"activeDb": ACTIVE_DB,
"roleGroups": ROLE_GROUPS,
# NOTE: the "Hamburg/39C3" take was MISIDENTIFIED — locate (z=10, weak) grabbed
# a La Fin de l'Insouciance → Liquid Finale stretch from Take87 (PunkAChien is
# not in the 39C3 set). Real Hamburg PunkAChien = 38C3 Toilet Rave 2024 ("Pitbul
# Punk", Take35/36); to be re-sourced + ear-verified before it returns to the A/B.
"note": "Single confirmed take (Montreuil). Hamburg/39C3 was misidentified "
"(actually Insouciance→Liquid Finale) and pulled; real Hamburg PunkAChien "
"= 38C3 Toilet 2024, pending re-source.",
"takes": [
take("montreuil", "Montreuil", "Montreuil Algorave V3 — Mai Floral",
"2026-05-22", "masters/montreuil_stream_v1.flac",
"masters/montreuil_club_v1.flac", montreuil_orbits),
],
}
data = PlayerData(
track="PunkAChien",
calibration="stem ≈ master − 3 s (verified by cross-correlation, z=29)",
activeDb=ACTIVE_DB,
litFloorDb=LIT_FLOOR,
roleGroups=ROLE_GROUPS,
# The "Hamburg/39C3" take was MISIDENTIFIED (locate z=10) — actually a
# La Fin de l'Insouciance → Liquid Finale stretch — and pulled. Real
# Hamburg PunkAChien = 38C3 Toilet Rave 2024, pending re-source (#27/#35).
note="Single confirmed take (Montreuil). Hamburg/39C3 was misidentified "
"(actually Insouciance→Liquid Finale) and pulled; real Hamburg "
"PunkAChien = 38C3 Toilet 2024, pending re-source.",
takes=[build_take("montreuil", "Montreuil",
"Montreuil Algorave V3 — Mai Floral", "2026-05-22",
"masters/montreuil_stream_v1.flac",
"masters/montreuil_club_v1.flac")],
)
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")))
OUT.write_text(data.model_dump_json()) # validated on the way out
kb = OUT.stat().st_size / 1024
for t in data["takes"]:
print(f" {t['id']}: {len(t['orbits'])} active orbits "
f"({', '.join(o['label'] for o in t['orbits'])})")
for t in data.takes:
lanes = ", ".join(f"{o.label}·{o.orbit}[{o.group}"
f"{'/'+str(o.centroid)+'Hz' if o.centroid else ''}]"
for o in t.orbits)
print(f" {t.id}: {len(t.orbits)} orbits → {lanes}")
print(f"✓ {OUT} ({kb:.0f} KB)")
......
#!/usr/bin/env python3
"""tidal_score — parse a track's `.tidal` into a per-orbit sound map.
The Judge UI mislabeled orbits because it used a hardcoded generic ROLE_MAP
("hats" on orbit-3) instead of the track's actual score (punkachien plays "dr"
there). This derives the truth straight from the source: for each `dN`, which
SOUND is loaded on it. Reused by build_player_data (#40), the locate-matrix L3
fingerprint (#34), and the sound-palette dashboard (#38).
Mapping (reference_orbit_channel_map): stemmap `orbit-NN` == `dN`. Tidal's
`# orbit X` parameter is SuperDirt-0-indexed → `d(X+1)`; we record such
overrides but `dN` blocks map 1:1 to orbit N unless a `# orbit X` reassigns.
Sound extraction reuses the TF-IDF vocab (Dirt-Samples ∪ synthdefs) and the
sound-context regex from tools/sample_tfidf, so mininotation/boolean/note
tokens never pollute the result. The BASE sound = the last vocab token in the
block (Tidal's source pattern sits at the bottom; transforms above it).
python3 tidal_score.py live/collab/raph/punkachien.tidal # print the map
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# reuse the authoritative vocab + tokenizer (DRY)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools"))
from sample_tfidf import SPLIT, sound_vocab # noqa: E402
# Like sample_tfidf.SOUND_CTX but ALSO accepts the bare source form `$ "..."`
# (e.g. `$ "[kick:4]"`, `$ "meth_bass:9"`). TF-IDF excludes `$` to avoid
# note/structure pollution across the whole corpus; here the vocab filter +
# "last token wins" make `$` safe and necessary (kick/snare/meth_bass live there).
SOURCE_CTX = re.compile(r'(?:\bsound\b|\bs\b|#|\$)\s*"([^"]*)"')
# a `dN` block header at column 0 (real, not commented). Tidal comments are `--`.
DN_HEADER = re.compile(r"^(d|p)(\d{1,2})\b")
ORBIT_OVERRIDE = re.compile(r"#\s*orbit\s+(\d+)")
def _strip_comment(line: str) -> str:
"""Drop a Tidal `--` line comment (keep everything before it)."""
i = line.find("--")
return line[:i] if i != -1 else line
def _blocks(text: str):
"""Yield (orbit:int, block_text) for each top-level dN/pN definition."""
cur_n, buf = None, []
for raw in text.splitlines():
line = _strip_comment(raw)
m = DN_HEADER.match(line)
if m:
if cur_n is not None:
yield cur_n, "\n".join(buf)
cur_n, buf = int(m.group(2)), [line]
elif cur_n is not None:
buf.append(line)
if cur_n is not None:
yield cur_n, "\n".join(buf)
def _sounds_in_block(block: str, vocab: set[str]):
"""Ordered (base sound, raw token) pairs in a block, sound-context only."""
out = []
for q in SOURCE_CTX.findall(block):
for tok in SPLIT.split(q):
if len(tok) > 1 and tok in vocab:
out.append(tok)
return out
def _raw_sounds(block: str):
"""Fallback: ordered base tokens from any sound-context string (vocab miss).
Handles custom synths not in the vocab (e.g. `# "moog"`)."""
out = []
for q in SOURCE_CTX.findall(block):
for tok in SPLIT.split(q):
if len(tok) > 1 and not tok.isdigit():
out.append(tok)
return out
def orbit_sounds(tidal_path, vocab=None, kind=None) -> dict[int, dict]:
"""{orbit:int -> {'sound','raw_all','kind','orbit_override'}} for a .tidal.
`sound` = base name of the source pattern (last vocab token, else last
plausible token). `orbit` key already accounts for any `# orbit X` override
(→ d(X+1)). Commented-out dN blocks are ignored.
"""
if vocab is None:
vocab, kind = sound_vocab()
text = Path(tidal_path).read_text(errors="ignore")
out = {}
for n, block in _blocks(text):
voc_toks = _sounds_in_block(block, vocab)
all_toks = voc_toks or _raw_sounds(block)
if not all_toks:
continue
base = voc_toks[-1] if voc_toks else all_toks[-1]
ov = ORBIT_OVERRIDE.search(block)
orbit = int(ov.group(1)) + 1 if ov else n # # orbit X == d(X+1)
# de-dup preserving order
seen, uniq = set(), []
for t in all_toks:
if t not in seen:
seen.add(t); uniq.append(t)
out[orbit] = {
"sound": base,
"raw_all": uniq,
"kind": (kind or {}).get(base, "?"),
"orbit_override": bool(ov),
}
return out
def main():
if len(sys.argv) < 2:
sys.exit("usage: tidal_score.py path/to/track.tidal")
path = sys.argv[1]
m = orbit_sounds(path)
print(f"\n{Path(path).name} — orbit → sound map (derived from score)\n")
for o in sorted(m):
d = m[o]
tag = " (via # orbit override)" if d["orbit_override"] else ""
extra = f" [{', '.join(d['raw_all'])}]" if len(d["raw_all"]) > 1 else ""
print(f" d{o:<2} orbit-{o:02d} {d['sound']:<16} {d['kind']:<7}{tag}{extra}")
print()
if __name__ == "__main__":
main()
{"track":"PunkAChien","calibration":"stem ≈ master − 3 s (verified by cross-correlation, z=29)","activeDb":-38.0,"roleGroups":[{"key":"percs","label":"Percs","color":"#ff8c00","glyph":"▰"},{"key":"bass","label":"Bass","color":"#7c5cff","glyph":"▂"},{"key":"melodic","label":"Melodic","color":"#36c5f0","glyph":"♪"},{"key":"tops","label":"Tops","color":"#2dd4bf","glyph":"≈"},{"key":"atmos","label":"Atmos","color":"#8a93a6","glyph":"◌"}],"note":"Single confirmed take (Montreuil). Hamburg/39C3 was misidentified (actually Insouciance→Liquid Finale) and pulled; real Hamburg PunkAChien = 38C3 Toilet 2024, pending re-source.","takes":[{"id":"montreuil","label":"Montreuil","gig":"Montreuil Algorave V3 — Mai Floral","date":"2026-05-22","dur":287.74,"binS":2.0,"masters":{"stream":{"file":"masters/montreuil_stream_v1.flac","I":-14.0,"LRA":6.4,"TP":-1.0},"club":{"file":"masters/montreuil_club_v1.flac","I":-11.4,"LRA":3.8,"TP":-0.7}},"loud":{"stream":{"trace":[-80.2,-17.8,-15.9,-13.3,-12.9,-11.6,-13.9,-12.3,-11.0,-11.1,-13.9,-13.5,-15.6,-14.4,-12.8,-14.7,-13.9,-16.5,-17.7,-13.2,-10.2,-12.8,-13.4,-19.2,-16.7,-14.5,-17.5,-20.9,-19.4,-21.6,-18.3,-16.1,-18.8,-17.8,-19.4,-20.0,-17.2,-15.4,-18.3,-16.2,-19.2,-16.5,-13.7,-12.1,-12.8,-14.0,-16.9,-14.0,-12.1,-14.2,-12.3,-14.1,-12.7,-10.2,-10.5,-14.1,-12.0,-16.3,-14.6,-11.4,-13.8,-13.2,-13.4,-14.9,-13.0,-11.6,-13.3,-15.2,-15.4,-15.2,-14.3,-14.0,-14.0,-13.9,-14.9,-14.4,-13.4,-14.6,-14.4,-14.7,-15.7,-13.1,-12.4,-13.0,-11.3,-12.1,-11.7,-10.7,-11.1,-14.1,-12.9,-14.5,-14.0,-12.5,-14.1,-12.8,-13.4,-15.0,-11.4,-10.3,-12.0,-11.6,-12.5,-12.4,-11.3,-11.3,-12.3,-11.0,-12.5,-11.3,-9.5,-12.9,-13.3,-14.6,-15.2,-13.1,-12.7,-14.6,-13.4,-15.0,-14.4,-13.0,-12.2,-13.8,-14.0,-16.0,-13.8,-12.1,-13.1,-12.2,-12.9,-13.7,-13.8,-13.9,-11.9,-11.7,-13.8,-12.9,-12.1,-13.1,-12.5,-12.7,-14.0,-11.6,-11.2,-11.7,-12.0,-13.2,-13.5,-12.1,-12.7,-13.2,-12.4,-13.8,-13.0,-11.8,-13.9,-18.1,-19.4,-21.9,-18.4,-14.9,-16.4,-14.6,-15.3,-15.5,-13.4,-12.3,-15.0,-14.1,-16.2,-14.2,-12.5,-12.9,-13.1,-12.9,-21.3,-21.7,-24.1,-17.9,-16.7,-16.1,-15.3,-14.8,-15.4,-15.4,-15.0,-14.6,-15.2,-15.1,-14.2,-15.0,-15.9,-16.2,-15.2,-15.5,-16.3,-16.3,-14.7,-17.1,-18.2,-14.0,-14.8,-15.4,-15.9,-15.3,-14.1,-14.7,-14.1,-15.1,-15.7,-14.0,-13.0,-14.8,-14.2,-16.1,-15.3,-13.9,-14.3,-14.8,-14.4,-16.2,-12.5,-10.9,-12.7,-13.8,-15.0,-16.9,-13.9,-14.5,-16.6,-13.7,-16.0,-13.8,-12.6,-14.7,-14.5,-13.3,-16.8,-14.2,-13.7,-17.5,-14.1,-14.7,-16.8,-13.4,-13.0,-15.3,-16.2,-17.1,-16.9,-15.8,-14.7,-14.5,-14.3,-13.8,-13.5,-13.1,-11.9,-13.2,-13.6,-13.5,-13.4,-13.2,-13.8,-13.9,-16.1,-26.2,-30.6,-20.1,-12.8,-13.3,-13.8,-12.8,-13.1,-13.5,-12.8,-13.9,-13.3,-12.3,-12.2,-12.4,-12.9,-13.3,-13.0,-12.7,-13.3,-12.6,-12.7,-16.5,-21.0,-23.1,-12.7,-12.5,-13.0,-12.8,-12.7,-13.0,-13.2,-13.2,-13.2,-14.6,-20.0,-17.1,-13.3,-13.5,-13.8,-13.5,-13.3,-13.4,-13.3,-13.9,-14.7,-13.8,-12.5,-12.6,-14.6,-19.2,-16.0,-15.2,-17.8,-15.2,-17.2,-16.5,-16.1,-19.6,-21.5,-19.7,-21.7,-20.6,-18.1,-19.7,-20.3,-20.5,-21.3,-19.0,-16.8,-15.5,-17.3,-17.9,-17.5,-15.3,-14.5,-16.2,-15.0,-16.5,-16.9,-16.2,-15.5,-19.1,-19.7,-20.7,-15.9,-14.4,-16.5,-15.1,-16.8,-16.4,-13.7,-12.2,-12.4,-12.7,-14.6,-13.0,-12.6,-13.7,-12.7,-13.6,-14.5,-11.5,-11.7,-12.6,-12.6,-15.0,-14.2,-12.7,-13.8,-21.2,-22.4,-22.8,-25.4,-24.9,-16.8,-15.9,-18.8,-21.4,-18.2,-17.1,-20.1,-18.2,-20.8,-20.0,-17.4,-16.7,-19.7,-18.5,-21.5,-18.8,-17.4,-19.5,-18.3,-19.6,-18.8,-14.6,-12.9,-15.1,-13.4,-14.7,-13.9,-13.5,-14.1,-13.6,-13.7,-14.8,-13.0,-12.6,-13.5,-13.5,-13.6,-15.6,-13.2,-13.8,-15.0,-13.3,-15.9,-14.3,-11.8,-12.6,-16.5,-16.5,-15.4,-13.0,-12.7,-13.8,-13.1,-13.4,-14.3,-14.5,-13.7,-13.7,-12.9,-13.7,-13.5,-12.0,-13.6,-13.1,-12.9,-13.0,-14.2,-13.6,-12.6,-13.7,-14.1,-14.5,-13.9,-13.6,-14.0,-14.5,-13.8,-15.4,-15.0,-13.0,-13.3,-14.5,-14.4,-13.8,-13.7,-15.0,-15.8,-16.2,-14.6,-14.8,-13.2,-13.5,-14.4,-13.8,-14.3,-13.8,-14.1,-13.6,-14.3,-14.6,-15.3,-14.0,-13.1,-14.0,-13.7,-14.0,-13.7,-13.1,-13.5,-14.0,-13.7,-15.2,-14.1,-15.6,-18.6,-19.5,-18.5,-17.5,-18.2,-18.2,-18.2,-18.0,-18.1,-17.8,-17.7,-17.8,-18.8,-19.0,-17.1,-17.9,-18.4,-19.2,-18.6,-18.6,-16.2,-12.6,-14.3,-15.2,-16.2,-13.9,-13.2,-13.6,-13.9,-14.5,-15.3,-13.9,-12.4,-13.9,-14.1,-15.2,-14.6,-13.7,-13.1,-14.5,-14.7,-16.9,-15.1,-13.2,-10.7,-10.6,-10.6,-10.2,-10.8,-10.1,-10.0,-11.3,-11.2,-11.1,-11.4,-11.5,-11.4,-10.5,-10.6,-10.5,-10.4,-9.5,-11.5,-11.8,-10.3,-11.0,-11.2,-17.7,-18.3,-22.1,-21.6,-18.7,-18.9,-19.4,-19.8,-27.0,-26.5,-27.9],"stepS":0.5},"club":{"trace":[-77.4,-10.9,-10.2,-8.3,-10.1,-11.4,-11.7,-9.4,-8.7,-9.2,-10.8,-12.5,-11.4,-11.9,-13.6,-12.6,-12.1,-13.0,-12.9,-11.9,-10.2,-12.0,-9.3,-12.5,-10.7,-9.1,-11.8,-14.0,-12.4,-14.7,-11.9,-9.5,-12.5,-11.5,-12.4,-13.3,-10.8,-9.3,-11.9,-10.1,-12.2,-10.8,-8.9,-9.1,-12.0,-11.0,-11.4,-12.0,-10.0,-11.5,-11.7,-11.5,-11.9,-10.0,-10.5,-11.5,-11.7,-11.9,-12.1,-10.6,-10.9,-11.0,-9.1,-11.1,-10.7,-10.3,-11.1,-12.4,-10.6,-12.9,-13.9,-10.2,-12.0,-11.4,-11.6,-12.3,-10.3,-11.0,-10.0,-9.5,-10.3,-11.7,-10.3,-10.6,-11.6,-12.4,-10.8,-9.5,-10.7,-10.9,-9.2,-11.9,-12.1,-11.8,-12.7,-12.2,-15.7,-14.6,-12.9,-13.5,-14.7,-11.9,-12.9,-13.2,-12.1,-13.2,-12.8,-11.5,-12.7,-12.0,-10.7,-15.1,-16.3,-11.7,-12.3,-13.1,-10.1,-11.7,-13.5,-11.4,-11.7,-13.1,-11.3,-10.4,-8.6,-9.5,-9.1,-9.9,-11.9,-10.8,-10.4,-12.1,-12.3,-12.5,-14.6,-15.7,-14.9,-12.9,-15.2,-15.5,-12.8,-15.4,-14.6,-11.9,-14.2,-14.4,-10.9,-11.6,-11.8,-11.7,-12.4,-11.4,-10.9,-11.9,-11.6,-12.0,-11.2,-11.4,-12.4,-14.8,-11.7,-9.2,-9.9,-9.1,-9.4,-9.2,-9.2,-9.8,-9.6,-9.6,-9.8,-9.7,-10.3,-11.1,-10.6,-11.2,-15.8,-16.7,-17.6,-13.0,-12.1,-13.7,-16.0,-12.4,-12.2,-14.3,-12.3,-13.5,-13.1,-10.8,-13.9,-13.3,-11.1,-13.4,-12.3,-10.5,-11.9,-10.1,-9.4,-10.7,-11.9,-10.3,-11.2,-9.4,-11.7,-10.6,-9.5,-10.8,-11.7,-12.1,-11.3,-12.8,-15.0,-10.7,-9.3,-9.8,-9.3,-9.2,-9.0,-9.2,-9.3,-9.5,-8.9,-9.5,-10.0,-12.1,-13.6,-13.2,-12.9,-12.3,-13.0,-12.5,-13.3,-11.4,-10.8,-9.6,-11.1,-12.2,-12.2,-12.0,-10.8,-11.3,-11.7,-12.5,-12.1,-10.7,-11.0,-11.7,-10.5,-11.3,-10.9,-9.5,-8.9,-9.7,-8.8,-11.8,-12.4,-11.5,-11.6,-13.0,-13.0,-12.3,-12.5,-11.5,-12.3,-10.5,-10.3,-19.3,-23.6,-16.8,-12.2,-12.0,-14.0,-12.7,-11.3,-13.1,-12.5,-12.9,-12.9,-15.0,-15.8,-16.7,-12.6,-12.1,-13.7,-13.8,-13.7,-12.4,-13.1,-14.4,-16.1,-16.8,-12.1,-12.2,-11.5,-11.9,-11.4,-10.6,-12.4,-11.6,-11.9,-11.1,-13.2,-13.1,-13.2,-12.0,-13.3,-13.6,-11.3,-12.8,-13.3,-12.8,-11.1,-9.4,-10.7,-15.2,-12.3,-13.3,-11.9,-10.5,-11.6,-11.3,-12.0,-10.6,-11.1,-12.9,-14.6,-12.8,-14.9,-14.3,-12.3,-13.4,-14.7,-14.0,-16.1,-13.3,-10.4,-10.1,-12.0,-12.8,-12.9,-11.2,-9.3,-11.2,-10.5,-12.6,-11.2,-10.1,-12.6,-12.8,-12.7,-13.7,-10.5,-9.0,-10.3,-9.9,-10.1,-10.3,-9.4,-9.4,-14.1,-16.3,-17.0,-12.7,-14.8,-13.3,-12.6,-17.1,-15.7,-12.2,-15.1,-16.0,-16.2,-15.4,-12.5,-14.2,-16.1,-18.0,-16.6,-19.1,-18.4,-17.9,-11.2,-10.5,-11.9,-14.5,-11.6,-10.2,-13.3,-11.5,-13.8,-13.4,-10.8,-10.4,-13.1,-11.6,-14.5,-12.3,-10.4,-12.6,-11.8,-12.6,-12.2,-8.9,-8.8,-9.7,-8.4,-11.6,-10.4,-8.8,-11.3,-10.0,-9.4,-12.3,-10.5,-12.1,-9.6,-10.8,-9.6,-11.7,-11.4,-9.4,-11.3,-10.6,-11.8,-12.2,-10.4,-10.3,-9.9,-10.3,-9.6,-9.2,-10.2,-9.7,-9.4,-9.8,-10.0,-11.9,-11.0,-9.3,-10.1,-9.8,-9.1,-10.2,-9.8,-9.5,-11.0,-10.5,-11.5,-10.6,-9.7,-11.2,-11.0,-12.4,-14.2,-10.9,-11.1,-12.2,-11.8,-13.8,-12.1,-12.7,-11.7,-11.5,-12.6,-13.8,-10.7,-11.4,-9.9,-10.2,-11.2,-12.3,-10.5,-10.4,-12.0,-11.8,-12.7,-12.5,-12.1,-11.0,-11.3,-12.7,-14.7,-10.1,-13.1,-11.4,-10.4,-11.9,-15.1,-11.1,-10.3,-10.6,-11.4,-13.1,-11.9,-12.9,-11.6,-12.5,-11.5,-10.5,-11.2,-11.5,-11.3,-11.2,-11.2,-11.0,-10.8,-11.0,-11.8,-12.1,-10.2,-10.9,-11.4,-12.2,-11.6,-11.6,-11.0,-10.6,-10.5,-10.2,-9.8,-11.3,-11.6,-9.8,-9.5,-10.7,-9.5,-9.9,-10.7,-12.3,-12.5,-11.7,-10.7,-12.0,-10.6,-9.4,-11.0,-10.7,-9.7,-8.6,-10.3,-12.2,-12.8,-12.5,-12.9,-13.4,-13.3,-14.2,-14.4,-14.2,-14.0,-14.0,-14.5,-13.6,-13.9,-13.3,-13.3,-13.0,-14.1,-14.6,-13.3,-12.3,-12.1,-12.8,-11.7,-15.1,-14.7,-11.7,-11.9,-12.9,-13.0,-20.1,-19.6,-20.9,-29.1],"stepS":0.5}},"orbits":[{"orbit":"01","label":"kick","group":"percs","activity":[-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-18.0,-21.0,-18.9,-22.4,-19.5,-16.6,-15.5,-15.9,-17.0,-240.0,-240.0,-22.5,-17.1,-16.5,-18.3,-240.0,-240.0,-17.0,-16.8,-16.5,-16.2,-12.6,-11.4,-11.1,-11.7,-10.7,-11.8,-11.7,-13.9,-240.0,-240.0,-240.0,-240.0,-240.0,-22.4,-15.4,-15.6,-14.6,-15.7,-16.3,-17.1,-17.7,-16.9,-17.3,-17.7,-23.0,-240.0,-240.0,-240.0,-240.0,-240.0,-24.7,-15.0,-12.4,-11.0,-13.2,-23.3,-11.7,-11.5,-11.2,-11.7,-12.2,-13.9,-11.7,-12.2,-13.9,-11.7,-14.5,-14.0,-240.0,-240.0,-240.0,-240.0,-18.9,-18.0,-17.9,-18.9,-240.0,-240.0,-14.7,-13.3,-13.1,-12.8,-15.5,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-23.7,-13.2,-13.6,-14.3,-17.5,-22.1,-19.8,-17.0,-19.0,-17.4,-17.4,-17.4,-15.1,-14.4,-14.1,-14.6,-14.4,-20.6,-14.6,-14.4,-13.9,-14.8,-14.4,-19.1,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-15.6,-11.3,-12.2,-12.3,-16.9,-19.0,-240.0,-240.0,-240.0,-240.0]},{"orbit":"02","label":"snare","group":"percs","activity":[-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-65.8,-29.7,-28.7,-28.9,-240.0,-240.0,-33.6,-26.8,-24.9,-23.8,-25.0,-25.0,-23.9,-26.6,-24.9,-26.9,-240.0,-33.5,-24.3,-26.6,-24.9,-240.0,-240.0,-30.2,-240.0,-240.0,-240.0,-240.0,-240.0,-26.9,-24.6,-25.9,-23.1,-26.1,-34.5,-26.9,-27.1,-28.6,-240.0,-240.0,-240.0,-48.1,-240.0,-240.0,-240.0,-240.0,-70.2,-70.3,-70.4,-28.4,-24.5,-27.6,-22.0,-22.2,-22.8,-240.0,-240.0,-240.0,-240.0,-240.0,-27.4,-28.9,-29.1,-29.1,-240.0,-240.0,-240.0,-240.0,-30.2,-26.0,-25.9,-26.2,-240.0,-240.0,-24.8,-22.3,-22.2,-24.7,-27.1,-32.7,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-106.3,-48.0,-33.7,-29.5,-34.2,-240.0,-240.0,-240.0,-240.0,-29.5,-24.8,-24.6,-24.6,-23.5,-24.6,-29.2,-29.7,-26.2,-24.6,-23.4,-24.7,-24.7,-27.1,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-49.1,-50.4,-30.9,-30.0,-31.6,-240.0,-240.0,-240.0,-240.0]},{"orbit":"03","label":"hats","group":"percs","activity":[-240.0,-240.0,-240.0,-240.0,-240.0,-75.9,-53.0,-48.1,-41.7,-240.0,-240.0,-240.0,-240.0,-44.0,-40.3,-40.3,-41.0,-40.6,-40.5,-37.7,-43.3,-41.0,-37.4,-37.3,-36.7,-37.7,-36.7,-37.2,-37.1,-36.7,-45.2,-133.8,-49.9,-36.2,-36.1,-36.5,-93.2,-142.3,-40.2,-240.0,-240.0,-240.0,-240.0,-240.0,-41.1,-39.2,-39.0,-39.7,-42.3,-55.8,-40.2,-40.6,-41.1,-103.7,-143.1,-44.3,-40.7,-40.2,-40.5,-40.7,-40.0,-41.5,-40.3,-40.3,-41.0,-40.6,-40.4,-40.5,-41.1,-41.4,-240.0,-240.0,-41.5,-41.9,-39.2,-39.1,-38.9,-43.3,-41.9,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-48.1,-39.1,-38.1,-37.8,-38.5,-37.4,-42.3,-46.0,-51.4,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-65.2,-56.4,-40.2,-40.0,-43.7,-76.6,-240.0,-240.0,-240.0,-240.0,-240.0,-44.0,-41.6,-41.5,-41.5,-47.8,-47.2,-42.0,-41.8,-41.3,-42.2,-41.1,-41.5,-37.8,-37.3,-38.3,-40.0,-115.4,-143.8,-240.0,-45.5,-37.8,-45.2,-41.7,-38.0,-37.7,-37.5,-39.2,-41.4,-240.0,-240.0,-240.0,-240.0]},{"orbit":"04","label":"acid","group":"bass","activity":[-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-37.8,-29.0,-32.5,-33.6,-32.8,-32.2,-29.3,-28.1,-28.0,-29.3,-25.5,-22.0,-22.8,-25.7,-32.1,-34.2,-31.0,-31.6,-33.3,-27.9,-29.5,-28.0,-27.5,-32.7,-28.2,-27.7,-29.8,-27.4,-27.9,-32.2,-240.0,-240.0,-240.0,-240.0,-240.0,-30.7,-28.8,-29.0,-27.2,-25.9,-21.0,-26.4,-30.2,-32.4,-30.6,-30.6,-29.8,-44.4,-240.0,-240.0,-240.0,-240.0,-27.9,-29.2,-29.4,-27.6,-26.0,-29.8,-27.0,-27.5,-28.6,-27.4,-27.9,-30.0,-27.2,-28.6,-29.5,-27.3,-26.2,-31.3,-240.0,-240.0,-240.0,-240.0,-42.9,-34.1,-34.5,-35.8,-32.7,-29.7,-34.1,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-36.2,-24.6,-19.0,-18.9,-18.9,-18.3,-19.7,-20.1,-21.0,-22.3,-20.5,-25.6,-18.8,-20.9,-21.1,-22.2,-20.5,-21.7,-27.1,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0]},{"orbit":"05","label":"cpluck","group":"bass","activity":[-19.5,-16.8,-19.5,-19.3,-15.6,-18.1,-21.9,-20.9,-21.6,-21.1,-19.3,-19.9,-17.1,-17.5,-17.9,-17.1,-29.2,-19.8,-19.1,-19.0,-17.0,-15.7,-18.6,-17.5,-12.4,-13.4,-13.0,-13.2,-15.1,-15.5,-15.0,-14.7,-21.6,-16.7,-16.9,-15.2,-16.9,-17.6,-16.3,-19.3,-16.8,-15.0,-16.1,-18.3,-34.8,-30.5,-28.2,-28.0,-28.2,-33.6,-20.1,-16.8,-16.5,-17.1,-16.9,-14.5,-18.1,-17.5,-17.9,-19.2,-19.2,-20.2,-20.8,-20.3,-21.9,-19.8,-26.4,-21.2,-21.4,-19.1,-21.0,-22.8,-24.3,-21.3,-22.6,-23.9,-20.9,-19.2,-19.7,-20.4,-22.7,-30.1,-29.8,-28.2,-24.2,-18.2,-21.2,-19.1,-19.0,-16.6,-18.7,-17.3,-17.3,-19.3,-70.6,-20.5,-20.4,-20.7,-21.8,-20.8,-17.7,-21.1,-21.4,-18.4,-19.5,-19.3,-20.1,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-55.8,-22.4,-19.0,-17.3,-17.1,-18.5,-18.5,-17.4,-20.7,-21.0,-19.0,-21.5,-21.1,-20.5,-21.0,-21.3,-31.5]},{"orbit":"08","label":"break","group":"tops","activity":[-36.0,-34.8,-35.7,-35.3,-32.3,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-58.9,-66.3,-240.0,-240.0,-240.0,-240.0,-37.0,-34.8,-36.2,-46.5,-240.0,-54.2,-34.5,-36.4,-37.1,-240.0,-240.0,-36.8,-36.6,-37.0,-37.7,-34.1,-29.7,-28.5,-23.1,-23.7,-44.2,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-44.3,-30.2,-26.2,-26.0,-76.7,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-39.8,-26.2,-24.3,-21.7,-20.9,-24.6,-22.4,-25.6,-32.6,-26.5,-25.7,-28.3,-25.0,-32.9,-32.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-30.7,-27.5,-29.6,-32.1,-34.3,-35.7,-33.5,-35.3,-37.4,-35.2,-34.6,-34.0,-35.8,-36.8,-36.3,-34.6,-34.3,-35.5,-33.5,-35.7,-33.7,-35.1,-34.8,-34.0,-44.6,-37.1,-35.6,-34.4,-34.3,-35.7,-33.5,-35.4,-32.2,-32.7,-31.8,-35.1,-240.0,-240.0,-240.0,-35.8,-32.2,-42.5,-33.7,-33.0,-30.9,-32.4,-31.9,-31.4,-46.5,-240.0,-240.0,-240.0]},{"orbit":"09","label":"moog sub","group":"bass","activity":[-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-34.7,-19.2,-17.5,-17.0,-17.0,-16.3,-17.4,-16.7,-16.3,-17.5,-17.3,-16.8,-17.3,-18.3,-6.3,-3.3,-2.7,-2.9,-3.3,-2.6,-9.0,-240.0,-240.0,-240.0]}]}]}
\ No newline at end of file
{"track":"PunkAChien","calibration":"stem ≈ master − 3 s (verified by cross-correlation, z=29)","activeDb":-38.0,"litFloorDb":-52.0,"roleGroups":[{"key":"percs","label":"Percs","color":"#ff8c00","glyph":"▰"},{"key":"bass","label":"Bass","color":"#7c5cff","glyph":"▂"},{"key":"melodic","label":"Melodic","color":"#36c5f0","glyph":"♪"},{"key":"tops","label":"Tops","color":"#2dd4bf","glyph":"≈"},{"key":"atmos","label":"Atmos","color":"#8a93a6","glyph":"◌"}],"note":"Single confirmed take (Montreuil). Hamburg/39C3 was misidentified (actually Insouciance→Liquid Finale) and pulled; real Hamburg PunkAChien = 38C3 Toilet 2024, pending re-source.","takes":[{"id":"montreuil","label":"Montreuil","gig":"Montreuil Algorave V3 — Mai Floral","date":"2026-05-22","tidal":"/home/pln/Work/Sound/Tidal/live/collab/raph/punkachien.tidal","dur":287.74,"binS":2.0,"masters":{"stream":{"file":"masters/montreuil_stream_v1.flac","I":-14.0,"LRA":6.4,"TP":-1.0},"club":{"file":"masters/montreuil_club_v1.flac","I":-11.4,"LRA":3.8,"TP":-0.7}},"loud":{"stream":{"trace":[-80.2,-17.8,-15.9,-13.3,-12.9,-11.6,-13.9,-12.3,-11.0,-11.1,-13.9,-13.5,-15.6,-14.4,-12.8,-14.7,-13.9,-16.5,-17.7,-13.2,-10.2,-12.8,-13.4,-19.2,-16.7,-14.5,-17.5,-20.9,-19.4,-21.6,-18.3,-16.1,-18.8,-17.8,-19.4,-20.0,-17.2,-15.4,-18.3,-16.2,-19.2,-16.5,-13.7,-12.1,-12.8,-14.0,-16.9,-14.0,-12.1,-14.2,-12.3,-14.1,-12.7,-10.2,-10.5,-14.1,-12.0,-16.3,-14.6,-11.4,-13.8,-13.2,-13.4,-14.9,-13.0,-11.6,-13.3,-15.2,-15.4,-15.2,-14.3,-14.0,-14.0,-13.9,-14.9,-14.4,-13.4,-14.6,-14.4,-14.7,-15.7,-13.1,-12.4,-13.0,-11.3,-12.1,-11.7,-10.7,-11.1,-14.1,-12.9,-14.5,-14.0,-12.5,-14.1,-12.8,-13.4,-15.0,-11.4,-10.3,-12.0,-11.6,-12.5,-12.4,-11.3,-11.3,-12.3,-11.0,-12.5,-11.3,-9.5,-12.9,-13.3,-14.6,-15.2,-13.1,-12.7,-14.6,-13.4,-15.0,-14.4,-13.0,-12.2,-13.8,-14.0,-16.0,-13.8,-12.1,-13.1,-12.2,-12.9,-13.7,-13.8,-13.9,-11.9,-11.7,-13.8,-12.9,-12.1,-13.1,-12.5,-12.7,-14.0,-11.6,-11.2,-11.7,-12.0,-13.2,-13.5,-12.1,-12.7,-13.2,-12.4,-13.8,-13.0,-11.8,-13.9,-18.1,-19.4,-21.9,-18.4,-14.9,-16.4,-14.6,-15.3,-15.5,-13.4,-12.3,-15.0,-14.1,-16.2,-14.2,-12.5,-12.9,-13.1,-12.9,-21.3,-21.7,-24.1,-17.9,-16.7,-16.1,-15.3,-14.8,-15.4,-15.4,-15.0,-14.6,-15.2,-15.1,-14.2,-15.0,-15.9,-16.2,-15.2,-15.5,-16.3,-16.3,-14.7,-17.1,-18.2,-14.0,-14.8,-15.4,-15.9,-15.3,-14.1,-14.7,-14.1,-15.1,-15.7,-14.0,-13.0,-14.8,-14.2,-16.1,-15.3,-13.9,-14.3,-14.8,-14.4,-16.2,-12.5,-10.9,-12.7,-13.8,-15.0,-16.9,-13.9,-14.5,-16.6,-13.7,-16.0,-13.8,-12.6,-14.7,-14.5,-13.3,-16.8,-14.2,-13.7,-17.5,-14.1,-14.7,-16.8,-13.4,-13.0,-15.3,-16.2,-17.1,-16.9,-15.8,-14.7,-14.5,-14.3,-13.8,-13.5,-13.1,-11.9,-13.2,-13.6,-13.5,-13.4,-13.2,-13.8,-13.9,-16.1,-26.2,-30.6,-20.1,-12.8,-13.3,-13.8,-12.8,-13.1,-13.5,-12.8,-13.9,-13.3,-12.3,-12.2,-12.4,-12.9,-13.3,-13.0,-12.7,-13.3,-12.6,-12.7,-16.5,-21.0,-23.1,-12.7,-12.5,-13.0,-12.8,-12.7,-13.0,-13.2,-13.2,-13.2,-14.6,-20.0,-17.1,-13.3,-13.5,-13.8,-13.5,-13.3,-13.4,-13.3,-13.9,-14.7,-13.8,-12.5,-12.6,-14.6,-19.2,-16.0,-15.2,-17.8,-15.2,-17.2,-16.5,-16.1,-19.6,-21.5,-19.7,-21.7,-20.6,-18.1,-19.7,-20.3,-20.5,-21.3,-19.0,-16.8,-15.5,-17.3,-17.9,-17.5,-15.3,-14.5,-16.2,-15.0,-16.5,-16.9,-16.2,-15.5,-19.1,-19.7,-20.7,-15.9,-14.4,-16.5,-15.1,-16.8,-16.4,-13.7,-12.2,-12.4,-12.7,-14.6,-13.0,-12.6,-13.7,-12.7,-13.6,-14.5,-11.5,-11.7,-12.6,-12.6,-15.0,-14.2,-12.7,-13.8,-21.2,-22.4,-22.8,-25.4,-24.9,-16.8,-15.9,-18.8,-21.4,-18.2,-17.1,-20.1,-18.2,-20.8,-20.0,-17.4,-16.7,-19.7,-18.5,-21.5,-18.8,-17.4,-19.5,-18.3,-19.6,-18.8,-14.6,-12.9,-15.1,-13.4,-14.7,-13.9,-13.5,-14.1,-13.6,-13.7,-14.8,-13.0,-12.6,-13.5,-13.5,-13.6,-15.6,-13.2,-13.8,-15.0,-13.3,-15.9,-14.3,-11.8,-12.6,-16.5,-16.5,-15.4,-13.0,-12.7,-13.8,-13.1,-13.4,-14.3,-14.5,-13.7,-13.7,-12.9,-13.7,-13.5,-12.0,-13.6,-13.1,-12.9,-13.0,-14.2,-13.6,-12.6,-13.7,-14.1,-14.5,-13.9,-13.6,-14.0,-14.5,-13.8,-15.4,-15.0,-13.0,-13.3,-14.5,-14.4,-13.8,-13.7,-15.0,-15.8,-16.2,-14.6,-14.8,-13.2,-13.5,-14.4,-13.8,-14.3,-13.8,-14.1,-13.6,-14.3,-14.6,-15.3,-14.0,-13.1,-14.0,-13.7,-14.0,-13.7,-13.1,-13.5,-14.0,-13.7,-15.2,-14.1,-15.6,-18.6,-19.5,-18.5,-17.5,-18.2,-18.2,-18.2,-18.0,-18.1,-17.8,-17.7,-17.8,-18.8,-19.0,-17.1,-17.9,-18.4,-19.2,-18.6,-18.6,-16.2,-12.6,-14.3,-15.2,-16.2,-13.9,-13.2,-13.6,-13.9,-14.5,-15.3,-13.9,-12.4,-13.9,-14.1,-15.2,-14.6,-13.7,-13.1,-14.5,-14.7,-16.9,-15.1,-13.2,-10.7,-10.6,-10.6,-10.2,-10.8,-10.1,-10.0,-11.3,-11.2,-11.1,-11.4,-11.5,-11.4,-10.5,-10.6,-10.5,-10.4,-9.5,-11.5,-11.8,-10.3,-11.0,-11.2,-17.7,-18.3,-22.1,-21.6,-18.7,-18.9,-19.4,-19.8,-27.0,-26.5,-27.9],"stepS":0.5},"club":{"trace":[-77.4,-10.9,-10.2,-8.3,-10.1,-11.4,-11.7,-9.4,-8.7,-9.2,-10.8,-12.5,-11.4,-11.9,-13.6,-12.6,-12.1,-13.0,-12.9,-11.9,-10.2,-12.0,-9.3,-12.5,-10.7,-9.1,-11.8,-14.0,-12.4,-14.7,-11.9,-9.5,-12.5,-11.5,-12.4,-13.3,-10.8,-9.3,-11.9,-10.1,-12.2,-10.8,-8.9,-9.1,-12.0,-11.0,-11.4,-12.0,-10.0,-11.5,-11.7,-11.5,-11.9,-10.0,-10.5,-11.5,-11.7,-11.9,-12.1,-10.6,-10.9,-11.0,-9.1,-11.1,-10.7,-10.3,-11.1,-12.4,-10.6,-12.9,-13.9,-10.2,-12.0,-11.4,-11.6,-12.3,-10.3,-11.0,-10.0,-9.5,-10.3,-11.7,-10.3,-10.6,-11.6,-12.4,-10.8,-9.5,-10.7,-10.9,-9.2,-11.9,-12.1,-11.8,-12.7,-12.2,-15.7,-14.6,-12.9,-13.5,-14.7,-11.9,-12.9,-13.2,-12.1,-13.2,-12.8,-11.5,-12.7,-12.0,-10.7,-15.1,-16.3,-11.7,-12.3,-13.1,-10.1,-11.7,-13.5,-11.4,-11.7,-13.1,-11.3,-10.4,-8.6,-9.5,-9.1,-9.9,-11.9,-10.8,-10.4,-12.1,-12.3,-12.5,-14.6,-15.7,-14.9,-12.9,-15.2,-15.5,-12.8,-15.4,-14.6,-11.9,-14.2,-14.4,-10.9,-11.6,-11.8,-11.7,-12.4,-11.4,-10.9,-11.9,-11.6,-12.0,-11.2,-11.4,-12.4,-14.8,-11.7,-9.2,-9.9,-9.1,-9.4,-9.2,-9.2,-9.8,-9.6,-9.6,-9.8,-9.7,-10.3,-11.1,-10.6,-11.2,-15.8,-16.7,-17.6,-13.0,-12.1,-13.7,-16.0,-12.4,-12.2,-14.3,-12.3,-13.5,-13.1,-10.8,-13.9,-13.3,-11.1,-13.4,-12.3,-10.5,-11.9,-10.1,-9.4,-10.7,-11.9,-10.3,-11.2,-9.4,-11.7,-10.6,-9.5,-10.8,-11.7,-12.1,-11.3,-12.8,-15.0,-10.7,-9.3,-9.8,-9.3,-9.2,-9.0,-9.2,-9.3,-9.5,-8.9,-9.5,-10.0,-12.1,-13.6,-13.2,-12.9,-12.3,-13.0,-12.5,-13.3,-11.4,-10.8,-9.6,-11.1,-12.2,-12.2,-12.0,-10.8,-11.3,-11.7,-12.5,-12.1,-10.7,-11.0,-11.7,-10.5,-11.3,-10.9,-9.5,-8.9,-9.7,-8.8,-11.8,-12.4,-11.5,-11.6,-13.0,-13.0,-12.3,-12.5,-11.5,-12.3,-10.5,-10.3,-19.3,-23.6,-16.8,-12.2,-12.0,-14.0,-12.7,-11.3,-13.1,-12.5,-12.9,-12.9,-15.0,-15.8,-16.7,-12.6,-12.1,-13.7,-13.8,-13.7,-12.4,-13.1,-14.4,-16.1,-16.8,-12.1,-12.2,-11.5,-11.9,-11.4,-10.6,-12.4,-11.6,-11.9,-11.1,-13.2,-13.1,-13.2,-12.0,-13.3,-13.6,-11.3,-12.8,-13.3,-12.8,-11.1,-9.4,-10.7,-15.2,-12.3,-13.3,-11.9,-10.5,-11.6,-11.3,-12.0,-10.6,-11.1,-12.9,-14.6,-12.8,-14.9,-14.3,-12.3,-13.4,-14.7,-14.0,-16.1,-13.3,-10.4,-10.1,-12.0,-12.8,-12.9,-11.2,-9.3,-11.2,-10.5,-12.6,-11.2,-10.1,-12.6,-12.8,-12.7,-13.7,-10.5,-9.0,-10.3,-9.9,-10.1,-10.3,-9.4,-9.4,-14.1,-16.3,-17.0,-12.7,-14.8,-13.3,-12.6,-17.1,-15.7,-12.2,-15.1,-16.0,-16.2,-15.4,-12.5,-14.2,-16.1,-18.0,-16.6,-19.1,-18.4,-17.9,-11.2,-10.5,-11.9,-14.5,-11.6,-10.2,-13.3,-11.5,-13.8,-13.4,-10.8,-10.4,-13.1,-11.6,-14.5,-12.3,-10.4,-12.6,-11.8,-12.6,-12.2,-8.9,-8.8,-9.7,-8.4,-11.6,-10.4,-8.8,-11.3,-10.0,-9.4,-12.3,-10.5,-12.1,-9.6,-10.8,-9.6,-11.7,-11.4,-9.4,-11.3,-10.6,-11.8,-12.2,-10.4,-10.3,-9.9,-10.3,-9.6,-9.2,-10.2,-9.7,-9.4,-9.8,-10.0,-11.9,-11.0,-9.3,-10.1,-9.8,-9.1,-10.2,-9.8,-9.5,-11.0,-10.5,-11.5,-10.6,-9.7,-11.2,-11.0,-12.4,-14.2,-10.9,-11.1,-12.2,-11.8,-13.8,-12.1,-12.7,-11.7,-11.5,-12.6,-13.8,-10.7,-11.4,-9.9,-10.2,-11.2,-12.3,-10.5,-10.4,-12.0,-11.8,-12.7,-12.5,-12.1,-11.0,-11.3,-12.7,-14.7,-10.1,-13.1,-11.4,-10.4,-11.9,-15.1,-11.1,-10.3,-10.6,-11.4,-13.1,-11.9,-12.9,-11.6,-12.5,-11.5,-10.5,-11.2,-11.5,-11.3,-11.2,-11.2,-11.0,-10.8,-11.0,-11.8,-12.1,-10.2,-10.9,-11.4,-12.2,-11.6,-11.6,-11.0,-10.6,-10.5,-10.2,-9.8,-11.3,-11.6,-9.8,-9.5,-10.7,-9.5,-9.9,-10.7,-12.3,-12.5,-11.7,-10.7,-12.0,-10.6,-9.4,-11.0,-10.7,-9.7,-8.6,-10.3,-12.2,-12.8,-12.5,-12.9,-13.4,-13.3,-14.2,-14.4,-14.2,-14.0,-14.0,-14.5,-13.6,-13.9,-13.3,-13.3,-13.0,-14.1,-14.6,-13.3,-12.3,-12.1,-12.8,-11.7,-15.1,-14.7,-11.7,-11.9,-12.9,-13.0,-20.1,-19.6,-20.9,-29.1],"stepS":0.5}},"orbits":[{"orbit":"01","label":"kick","group":"percs","sound":"kick","centroid":113.0,"activity":[-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-18.0,-21.0,-18.9,-22.4,-19.5,-16.6,-15.5,-15.9,-17.0,-240.0,-240.0,-22.5,-17.1,-16.5,-18.3,-240.0,-240.0,-17.0,-16.8,-16.5,-16.2,-12.6,-11.4,-11.1,-11.7,-10.7,-11.8,-11.7,-13.9,-240.0,-240.0,-240.0,-240.0,-240.0,-22.4,-15.4,-15.6,-14.6,-15.7,-16.3,-17.1,-17.7,-16.9,-17.3,-17.7,-23.0,-240.0,-240.0,-240.0,-240.0,-240.0,-24.7,-15.0,-12.4,-11.0,-13.2,-23.3,-11.7,-11.5,-11.2,-11.7,-12.2,-13.9,-11.7,-12.2,-13.9,-11.7,-14.5,-14.0,-240.0,-240.0,-240.0,-240.0,-18.9,-18.0,-17.9,-18.9,-240.0,-240.0,-14.7,-13.3,-13.1,-12.8,-15.5,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-23.7,-13.2,-13.6,-14.3,-17.5,-22.1,-19.8,-17.0,-19.0,-17.4,-17.4,-17.4,-15.1,-14.4,-14.1,-14.6,-14.4,-20.6,-14.6,-14.4,-13.9,-14.8,-14.4,-19.1,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-15.6,-11.3,-12.2,-12.3,-16.9,-19.0,-240.0,-240.0,-240.0,-240.0]},{"orbit":"02","label":"snare","group":"percs","sound":"snare","centroid":1157.0,"activity":[-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-65.8,-29.7,-28.7,-28.9,-240.0,-240.0,-33.6,-26.8,-24.9,-23.8,-25.0,-25.0,-23.9,-26.6,-24.9,-26.9,-240.0,-33.5,-24.3,-26.6,-24.9,-240.0,-240.0,-30.2,-240.0,-240.0,-240.0,-240.0,-240.0,-26.9,-24.6,-25.9,-23.1,-26.1,-34.5,-26.9,-27.1,-28.6,-240.0,-240.0,-240.0,-48.1,-240.0,-240.0,-240.0,-240.0,-70.2,-70.3,-70.4,-28.4,-24.5,-27.6,-22.0,-22.2,-22.8,-240.0,-240.0,-240.0,-240.0,-240.0,-27.4,-28.9,-29.1,-29.1,-240.0,-240.0,-240.0,-240.0,-30.2,-26.0,-25.9,-26.2,-240.0,-240.0,-24.8,-22.3,-22.2,-24.7,-27.1,-32.7,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-106.3,-48.0,-33.7,-29.5,-34.2,-240.0,-240.0,-240.0,-240.0,-29.5,-24.8,-24.6,-24.6,-23.5,-24.6,-29.2,-29.7,-26.2,-24.6,-23.4,-24.7,-24.7,-27.1,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-49.1,-50.4,-30.9,-30.0,-31.6,-240.0,-240.0,-240.0,-240.0]},{"orbit":"03","label":"dr","group":"percs","sound":"dr","centroid":8215.0,"activity":[-240.0,-240.0,-240.0,-240.0,-240.0,-75.9,-53.0,-48.1,-41.7,-240.0,-240.0,-240.0,-240.0,-44.0,-40.3,-40.3,-41.0,-40.6,-40.5,-37.7,-43.3,-41.0,-37.4,-37.3,-36.7,-37.7,-36.7,-37.2,-37.1,-36.7,-45.2,-133.8,-49.9,-36.2,-36.1,-36.5,-93.2,-142.3,-40.2,-240.0,-240.0,-240.0,-240.0,-240.0,-41.1,-39.2,-39.0,-39.7,-42.3,-55.8,-40.2,-40.6,-41.1,-103.7,-143.1,-44.3,-40.7,-40.2,-40.5,-40.7,-40.0,-41.5,-40.3,-40.3,-41.0,-40.6,-40.4,-40.5,-41.1,-41.4,-240.0,-240.0,-41.5,-41.9,-39.2,-39.1,-38.9,-43.3,-41.9,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-48.1,-39.1,-38.1,-37.8,-38.5,-37.4,-42.3,-46.0,-51.4,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-65.2,-56.4,-40.2,-40.0,-43.7,-76.6,-240.0,-240.0,-240.0,-240.0,-240.0,-44.0,-41.6,-41.5,-41.5,-47.8,-47.2,-42.0,-41.8,-41.3,-42.2,-41.1,-41.5,-37.8,-37.3,-38.3,-40.0,-115.4,-143.8,-240.0,-45.5,-37.8,-45.2,-41.7,-38.0,-37.7,-37.5,-39.2,-41.4,-240.0,-240.0,-240.0,-240.0]},{"orbit":"04","label":"vec1_acid","group":"bass","sound":"vec1_acid","centroid":651.0,"activity":[-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-37.8,-29.0,-32.5,-33.6,-32.8,-32.2,-29.3,-28.1,-28.0,-29.3,-25.5,-22.0,-22.8,-25.7,-32.1,-34.2,-31.0,-31.6,-33.3,-27.9,-29.5,-28.0,-27.5,-32.7,-28.2,-27.7,-29.8,-27.4,-27.9,-32.2,-240.0,-240.0,-240.0,-240.0,-240.0,-30.7,-28.8,-29.0,-27.2,-25.9,-21.0,-26.4,-30.2,-32.4,-30.6,-30.6,-29.8,-44.4,-240.0,-240.0,-240.0,-240.0,-27.9,-29.2,-29.4,-27.6,-26.0,-29.8,-27.0,-27.5,-28.6,-27.4,-27.9,-30.0,-27.2,-28.6,-29.5,-27.3,-26.2,-31.3,-240.0,-240.0,-240.0,-240.0,-42.9,-34.1,-34.5,-35.8,-32.7,-29.7,-34.1,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-36.2,-24.6,-19.0,-18.9,-18.9,-18.3,-19.7,-20.1,-21.0,-22.3,-20.5,-25.6,-18.8,-20.9,-21.1,-22.2,-20.5,-21.7,-27.1,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0]},{"orbit":"05","label":"cpluck","group":"melodic","sound":"cpluck","centroid":536.0,"activity":[-19.5,-16.8,-19.5,-19.3,-15.6,-18.1,-21.9,-20.9,-21.6,-21.1,-19.3,-19.9,-17.1,-17.5,-17.9,-17.1,-29.2,-19.8,-19.1,-19.0,-17.0,-15.7,-18.6,-17.5,-12.4,-13.4,-13.0,-13.2,-15.1,-15.5,-15.0,-14.7,-21.6,-16.7,-16.9,-15.2,-16.9,-17.6,-16.3,-19.3,-16.8,-15.0,-16.1,-18.3,-34.8,-30.5,-28.2,-28.0,-28.2,-33.6,-20.1,-16.8,-16.5,-17.1,-16.9,-14.5,-18.1,-17.5,-17.9,-19.2,-19.2,-20.2,-20.8,-20.3,-21.9,-19.8,-26.4,-21.2,-21.4,-19.1,-21.0,-22.8,-24.3,-21.3,-22.6,-23.9,-20.9,-19.2,-19.7,-20.4,-22.7,-30.1,-29.8,-28.2,-24.2,-18.2,-21.2,-19.1,-19.0,-16.6,-18.7,-17.3,-17.3,-19.3,-70.6,-20.5,-20.4,-20.7,-21.8,-20.8,-17.7,-21.1,-21.4,-18.4,-19.5,-19.3,-20.1,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-55.8,-22.4,-19.0,-17.3,-17.1,-18.5,-18.5,-17.4,-20.7,-21.0,-19.0,-21.5,-21.1,-20.5,-21.0,-21.3,-31.5]},{"orbit":"08","label":"jungle_breaks","group":"tops","sound":"jungle_breaks","centroid":705.0,"activity":[-36.0,-34.8,-35.7,-35.3,-32.3,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-58.9,-66.3,-240.0,-240.0,-240.0,-240.0,-37.0,-34.8,-36.2,-46.5,-240.0,-54.2,-34.5,-36.4,-37.1,-240.0,-240.0,-36.8,-36.6,-37.0,-37.7,-34.1,-29.7,-28.5,-23.1,-23.7,-44.2,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-44.3,-30.2,-26.2,-26.0,-76.7,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-39.8,-26.2,-24.3,-21.7,-20.9,-24.6,-22.4,-25.6,-32.6,-26.5,-25.7,-28.3,-25.0,-32.9,-32.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-30.7,-27.5,-29.6,-32.1,-34.3,-35.7,-33.5,-35.3,-37.4,-35.2,-34.6,-34.0,-35.8,-36.8,-36.3,-34.6,-34.3,-35.5,-33.5,-35.7,-33.7,-35.1,-34.8,-34.0,-44.6,-37.1,-35.6,-34.4,-34.3,-35.7,-33.5,-35.4,-32.2,-32.7,-31.8,-35.1,-240.0,-240.0,-240.0,-35.8,-32.2,-42.5,-33.7,-33.0,-30.9,-32.4,-31.9,-31.4,-46.5,-240.0,-240.0,-240.0]},{"orbit":"09","label":"moog","group":"bass","sound":"moog","centroid":100.0,"activity":[-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-240.0,-34.7,-19.2,-17.5,-17.0,-17.0,-16.3,-17.4,-16.7,-16.3,-17.5,-17.3,-16.8,-17.3,-18.3,-6.3,-3.3,-2.7,-2.9,-3.3,-2.6,-9.0,-240.0,-240.0,-240.0]}]}]}
\ No newline at end of file
......@@ -110,6 +110,7 @@ export default function App() {
variant={variant}
roleGroups={data.roleGroups}
activeDb={data.activeDb}
litFloorDb={data.litFloorDb ?? -52}
calibration={data.calibration}
notes={notes.filter((n) => n.takeId === take.id)}
onAddNote={(t, text) => add(take.id, t, text)}
......
import { useMemo } from 'react'
import type { RoleGroup, Take } from '@/types'
import { loudFrac } from '@/lib/format'
interface Props {
take: Take
roleGroups: RoleGroup[]
currentTime: number
/** "strongly on / driving" — full intensity at/above this dB */
activeDb: number
/** audible floor — orbit is shown (dim) above this, hidden below it */
litFloorDb: number
}
/**
* The orbit-group rail: five labelled lanes (role families), showing the orbits
* active in THIS take, lit by their RMS at the playhead. Per-track, grouped by
* measured register. Color reinforces label + glyph + lane (never hue alone).
* active in THIS take, lit by their RMS at the playhead. Labels + families are
* derived from the track's score + measured spectrum (build_player_data, #40).
*
* Activation is GRADED, not a hard on/off: a sound clearly audible can dip below
* the old −38 dB gate within a 2 s bin and would wrongly go dark (PLN saw only
* d3 OR d8 while hearing both). Now an orbit shows above `litFloorDb` (~−52) and
* its intensity ramps to full by `activeDb`, so a quiet-but-playing orbit never
* vanishes — it just dims.
*/
export function OrbitRail({ take, roleGroups, currentTime, activeDb }: Props) {
export function OrbitRail({ take, roleGroups, currentTime, activeDb, litFloorDb }: Props) {
const bin = Math.max(0, Math.floor(currentTime / take.binS))
const byGroup = useMemo(() => {
const m: Record<string, Take['orbits']> = {}
......@@ -22,16 +30,22 @@ export function OrbitRail({ take, roleGroups, currentTime, activeDb }: Props) {
return m
}, [take])
// dB → 0..1: 0 at the audible floor, 1 by the "driving" threshold.
const intensity = (db: number) => {
if (!isFinite(db) || db <= litFloorDb) return 0
return Math.max(0, Math.min(1, (db - litFloorDb) / (activeDb - litFloorDb)))
}
return (
<div className="flex flex-col gap-px overflow-hidden rounded-md border border-hairline">
{roleGroups.map((g) => {
const orbits = byGroup[g.key] ?? []
const anyLive = orbits.some((o) => (o.activity[bin] ?? -240) > activeDb)
const laneLit = orbits.some((o) => (o.activity[bin] ?? -240) > litFloorDb)
return (
<div key={g.key} className="flex items-stretch gap-2 bg-raised px-2 py-1.5">
<div
className="flex w-20 shrink-0 items-center gap-1.5 text-xs font-medium"
style={{ color: anyLive ? g.color : 'var(--color-ink-faint)' }}
className="flex w-20 shrink-0 items-center gap-1.5 text-xs font-medium transition-colors"
style={{ color: laneLit ? g.color : 'var(--color-ink-faint)' }}
>
<span aria-hidden className="text-sm leading-none">{g.glyph}</span>
<span>{g.label}</span>
......@@ -42,26 +56,32 @@ export function OrbitRail({ take, roleGroups, currentTime, activeDb }: Props) {
)}
{orbits.map((o) => {
const db = o.activity[bin] ?? -240
const live = db > activeDb
const frac = loudFrac(db)
const lvl = intensity(db) // 0..1
const lit = lvl > 0
// dim-but-visible at the floor, vivid when driving (0.35 → 1.0)
const op = lit ? 0.35 + 0.65 * lvl : 1
const cenTip = o.centroid ? ` · ${o.centroid}Hz` : ''
return (
<span
key={o.orbit}
title={`orbit-${o.orbit} · ${o.label} · ${isFinite(db) ? db.toFixed(0) : '−∞'} dB`}
title={`orbit-${o.orbit} · ${o.label} · ${o.group}${cenTip} · ${
isFinite(db) ? db.toFixed(0) : '−∞'
} dB`}
className="relative flex items-center gap-1 overflow-hidden rounded-full
border px-2 py-0.5 font-mono text-[11px] tnum transition-colors"
border px-2 py-0.5 font-mono text-[11px] tnum transition-all"
style={{
borderColor: live ? g.color : 'var(--color-hairline)',
color: live ? g.color : 'var(--color-ink-faint)',
background: live ? `${g.color}1a` : 'transparent',
borderColor: lit ? g.color : 'var(--color-hairline)',
color: lit ? g.color : 'var(--color-ink-faint)',
background: lit ? `${g.color}1a` : 'transparent',
opacity: op,
}}
>
{/* level fill — reinforces loudness without relying on hue alone */}
{live && (
{lit && (
<span
aria-hidden
className="absolute inset-y-0 left-0"
style={{ width: `${frac * 100}%`, background: `${g.color}14` }}
style={{ width: `${lvl * 100}%`, background: `${g.color}22` }}
/>
)}
<span className="relative">{o.label}</span>
......
......@@ -11,12 +11,13 @@ interface Props {
variant: Variant
roleGroups: RoleGroup[]
activeDb: number
litFloorDb: number
calibration: string
notes: Note[]
onAddNote: (t: number, text: string) => void
}
export function TakePanel({ take, variant, roleGroups, activeDb, calibration, notes, onAddNote }: Props) {
export function TakePanel({ take, variant, roleGroups, activeDb, litFloorDb, calibration, notes, onAddNote }: Props) {
const [t, setT] = useState(0)
const [draft, setDraft] = useState('')
const master = take.masters[variant]
......@@ -50,7 +51,7 @@ export function TakePanel({ take, variant, roleGroups, activeDb, calibration, no
<Meter master={master} loud={take.loud[variant]} currentTime={t} />
<OrbitRail take={take} roleGroups={roleGroups} currentTime={t} activeDb={activeDb} />
<OrbitRail take={take} roleGroups={roleGroups} currentTime={t} activeDb={activeDb} litFloorDb={litFloorDb} />
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
......
import { useEffect, useRef, useState } from 'react'
import WaveSurfer from 'wavesurfer.js'
import { Play, Pause } from 'lucide-react'
import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.esm.js'
import { Play, Pause, ZoomIn, ZoomOut, Repeat, X } from 'lucide-react'
import { mmss } from '@/lib/format'
interface Props {
......@@ -10,19 +11,39 @@ interface Props {
onTime: (t: number) => void
}
/** A single take's transport: wavesurfer waveform, click-seek, Space=play/pause. */
const MIN_ZOOM = 0 // 0 = fit-to-width
const MAX_ZOOM = 600 // px per second
const ZOOM_STEP = 1.6
/**
* A take's transport: wavesurfer waveform with Audacity-style zoom/scroll and
* drag-to-select + loop. Zoom: +/- buttons or ctrl/⌘ + wheel. Loop: drag a span
* on the waveform, toggle Repeat to loop it (great for auditing a transition or
* a single moment while iterating on the sound). Space = play/pause.
*/
export function WaveformPlayer({ url, dur, onTime }: Props) {
const elRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WaveSurfer | null>(null)
const regionsRef = useRef<ReturnType<typeof RegionsPlugin.create> | null>(null)
const activeRegionRef = useRef<{ start: number; end: number; play: (from?: boolean) => void } | null>(null)
const onTimeRef = useRef(onTime)
onTimeRef.current = onTime
const loopRef = useRef(true)
const [playing, setPlaying] = useState(false)
const [ready, setReady] = useState(false)
const [t, setT] = useState(0)
const [zoom, setZoom] = useState(MIN_ZOOM)
const [loop, setLoop] = useState(true)
const [region, setRegion] = useState<{ start: number; end: number } | null>(null)
useEffect(() => {
loopRef.current = loop
}, [loop])
useEffect(() => {
if (!elRef.current) return
const regions = RegionsPlugin.create()
const ws = WaveSurfer.create({
container: elRef.current,
height: 76,
......@@ -34,27 +55,72 @@ export function WaveformPlayer({ url, dur, onTime }: Props) {
barGap: 1,
barRadius: 1,
normalize: true,
autoScroll: true,
url,
plugins: [regions],
})
wsRef.current = ws
regionsRef.current = regions
setReady(false)
setPlaying(false)
setRegion(null)
activeRegionRef.current = null
const emit = (time: number) => {
setT(time)
onTimeRef.current(time)
}
ws.on('ready', () => setReady(true))
ws.on('timeupdate', emit)
ws.on('interaction', () => emit(ws.getCurrentTime()))
ws.on('interaction', () => {
activeRegionRef.current = null
emit(ws.getCurrentTime())
})
ws.on('play', () => setPlaying(true))
ws.on('pause', () => setPlaying(false))
ws.on('finish', () => setPlaying(false))
// single drag-selection that can be looped
regions.enableDragSelection({ color: 'rgba(217, 0, 255, 0.12)' })
regions.on('region-created', (r) => {
for (const other of regions.getRegions()) if (other.id !== r.id) other.remove()
activeRegionRef.current = r
setRegion({ start: r.start, end: r.end })
})
regions.on('region-updated', (r) => setRegion({ start: r.start, end: r.end }))
regions.on('region-in', (r) => {
activeRegionRef.current = r
})
regions.on('region-out', (r) => {
if (activeRegionRef.current === r && loopRef.current) r.play(true)
})
regions.on('region-clicked', (r, e) => {
e.stopPropagation()
activeRegionRef.current = r
r.play(true)
})
return () => {
ws.destroy()
}
}, [url])
// apply zoom when it changes (and once ready)
useEffect(() => {
if (ready && wsRef.current) wsRef.current.zoom(zoom)
}, [zoom, ready])
const toggle = () => wsRef.current?.playPause()
const zoomBy = (factor: number) =>
setZoom((z) => {
const base = z <= 0 ? 40 : z
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, factor > 1 ? base * factor : z <= 40 ? 0 : z / ZOOM_STEP))
})
const clearRegion = () => {
regionsRef.current?.clearRegions()
activeRegionRef.current = null
setRegion(null)
}
return (
<div
......@@ -63,6 +129,18 @@ export function WaveformPlayer({ url, dur, onTime }: Props) {
if (e.code === 'Space') {
e.preventDefault()
toggle()
} else if (e.key === 'l') {
setLoop((v) => !v)
} else if (e.key === '+' || e.key === '=') {
zoomBy(ZOOM_STEP)
} else if (e.key === '-') {
zoomBy(1 / ZOOM_STEP)
}
}}
onWheel={(e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
zoomBy(e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP)
}
}}
className="group rounded-md outline-none focus-visible:ring-2 focus-visible:ring-magenta/60"
......@@ -80,10 +158,58 @@ export function WaveformPlayer({ url, dur, onTime }: Props) {
</button>
<div ref={elRef} className="min-w-0 flex-1" />
</div>
<div className="mt-1 flex justify-between font-mono text-xs text-ink-muted tnum">
<div className="mt-1 flex items-center justify-between gap-2 font-mono text-xs text-ink-muted tnum">
<span>{ready ? mmss(t) : 'loading…'}</span>
<span>{mmss(dur)}</span>
<div className="flex items-center gap-1">
{region && (
<>
<button
onClick={() => setLoop((v) => !v)}
title="Loop the selection (L)"
aria-pressed={loop}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors ${
loop ? 'bg-magenta/15 text-magenta' : 'text-ink-faint hover:text-ink'
}`}
>
<Repeat size={12} /> {mmss(region.start)}{mmss(region.end)}
</button>
<button
onClick={clearRegion}
title="Clear selection"
className="flex items-center rounded px-1 py-0.5 text-ink-faint hover:text-ink"
>
<X size={12} />
</button>
<span className="mx-1 text-hairline">|</span>
</>
)}
<button
onClick={() => zoomBy(1 / ZOOM_STEP)}
disabled={!ready}
title="Zoom out (− / ctrl-wheel)"
className="flex items-center rounded px-1 py-0.5 text-ink-faint hover:text-ink disabled:opacity-30"
>
<ZoomOut size={13} />
</button>
<button
onClick={() => zoomBy(ZOOM_STEP)}
disabled={!ready}
title="Zoom in (+ / ctrl-wheel)"
className="flex items-center rounded px-1 py-0.5 text-ink-faint hover:text-ink disabled:opacity-30"
>
<ZoomIn size={13} />
</button>
<span className="w-14 text-right text-ink-faint">{mmss(dur)}</span>
</div>
</div>
{!region && (
<p className="mt-0.5 text-[10px] text-ink-faint/70">
drag on the waveform to select &amp; loop a span · ctrl-wheel to zoom
</p>
)}
</div>
)
}
......@@ -12,6 +12,6 @@ export function loudFrac(db: number, lo = -40, hi = -3): number {
return Math.max(0, Math.min(1, (db - lo) / (hi - lo)))
}
export function fmtDb(v: number | null, unit = ''): string {
export function fmtDb(v: number | null | undefined, unit = ''): string {
return v == null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(1)}${unit}`
}
// AUTO-GENERATED from armada/tide-table/models.py — DO NOT EDIT.
// Regenerate: python3 tools/gen_ts_types.py (DRY data layer, #42)
export type Variant = 'stream' | 'club'
export interface LoudTrace {
trace: number[]
stepS: number
}
export interface MasterInfo {
file: string
I?: number | null
LRA?: number | null
TP?: number | null
}
/** One orbit's lane in a take: label DERIVED from the .tidal sound, family VALIDATED by audio (centroid), activity = RMS dB per bin. (#40) */
export interface OrbitActivity {
orbit: string
label: string
group: string
sound?: string
centroid?: number | null
activity: number[]
}
export interface RoleGroup {
key: string
label: string
color: string
glyph: string
}
export interface Take {
id: string
label: string
gig: string
date: string
tidal?: string | null
dur: number
binS: number
masters: Record<Variant, MasterInfo>
loud: Record<Variant, LoudTrace>
orbits: OrbitActivity[]
}
export interface PlayerData {
track: string
calibration: string
activeDb: number
litFloorDb?: number
roleGroups: RoleGroup[]
note?: string
takes: Take[]
}
export interface Note {
id: string
takeId: string
t: number
text: string
}
// Shape of public/punkachien.json (produced by build_player_data.py).
export interface RoleGroup {
key: string
label: string
color: string
glyph: string
}
export interface Orbit {
orbit: string
label: string
group: string
/** RMS dB per `binS` bin, aligned to track t0 */
activity: number[]
}
export interface MasterInfo {
file: string
I: number | null // integrated LUFS
LRA: number | null
TP: number | null // true peak dBFS
}
export interface LoudTrace {
/** momentary LUFS per `stepS` */
trace: number[]
stepS: number
}
export type Variant = 'stream' | 'club'
export interface Take {
id: string
label: string
gig: string
date: string
dur: number
binS: number
masters: Record<Variant, MasterInfo>
loud: Record<Variant, LoudTrace>
orbits: Orbit[]
}
export interface PlayerData {
track: string
calibration: string
activeDb: number
roleGroups: RoleGroup[]
takes: Take[]
}
export interface Note {
id: string
takeId: string
t: number // seconds
text: string
}
// Shapes are GENERATED from armada/tide-table/models.py (single source of truth).
// Do not hand-edit field definitions here — edit the pydantic models and run
// python3 tools/gen_ts_types.py
// This barrel keeps the stable `@/types` import path. (#42, DRY)
export * from './types.gen'
#!/usr/bin/env python3
"""gen_ts_types — pydantic models → generated TypeScript types (DRY, #42).
ONE source of truth: armada/tide-table/models.py. The Judge UI's shapes
(armada/ui/src/types.gen.ts) are GENERATED from the same models the Python build
emits, so the two can never drift. Edit the models, re-run this, never hand-edit
the .ts. Dependency-free (no node/npx) and deterministic.
python3 tools/gen_ts_types.py # writes armada/ui/src/types.gen.ts
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "armada" / "tide-table"))
from models import Note, PlayerData # noqa: E402
ROOTS = [PlayerData, Note] # top-level UI-facing models
OUT = ROOT / "armada" / "ui" / "src" / "types.gen.ts"
SCALARS = {"string": "string", "number": "number", "integer": "number",
"boolean": "boolean", "null": "null"}
def ref_name(node):
return node["$ref"].split("/")[-1]
def ts_type(node):
if "$ref" in node:
return ref_name(node)
if "anyOf" in node:
return " | ".join(dict.fromkeys(ts_type(s) for s in node["anyOf"]))
if "enum" in node: # inline enum
return " | ".join(f"'{v}'" for v in node["enum"])
t = node.get("type")
if isinstance(t, list): # e.g. ["number","null"]
return " | ".join(SCALARS.get(x, "unknown") for x in t)
if t == "array":
return f"{ts_type(node['items'])}[]"
if t == "object":
ap = node.get("additionalProperties")
if isinstance(ap, dict):
key = ref_name(node["propertyNames"]) if "propertyNames" in node else "string"
return f"Record<{key}, {ts_type(ap)}>"
return "Record<string, unknown>"
return SCALARS.get(t, "unknown")
def emit_interface(name, schema, lines):
desc = schema.get("description")
if desc:
lines.append("/** " + desc.replace("\n", " ") + " */")
lines.append(f"export interface {name} {{")
req = set(schema.get("required", []))
for field, node in schema.get("properties", {}).items():
opt = "" if field in req else "?"
lines.append(f" {field}{opt}: {ts_type(node)}")
lines.append("}")
lines.append("")
def main():
defs, root_schemas = {}, []
for model in ROOTS:
s = model.model_json_schema()
defs.update(s.pop("$defs", {}))
root_schemas.append((model.__name__, s))
lines = ["// AUTO-GENERATED from armada/tide-table/models.py — DO NOT EDIT.",
"// Regenerate: python3 tools/gen_ts_types.py (DRY data layer, #42)",
""]
# enums first
for name, schema in sorted(defs.items()):
if "enum" in schema:
union = " | ".join(f"'{v}'" for v in schema["enum"])
lines.append(f"export type {name} = {union}")
lines.append("")
# object $defs, then roots
for name, schema in sorted(defs.items()):
if "enum" not in schema:
emit_interface(name, schema, lines)
for name, schema in root_schemas:
emit_interface(name, schema, lines)
OUT.write_text("\n".join(lines))
n_if = sum(1 for _, s in defs.items() if "enum" not in s) + len(root_schemas)
n_en = sum(1 for _, s in defs.items() if "enum" in s)
print(f"✓ {OUT.relative_to(ROOT)} — {n_if} interfaces, {n_en} enums")
if __name__ == "__main__":
main()
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