/library/decisions/dj-agent
ADR 0005 — Calibrated beats/downbeats and cross-track energy
Two structured-feature gaps undermine the core promises (beatmatching and a planned energy arc), independent of the embeddings:
mirrored from ~/dev/dj-agent/docs/adr/0005-calibrated-beats-and-energy.md · commit 52419d2 · synced 2026.07.09Accepted
Status: Accepted Date: 2026-06-03
Context
Two structured-feature gaps undermine the core promises (beatmatching and a planned energy arc), independent of the embeddings:
- BPM from a single
librosa.beat.beat_trackestimate octave-errors — it reports 70 for a 140-BPM track, 62 for 124, etc. Beatmatching lives or dies on tempo and downbeat phase; one global scalar with no downbeats isn't enough. - Energy is normalized per track.
analyze._downsample_normalizedmin-max scales each track's RMS curve to 0–1, so a whisper-quiet ambient track and a slamming techno track both span 0→1. That encodes shape only — you cannot compare absolute energy across tracks. But "plan an energy arc across a set" is fundamentally a cross-track comparison. The feature as built can't support the feature it exists for.
Decision
- Beats/downbeats/structure from a structure-aware detector. Target
allin1(All-In-One Music Structure Analyzer), which returns beats, downbeats, functional segment labels, and tempo in one pass — serving bothADR 0004(segmentation) and accurate BPM. Fallback ifallin1won't install cleanly:librosabeat tracking with octave correction +msaffor boundaries + heuristic labels. Verify installability on first run; keep the fallback wired. - Energy = cross-track-comparable loudness (LUFS). Use
pyloudnormfor integrated loudness per track (tracks.loudness_lufs) and short-term loudness per section (sections.energy_lufs). The arc is planned and scored in LUFS. Optionally keep a within-track normalized contour for display, but the comparable measure is what the Selector and the Energy-arc-RMSE eval use.
Rationale
- Downbeats are non-negotiable for phrase-aligned mixing. Crossfading on the downbeat grid is the difference between a beatmatch and a train wreck. A detector that emits downbeats + segments + tempo together is strictly better than stitching three librosa calls.
- LUFS is the standard, perceptual, cross-track loudness unit. It's what mastering and broadcast use; it makes "warm-up at −18 LUFS, peak at −9" a meaningful, schedulable target. Per-track min-max never could.
- One detector, two ADRs served.
allin1gives both the segmentation (ADR 0004) and the calibrated BPM here — less code, one source of truth for the beat grid that the Mixer also reuses.
Trade-offs accepted
allin1install risk. It pulls heavier deps (madmom/NATTEN-class) that can fight the torch/Python pins. Mitigation: the librosa + msaf fallback path, and a first-run verification step. This is the one dependency flagged as "confirm before relying on it."- Slower ingestion. Segment + downbeat detection adds seconds per track on
top of CLAP. It's an ingestion-time cost only (queries stay sub-ms), and the
author already accepted the one-time ingest hit (
ADR 0002). pyloudnormneeds the decoded waveform — already loaded for analysis, so near-free.
Consequences
- Stack adds
allin1(orlibrosa+msaffallback) andpyloudnorm. audio/analyze.py: BPM/downbeats from the detector; replace per-track RMS normalization with integrated LUFS; expose the beat grid for the Mixer.audio/segment.py(new): consumes the detector's segment output (ADR 0004).schema.sql:tracks.loudness_lufs REAL,sections.energy_lufs REAL,sections.start_beat INT. The oldenergy_mean/energy_curveare replaced.- Eval Energy-arc RMSE is computed in LUFS; BPM continuity uses downbeat-derived tempo.