Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/backend/JSON_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
78 changes: 78 additions & 0 deletions apps/backend/analyze_rhythm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -284,6 +361,7 @@ def analyze_rhythm_detail(
"tempoStability": tempo_stability,
"phraseGrid": phrase_grid,
"tempoCurve": tempo_curve,
"swingDetail": swing_detail,
}
}
except Exception as e:
Expand Down
11 changes: 11 additions & 0 deletions apps/backend/fundamentals_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
20 changes: 12 additions & 8 deletions apps/backend/scripts/build_synthetic_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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",),
Expand All @@ -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},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1121,7 +1123,6 @@
11.612903,
13.548387
],
"swingPercent": 50.0,
"hitTimes": {
"hihat": [
0.241935,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1217,7 +1220,6 @@
11.612903,
13.548387
],
"swingPercent": 54.0,
"hitTimes": {
"hihat": [
0.26129,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1313,7 +1317,6 @@
11.612903,
13.548387
],
"swingPercent": 58.0,
"hitTimes": {
"hihat": [
0.280645,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1409,7 +1414,6 @@
11.612903,
13.548387
],
"swingPercent": 62.0,
"hitTimes": {
"hihat": [
0.3,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1505,7 +1511,6 @@
11.612903,
13.548387
],
"swingPercent": 66.0,
"hitTimes": {
"hihat": [
0.319355,
Expand Down
1 change: 1 addition & 0 deletions apps/backend/tests/fixtures/golden/phase1_default.json
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@
"totalBars": "number",
"totalPhrases8Bar": "number"
},
"swingDetail": "null",
"tempoCurve": {
"[]": {
"bpm": "number",
Expand Down
9 changes: 4 additions & 5 deletions apps/backend/tests/test_build_synthetic_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down
56 changes: 56 additions & 0 deletions apps/backend/tests/test_swing_detail.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading