diff --git a/apps/backend/JSON_SCHEMA.md b/apps/backend/JSON_SCHEMA.md index d85bff56..71f03ddb 100644 --- a/apps/backend/JSON_SCHEMA.md +++ b/apps/backend/JSON_SCHEMA.md @@ -428,6 +428,13 @@ Type: `object \| null` | `rhythmDetail.phraseGrid.totalBars` | `int` | Total number of detected bars. | count | Track length in bars for arrangement planning. | | `rhythmDetail.phraseGrid.totalPhrases8Bar` | `int` | Total number of 8-bar phrases. | count | Quick structural count for electronic arrangement estimation. | | `rhythmDetail.tempoCurve` | `Array<{t: float, bpm: float}> \| null` | Instantaneous-BPM curve from beat ticks, smoothed with a 4-beat rolling median, downsampled to ~200 points. | seconds / BPM | Surfaces deliberate ritardando/accelerando and DJ-tool tempo blends that the single mean `bpm` scalar conflates away. Phase 2 cites this to explain tempo-modulated sections. | +| `rhythmDetail.swingDetail` | `object \| null` | Real 8th-note swing / micro-timing from the long/short alternation of inter-onset intervals (accuracy program PR-B2). `null` when there isn't enough 8th-note activity to measure. Grid-phase-independent: keys off interval structure, not each onset's phase against the beat grid, so it survives the beat tracker anchoring "the beat" on a loud offbeat. | object | Prefer over `grooveDetail.kickSwing`/`hihatSwing` (loudness-interval proxies) when present. | +| `rhythmDetail.swingDetail.swingPercent` | `float` | Swing ratio = long / (long + short) 8th interval, ×100. | 50-75 | Ableton Groove Pool swing scale: 50 = straight, 66.7 = full triplet swing. Drops straight into a groove template. | +| `rhythmDetail.swingDetail.swingConfidence` | `float` | Confidence from long/short population balance and count. | 0-1 | Low values = weak or ambiguous 8th activity; hedge the swing claim. | +| `rhythmDetail.swingDetail.gridResolution` | `string` | Grid the swing was read at. Currently always `"8th"`. | categorical | Reserved for a future 16th-note swing pass. | +| `rhythmDetail.swingDetail.direction` | `"straight" \| "swung"` | Whether a clear long/short 8th alternation was found. | categorical | `straight` pins `swingPercent` at 50; `swung` reports the measured ratio. | +| `rhythmDetail.swingDetail.meanAbsOffsetMs` | `float` | Mean absolute deviation of 8th intervals from the straight 0.5-beat, in ms. | milliseconds | General timing-tightness measure independent of swing direction. | +| `rhythmDetail.swingDetail.offbeatOnsetCount` | `int` | Number of 8th-note-scale intervals the estimate is built from. | count | The evidence base behind `swingConfidence`. | Note: `rhythmDetail.beatPositions` previously referred to a truncated beat-timestamp alias. That timestamp array is now exposed as `rhythmDetail.beatGrid` for the full track. diff --git a/apps/backend/analyze_rhythm.py b/apps/backend/analyze_rhythm.py index 377cc554..112159df 100644 --- a/apps/backend/analyze_rhythm.py +++ b/apps/backend/analyze_rhythm.py @@ -188,6 +188,80 @@ def _compute_downbeat_phase(low_band: np.ndarray, meter: int) -> tuple[int, floa return phase, float(np.clip(confidence, 0.0, 1.0)) +def compute_swing_detail( + onset_times: np.ndarray, + ticks: np.ndarray, +) -> dict | None: + """Real micro-timing swing from the onset inter-onset-interval pattern. + + Swing delays the offbeat 8th, so a swung 8th-note stream alternates a long + interval (beat -> delayed "and") and a short one ("and" -> next beat) that + together span one beat. The swing ratio = long / (long + short): 50% is + straight, ~66.7% is full triplet swing — the same 50-75 scale Ableton's + Groove Pool uses, so ``swingPercent`` drops straight into a groove. + + Working from the *intervals* between onsets rather than each onset's phase + against the beat grid deliberately sidesteps the grid-phase ambiguity: on + sparse material the beat tracker sometimes anchors "the beat" on the loud + offbeat hat, which would flip a phase measurement (58% read as 42%). The + long/short interval structure is invariant to that choice. + + Returns ``None`` when there isn't enough 8th-note activity to say anything + honest. + """ + ticks = np.asarray(ticks, dtype=np.float64) + onsets = np.sort(np.asarray(onset_times, dtype=np.float64)) + if ticks.size < 3 or onsets.size < 6: + return None + + beat_diffs = np.diff(ticks) + beat_diffs = beat_diffs[beat_diffs > 0] + if beat_diffs.size == 0: + return None + beat_dur = float(np.median(beat_diffs)) + if beat_dur <= 0: + return None + + # Inter-onset intervals in beats; keep the 8th-note-scale ones. + iois = np.diff(onsets) / beat_dur + eighth = iois[(iois >= 0.30) & (iois <= 0.70)] + if eighth.size < 4: + return None + + long_iois = eighth[eighth > 0.53] + short_iois = eighth[eighth < 0.47] + + if long_iois.size >= 2 and short_iois.size >= 2: + long_med = float(np.median(long_iois)) + short_med = float(np.median(short_iois)) + swing_percent = round(long_med / (long_med + short_med) * 100.0, 1) + direction = "swung" + # Confidence: long/short populations should be balanced (a real + # alternation) and plentiful. + balance = 1.0 - abs(long_iois.size - short_iois.size) / float(eighth.size) + count_factor = min(1.0, eighth.size / 8.0) + swing_confidence = round(max(0.0, balance) * count_factor, 3) + else: + # No clear long/short split — a straight 8th grid. + swing_percent = 50.0 + direction = "straight" + count_factor = min(1.0, eighth.size / 8.0) + # Tighter clustering around 0.5 => more confident it's genuinely straight. + spread = float(np.percentile(eighth, 75) - np.percentile(eighth, 25)) + swing_confidence = round(max(0.0, 1.0 - spread / 0.15) * count_factor, 3) + + mean_abs_offset_ms = round(float(np.mean(np.abs(eighth - 0.5))) * beat_dur * 1000.0, 2) + + return { + "swingPercent": swing_percent, + "swingConfidence": swing_confidence, + "gridResolution": "8th", + "direction": direction, + "meanAbsOffsetMs": mean_abs_offset_ms, + "offbeatOnsetCount": int(eighth.size), + } + + def analyze_rhythm_detail( mono: np.ndarray, sample_rate: int, @@ -272,6 +346,9 @@ def analyze_rhythm_detail( # DJ-tool transitions that the single mean BPM scalar conflates away. tempo_curve = _compute_tempo_curve_from_ticks(ticks) + # Reuse the onset array already computed above (no extra DSP pass). + swing_detail = compute_swing_detail(onset_times, ticks) + return { "rhythmDetail": { "onsetRate": round(onset_rate, 2), @@ -284,6 +361,7 @@ def analyze_rhythm_detail( "tempoStability": tempo_stability, "phraseGrid": phrase_grid, "tempoCurve": tempo_curve, + "swingDetail": swing_detail, } } except Exception as e: diff --git a/apps/backend/fundamentals_evaluation.py b/apps/backend/fundamentals_evaluation.py index c16a23ce..4cd7e8d4 100644 --- a/apps/backend/fundamentals_evaluation.py +++ b/apps/backend/fundamentals_evaluation.py @@ -253,6 +253,17 @@ def _evaluate_expected( f"target>={threshold} actual={round(score, 4)}", )) + swing = expected.get("swingPercent") + if isinstance(swing, (int, float)): + tolerance = float(thresholds.get("swingTolerance", 3.0)) + actual = _number(_nested_value(payload, "rhythmDetail.swingDetail.swingPercent")) + passed = actual is not None and abs(actual - float(swing)) <= tolerance + checks.append(FundamentalsCheck( + "swing:swingPercent", + passed, + f"target={swing} tolerance={tolerance} actual={actual}", + )) + percussion = expected.get("percussion") if isinstance(percussion, dict): checks.extend(_evaluate_percussion_counts(payload, percussion, thresholds)) diff --git a/apps/backend/scripts/build_synthetic_corpus.py b/apps/backend/scripts/build_synthetic_corpus.py index 95ca3857..7e74c0e0 100755 --- a/apps/backend/scripts/build_synthetic_corpus.py +++ b/apps/backend/scripts/build_synthetic_corpus.py @@ -158,8 +158,12 @@ def render_grid_pattern( with_hats. Off-beat onsets bin ambiguously in analyze_time_signature's ±half-beat onset counting and corrupt the bar-length autocorrelation (verified: hats on every "and" flip a straight 4/4 read to 6/8 or 3/4), - so meter-checked clips carry the bare pulse. with_hats adds swung 8th - "and" hats for the swing-truth clips, whose manifest checks BPM only. + so meter-checked clips carry the bare pulse. with_hats adds a realistic + swung 8th-note hat pattern — a straight on-beat hat plus the delayed + off-beat "and" hat — for the swing-truth clips, whose manifest checks BPM + and swingPercent (the long/short 8th-interval ratio the swing measurement + reads). Both hats are present because the measurement keys off the + alternation of long and short intervals, not a single offbeat position. """ beats_per_bar = _meter_beats_per_bar(meter) total_beats = bars * beats_per_bar @@ -172,9 +176,10 @@ def render_grid_pattern( accent = beat % beats_per_bar == 0 _overlay(buf, kick, beat * beat_s, 1.0 if accent else 0.8) if with_hats: + _overlay(buf, hat, beat * beat_s, 0.6) # straight on-beat 8th and_sec = (beat + swing_percent / 100.0) * beat_s hat_times.append(round(and_sec, 6)) - _overlay(buf, hat, and_sec, 0.6) + _overlay(buf, hat, and_sec, 0.6) # swung off-beat 8th truth = { "bpm": bpm, @@ -365,10 +370,9 @@ def _render_spec(spec: dict[str, Any]) -> RenderedClip: _EXPECTED_KEYS_BY_KIND: dict[str, tuple[str, ...]] = { "grid": ("bpm", "timeSignature", "beatGrid", "downbeats"), "counts": ("bpm", "percussion"), - # Swing clips exist to carry swing ground truth for the swing-measurement - # PR; their swung "and" hats corrupt the meter autocorrelation, so only - # BPM is an active check. - "swing": ("bpm",), + # Swing clips: their swung "and" hats corrupt the meter autocorrelation, + # so meter/beat checks stay off; BPM and swingPercent are the active checks. + "swing": ("bpm", "swingPercent"), "chords": ("key", "chordTimeline"), "multi": ("key", "chordTimeline", "timeSignature"), "bass": ("transcriptionNotes",), @@ -377,7 +381,7 @@ def _render_spec(spec: dict[str, Any]) -> RenderedClip: _THRESHOLDS_BY_KIND: dict[str, dict[str, Any]] = { "grid": {"bpmTolerance": 1.0, "beatF1": 0.9, "downbeatF1": 0.75}, "counts": {"bpmTolerance": 1.0, "percussionCountTolerance": 1}, - "swing": {"bpmTolerance": 1.0}, + "swing": {"bpmTolerance": 1.0, "swingTolerance": 3.0}, "chords": {"chordSegmentAccuracy": 0.65}, "multi": {"chordSegmentAccuracy": 0.45, "allowRelativeMajorMinor": True}, "bass": {"transcriptionNoteF1": 0.75}, diff --git a/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json b/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json index 4722ded3..92ca4084 100644 --- a/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json +++ b/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json @@ -1070,10 +1070,12 @@ "category": "swing", "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { - "bpm": 124.0 + "bpm": 124.0, + "swingPercent": 50.0 }, "thresholds": { - "bpmTolerance": 1.0 + "bpmTolerance": 1.0, + "swingTolerance": 3.0 }, "truth": { "timeSignature": "4/4", @@ -1121,7 +1123,6 @@ 11.612903, 13.548387 ], - "swingPercent": 50.0, "hitTimes": { "hihat": [ 0.241935, @@ -1166,10 +1167,12 @@ "category": "swing", "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { - "bpm": 124.0 + "bpm": 124.0, + "swingPercent": 54.0 }, "thresholds": { - "bpmTolerance": 1.0 + "bpmTolerance": 1.0, + "swingTolerance": 3.0 }, "truth": { "timeSignature": "4/4", @@ -1217,7 +1220,6 @@ 11.612903, 13.548387 ], - "swingPercent": 54.0, "hitTimes": { "hihat": [ 0.26129, @@ -1262,10 +1264,12 @@ "category": "swing", "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { - "bpm": 124.0 + "bpm": 124.0, + "swingPercent": 58.0 }, "thresholds": { - "bpmTolerance": 1.0 + "bpmTolerance": 1.0, + "swingTolerance": 3.0 }, "truth": { "timeSignature": "4/4", @@ -1313,7 +1317,6 @@ 11.612903, 13.548387 ], - "swingPercent": 58.0, "hitTimes": { "hihat": [ 0.280645, @@ -1358,10 +1361,12 @@ "category": "swing", "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { - "bpm": 124.0 + "bpm": 124.0, + "swingPercent": 62.0 }, "thresholds": { - "bpmTolerance": 1.0 + "bpmTolerance": 1.0, + "swingTolerance": 3.0 }, "truth": { "timeSignature": "4/4", @@ -1409,7 +1414,6 @@ 11.612903, 13.548387 ], - "swingPercent": 62.0, "hitTimes": { "hihat": [ 0.3, @@ -1454,10 +1458,12 @@ "category": "swing", "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { - "bpm": 124.0 + "bpm": 124.0, + "swingPercent": 66.0 }, "thresholds": { - "bpmTolerance": 1.0 + "bpmTolerance": 1.0, + "swingTolerance": 3.0 }, "truth": { "timeSignature": "4/4", @@ -1505,7 +1511,6 @@ 11.612903, 13.548387 ], - "swingPercent": 66.0, "hitTimes": { "hihat": [ 0.319355, diff --git a/apps/backend/tests/fixtures/golden/phase1_default.json b/apps/backend/tests/fixtures/golden/phase1_default.json index 992a49c9..29901b91 100644 --- a/apps/backend/tests/fixtures/golden/phase1_default.json +++ b/apps/backend/tests/fixtures/golden/phase1_default.json @@ -315,6 +315,7 @@ "totalBars": "number", "totalPhrases8Bar": "number" }, + "swingDetail": "null", "tempoCurve": { "[]": { "bpm": "number", diff --git a/apps/backend/tests/test_build_synthetic_corpus.py b/apps/backend/tests/test_build_synthetic_corpus.py index c6fc0eaf..7171eddf 100644 --- a/apps/backend/tests/test_build_synthetic_corpus.py +++ b/apps/backend/tests/test_build_synthetic_corpus.py @@ -47,10 +47,10 @@ def test_no_spec_is_silently_dropped(self) -> None: self.assertEqual(len(ids), len(set(ids)), "duplicate spec ids") def test_expected_blocks_carry_only_active_check_keys(self) -> None: - # Ground-truth-for-later (hitTimes, swingPercent, unchecked fields) - # must live under "truth", never inside "expected" — otherwise it - # silently becomes an uncalibrated live check the moment the harness - # learns the key. + # Ground-truth-for-later (hitTimes, unchecked fields) must live under + # "truth", never inside "expected" — otherwise it silently becomes an + # uncalibrated live check the moment the harness learns the key. Only + # keys in _EXPECTED_KEYS_BY_KIND for the clip kind may appear. with tempfile.TemporaryDirectory(prefix="asa_synth_expected_") as temp_dir: root = Path(temp_dir) manifest = root / "m.synthetic.json" @@ -63,7 +63,6 @@ def test_expected_blocks_carry_only_active_check_keys(self) -> None: f"{track['id']} expected keys leak beyond active checks", ) self.assertNotIn("hitTimes", track["expected"]) - self.assertNotIn("swingPercent", track["expected"]) def test_bass_and_multi_specs_get_their_analyze_flags(self) -> None: with tempfile.TemporaryDirectory(prefix="asa_synth_flags_") as temp_dir: diff --git a/apps/backend/tests/test_swing_detail.py b/apps/backend/tests/test_swing_detail.py new file mode 100644 index 00000000..5dfac2a2 --- /dev/null +++ b/apps/backend/tests/test_swing_detail.py @@ -0,0 +1,56 @@ +import unittest + +import numpy as np + +from analyze_rhythm import compute_swing_detail + + +def _swung_onsets(bpm: float, bars: int, swing: float) -> tuple[np.ndarray, np.ndarray]: + """Build ticks (quarter grid) and onsets (on-beat + swung off-beat 8ths).""" + beat_s = 60.0 / bpm + beats = bars * 4 + ticks = np.array([b * beat_s for b in range(beats + 1)], dtype=np.float64) + onsets = [] + for b in range(beats): + onsets.append(b * beat_s) # on-beat 8th + onsets.append((b + swing / 100.0) * beat_s) # off-beat 8th + return np.asarray(onsets, dtype=np.float64), ticks + + +class SwingDetailTests(unittest.TestCase): + def test_straight_reads_fifty(self) -> None: + onsets, ticks = _swung_onsets(124, 8, 50.0) + result = compute_swing_detail(onsets, ticks) + self.assertIsNotNone(result) + self.assertEqual(result["swingPercent"], 50.0) + self.assertEqual(result["direction"], "straight") + + def test_swung_ratios_recovered_within_tolerance(self) -> None: + for swing in (54.0, 58.0, 62.0, 66.0): + onsets, ticks = _swung_onsets(124, 8, swing) + result = compute_swing_detail(onsets, ticks) + self.assertIsNotNone(result, f"swing {swing}") + self.assertEqual(result["direction"], "swung") + self.assertLessEqual( + abs(result["swingPercent"] - swing), 3.0, + f"swing {swing} -> {result['swingPercent']}", + ) + self.assertEqual(result["gridResolution"], "8th") + self.assertGreater(result["offbeatOnsetCount"], 0) + + def test_phase_flip_invariance(self) -> None: + # Shifting every onset by a constant (as an offbeat-anchored beat + # tracker effectively would) must not change the swing reading — the + # whole point of the interval-ratio approach. + onsets, ticks = _swung_onsets(124, 8, 62.0) + shifted = compute_swing_detail(onsets + 0.113, ticks + 0.113) + base = compute_swing_detail(onsets, ticks) + self.assertEqual(base["swingPercent"], shifted["swingPercent"]) + + def test_insufficient_onsets_returns_none(self) -> None: + self.assertIsNone(compute_swing_detail(np.array([0.0, 0.5]), np.array([0.0, 0.5, 1.0]))) + self.assertIsNone(compute_swing_detail(np.array([]), np.array([]))) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/ui/src/components/MeasurementDashboard.tsx b/apps/ui/src/components/MeasurementDashboard.tsx index 3c34f7f8..996c6c10 100644 --- a/apps/ui/src/components/MeasurementDashboard.tsx +++ b/apps/ui/src/components/MeasurementDashboard.tsx @@ -1202,6 +1202,16 @@ export function MeasurementDashboard({