feat(phase 1.D #2): migrate chordTimeline from Essentia smoothing to librosa+Viterbi - #20
Conversation
slittycode
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Summary
Replaces the 5-frame median-filter smoothing of Essentia's per-frame chord labels with a 25-state Viterbi decoder over librosa.feature.chroma_cqt. Additive schema change only — labelLong, chordTimelineSource, and chordTimelineAgreement are new optional fields; existing startSec, endSec, label, confidence are untouched. Frontend tests: 82/82 pass for the directly changed files. Backend Viterbi unit tests pass in this environment; the 4 tests that invoke analyze_chords require Essentia and fail here for the same reason the entire existing test suite does — not a regression introduced by this PR.
Findings
Worth considering
chordTimelineAgreement computes top_viterbi by counting segments, not duration:
top_viterbi = Counter(viterbi_labels_non_n).most_common(1)[0][0]A track with 20 short Eb fragments and one 32-bar C section would declare Eb the dominant Viterbi label. Duration-weighting (sum(seg["endSec"] - seg["startSec"]) for segs matching label) would be more representative. Not blocking — the PR explicitly documents this as a hedging signal rather than a precise metric, and the real-audio sanity check (Vtss) shows it surfacing honest disagreement — but worth fixing before this metric gets baked into Phase 2 prompt calibration.
Also confirming (not flagging)
fieldAnalytics.ts: 'chordDetail.chordProgression' → 'chordDetail.progression' is a bug fix, not a change. The old path never matched anything (progression is the actual field name in both backend and TypeScript types). Correct.
CONFIDENCE_PAIRS maps 'chordDetail.chordTimeline' to 'chordDetail.chordStrength'. Using Essentia's aggregate strength as the hedging gate for the Viterbi timeline is a reasonable proxy and the low-confidence hedging test (chordStrength: 0.28) confirms the gate fires correctly.
Test results
Frontend (changed files): 82 / 82 pass. npm run lint clean.
Frontend (full suite): 299 / 303 pass. 3 failures are decision_gate.*.live.test.ts — pre-existing, require /tmp/decision_gate_*.json fixture files from an external setup script, fail identically on the base branch.
Backend (new tests, no-Essentia environment): 2 / 6 pass.
test_chord_templates_25_has_correct_shape_and_triad_masks✓test_normalize_chord_label_handles_enharmonics_and_quality✓- 4 tests that call
analyze_chordsERROR:'NoneType' object has no attribute 'HighPass'— Essentia not installed in this review environment; identical failure mode as the entire pre-existing backend test suite.
Phase boundary check
Clean. analyze_segments.py computes chordTimeline, chordTimelineSource, chordTimelineAgreement as ground truth. phase2_system.txt reads and hedges against them. phase2Validator.ts validates that Phase 2 cites them — it never writes Phase 1 fields. No violations.
Generated by Claude Code
…brosa+Viterbi Replaces the 5-frame median-filter smoothing of Essentia's per-frame ChordsDetection labels (PR #18) with a 25-state (12 major + 12 minor + 'N' no-chord) Viterbi decoder over librosa.feature.chroma_cqt. The HMM self-loop prior produces fewer, more stable segments on hard material (electronic, modal harmony) and exposes a per-segment confidence we can hedge against. Public schema preserved — chordTimeline[].label still matches dominantChords convention; chordChangeCount recomputed from the new timeline. Backend (apps/backend): - analyze_segments.py: new helpers _chord_templates_25, _viterbi_chord_timeline, _state_label_short/long, _normalize_chord_label_for_compare. Viterbi decoder uses L1 mass-overlap for emission (N-safe on dense chroma) and L2 cosine similarity for the per-segment confidence metric (intuitive [0, 1] range, 0.5 = noise floor, 0.7+ = clean match). analyze_chords now also returns chordTimelineSource = 'librosa_viterbi' and chordTimelineAgreement (bool | None) computed against Essentia's dominantChords[0] after enharmonic normalization (Eb <-> D#, Bb <-> A#, etc.). - JSON_SCHEMA.md: update chordTimeline row for the new engine and labelLong field; add rows for labelLong, chordTimelineSource, chordTimelineAgreement. - prompts/phase2_system.txt CHORD TIMELINE section: note the engine change, document labelLong, add chordTimelineAgreement hedging rule. - tests/test_analyze.py: new ChordTimelineViterbiTests class with 6 tests — synthetic C-major triad fixture, white-noise no-spurious- triads invariant, source/agreement field shape, chordChangeCount recompute, enharmonic normalization, and template-matrix structure. Frontend (apps/ui): - types/measurement.ts: ChordTimelineEntry gains optional labelLong; ChordDetail gains chordTimelineSource and chordTimelineAgreement. - services/backendPhase1Client.ts: replace the as-cast passthrough on chordDetail with a tolerant parseOptionalChordDetail helper modeled on parseOptionalRhythmTimeline. Malformed nested timeline entries are dropped (missing required keys, NaN confidence, endSec < startSec, non- records); confidence clamped to [0, 1]; segments sorted by startSec. Back-compat with payloads lacking labelLong. - services/phase2Validator.ts: add chordDetail.chordTimeline and chordDetail.chordChangeCount to PHASE1_NEW_FIELD_PATHS; map chordDetail.chordTimeline to chordDetail.chordStrength in CONFIDENCE_PAIRS so the existing LOW_CONFIDENCE_NOT_HEDGED rule covers timeline citations. No new validators or shape changes. - services/fieldAnalytics.ts: fix the existing chordDetail.chordProgression typo (real field is chordDetail.progression); add chordDetail.chordTimeline and chordDetail.chordChangeCount to the analytics tracking list. - tests/services/backendPhase1Client.test.ts: extend chord fixture with the new Viterbi fields; add 4 new tests covering the tolerant parser, back-compat with missing labelLong, malformed-segment filtering, and chordTimelineAgreement coercion. - tests/services/phase2Validator.test.ts: extend the PHASE1_NEW_FIELD_PATHS guard with chord-timeline entries; add new-field-coverage tests for chordDetail.chordTimeline (cited vs uncited); add a low-confidence hedging test for a chord-citing recommendation with imperative text. Verification: - Backend: 439 tests pass (439 before; 6 new chord tests added, 0 regressions). On the Vtss-CantCatchMe.mp3 bench fixture: 15 segments, labels Eb/C/Cm/Db matching dominantChords ['Cm','Eb','C','F'], chordChangeCount 14, confidence range [0.54, 0.74], chordTimelineAgreement False (honest Eb-vs-Cm disagreement worth hedging on a track that bounces between relative major/minor). - Frontend: 303 tests pass, lint clean. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
9bf65af to
3d3bd3c
Compare
What
Replaces the 5-frame median-filter smoothing of Essentia's per-frame
ChordsDetectionlabels (introduced in #18) with a 25-state Viterbi decoder overlibrosa.feature.chroma_cqt— 12 major + 12 minor triads + 1 "N" no-chord. The HMM self-loop prior produces fewer, more stable segments on hard material (electronic, modal harmony) and exposes a per-segment confidence Phase 2 can hedge against.Public schema preserved —
chordTimeline[].labelstill matchesdominantChordsconvention.chordChangeCountis recomputed from the new timeline. New meta-fields added:labelLongper segment,chordTimelineSource,chordTimelineAgreement.Why
PURPOSE.mdinvariant #1 is "Phase 1 measurements are ground truth." A noisier chord engine bleeds into low confidence → hedged recommendations → vague Ableton blueprints. Viterbi over a 25-state triadic vocabulary with a self-loop bias is the standard tool for chord-timeline decoding; the reason PR #18 took the smaller bite of "smooth what Essentia gives us" was to ship the contract slice end-to-end first. This PR finishes the Phase 1.D #2 work.The cross-citation against Essentia (
chordTimelineAgreement) is the practical win — when the two engines disagree on the dominant chord, Phase 2 has a concrete signal to hedge harmonic recommendations instead of silently committing to one reading.Base branch
This PR targets feat/phase-1-depth-audit-and-gate-v3 because PR #18 is the prerequisite (it landed the original
chordTimeline+chordChangeCountschema, thePHASE1_NEW_FIELD_PATHSvalidator, the CHORD TIMELINE prompt section, and theChordTimelineEntryfrontend type — all of which this PR builds on). When #18 merges tomain, retarget this PR or rebase.Files changed (10 files; +772 / −82)
Backend
apps/backend/analyze_segments.py— new helpers_chord_templates_25,_viterbi_chord_timeline,_state_label_short/long,_normalize_chord_label_for_compare.analyze_chordsnow wires the Viterbi path and computeschordTimelineAgreement.apps/backend/JSON_SCHEMA.md—chordDetail.chordTimelinerow rewritten for the new engine +labelLongfield; new rows forlabelLong,chordTimelineSource,chordTimelineAgreement.apps/backend/prompts/phase2_system.txt— CHORD TIMELINE section: engine note,labelLongdoc,chordTimelineAgreementhedging rule.apps/backend/tests/test_analyze.py— newChordTimelineViterbiTestsclass with 6 tests.Frontend
apps/ui/src/types/measurement.ts—ChordTimelineEntry.labelLong?,ChordDetail.chordTimelineSource?,chordTimelineAgreement?.apps/ui/src/services/backendPhase1Client.ts— tolerantparseOptionalChordDetailreplaces the cast-passthrough. One malformed nested segment doesn't reject the wholechordDetail.apps/ui/src/services/phase2Validator.ts— addedchordDetail.chordTimeline+chordChangeCounttoPHASE1_NEW_FIELD_PATHS; addedchordDetail.chordTimeline → chordDetail.chordStrengthtoCONFIDENCE_PAIRS. No new validators or shape changes.apps/ui/src/services/fieldAnalytics.ts— fix stalechordDetail.chordProgressiontypo (real field isprogression); add the two new tracking paths.apps/ui/tests/services/backendPhase1Client.test.ts— extended chord fixture + 4 new parser-tolerance tests.apps/ui/tests/services/phase2Validator.test.ts— extendedPHASE1_NEW_FIELD_PATHSguard with chord-timeline entries; new tests for new-field coverage and low-confidence hedging on chord citations.Design decisions reviewers should know about
_viterbi_chord_timeline(chroma, hop_seconds)returns the segment list only.analyze_chordscomputes agreement after both engines run, via a private_normalize_chord_label_for_comparethat handles enharmonics (Eb ↔ D#,Bb ↔ A#,Db ↔ C#,Gb ↔ F#,Ab ↔ G#).label(short form, e.g."Cm"— back-compat withdominantChords) andlabelLong("C minor"— readable). Phase 2 prompt can cite either.parseOptionalChordDetailusestoNumber/isRecord/Array.isArraydefensively; never usesexpectStringArray(which throws). Phase 1 payloads sometimes come from disk caches written by older analyzer versions and one bad segment shouldn't reject the wholechordDetail.Verification
cd apps/backend && ./venv/bin/python -m unittest discover -s tests.npm run lintclean. Run withcd apps/ui && npm run lint && npx vitest run.tests/fixtures/bench_tracks/Vtss-CantCatchMe.mp3(gitignored): 15 segments, labels Eb/C/Cm/Db matchingdominantChords ['Cm','Eb','C','F'],chordChangeCount14, confidence range [0.54, 0.74].chordTimelineAgreementisFalse— Viterbi sees Eb-dominant, Essentia sees Cm-dominant. That's an honest disagreement on a track that bounces between relative major and minor, exactly the kind of signalchordTimelineAgreementis meant to surface so Phase 2 hedges.Risks / follow-ups
confidence < 0.5) is now closer to the noise floor than before. If real-world recommendations come back over-hedged, raise the prompt threshold to 0.55 or 0.6 in a follow-up.otherstem when Demucs separation has run is a logical follow-up that should reduce drum/bass interference on chord detection.🤖 Generated with Claude Code