diff --git a/apps/backend/analyze_rhythm.py b/apps/backend/analyze_rhythm.py index 5143350..026f44e 100644 --- a/apps/backend/analyze_rhythm.py +++ b/apps/backend/analyze_rhythm.py @@ -206,8 +206,17 @@ def compute_swing_detail( 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. + Grid resolution (accuracy program PR-G5): the 8th grid is analyzed first + and, when it swings, wins — unchanged from the original measurement. When + the 8th grid reads straight (or has too little activity), the 16th grid + is probed with the same long/short-split logic at half scale: UKG/2-step + shuffle lives on the 16ths and was previously invisible (its swung-16th + IOIs never split the 8th window, reading "straight"). A 16th split only + ships when a genuine long/short alternation exists — straight material + and sparse material keep their original outputs byte-identical. + + Returns ``None`` when there isn't enough 8th- or 16th-note activity to + say anything honest. """ ticks = np.asarray(ticks, dtype=np.float64) onsets = np.sort(np.asarray(onset_times, dtype=np.float64)) @@ -222,33 +231,65 @@ def compute_swing_detail( if beat_dur <= 0: return None - # Inter-onset intervals in beats; keep the 8th-note-scale ones. + def _swung_split(scaled: np.ndarray, long_threshold: float, short_threshold: float) -> dict | None: + """Long/short alternation within one grid span; None without a genuine split.""" + long_iois = scaled[scaled > long_threshold] + short_iois = scaled[scaled < short_threshold] + if long_iois.size < 2 or short_iois.size < 2: + return None + long_med = float(np.median(long_iois)) + short_med = float(np.median(short_iois)) + balance = 1.0 - abs(long_iois.size - short_iois.size) / float(scaled.size) + count_factor = min(1.0, scaled.size / 8.0) + return { + "swingPercent": round(long_med / (long_med + short_med) * 100.0, 1), + "swingConfidence": round(max(0.0, balance) * count_factor, 3), + } + + # Inter-onset intervals in beats; the 8th-note window first. iois = np.diff(onsets) / beat_dur eighth = iois[(iois >= 0.30) & (iois <= 0.70)] + + if eighth.size >= 4: + split = _swung_split(eighth, 0.53, 0.47) + if split is not None: + mean_abs_offset_ms = round(float(np.mean(np.abs(eighth - 0.5))) * beat_dur * 1000.0, 2) + return { + "swingPercent": split["swingPercent"], + "swingConfidence": split["swingConfidence"], + "gridResolution": "8th", + "direction": "swung", + "meanAbsOffsetMs": mean_abs_offset_ms, + "offbeatOnsetCount": int(eighth.size), + } + + # 8th grid is straight or absent — probe the 16th grid (PR-G5). Same + # split logic at half scale: a 16th pair spans half a beat, so the + # straight center is 0.25 and the split thresholds halve. + sixteenth = iois[(iois >= 0.15) & (iois <= 0.35)] + if sixteenth.size >= 4: + split = _swung_split(sixteenth, 0.265, 0.235) + if split is not None: + mean_abs_offset_ms = round(float(np.mean(np.abs(sixteenth - 0.25))) * beat_dur * 1000.0, 2) + return { + "swingPercent": split["swingPercent"], + "swingConfidence": split["swingConfidence"], + "gridResolution": "16th", + "direction": "swung", + "meanAbsOffsetMs": mean_abs_offset_ms, + "offbeatOnsetCount": int(sixteenth.size), + } + 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) + # No clear long/short split on either grid — 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) diff --git a/apps/backend/fundamentals_evaluation.py b/apps/backend/fundamentals_evaluation.py index f06d1d7..42e7dde 100644 --- a/apps/backend/fundamentals_evaluation.py +++ b/apps/backend/fundamentals_evaluation.py @@ -277,6 +277,18 @@ def _evaluate_expected( f"target={swing} tolerance={tolerance} actual={actual}", )) + swing_grid = expected.get("swingGrid") + if isinstance(swing_grid, str) and swing_grid.strip(): + # Gates grid DETECTION separately from ratio accuracy (PR-G5): a + # 16th shuffle must be seen as swung on the 16th grid even while + # the ratio value is limited by onset frame resolution. + actual_grid = _nested_value(payload, "rhythmDetail.swingDetail.gridResolution") + checks.append(FundamentalsCheck( + "swing:gridResolution", + actual_grid == swing_grid, + f"expected={swing_grid} actual={actual_grid}", + )) + 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 2ebc9e3..4a0b256 100755 --- a/apps/backend/scripts/build_synthetic_corpus.py +++ b/apps/backend/scripts/build_synthetic_corpus.py @@ -267,7 +267,7 @@ def render_shuffle16_pattern(*, bpm: float, bars: int, swing_percent: float) -> truth = { "bpm": bpm, "swingPercent": swing_percent, - "gridResolution": "16th", + "swingGrid": "16th", "hitTimes": {"hihat": hat_times}, } return RenderedClip(_peak_guard(buf), truth) @@ -514,7 +514,7 @@ def _render_spec(spec: dict[str, Any]) -> RenderedClip: "twostep": ("bpm", "bpmOctave", "timeSignature", "beatGrid", "downbeats"), "halftime": ("bpm", "bpmOctave", "timeSignature", "beatGrid", "downbeats"), "breakbeat": ("bpm", "bpmOctave", "timeSignature", "beatGrid", "downbeats"), - "shuffle16": ("bpm", "swingPercent"), + "shuffle16": ("bpm", "swingPercent", "swingGrid"), "ambient": ("key", "honesty"), } @@ -557,9 +557,13 @@ def _render_spec(spec: dict[str, Any]) -> RenderedClip: # (beatGrid 0.370, downbeats 0.308). The same arrangement at 140 passes # every check, so the trap is specifically tempo-extreme halftime. "halftime_174": ["tempo:bpm", "beatGrid:f1", "downbeats:f1"], - # 16th-grid shuffle is invisible to the swing measurement (swingDetail - # None): compute_swing_detail keeps only 8th-scale IOIs (0.30-0.70 - # beats) and hardwires gridResolution "8th". PR-G5 target. + # PR-G5 made the 16th-grid shuffle DETECTABLE (swing:gridResolution now + # gates: swung, 16th, confidence 0.99). The ratio VALUE stays + # informational: onset detection quantizes to 11.6 ms frames (hop 512) + # and shows a measured one-frame-per-side inward bias, compressing + # 62% shuffle to a read of 55% at 130 BPM (one frame ~ 2.7 swing + # points at 16th scale). Value accuracy needs finer onset timing — + # a future, separately gated refinement. "shuffle16_130_62": ["swing:swingPercent"], } diff --git a/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json b/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json index 7e306ec..9f6094d 100644 --- a/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json +++ b/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json @@ -2223,14 +2223,14 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 130.0, - "swingPercent": 62.0 + "swingPercent": 62.0, + "swingGrid": "16th" }, "thresholds": { "bpmTolerance": 1.0, "swingTolerance": 3.0 }, "truth": { - "gridResolution": "16th", "hitTimes": { "hihat": [ 0.143077, diff --git a/apps/backend/tests/test_build_synthetic_corpus.py b/apps/backend/tests/test_build_synthetic_corpus.py index 5a9cd71..69be926 100644 --- a/apps/backend/tests/test_build_synthetic_corpus.py +++ b/apps/backend/tests/test_build_synthetic_corpus.py @@ -152,7 +152,7 @@ def test_broken_grid_truth_matches_placement(self) -> None: def test_shuffle16_truth_places_swung_16ths(self) -> None: rendered = render_shuffle16_pattern(bpm=120, bars=1, swing_percent=62) self.assertEqual(rendered.truth["swingPercent"], 62) - self.assertEqual(rendered.truth["gridResolution"], "16th") + self.assertEqual(rendered.truth["swingGrid"], "16th") # First swung 16th: 62% of a half-beat into beat 0 => 0.31 beats = 0.155 s. self.assertAlmostEqual(rendered.truth["hitTimes"]["hihat"][0], 0.155, places=4) diff --git a/apps/backend/tests/test_fundamentals_evaluation.py b/apps/backend/tests/test_fundamentals_evaluation.py index 3dd8408..77d4863 100644 --- a/apps/backend/tests/test_fundamentals_evaluation.py +++ b/apps/backend/tests/test_fundamentals_evaluation.py @@ -283,6 +283,23 @@ def overconfident_runner(_path: Path, _flags: list[str] | None) -> dict: {"honesty:bpmConfidence", "honesty:swingAbsent", "honesty:meterSource"}, ) + def test_swing_grid_check_gates_detection_separately_from_ratio(self) -> None: + from fundamentals_evaluation import _evaluate_expected + + payload = { + "rhythmDetail": { + "swingDetail": {"swingPercent": 55.0, "gridResolution": "16th"}, + }, + } + checks = _evaluate_expected( + payload, + {"swingPercent": 62.0, "swingGrid": "16th"}, + {"swingTolerance": 3.0}, + ) + by_name = {check.name: check for check in checks} + self.assertFalse(by_name["swing:swingPercent"].passed) # value compressed + self.assertTrue(by_name["swing:gridResolution"].passed) # detection gates + def test_honesty_bpm_confidence_passes_when_field_is_absent(self) -> None: # A pipeline that cannot say anything (bpmConfidence None/missing) is # abstaining, which is exactly what the honesty gate wants. diff --git a/apps/backend/tests/test_swing_detail.py b/apps/backend/tests/test_swing_detail.py index 5dfac2a..fbadc00 100644 --- a/apps/backend/tests/test_swing_detail.py +++ b/apps/backend/tests/test_swing_detail.py @@ -52,5 +52,51 @@ def test_insufficient_onsets_returns_none(self) -> None: self.assertIsNone(compute_swing_detail(np.array([]), np.array([]))) +def _shuffled_16th_onsets(bpm: float, bars: int, swing: float) -> tuple[np.ndarray, np.ndarray]: + """Straight 8ths plus swung inner 16ths (UKG shuffle), PR-G5.""" + 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): + for eighth in (0.0, 0.5): + base = b + eighth + onsets.append(base * beat_s) + onsets.append((base + swing / 100.0 * 0.5) * beat_s) + return np.asarray(onsets, dtype=np.float64), ticks + + +class SixteenthShuffleTests(unittest.TestCase): + def test_shuffled_16ths_recovered_on_the_16th_grid(self) -> None: + for swing in (58.0, 62.0, 66.0): + onsets, ticks = _shuffled_16th_onsets(130, 8, swing) + result = compute_swing_detail(onsets, ticks) + self.assertIsNotNone(result, f"shuffle {swing}") + self.assertEqual(result["gridResolution"], "16th") + self.assertEqual(result["direction"], "swung") + self.assertLessEqual( + abs(result["swingPercent"] - swing), 3.0, + f"shuffle {swing} -> {result['swingPercent']}", + ) + + def test_swung_8ths_still_win_over_the_16th_probe(self) -> None: + # The 8th grid keeps priority: a swung-8th stream must report the + # 8th grid exactly as before PR-G5. + onsets, ticks = _swung_onsets(124, 8, 58.0) + result = compute_swing_detail(onsets, ticks) + self.assertEqual(result["gridResolution"], "8th") + self.assertEqual(result["direction"], "swung") + + def test_straight_16ths_do_not_fabricate_a_shuffle(self) -> None: + # Straight 16th activity (all IOIs at 0.25 beats) has no long/short + # split — nothing on the 16th grid may ship, preserving the original + # output (None here: no 8th-scale IOIs at all). + beat_s = 60.0 / 130 + beats = 8 * 4 + ticks = np.array([b * beat_s for b in range(beats + 1)], dtype=np.float64) + onsets = np.arange(0, beats, 0.25) * beat_s + self.assertIsNone(compute_swing_detail(onsets, ticks)) + + if __name__ == "__main__": unittest.main()