feat(rhythm): real 8th-note swing measurement (accuracy PR-B2) - #205
Merged
Conversation
Replaces the loudness-interval swing proxy (grooveDetail.kickSwing/
hihatSwing) with a real micro-timing measurement. compute_swing_detail
reads swing from the long/short alternation of 8th-note inter-onset
intervals: swingPercent = long/(long+short)*100 on Ableton's Groove Pool
50-75 scale (50 straight, 66.7 full triplet), so it drops straight into a
groove template. Additive rhythmDetail.swingDetail {swingPercent,
swingConfidence, gridResolution, direction, meanAbsOffsetMs,
offbeatOnsetCount}; reuses the onset array already computed in
analyze_rhythm_detail (no extra DSP pass); null when unmeasurable.
Interval-ratio rather than phase-vs-grid is deliberate: on sparse
material the beat tracker sometimes anchors 'the beat' on a loud offbeat
hat, which flips a phase reading (58% -> 42%). The long/short structure
is invariant to that choice — validated by a phase-shift-invariance test
and against the corpus swing clips (truth 50/54/58/62/66 -> measured
50.0/54.8/57.1/61.9/65.9, all within +-1.3, well inside the +-3 gate).
Corpus: swing clips now render a realistic swung hat pattern (straight
on-beat 8th + delayed off-beat 8th) so the interval alternation exists;
swingPercent promoted from manifest truth to an active +-3.0 check on all
5 swing clips (fundamentals_evaluation learns expected.swingPercent).
UI: reconstructionBrief groove sentence prefers the measured percentage
('swings at about 62% - set the Groove Pool swing to match'), falls back
to the proxy; MeasurementDashboard Swing panel shows the Groove-Pool
number.
Contract mirrors (tripwire #4): JSON_SCHEMA.md swingDetail sub-fields;
types/measurement.ts SwingDetail (rhythmDetail passes through wholesale,
no reconstructor); phase1FullPayload fixture; golden re-baselined
(nested, structural). Tests: test_swing_detail (ratios, phase-flip
invariance, degenerate), brief swing-sentence, corpus expected-keys.
Backend 1299 OK, fundamentals gate green, frontend verify green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wfz87k6kzJqkKLKZE7YL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase B of the accuracy program: turns swing from a scalar proxy into a real, Groove-Pool-ready number.
What
The old swing signal (
grooveDetail.kickSwing/hihatSwing) is a tanh-normalized beat-loudness-interval variance — a "how loose" hint, not a swing percentage. This addscompute_swing_detail, which measures swing from the long/short alternation of 8th-note inter-onset intervals:swingPercent = long / (long + short) × 100, on Ableton's Groove Pool 50–75 scale (50 = straight, 66.7 = full triplet). AdditiverhythmDetail.swingDetail{swingPercent, swingConfidence, gridResolution, direction, meanAbsOffsetMs, offbeatOnsetCount}, reusing the onset array already computed inanalyze_rhythm_detail(no extra DSP pass),nullwhen unmeasurable.Why interval-ratio, not phase-vs-grid
I first built the obvious phase-against-beat-grid version. It failed on the corpus: the beat tracker, on sparse material, sometimes anchors "the beat" on the loud offbeat hat, which flips a phase reading (58% measured as 42%). The long/short interval structure is invariant to that choice — proven by a phase-shift-invariance test and by the corpus results: truth 50/54/58/62/66 → measured 50.0/54.8/57.1/61.9/65.9, all within ±1.3, well inside the ±3 gate.
Validation
swingPercentis now an active ±3.0 check on all 5 swing clips (the harness learnsexpected.swingPercent; the swing fixtures render a realistic swung hat pattern — straight on-beat 8th + delayed off-beat 8th — so the interval alternation exists). Full synthetic run: 54/54 active + 8 informational,allPassed.User-facing
The Reconstruction Brief groove line now says "swings at about 62% — set the Groove Pool swing to match" (falls back to the proxy when swing is unmeasurable), and the MeasurementDashboard Swing panel shows the Groove-Pool percentage above the old kick/hat bars.
Contract mirrors (tripwire #4)
JSON_SCHEMA.md sub-fields ·
types/measurement.tsSwingDetail(rhythmDetailpasses through wholesale — no reconstructor) ·phase1FullPayloadfixture · golden re-baselined (nested, structural).Tests
test_swing_detail(ratio recovery, phase-flip invariance, degenerate inputs), brief swing-sentence, corpus expected-keys. Backend 1299 OK, fundamentals gate green, frontend verify green (unit + build + 51 smoke).🤖 Generated with Claude Code
https://claude.ai/code/session_01T4wfz87k6kzJqkKLKZE7YL
Generated by Claude Code