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): ...@@ -43,9 +43,15 @@ def decode(path, ac=1, ss=None, t=None):
def orbit_files(take, o): def orbit_files(take, o):
L = IX / f"{take}_Tidal {o}-1%L.wav" """Interchange stem L/R for orbit `o` (== dN). Ardour exports vary between
R = IX / f"{take}_Tidal {o}-1%R.wav" padded ("Tidal 01-1") and unpadded ("Tidal 1-1") channel names across takes,
return (L if L.exists() else None), (R if R.exists() else None) 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 ──────────────────────────────────────────────── # ── the lens: spectral profile ────────────────────────────────────────────────
...@@ -59,9 +65,47 @@ def profile(sig): ...@@ -59,9 +65,47 @@ def profile(sig):
tot = S.sum() + 1e-20 tot = S.sum() + 1e-20
bands = {name: 100 * S[(f >= lo) & (f < hi)].sum() / tot for name, lo, hi in BANDS} 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), 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} "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): def is_broadband(p):
"""A real musical mix has mid+high energy and a centroid above pure-sub. """A real musical mix has mid+high energy and a centroid above pure-sub.
Pure kick/sub ≈ centroid <130Hz and <20% above 150Hz → FAIL.""" Pure kick/sub ≈ centroid <130Hz and <20% above 150Hz → FAIL."""
......
...@@ -150,3 +150,73 @@ class TfidfReport(BaseModel): ...@@ -150,3 +150,73 @@ class TfidfReport(BaseModel):
idf: dict[str, float] idf: dict[str, float]
kinds: dict[str, str] = Field(default_factory=dict) # sound -> sample|synth kinds: dict[str, str] = Field(default_factory=dict) # sound -> sample|synth
tracks: dict[str, TrackSignature] 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]
#!/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()
...@@ -110,6 +110,7 @@ export default function App() { ...@@ -110,6 +110,7 @@ export default function App() {
variant={variant} variant={variant}
roleGroups={data.roleGroups} roleGroups={data.roleGroups}
activeDb={data.activeDb} activeDb={data.activeDb}
litFloorDb={data.litFloorDb ?? -52}
calibration={data.calibration} calibration={data.calibration}
notes={notes.filter((n) => n.takeId === take.id)} notes={notes.filter((n) => n.takeId === take.id)}
onAddNote={(t, text) => add(take.id, t, text)} onAddNote={(t, text) => add(take.id, t, text)}
......
import { useMemo } from 'react' import { useMemo } from 'react'
import type { RoleGroup, Take } from '@/types' import type { RoleGroup, Take } from '@/types'
import { loudFrac } from '@/lib/format'
interface Props { interface Props {
take: Take take: Take
roleGroups: RoleGroup[] roleGroups: RoleGroup[]
currentTime: number currentTime: number
/** "strongly on / driving" — full intensity at/above this dB */
activeDb: number 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 * 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 * active in THIS take, lit by their RMS at the playhead. Labels + families are
* measured register. Color reinforces label + glyph + lane (never hue alone). * 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 bin = Math.max(0, Math.floor(currentTime / take.binS))
const byGroup = useMemo(() => { const byGroup = useMemo(() => {
const m: Record<string, Take['orbits']> = {} const m: Record<string, Take['orbits']> = {}
...@@ -22,16 +30,22 @@ export function OrbitRail({ take, roleGroups, currentTime, activeDb }: Props) { ...@@ -22,16 +30,22 @@ export function OrbitRail({ take, roleGroups, currentTime, activeDb }: Props) {
return m return m
}, [take]) }, [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 ( return (
<div className="flex flex-col gap-px overflow-hidden rounded-md border border-hairline"> <div className="flex flex-col gap-px overflow-hidden rounded-md border border-hairline">
{roleGroups.map((g) => { {roleGroups.map((g) => {
const orbits = byGroup[g.key] ?? [] 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 ( return (
<div key={g.key} className="flex items-stretch gap-2 bg-raised px-2 py-1.5"> <div key={g.key} className="flex items-stretch gap-2 bg-raised px-2 py-1.5">
<div <div
className="flex w-20 shrink-0 items-center gap-1.5 text-xs font-medium" className="flex w-20 shrink-0 items-center gap-1.5 text-xs font-medium transition-colors"
style={{ color: anyLive ? g.color : 'var(--color-ink-faint)' }} style={{ color: laneLit ? g.color : 'var(--color-ink-faint)' }}
> >
<span aria-hidden className="text-sm leading-none">{g.glyph}</span> <span aria-hidden className="text-sm leading-none">{g.glyph}</span>
<span>{g.label}</span> <span>{g.label}</span>
...@@ -42,26 +56,32 @@ export function OrbitRail({ take, roleGroups, currentTime, activeDb }: Props) { ...@@ -42,26 +56,32 @@ export function OrbitRail({ take, roleGroups, currentTime, activeDb }: Props) {
)} )}
{orbits.map((o) => { {orbits.map((o) => {
const db = o.activity[bin] ?? -240 const db = o.activity[bin] ?? -240
const live = db > activeDb const lvl = intensity(db) // 0..1
const frac = loudFrac(db) 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 ( return (
<span <span
key={o.orbit} 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 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={{ style={{
borderColor: live ? g.color : 'var(--color-hairline)', borderColor: lit ? g.color : 'var(--color-hairline)',
color: live ? g.color : 'var(--color-ink-faint)', color: lit ? g.color : 'var(--color-ink-faint)',
background: live ? `${g.color}1a` : 'transparent', background: lit ? `${g.color}1a` : 'transparent',
opacity: op,
}} }}
> >
{/* level fill — reinforces loudness without relying on hue alone */} {/* level fill — reinforces loudness without relying on hue alone */}
{live && ( {lit && (
<span <span
aria-hidden aria-hidden
className="absolute inset-y-0 left-0" 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> <span className="relative">{o.label}</span>
......
...@@ -11,12 +11,13 @@ interface Props { ...@@ -11,12 +11,13 @@ interface Props {
variant: Variant variant: Variant
roleGroups: RoleGroup[] roleGroups: RoleGroup[]
activeDb: number activeDb: number
litFloorDb: number
calibration: string calibration: string
notes: Note[] notes: Note[]
onAddNote: (t: number, text: string) => void 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 [t, setT] = useState(0)
const [draft, setDraft] = useState('') const [draft, setDraft] = useState('')
const master = take.masters[variant] const master = take.masters[variant]
...@@ -50,7 +51,7 @@ export function TakePanel({ take, variant, roleGroups, activeDb, calibration, no ...@@ -50,7 +51,7 @@ export function TakePanel({ take, variant, roleGroups, activeDb, calibration, no
<Meter master={master} loud={take.loud[variant]} currentTime={t} /> <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 flex-col gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
......
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import WaveSurfer from 'wavesurfer.js' 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' import { mmss } from '@/lib/format'
interface Props { interface Props {
...@@ -10,19 +11,39 @@ interface Props { ...@@ -10,19 +11,39 @@ interface Props {
onTime: (t: number) => void 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) { export function WaveformPlayer({ url, dur, onTime }: Props) {
const elRef = useRef<HTMLDivElement>(null) const elRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WaveSurfer | null>(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) const onTimeRef = useRef(onTime)
onTimeRef.current = onTime onTimeRef.current = onTime
const loopRef = useRef(true)
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const [t, setT] = useState(0) 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(() => { useEffect(() => {
if (!elRef.current) return if (!elRef.current) return
const regions = RegionsPlugin.create()
const ws = WaveSurfer.create({ const ws = WaveSurfer.create({
container: elRef.current, container: elRef.current,
height: 76, height: 76,
...@@ -34,27 +55,72 @@ export function WaveformPlayer({ url, dur, onTime }: Props) { ...@@ -34,27 +55,72 @@ export function WaveformPlayer({ url, dur, onTime }: Props) {
barGap: 1, barGap: 1,
barRadius: 1, barRadius: 1,
normalize: true, normalize: true,
autoScroll: true,
url, url,
plugins: [regions],
}) })
wsRef.current = ws wsRef.current = ws
regionsRef.current = regions
setReady(false) setReady(false)
setPlaying(false) setPlaying(false)
setRegion(null)
activeRegionRef.current = null
const emit = (time: number) => { const emit = (time: number) => {
setT(time) setT(time)
onTimeRef.current(time) onTimeRef.current(time)
} }
ws.on('ready', () => setReady(true)) ws.on('ready', () => setReady(true))
ws.on('timeupdate', emit) 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('play', () => setPlaying(true))
ws.on('pause', () => setPlaying(false)) ws.on('pause', () => setPlaying(false))
ws.on('finish', () => 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 () => { return () => {
ws.destroy() ws.destroy()
} }
}, [url]) }, [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 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 ( return (
<div <div
...@@ -63,6 +129,18 @@ export function WaveformPlayer({ url, dur, onTime }: Props) { ...@@ -63,6 +129,18 @@ export function WaveformPlayer({ url, dur, onTime }: Props) {
if (e.code === 'Space') { if (e.code === 'Space') {
e.preventDefault() e.preventDefault()
toggle() 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" 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) { ...@@ -80,10 +158,58 @@ export function WaveformPlayer({ url, dur, onTime }: Props) {
</button> </button>
<div ref={elRef} className="min-w-0 flex-1" /> <div ref={elRef} className="min-w-0 flex-1" />
</div> </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>{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>
</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 { ...@@ -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))) 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}` 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). // 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
export interface RoleGroup { // python3 tools/gen_ts_types.py
key: string // This barrel keeps the stable `@/types` import path. (#42, DRY)
label: string export * from './types.gen'
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
}
#!/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