Skip to content
89 changes: 65 additions & 24 deletions apps/backend/analyze_rhythm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)

Expand Down
12 changes: 12 additions & 0 deletions apps/backend/fundamentals_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
14 changes: 9 additions & 5 deletions apps/backend/scripts/build_synthetic_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"),
}

Expand Down Expand Up @@ -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"],
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/tests/test_build_synthetic_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
17 changes: 17 additions & 0 deletions apps/backend/tests/test_fundamentals_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions apps/backend/tests/test_swing_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading