Skip to content

feat(phase 1.D #2): migrate chordTimeline from Essentia smoothing to librosa+Viterbi - #20

Merged
slittycode merged 1 commit into
mainfrom
claude/eloquent-mccarthy-7ba5b3
May 13, 2026
Merged

feat(phase 1.D #2): migrate chordTimeline from Essentia smoothing to librosa+Viterbi#20
slittycode merged 1 commit into
mainfrom
claude/eloquent-mccarthy-7ba5b3

Conversation

@slittycode

Copy link
Copy Markdown
Owner

What

Replaces the 5-frame median-filter smoothing of Essentia's per-frame ChordsDetection labels (introduced in #18) with a 25-state Viterbi decoder over librosa.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[].label still matches dominantChords convention. chordChangeCount is recomputed from the new timeline. New meta-fields added: labelLong per segment, chordTimelineSource, chordTimelineAgreement.

Why

PURPOSE.md invariant #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 + chordChangeCount schema, the PHASE1_NEW_FIELD_PATHS validator, the CHORD TIMELINE prompt section, and the ChordTimelineEntry frontend type — all of which this PR builds on). When #18 merges to main, retarget this PR or rebase.

Files changed (10 files; +772 / −82)

Backend

  1. apps/backend/analyze_segments.py — new helpers _chord_templates_25, _viterbi_chord_timeline, _state_label_short/long, _normalize_chord_label_for_compare. analyze_chords now wires the Viterbi path and computes chordTimelineAgreement.
  2. apps/backend/JSON_SCHEMA.mdchordDetail.chordTimeline row rewritten for the new engine + labelLong field; new rows for labelLong, chordTimelineSource, chordTimelineAgreement.
  3. apps/backend/prompts/phase2_system.txt — CHORD TIMELINE section: engine note, labelLong doc, chordTimelineAgreement hedging rule.
  4. apps/backend/tests/test_analyze.py — new ChordTimelineViterbiTests class with 6 tests.

Frontend

  1. apps/ui/src/types/measurement.tsChordTimelineEntry.labelLong?, ChordDetail.chordTimelineSource?, chordTimelineAgreement?.
  2. apps/ui/src/services/backendPhase1Client.ts — tolerant parseOptionalChordDetail replaces the cast-passthrough. One malformed nested segment doesn't reject the whole chordDetail.
  3. apps/ui/src/services/phase2Validator.ts — added chordDetail.chordTimeline + chordChangeCount to PHASE1_NEW_FIELD_PATHS; added chordDetail.chordTimeline → chordDetail.chordStrength to CONFIDENCE_PAIRS. No new validators or shape changes.
  4. apps/ui/src/services/fieldAnalytics.ts — fix stale chordDetail.chordProgression typo (real field is progression); add the two new tracking paths.
  5. apps/ui/tests/services/backendPhase1Client.test.ts — extended chord fixture + 4 new parser-tolerance tests.
  6. apps/ui/tests/services/phase2Validator.test.ts — extended PHASE1_NEW_FIELD_PATHS guard with chord-timeline entries; new tests for new-field coverage and low-confidence hedging on chord citations.

Design decisions reviewers should know about

  1. Emission uses L1 mass-overlap, not L2 cosine. L2 cosine of the uniform "N" template against dense electronic chroma is so high that N steals every frame — the very first end-to-end run on Vtss collapsed the whole track to a single "N" segment. L1 mass-overlap ties N to triads only when chroma is genuinely uniform; on real chord content, triads win.
  2. Confidence is L2 cosine similarity, not softmax posterior. 25 closely-overlapping state templates split posterior mass so much that even a perfect C-major chroma yields ≈0.29 posterior. Cosine similarity gives the intuitive [0, 1] range producers expect (clean = 1.0, real-mix = 0.55–0.75, noise floor ≈ 0.5).
  3. Agreement computed outside the Viterbi helper. _viterbi_chord_timeline(chroma, hop_seconds) returns the segment list only. analyze_chords computes agreement after both engines run, via a private _normalize_chord_label_for_compare that handles enharmonics (Eb ↔ D#, Bb ↔ A#, Db ↔ C#, Gb ↔ F#, Ab ↔ G#).
  4. Both label formats. Each segment carries label (short form, e.g. "Cm" — back-compat with dominantChords) and labelLong ("C minor" — readable). Phase 2 prompt can cite either.
  5. Tolerant parser, not throwing. parseOptionalChordDetail uses toNumber/isRecord/Array.isArray defensively; never uses expectStringArray (which throws). Phase 1 payloads sometimes come from disk caches written by older analyzer versions and one bad segment shouldn't reject the whole chordDetail.

Verification

  1. Backend: 439 unittest tests pass (6 new chord tests added; zero regressions). Run with cd apps/backend && ./venv/bin/python -m unittest discover -s tests.
  2. Frontend: 303 vitest tests pass, npm run lint clean. Run with cd apps/ui && npm run lint && npx vitest run.
  3. Real-audio sanity check on tests/fixtures/bench_tracks/Vtss-CantCatchMe.mp3 (gitignored): 15 segments, labels Eb/C/Cm/Db matching dominantChords ['Cm','Eb','C','F'], chordChangeCount 14, confidence range [0.54, 0.74]. chordTimelineAgreement is False — 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 signal chordTimelineAgreement is meant to surface so Phase 2 hedges.

Risks / follow-ups

  1. Viterbi confidence values are lower than the median-smoothed strength values were. Phase 2's hedging rule (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.
  2. Stem input is still full-mix. Switching to the other stem when Demucs separation has run is a logical follow-up that should reduce drum/bass interference on chord detection.
  3. Transition matrix is uniform-off-diagonal. A Markov-trained transition matrix (e.g., learned from a labeled chord corpus) is a known accuracy improvement; deferred to keep this PR scoped.

🤖 Generated with Claude Code

@slittycode slittycode left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_chords ERROR: '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

Base automatically changed from feat/phase-1-depth-audit-and-gate-v3 to main May 13, 2026 03:06
…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>
@slittycode
slittycode force-pushed the claude/eloquent-mccarthy-7ba5b3 branch from 9bf65af to 3d3bd3c Compare May 13, 2026 03:07
@slittycode
slittycode merged commit 1c059a8 into main May 13, 2026
2 checks passed
@slittycode
slittycode deleted the claude/eloquent-mccarthy-7ba5b3 branch May 13, 2026 03:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant