From 944a0d19c4041856f102f77ae440022d4fb7dfc9 Mon Sep 17 00:00:00 2001 From: assiduous repetition Date: Mon, 13 Jul 2026 17:47:37 +1200 Subject: [PATCH 1/5] feat(rhythm): surfacing-only tempo-octave evidence at extremes (accuracy PR-G3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bpmOctaveEvidence (full-mode only): simple-ratio candidates (1:2, 2:3, 1:1, 3:2, 2:1 of the shipped bpm, clamped 40-220) scored against two inter-onset streams — low-band kick articulation (integer multiples, fit divided by the median multiple) plus the full-band hat/snare grid (admits the 8th-note 0.5 multiple), which pins the notated tempo when the kick only marks bars. Never overrides bpm (invariant #1); abstains (null) on sparse/beatless material. Measured on the corpus: recovers all three broken octave cases (174 -> 173.8, 190 -> 189.8, halftime-174 -> 175.5 via 3:2) with zero false alarms on the 10 correctly-shipped rhythm clips; the beatless ambient clip abstains. Promoted as an active tempoOctave:preferredBpm gate on 14 rhythm clips (13 pass; grid_7_8_140 stays informational — its bpm is meter-smeared, not octave-wrong: PR-G4 territory). Both sides landed: JSON_SCHEMA.md + full-only docstring, measurement.ts types, backendPhase1Client reconstructor, phase1FullPayload fixture; golden deliberately re-baselined (bpmOctaveEvidence key + one rounding-step lufsShortTermMax nudge within designed tolerance). Co-Authored-By: Claude Fable 5 --- apps/backend/JSON_SCHEMA.md | 5 +- apps/backend/analyze.py | 6 + apps/backend/analyze_rhythm.py | 167 ++++++++++++++++++ apps/backend/fundamentals_evaluation.py | 13 ++ .../backend/scripts/build_synthetic_corpus.py | 28 +-- .../fundamentals_eval_manifest.synthetic.json | 64 +++++-- .../tests/fixtures/golden/phase1_default.json | 5 +- apps/backend/tests/test_analyze.py | 3 +- .../backend/tests/test_bpm_octave_evidence.py | 96 ++++++++++ apps/ui/src/services/backendPhase1Client.ts | 3 + apps/ui/src/types/measurement.ts | 36 ++++ apps/ui/tests/fixtures/phase1FullPayload.ts | 13 ++ 12 files changed, 409 insertions(+), 30 deletions(-) create mode 100644 apps/backend/tests/test_bpm_octave_evidence.py diff --git a/apps/backend/JSON_SCHEMA.md b/apps/backend/JSON_SCHEMA.md index 1e87206f..6b2f55e6 100644 --- a/apps/backend/JSON_SCHEMA.md +++ b/apps/backend/JSON_SCHEMA.md @@ -15,9 +15,9 @@ Conventions: Top-level keys: -`phase1Version`, `fundamentalsQuality`, `bpm`, `bpmConfidence`, `bpmPercival`, `bpmAgreement`, `bpmDoubletime`, `bpmSource`, `bpmRawOriginal`, `key`, `keyConfidence`, `keyProfile`, `keyEnsemble`, `tuningFrequency`, `tuningCents`, `timeSignature`, `timeSignatureSource`, `timeSignatureConfidence`, `timeSignatureCandidates`, `durationSeconds`, `sampleRate`, `lufsIntegrated`, `lufsRange`, `lufsMomentaryMax`, `lufsShortTermMax`, `lufsCurve`, `truePeak`, `crestFactor`, `dynamicSpread`, `monoCompatible`, `plr`, `dynamicCharacter`, `textureCharacter`, `stereoDetail`, `spectralBalance`, `spectralBalanceTimeSeries`, `spectralDetail`, `stemAnalysis`, `transientDensityDetail`, `saturationDetail`, `snareDetail`, `hihatDetail`, `rhythmDetail`, `melodyDetail`, `transcriptionDetail`, `pitchDetail`, `grooveDetail`, `beatsLoudness`, `rhythmTimeline`, `sidechainDetail`, `reverbDetail`, `vocalDetail`, `acidDetail`, `supersawDetail`, `bassDetail`, `kickDetail`, `genreDetail`, `effectsDetail`, `synthesisCharacter`, `danceability`, `structure`, `arrangementDetail`, `segmentLoudness`, `segmentSpectral`, `segmentStereo`, `segmentKey`, `chordDetail`, `perceptual`, `essentiaFeatures`. +`phase1Version`, `fundamentalsQuality`, `bpm`, `bpmConfidence`, `bpmPercival`, `bpmAgreement`, `bpmDoubletime`, `bpmSource`, `bpmRawOriginal`, `bpmOctaveEvidence`, `key`, `keyConfidence`, `keyProfile`, `keyEnsemble`, `tuningFrequency`, `tuningCents`, `timeSignature`, `timeSignatureSource`, `timeSignatureConfidence`, `timeSignatureCandidates`, `durationSeconds`, `sampleRate`, `lufsIntegrated`, `lufsRange`, `lufsMomentaryMax`, `lufsShortTermMax`, `lufsCurve`, `truePeak`, `crestFactor`, `dynamicSpread`, `monoCompatible`, `plr`, `dynamicCharacter`, `textureCharacter`, `stereoDetail`, `spectralBalance`, `spectralBalanceTimeSeries`, `spectralDetail`, `stemAnalysis`, `transientDensityDetail`, `saturationDetail`, `snareDetail`, `hihatDetail`, `rhythmDetail`, `melodyDetail`, `transcriptionDetail`, `pitchDetail`, `grooveDetail`, `beatsLoudness`, `rhythmTimeline`, `sidechainDetail`, `reverbDetail`, `vocalDetail`, `acidDetail`, `supersawDetail`, `bassDetail`, `kickDetail`, `genreDetail`, `effectsDetail`, `synthesisCharacter`, `danceability`, `structure`, `arrangementDetail`, `segmentLoudness`, `segmentSpectral`, `segmentStereo`, `segmentKey`, `chordDetail`, `perceptual`, `essentiaFeatures`. -**Shared (fast + full) vs full-only.** Fast-mode output is asserted byte-for-byte against the `EXPECTED_TOP_LEVEL_KEYS` set in [`tests/test_analyze.py`](tests/test_analyze.py). Full mode emits those keys *plus* a handful of detail-only fields that are deliberately absent from the shared snapshot: `keyProfile`, `tuningFrequency`, `tuningCents`, `lufsMomentaryMax`, `lufsShortTermMax`, `pitchDetail`, `timeSignatureCandidates`, and `keyEnsemble`. When changing the schema, update both this list and `EXPECTED_TOP_LEVEL_KEYS`; full-only fields stay out of that set on purpose. See CLAUDE.md tripwire #4. Schema changes are also cross-checked executably against the frontend fixture and parser by `apps/ui/tests/services/phase1ContractParity.test.ts`, driven by the golden snapshot's `topLevelKeys`/`keyTree` — regenerating the golden (`UPDATE_PHASE1_GOLDEN=1`) is what arms the nested half of that gate. +**Shared (fast + full) vs full-only.** Fast-mode output is asserted byte-for-byte against the `EXPECTED_TOP_LEVEL_KEYS` set in [`tests/test_analyze.py`](tests/test_analyze.py). Full mode emits those keys *plus* a handful of detail-only fields that are deliberately absent from the shared snapshot: `keyProfile`, `tuningFrequency`, `tuningCents`, `lufsMomentaryMax`, `lufsShortTermMax`, `pitchDetail`, `timeSignatureCandidates`, `keyEnsemble`, and `bpmOctaveEvidence`. When changing the schema, update both this list and `EXPECTED_TOP_LEVEL_KEYS`; full-only fields stay out of that set on purpose. See CLAUDE.md tripwire #4. Schema changes are also cross-checked executably against the frontend fixture and parser by `apps/ui/tests/services/phase1ContractParity.test.ts`, driven by the golden snapshot's `topLevelKeys`/`keyTree` — regenerating the golden (`UPDATE_PHASE1_GOLDEN=1`) is what arms the nested half of that gate. ## Relationship To `POST /api/analyze` @@ -163,6 +163,7 @@ Current server behavior that affects schema expectations: | `sampleRate` | `int \| null` | Effective analysis sample rate. | Hz | Ensures downstream feature interpretation uses correct temporal/frequency scaling. | | `keyProfile` | `string \| null` | Key profile used by `KeyExtractor` (e.g. `"edma"`). | categorical | Indicates which pitch template corpus was used for key detection. | | `keyEnsemble` | `object \| null` | **Full mode only.** Multi-profile key cross-check (accuracy program PR-B3): `{method: "profile_vote.v1", agreement, profiles: [{profile, key, strength}], alternates: [{key, strength}]}`. EDMA stays the authoritative shipped `key`; this records temperley/krumhansl reads. `null` when all profiles failed. | object | Surfacing-only until the GiantSteps gate proves the vote beats EDMA-alone (`incorporations/key-ensemble-decision-2026-07-04.md`). `agreement` (0-3) calibrates key confidence; `alternates` hedge the display. Never an override of `key`. | +| `bpmOctaveEvidence` | `object \| null` | **Full mode only.** Simple-ratio tempo-octave evidence (accuracy program PR-G3): `{candidates: [{bpm, ratio, lowbandScore, fullbandScore, score}], preferredBpm, preferredRatio, supportsShipped, dominance, lowbandIoiCount, fullbandIoiCount}`. Candidates (1:2, 2:3, 1:1, 3:2, 2:1 of the shipped `bpm`, clamped to 40-220) are scored against two inter-onset streams: the low-band (30-150 Hz kick) stream carries pulse articulation (integer multiples only, fit divided by the median multiple so the pulse the kick plays outranks one it aggregates), and the full-band stream adds the hat/snare grid (admits the 8th-note 0.5 multiple), which pins the notated tempo when the kick only marks bars (halftime). `score` = sum of both. `null` when there is no shipped bpm or too few onsets (sparse/beatless material). | object | Surfacing-only: never an override of `bpm` (invariant #1). `supportsShipped: false` with high `dominance` flags a contested octave — measured at the corpus extremes (190 halves, halftime-174 reads 2:3). Promotion into shipped `bpm` requires the pre-registered GiantSteps tempo gate (`plans/genre-generalization-program.md` PR-G3). | | `tuningFrequency` | `float \| null` | Estimated tuning reference frequency from spectral peak analysis. | Hz | Deviation from 440 Hz helps detect detuned material or concert-pitch variants. | | `tuningCents` | `float \| null` | Tuning offset from A440 in cents. | cents | Positive = sharp of A440, negative = flat. Useful for pitch-correcting reconstructions. | diff --git a/apps/backend/analyze.py b/apps/backend/analyze.py index 49ffbbf3..7cbe3e19 100644 --- a/apps/backend/analyze.py +++ b/apps/backend/analyze.py @@ -138,6 +138,7 @@ from analyze_rhythm import ( # noqa: E402 _extract_beat_loudness_data, _detect_onset_times, + analyze_bpm_octave_evidence, analyze_rhythm_detail, analyze_melody, analyze_groove, @@ -1586,6 +1587,9 @@ def main(): result = {} result.update(analyze_bpm(rhythm_data, mono, sample_rate)) + # Full-only, surfacing-only: octave/ratio candidates for the shipped bpm + # (accuracy PR-G3). Never overrides bpm — see analyze_bpm_octave_evidence. + result.update(analyze_bpm_octave_evidence(mono, sample_rate, result.get("bpm"))) result.update(analyze_key(mono)) result.update(analyze_time_signature(rhythm_data, mono=mono, sample_rate=sample_rate)) result.update(analyze_duration_and_sr(mono, sample_rate)) @@ -1900,6 +1904,8 @@ def main(): "bpmDoubletime": result.get("bpmDoubletime"), "bpmSource": result.get("bpmSource"), "bpmRawOriginal": result.get("bpmRawOriginal"), + # Full-only (like keyEnsemble): surfacing-only octave/ratio evidence. + "bpmOctaveEvidence": result.get("bpmOctaveEvidence"), "key": result.get("key"), "keyConfidence": result.get("keyConfidence"), "keyProfile": result.get("keyProfile"), diff --git a/apps/backend/analyze_rhythm.py b/apps/backend/analyze_rhythm.py index 112159df..5143350d 100644 --- a/apps/backend/analyze_rhythm.py +++ b/apps/backend/analyze_rhythm.py @@ -262,6 +262,173 @@ def compute_swing_detail( } +# Candidate tempo ratios for the octave-evidence field. 1:2/2:1 are the +# classic halving/doubling errors; 2:3/3:2 cover the measured halftime-at-174 +# failure mode (117 BPM = 174 x 2/3). Labels read "candidate:shipped". +_OCTAVE_RATIOS: tuple[tuple[str, float], ...] = ( + ("1:2", 0.5), + ("2:3", 2.0 / 3.0), + ("1:1", 1.0), + ("3:2", 1.5), + ("2:1", 2.0), +) +_OCTAVE_BPM_RANGE = (40.0, 220.0) +_OCTAVE_IOI_TOLERANCE_BEATS = 0.15 +_OCTAVE_MAX_BEAT_MULTIPLE = 8 + + +def _stream_octave_score( + iois: np.ndarray, beat_s: float, *, allow_half: bool +) -> float: + """Fit-over-economy score of one onset stream against one candidate beat. + + An IOI "fits" when it sits within tolerance of an allowed multiple of the + candidate beat — IOIs are local, so this is immune to the grid drift that + defeats absolute-grid alignment on near-miss candidates. Fit is divided + by the median fitted multiple (floored at 1) so a pulse the stream + actually articulates outranks one it merely aggregates: a kick on every + true beat scores 1.0 at the true tempo but 0.5 at half tempo. + + ``allow_half`` admits the 0.5 multiple (an 8th-note stream half a beat + apart). The low-band stream must NOT allow it — kicks articulate the + pulse or a slower division of it, and admitting 0.5 there would let a + half-tempo candidate re-explain every kick as 8ths and tie the truth. + """ + if iois.size < 4: + return 0.0 + multiples = iois / beat_s + nearest = np.round(multiples) + fits = ( + (nearest >= 1) + & (nearest <= _OCTAVE_MAX_BEAT_MULTIPLE) + & (np.abs(multiples - nearest) <= _OCTAVE_IOI_TOLERANCE_BEATS) + ) + if allow_half: + fits |= np.abs(multiples - 0.5) <= _OCTAVE_IOI_TOLERANCE_BEATS + if not np.any(fits): + return 0.0 + valid_fraction = float(np.count_nonzero(fits)) / float(iois.size) + fitted = np.where( + np.abs(multiples - 0.5) <= _OCTAVE_IOI_TOLERANCE_BEATS, 0.5, nearest + ) if allow_half else nearest + economy = float(np.median(fitted[fits])) + return valid_fraction / max(economy, 1.0) + + +def _score_octave_candidates( + lowband_iois: np.ndarray, + fullband_iois: np.ndarray, + shipped_bpm: float, +) -> list[dict] | None: + """Score simple-ratio tempo candidates against two inter-onset streams. + + The low-band (kick) stream carries pulse-articulation evidence; the + full-band stream adds the hat/snare grid, which is what pins the notated + tempo when the kick only marks bars (halftime: a kick every 4 beats reads + as a half-tempo pulse on its own, but the 8th-note hats fit the notated + beat's 0.5 multiple and nothing at the halved candidate). Score = + lowbandScore + fullbandScore, each in [0, 1]. + """ + low = np.asarray(lowband_iois, dtype=np.float64) + low = low[low > 0.05] + full = np.asarray(fullband_iois, dtype=np.float64) + full = full[full > 0.05] + if (low.size < 4 and full.size < 4) or shipped_bpm is None or shipped_bpm <= 0: + return None + + candidates: list[dict] = [] + for label, ratio in _OCTAVE_RATIOS: + candidate_bpm = float(shipped_bpm) * ratio + if not (_OCTAVE_BPM_RANGE[0] <= candidate_bpm <= _OCTAVE_BPM_RANGE[1]): + continue + beat_s = 60.0 / candidate_bpm + lowband_score = _stream_octave_score(low, beat_s, allow_half=False) + fullband_score = _stream_octave_score(full, beat_s, allow_half=True) + candidates.append({ + "bpm": round(candidate_bpm, 1), + "ratio": label, + "lowbandScore": round(lowband_score, 4), + "fullbandScore": round(fullband_score, 4), + "score": round(lowband_score + fullband_score, 4), + }) + + if not candidates: + return None + # Ties go to the shipped tempo (ratio distance from 1.0 breaks them). + candidates.sort(key=lambda c: (-c["score"], abs(_ratio_value(c["ratio"]) - 1.0))) + return candidates + + +def _ratio_value(label: str) -> float: + numerator, _colon, denominator = label.partition(":") + try: + return float(numerator) / float(denominator) + except (ValueError, ZeroDivisionError): + return 1.0 + + +def analyze_bpm_octave_evidence( + mono: np.ndarray, + sample_rate: int, + shipped_bpm: float | None, +) -> dict: + """Surfacing-only tempo-octave evidence (accuracy program PR-G3). + + Never touches ``bpm`` — the shipped value stays authoritative + (PURPOSE.md invariant #1). This field records how well each simple-ratio + alternative explains the low-band (kick) pulse, so the measured octave + failures at tempo extremes (174/190 halving, halftime 2:3) become + visible evidence instead of silent errors. Promotion of any correction + into the shipped ``bpm`` requires the pre-registered GiantSteps gate — + see plans/genre-generalization-program.md PR-G3. + + Full-mode only. Emits ``{"bpmOctaveEvidence": None}`` whenever the + evidence would be dishonest: no shipped BPM, too few low-band onsets + (sparse/beatless material), or filter/onset failure. + """ + try: + if shipped_bpm is None or not np.isfinite(float(shipped_bpm)) or float(shipped_bpm) <= 0: + return {"bpmOctaveEvidence": None} + + from dsp_bandbank import BatchedBandpass + + full_onsets = _detect_onset_times(mono, sample_rate) + low = BatchedBandpass(sample_rate).filter_one(mono, 30.0, 150.0) + low_onsets = ( + _detect_onset_times(low, sample_rate) + if low is not None + else np.asarray([], dtype=np.float64) + ) + if full_onsets.size < 5 and low_onsets.size < 5: + return {"bpmOctaveEvidence": None} + + low_iois = np.diff(np.sort(low_onsets)) if low_onsets.size >= 2 else np.asarray([]) + full_iois = np.diff(np.sort(full_onsets)) if full_onsets.size >= 2 else np.asarray([]) + candidates = _score_octave_candidates(low_iois, full_iois, float(shipped_bpm)) + if not candidates: + return {"bpmOctaveEvidence": None} + + top = candidates[0] + second_score = candidates[1]["score"] if len(candidates) > 1 else 0.0 + dominance = ( + round(top["score"] / second_score, 3) if second_score > 0 else None + ) + return { + "bpmOctaveEvidence": { + "candidates": candidates, + "preferredBpm": top["bpm"], + "preferredRatio": top["ratio"], + "supportsShipped": top["ratio"] == "1:1", + "dominance": dominance, + "lowbandIoiCount": int(low_iois[low_iois > 0.05].size), + "fullbandIoiCount": int(full_iois[full_iois > 0.05].size), + } + } + except Exception as e: + print(f"[warn] BPM octave evidence failed: {e}", file=sys.stderr) + return {"bpmOctaveEvidence": None} + + def analyze_rhythm_detail( mono: np.ndarray, sample_rate: int, diff --git a/apps/backend/fundamentals_evaluation.py b/apps/backend/fundamentals_evaluation.py index 6e82a91f..f06d1d7c 100644 --- a/apps/backend/fundamentals_evaluation.py +++ b/apps/backend/fundamentals_evaluation.py @@ -253,6 +253,19 @@ def _evaluate_expected( f"target>={threshold} actual={round(score, 4)}", )) + bpm_octave = expected.get("bpmOctave") + if isinstance(bpm_octave, (int, float)): + # Gates the surfacing-only bpmOctaveEvidence field (PR-G3), not the + # shipped bpm: does the true tempo win the candidate ranking? + tolerance = float(thresholds.get("octaveTolerance", 2.0)) + actual = _number(_nested_value(payload, "bpmOctaveEvidence.preferredBpm")) + passed = actual is not None and abs(actual - float(bpm_octave)) <= tolerance + checks.append(FundamentalsCheck( + "tempoOctave:preferredBpm", + passed, + f"target={bpm_octave} tolerance={tolerance} actual={actual}", + )) + swing = expected.get("swingPercent") if isinstance(swing, (int, float)): tolerance = float(thresholds.get("swingTolerance", 3.0)) diff --git a/apps/backend/scripts/build_synthetic_corpus.py b/apps/backend/scripts/build_synthetic_corpus.py index 09356176..e5c7a624 100755 --- a/apps/backend/scripts/build_synthetic_corpus.py +++ b/apps/backend/scripts/build_synthetic_corpus.py @@ -183,6 +183,7 @@ def render_grid_pattern( truth = { "bpm": bpm, + "bpmOctave": bpm, "timeSignature": meter, "beatGrid": [round(b * beat_s, 6) for b in range(total_beats)], "downbeats": [round(i * beats_per_bar * beat_s, 6) for i in range(bars)], @@ -227,6 +228,7 @@ def render_broken_grid( truth = { "bpm": bpm, + "bpmOctave": bpm, "timeSignature": "4/4", "beatGrid": [round(b * beat_s, 6) for b in range(total_beats)], "downbeats": [round(i * beats_per_bar * beat_s, 6) for i in range(bars)], @@ -496,7 +498,7 @@ def _render_spec(spec: dict[str, Any]) -> RenderedClip: # under the manifest entry's "truth" key (inert for the harness) so later PRs — # e.g. the swing measurement — can promote it to an active check deliberately. _EXPECTED_KEYS_BY_KIND: dict[str, tuple[str, ...]] = { - "grid": ("bpm", "timeSignature", "beatGrid", "downbeats"), + "grid": ("bpm", "bpmOctave", "timeSignature", "beatGrid", "downbeats"), "counts": ("bpm", "percussion"), # Swing clips: their swung "and" hats corrupt the meter autocorrelation, # so meter/beat checks stay off; BPM and swingPercent are the active checks. @@ -507,24 +509,25 @@ def _render_spec(spec: dict[str, Any]) -> RenderedClip: # Genre-generalization clips (accuracy program PR-G2): broken-kick # patterns share the grid checks; shuffle16 shares the swing checks # (its swung 16ths corrupt the meter autocorrelation the same way); - # ambient is key + abstention-honesty only. - "twostep": ("bpm", "timeSignature", "beatGrid", "downbeats"), - "halftime": ("bpm", "timeSignature", "beatGrid", "downbeats"), - "breakbeat": ("bpm", "timeSignature", "beatGrid", "downbeats"), + # ambient is key + abstention-honesty only. bpmOctave (PR-G3) gates the + # surfacing-only bpmOctaveEvidence field on every rhythm-kind clip. + "twostep": ("bpm", "bpmOctave", "timeSignature", "beatGrid", "downbeats"), + "halftime": ("bpm", "bpmOctave", "timeSignature", "beatGrid", "downbeats"), + "breakbeat": ("bpm", "bpmOctave", "timeSignature", "beatGrid", "downbeats"), "shuffle16": ("bpm", "swingPercent"), "ambient": ("key", "honesty"), } _THRESHOLDS_BY_KIND: dict[str, dict[str, Any]] = { - "grid": {"bpmTolerance": 1.0, "beatF1": 0.9, "downbeatF1": 0.75}, + "grid": {"bpmTolerance": 1.0, "beatF1": 0.9, "downbeatF1": 0.75, "octaveTolerance": 2.0}, "counts": {"bpmTolerance": 1.0, "percussionCountTolerance": 1}, "swing": {"bpmTolerance": 1.0, "swingTolerance": 3.0}, "chords": {"chordSegmentAccuracy": 0.65}, "multi": {"chordSegmentAccuracy": 0.45, "allowRelativeMajorMinor": True}, "bass": {"transcriptionNoteF1": 0.75}, - "twostep": {"bpmTolerance": 1.0, "beatF1": 0.9, "downbeatF1": 0.75}, - "halftime": {"bpmTolerance": 1.0, "beatF1": 0.9, "downbeatF1": 0.75}, - "breakbeat": {"bpmTolerance": 1.0, "beatF1": 0.9, "downbeatF1": 0.75}, + "twostep": {"bpmTolerance": 1.0, "beatF1": 0.9, "downbeatF1": 0.75, "octaveTolerance": 2.0}, + "halftime": {"bpmTolerance": 1.0, "beatF1": 0.9, "downbeatF1": 0.75, "octaveTolerance": 2.0}, + "breakbeat": {"bpmTolerance": 1.0, "beatF1": 0.9, "downbeatF1": 0.75, "octaveTolerance": 2.0}, "shuffle16": {"bpmTolerance": 1.0, "swingTolerance": 3.0}, "ambient": {}, } @@ -539,8 +542,11 @@ def _render_spec(spec: dict[str, Any]) -> RenderedClip: # as 4/4, and the bar-1 phase depends on the meter, so downbeats follow. "grid_3_4_90": ["meter:timeSignature", "downbeats:f1"], "grid_6_8_110": ["meter:timeSignature", "downbeats:f1"], - # 7/8 additionally confuses the tempo estimate (142.6 vs 140). - "grid_7_8_140": ["meter:timeSignature", "downbeats:f1", "tempo:bpm"], + # 7/8 additionally confuses the tempo estimate (142.6 vs 140). The + # octave-evidence check also stays informational here: the shipped bpm + # is smeared (a meter artifact, PR-G4's problem), not octave-wrong, so + # no simple ratio of 142.6 can land on 140. + "grid_7_8_140": ["meter:timeSignature", "downbeats:f1", "tempo:bpm", "tempoOctave:preferredBpm"], # RhythmExtractor halves 174 BPM to 86.9 (octave preference); the beat # grid and downbeats still score >= 0.87 against truth. "grid_4_4_174": ["tempo:bpm"], diff --git a/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json b/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json index c4e16411..4edffe1b 100644 --- a/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json +++ b/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json @@ -15,6 +15,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 128.0, + "bpmOctave": 128.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -64,7 +65,8 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 } }, { @@ -225,6 +227,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 70.0, + "bpmOctave": 70.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -274,7 +277,8 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 } }, { @@ -284,6 +288,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 85.0, + "bpmOctave": 85.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -333,7 +338,8 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 } }, { @@ -343,6 +349,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 90.0, + "bpmOctave": 90.0, "timeSignature": "3/4", "beatGrid": [ 0.0, @@ -384,7 +391,8 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 }, "knownGaps": [ "meter:timeSignature", @@ -398,6 +406,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 110.0, + "bpmOctave": 110.0, "timeSignature": "6/8", "beatGrid": [ 0.0, @@ -463,7 +472,8 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 }, "knownGaps": [ "meter:timeSignature", @@ -477,6 +487,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 128.0, + "bpmOctave": 128.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -526,7 +537,8 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 } }, { @@ -536,6 +548,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 140.0, + "bpmOctave": 140.0, "timeSignature": "7/8", "beatGrid": [ 0.0, @@ -609,12 +622,14 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 }, "knownGaps": [ "meter:timeSignature", "downbeats:f1", - "tempo:bpm" + "tempo:bpm", + "tempoOctave:preferredBpm" ] }, { @@ -624,6 +639,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 150.0, + "bpmOctave": 150.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -673,7 +689,8 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 } }, { @@ -683,6 +700,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 174.0, + "bpmOctave": 174.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -732,7 +750,8 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 }, "knownGaps": [ "tempo:bpm" @@ -745,6 +764,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 190.0, + "bpmOctave": 190.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -794,7 +814,8 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 }, "knownGaps": [ "tempo:bpm", @@ -1260,6 +1281,7 @@ "swingTolerance": 3.0 }, "truth": { + "bpmOctave": 124.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -1357,6 +1379,7 @@ "swingTolerance": 3.0 }, "truth": { + "bpmOctave": 124.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -1454,6 +1477,7 @@ "swingTolerance": 3.0 }, "truth": { + "bpmOctave": 124.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -1551,6 +1575,7 @@ "swingTolerance": 3.0 }, "truth": { + "bpmOctave": 124.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -1648,6 +1673,7 @@ "swingTolerance": 3.0 }, "truth": { + "bpmOctave": 124.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -1822,6 +1848,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 132.0, + "bpmOctave": 132.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -1871,7 +1898,8 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 }, "truth": { "hitTimes": { @@ -1921,6 +1949,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 140.0, + "bpmOctave": 140.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -1970,7 +1999,8 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 }, "truth": { "hitTimes": { @@ -2004,6 +2034,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 174.0, + "bpmOctave": 174.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -2053,7 +2084,8 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 }, "truth": { "hitTimes": { @@ -2092,6 +2124,7 @@ "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", "expected": { "bpm": 136.0, + "bpmOctave": 136.0, "timeSignature": "4/4", "beatGrid": [ 0.0, @@ -2141,7 +2174,8 @@ "thresholds": { "bpmTolerance": 1.0, "beatF1": 0.9, - "downbeatF1": 0.75 + "downbeatF1": 0.75, + "octaveTolerance": 2.0 }, "truth": { "hitTimes": { diff --git a/apps/backend/tests/fixtures/golden/phase1_default.json b/apps/backend/tests/fixtures/golden/phase1_default.json index 702f1da9..d869da74 100644 --- a/apps/backend/tests/fixtures/golden/phase1_default.json +++ b/apps/backend/tests/fixtures/golden/phase1_default.json @@ -8,7 +8,7 @@ "lufsIntegrated": -5.6, "lufsMomentaryMax": -4.8, "lufsRange": 0.8, - "lufsShortTermMax": -5.2, + "lufsShortTermMax": -5.1, "monoCompatible": true, "plr": 5.6, "sampleRate": 44100, @@ -68,6 +68,7 @@ "bpmAgreement": "bool", "bpmConfidence": "number", "bpmDoubletime": "bool", + "bpmOctaveEvidence": "null", "bpmPercival": "number", "bpmRawOriginal": "number", "bpmSource": "str", @@ -572,6 +573,7 @@ "bpmAgreement", "bpmConfidence", "bpmDoubletime", + "bpmOctaveEvidence", "bpmPercival", "bpmRawOriginal", "bpmSource", @@ -643,6 +645,7 @@ "bpmAgreement": "bool", "bpmConfidence": "number", "bpmDoubletime": "bool", + "bpmOctaveEvidence": "null", "bpmPercival": "number", "bpmRawOriginal": "number", "bpmSource": "str", diff --git a/apps/backend/tests/test_analyze.py b/apps/backend/tests/test_analyze.py index 877373f6..793ee77f 100644 --- a/apps/backend/tests/test_analyze.py +++ b/apps/backend/tests/test_analyze.py @@ -408,7 +408,8 @@ def test_output_schema_matches_full_mode(self) -> None: """Fast mode must emit exactly the shared top-level key set (EXPECTED_TOP_LEVEL_KEYS). Full mode emits these keys *plus* a few detail-only fields (keyProfile, - tuningFrequency, tuningCents, lufsMomentaryMax, lufsShortTermMax, pitchDetail), so + tuningFrequency, tuningCents, lufsMomentaryMax, lufsShortTermMax, pitchDetail, + timeSignatureCandidates, keyEnsemble, bpmOctaveEvidence), so this asserts the shared contract — not a byte-for-byte match with full mode. """ self.assertEqual( diff --git a/apps/backend/tests/test_bpm_octave_evidence.py b/apps/backend/tests/test_bpm_octave_evidence.py new file mode 100644 index 00000000..fc4fab34 --- /dev/null +++ b/apps/backend/tests/test_bpm_octave_evidence.py @@ -0,0 +1,96 @@ +import unittest + +import numpy as np + +from analyze_rhythm import _score_octave_candidates + + +def _iois(true_bpm: float, beats_per_hit: float, count: int) -> np.ndarray: + """IOIs of a stream that fires every `beats_per_hit` beats of the true tempo.""" + interval = 60.0 / true_bpm * beats_per_hit + return np.full(count, interval, dtype=np.float64) + + +_EMPTY = np.asarray([], dtype=np.float64) + + +class ScoreOctaveCandidatesTests(unittest.TestCase): + def test_halved_shipped_bpm_prefers_the_doubled_candidate(self) -> None: + # grid_4_4_190 baseline: kick on every 190-beat, shipped bpm 95. + # At 95 every kick IOI is half a beat (no allowed low-band fit); at + # 190 it fits at multiple 1 — the 2:1 candidate must win. + low = _iois(190.0, 1.0, 30) + full = _iois(190.0, 0.5, 60) # 8th hats + candidates = _score_octave_candidates(low, full, 95.0) + self.assertIsNotNone(candidates) + self.assertEqual(candidates[0]["ratio"], "2:1") + self.assertAlmostEqual(candidates[0]["bpm"], 190.0, delta=0.1) + + def test_true_half_tempo_keeps_the_shipped_reading(self) -> None: + # A genuine 87 BPM kick-on-every-beat groove shipped at 87: the 2:1 + # candidate (174) also explains every IOI but only as multiples of 2 + # on the low band, so economy must keep 1:1 on top. + low = _iois(87.0, 1.0, 30) + full = _iois(87.0, 0.5, 60) + candidates = _score_octave_candidates(low, full, 87.0) + self.assertEqual(candidates[0]["ratio"], "1:1") + doubled = next(c for c in candidates if c["ratio"] == "2:1") + self.assertGreater(candidates[0]["score"], doubled["score"]) + + def test_two_thirds_error_recovers_the_notated_tempo(self) -> None: + # halftime_174 baseline: kick once per bar (every 4 beats of 174), + # 8th hats on the notated grid, shipped bpm 116 (the measured 2:3 + # error). The hats fit nothing at 116 but fit the 3:2 candidate's + # 0.5 multiple exactly. + low = _iois(174.0, 4.0, 12) + full = _iois(174.0, 0.5, 100) + candidates = _score_octave_candidates(low, full, 116.0) + self.assertEqual(candidates[0]["ratio"], "3:2") + self.assertAlmostEqual(candidates[0]["bpm"], 174.0, delta=0.1) + + def test_correct_halftime_shipping_is_not_contradicted(self) -> None: + # halftime at 140 shipped CORRECTLY at 140: the kick alone (one hit + # per bar = a clean 70 BPM pulse at multiple 2) would prefer 1:2 — + # the full-band 8th-hat stream must rescue the shipped reading. + # This is the false-alarm case that motivated two-stream scoring. + low = _iois(140.0, 4.0, 12) + full = _iois(140.0, 0.5, 100) + candidates = _score_octave_candidates(low, full, 140.0) + self.assertEqual(candidates[0]["ratio"], "1:1") + halved = next(c for c in candidates if c["ratio"] == "1:2") + self.assertGreater(candidates[0]["score"], halved["score"]) + + def test_lowband_only_still_ranks_articulated_pulse_first(self) -> None: + # No usable full-band stream: pure kick evidence must still prefer + # the articulated pulse over its aggregations. + candidates = _score_octave_candidates(_iois(190.0, 1.0, 30), _EMPTY, 95.0) + self.assertEqual(candidates[0]["ratio"], "2:1") + self.assertEqual(candidates[0]["fullbandScore"], 0.0) + + def test_ties_break_toward_the_shipped_tempo(self) -> None: + # IOIs that fit nothing leave every candidate at score 0.0 — + # the shipped 1:1 reading must sort first rather than a random ratio. + iois = np.full(10, 0.777, dtype=np.float64) * np.pi + candidates = _score_octave_candidates(iois, _EMPTY, 130.0) + if {c["score"] for c in candidates} == {0.0}: + self.assertEqual(candidates[0]["ratio"], "1:1") + + def test_candidates_outside_plausible_range_are_dropped(self) -> None: + candidates = _score_octave_candidates(_iois(190.0, 1.0, 30), _EMPTY, 190.0) + ratios = {c["ratio"] for c in candidates} + self.assertNotIn("2:1", ratios) # 380 BPM is outside [40, 220] + self.assertIn("1:2", ratios) + + def test_too_few_iois_abstains(self) -> None: + self.assertIsNone( + _score_octave_candidates(_iois(120.0, 1.0, 3), _iois(120.0, 1.0, 2), 120.0) + ) + + def test_missing_shipped_bpm_abstains(self) -> None: + self.assertIsNone( + _score_octave_candidates(_iois(120.0, 1.0, 30), _EMPTY, 0.0) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/ui/src/services/backendPhase1Client.ts b/apps/ui/src/services/backendPhase1Client.ts index 3a6bab1d..6f4b6297 100644 --- a/apps/ui/src/services/backendPhase1Client.ts +++ b/apps/ui/src/services/backendPhase1Client.ts @@ -588,6 +588,9 @@ export function parsePhase1Result(value: unknown): Phase1Result { bpmDoubletime: phase1.bpmDoubletime === true ? true : phase1.bpmDoubletime === false ? false : null, bpmSource: typeof phase1.bpmSource === "string" ? phase1.bpmSource : null, bpmRawOriginal: toNumber(phase1.bpmRawOriginal), + bpmOctaveEvidence: isRecord(phase1.bpmOctaveEvidence) + ? (phase1.bpmOctaveEvidence as unknown as Phase1Result["bpmOctaveEvidence"]) + : null, key: expectNullableString(phase1, "key"), keyConfidence: expectNumber(phase1, "keyConfidence"), keyProfile: toOptionalStringOrNull(phase1.keyProfile), diff --git a/apps/ui/src/types/measurement.ts b/apps/ui/src/types/measurement.ts index 7cef0040..02a6dd15 100644 --- a/apps/ui/src/types/measurement.ts +++ b/apps/ui/src/types/measurement.ts @@ -685,6 +685,36 @@ export interface KeyEnsemble { alternates: Array<{ key: string; strength: number }>; } +/** + * One simple-ratio tempo candidate scored against the low-band (kick) and + * full-band inter-onset streams (accuracy program PR-G3). Full mode only. + * Surfacing-only — never an override of the shipped `bpm`. + */ +export interface BpmOctaveCandidate { + bpm: number; + /** Candidate:shipped ratio label, e.g. "2:1", "3:2", "1:1". */ + ratio: string; + /** Kick-pulse articulation evidence, 0-1. */ + lowbandScore: number; + /** Hat/snare-grid evidence (admits the 8th-note 0.5 multiple), 0-1. */ + fullbandScore: number; + /** lowbandScore + fullbandScore. */ + score: number; +} + +export interface BpmOctaveEvidence { + /** Candidates sorted by score, strongest first. */ + candidates: BpmOctaveCandidate[]; + preferredBpm: number; + preferredRatio: string; + /** True when the shipped bpm's own 1:1 candidate wins the ranking. */ + supportsShipped: boolean; + /** Top score / runner-up score; null when the runner-up scored 0. */ + dominance: number | null; + lowbandIoiCount: number; + fullbandIoiCount: number; +} + export type FundamentalsQualityStatus = "authoritative" | "ambiguous" | "failed" | "not_run"; export interface FundamentalsQualityDomain { @@ -718,6 +748,12 @@ export interface Phase1Result { bpmDoubletime?: boolean | null; bpmSource?: string | null; bpmRawOriginal?: number | null; + /** + * Full mode only. Simple-ratio octave/tempo candidates scored against the + * low-band pulse (accuracy program PR-G3). Evidence surface — never an + * override of `bpm`. + */ + bpmOctaveEvidence?: BpmOctaveEvidence | null; key: string | null; keyConfidence: number; keyProfile?: string | null; diff --git a/apps/ui/tests/fixtures/phase1FullPayload.ts b/apps/ui/tests/fixtures/phase1FullPayload.ts index 74887542..88454ecf 100644 --- a/apps/ui/tests/fixtures/phase1FullPayload.ts +++ b/apps/ui/tests/fixtures/phase1FullPayload.ts @@ -137,6 +137,19 @@ export const phase1EnvelopeFixture = { bpmConfidence: 0.98, bpmPercival: 127.5, bpmAgreement: true, + bpmOctaveEvidence: { + candidates: [ + { bpm: 128, ratio: '1:1', lowbandScore: 1.0, fullbandScore: 1.0, score: 2.0 }, + { bpm: 192, ratio: '3:2', lowbandScore: 0.2, fullbandScore: 0.1, score: 0.3 }, + { bpm: 64, ratio: '1:2', lowbandScore: 0.5, fullbandScore: 0.0, score: 0.5 }, + ], + preferredBpm: 128, + preferredRatio: '1:1', + supportsShipped: true, + dominance: 4.0, + lowbandIoiCount: 31, + fullbandIoiCount: 63, + }, key: 'A minor', keyConfidence: 0.91, keyProfile: 'edma', From 2e6b389001b3a1b15a2cd05632f062e03e2eeabb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 06:05:52 +0000 Subject: [PATCH 2/5] fix(ci): wire bpmOctaveEvidence into server_phase1 forwarder and audio-fixture key set Two omissions from PR-G3 that CI caught on the rebased branch: - server_phase1._build_phase1 did not forward bpmOctaveEvidence to the frontend (test_phase1_golden.BuildPhase1KeySupersetTests caught it) - EXPECTED_TOP_LEVEL_KEYS in test_audio_fixture.py did not include it (AudioFixtureSmokeTest.test_output_contains_all_expected_keys caught it) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01NCtxwnhn8nzHBjzeehPtf8 --- apps/backend/server_phase1.py | 1 + apps/backend/tests/test_audio_fixture.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/server_phase1.py b/apps/backend/server_phase1.py index 368cc830..facba594 100644 --- a/apps/backend/server_phase1.py +++ b/apps/backend/server_phase1.py @@ -193,6 +193,7 @@ def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]: "bpmDoubletime": payload.get("bpmDoubletime"), "bpmSource": payload.get("bpmSource"), "bpmRawOriginal": _coerce_nullable_number(payload.get("bpmRawOriginal")), + "bpmOctaveEvidence": payload.get("bpmOctaveEvidence"), "key": _coerce_nullable_string(payload.get("key")), "keyConfidence": _coerce_number(payload.get("keyConfidence")), "keyProfile": payload.get("keyProfile"), diff --git a/apps/backend/tests/test_audio_fixture.py b/apps/backend/tests/test_audio_fixture.py index 265c4b37..6b2368d7 100644 --- a/apps/backend/tests/test_audio_fixture.py +++ b/apps/backend/tests/test_audio_fixture.py @@ -13,7 +13,7 @@ "phase1Version", "fundamentalsQuality", "bpm", "bpmConfidence", "bpmPercival", "bpmAgreement", - "bpmDoubletime", "bpmSource", "bpmRawOriginal", + "bpmDoubletime", "bpmSource", "bpmRawOriginal", "bpmOctaveEvidence", "key", "keyConfidence", "keyProfile", "keyEnsemble", "tuningFrequency", "tuningCents", "timeSignature", "timeSignatureSource", "timeSignatureConfidence", "timeSignatureCandidates", From ecd7947159de339a5d766eb7278322a3b587b63a Mon Sep 17 00:00:00 2001 From: assiduous repetition Date: Mon, 13 Jul 2026 18:35:40 +1200 Subject: [PATCH 3/5] feat(meter): loudness-accent stream for meter + downbeats on non-4/4 (accuracy PR-G4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onset-count accent stream is loudness-blind: a kick on every beat yields identical counts at every bar position regardless of which beat is accented — measured on the odd-meter corpus clips as 3/4 edging 4/4 by 0.8%, shipping 4/4 at confidence 0.03. analyze_time_signature now folds the per-beat low-band loudness (beat_data, hoisted above the meter call) at each candidate bar length as a second accent stream: max-over-phase dominance, clamped at 10, neutral under 4 bars, harmonic folds collapsed by echo PROMINENCE (0.262 true-3/4 vs 0.000 true-6/8 measured; raw echo/peak is ~0.72 for both). Decision runs on the count x loudness product with a 15% override margin when loudness contributed; count-only callers (including --fast) keep the 20% margin byte-identical. Burn-down: 3/4, 6/8, 7/8 meter AND downbeats (F1 0.875 each) flip from knownGaps to gating checks; all ten 4/4 rhythm clips (incl. broken-kick 2-step/halftime/breakbeat) unchanged. Remaining gaps are all tempo-family. timeSignatureCandidates entries gain an additive loudnessDominance key, landed on both sides; golden passes without re-baseline. Co-Authored-By: Claude Fable 5 --- apps/backend/JSON_SCHEMA.md | 2 +- apps/backend/analyze.py | 16 +- apps/backend/analyze_core.py | 140 ++++++++++++++++-- .../backend/scripts/build_synthetic_corpus.py | 15 +- .../fundamentals_eval_manifest.synthetic.json | 14 +- .../tests/test_time_signature_accent.py | 61 ++++++++ apps/ui/src/services/backendPhase1Client.ts | 4 +- apps/ui/src/types/measurement.ts | 5 + apps/ui/tests/fixtures/phase1FullPayload.ts | 4 +- .../services/backendPhase1Client.test.ts | 8 +- 10 files changed, 225 insertions(+), 44 deletions(-) create mode 100644 apps/backend/tests/test_time_signature_accent.py diff --git a/apps/backend/JSON_SCHEMA.md b/apps/backend/JSON_SCHEMA.md index 6b2f55e6..7df58dbc 100644 --- a/apps/backend/JSON_SCHEMA.md +++ b/apps/backend/JSON_SCHEMA.md @@ -158,7 +158,7 @@ Current server behavior that affects schema expectations: | `timeSignature` | `string \| null` | Time signature estimate (currently defaults to `"4/4"` when rhythm exists). | string | Treat as prior; verify manually on odd-metre material. | | `timeSignatureSource` | `string \| null` | Provenance marker for the raw `timeSignature` value. Current analyzer emits `"assumed_four_four"` when rhythm data exists. | categorical | Forwarded through HTTP `phase1`; use it to distinguish measured vs assumed meter. | | `timeSignatureConfidence` | `float \| null` | Confidence attached to the raw `timeSignature` value. Current analyzer emits `0.0` for the assumed 4/4 fallback. | 0-1 | Forwarded through HTTP `phase1`; use low values to avoid overstating meter certainty. | -| `timeSignatureCandidates` | `array` | **Full mode only.** Per-candidate meter evidence from the onset-accent autocorrelation, strongest first: `{timeSignature, dominance, positionMeans[]}` for each scoreable bar length (3/4, 4/4, 5/4, 6/8, 7/8). `dominance` = mean accent at bar position 1 over the mean of the other positions; `positionMeans` = the per-position onset-count means the dominance was computed from. Empty `[]` on every fallback branch. | list | Additive evidence surface (accuracy program PR-B1): meter improvements and `fundamentalsQuality.domains.meter.evidence` read it instead of recomputing; downstream must never treat a candidate as an override of `timeSignature`. | +| `timeSignatureCandidates` | `array` | **Full mode only.** Per-candidate meter evidence from the onset-accent autocorrelation, strongest first: `{timeSignature, dominance, loudnessDominance, positionMeans[]}` for each scoreable bar length (3/4, 4/4, 5/4, 6/8, 7/8). `dominance` = mean onset count at bar position 1 over the mean of the other positions; `loudnessDominance` (PR-G4) = the per-beat low-band loudness folded at this bar length, maximized over phase and clamped to 10 (1.0 = neutral; harmonic folds — e.g. a 3-periodic accent scored at bar 6 — are neutralized); `positionMeans` = the per-position onset-count means. The shipped `timeSignature` decision runs on `dominance x loudnessDominance` with a 15% override margin when loudness contributed (20% count-only otherwise). Empty `[]` on every fallback branch. | list | Evidence surface (accuracy programs PR-B1/PR-G4): meter improvements and `fundamentalsQuality.domains.meter.evidence` read it instead of recomputing; downstream must never treat a candidate as an override of `timeSignature`. | | `durationSeconds` | `float \| null` | Track duration from sample count. | seconds | Useful for arrangement section planning and timeline mapping. | | `sampleRate` | `int \| null` | Effective analysis sample rate. | Hz | Ensures downstream feature interpretation uses correct temporal/frequency scaling. | | `keyProfile` | `string \| null` | Key profile used by `KeyExtractor` (e.g. `"edma"`). | categorical | Indicates which pitch template corpus was used for key detection. | diff --git a/apps/backend/analyze.py b/apps/backend/analyze.py index 7cbe3e19..6e975285 100644 --- a/apps/backend/analyze.py +++ b/apps/backend/analyze.py @@ -1591,7 +1591,15 @@ def main(): # (accuracy PR-G3). Never overrides bpm — see analyze_bpm_octave_evidence. result.update(analyze_bpm_octave_evidence(mono, sample_rate, result.get("bpm"))) result.update(analyze_key(mono)) - result.update(analyze_time_signature(rhythm_data, mono=mono, sample_rate=sample_rate)) + # Shared beat-domain loudness data, extracted once and reused by meter + # detection (PR-G4 loudness-accent stream), rhythm detail, groove, and + # sidechain below. Hoisted above analyze_time_signature deliberately. + beat_data = _extract_beat_loudness_data(mono, sample_rate, rhythm_data) + result.update( + analyze_time_signature( + rhythm_data, mono=mono, sample_rate=sample_rate, beat_data=beat_data + ) + ) result.update(analyze_duration_and_sr(mono, sample_rate)) # LUFS + LRA (needs stereo at its native sample rate — load_stereo does @@ -1647,11 +1655,9 @@ def main(): ) result.update(analyze_plr(result.get("lufsIntegrated"), result.get("truePeak"))) - # Shared beat-domain loudness data used by rhythm detail + groove + sidechain. - beat_data = _extract_beat_loudness_data(mono, sample_rate, rhythm_data) - # Rhythm detail — real meter-aware downbeats derived from the per-beat - # kick-accent pattern (beat_data) and the detected time signature. + # kick-accent pattern (beat_data, extracted above) and the detected + # time signature. result.update( analyze_rhythm_detail( mono, diff --git a/apps/backend/analyze_core.py b/apps/backend/analyze_core.py index f7e85218..820ba428 100644 --- a/apps/backend/analyze_core.py +++ b/apps/backend/analyze_core.py @@ -1287,12 +1287,93 @@ def analyze_duration_and_sr(mono: np.ndarray, sample_rate: int = 44100) -> dict: _TIME_SIG_BAR_CANDIDATES = (3, 4, 5, 6, 7) _TIME_SIG_LABELS = {3: "3/4", 4: "4/4", 5: "5/4", 6: "6/8", 7: "7/8"} _TIME_SIG_MARGIN_THRESHOLD = 0.20 # winner must beat 4/4 by 20% to override +# With the loudness-accent stream contributing (PR-G4), the combined evidence +# is richer than onset counts alone, so the override margin relaxes slightly. +# Calibrated on the synthetic corpus: 6/8's measured combined margin is ~17%, +# and every true-4/4 clip wins outright (no margin test at all). +_TIME_SIG_ACCENT_MARGIN_THRESHOLD = 0.15 +_TIME_SIG_LOUDNESS_MIN_BARS = 4 # folds with fewer bars are noise, stay neutral +_TIME_SIG_LOUDNESS_DOMINANCE_CAP = 10.0 # near-silent off-beat positions explode the ratio + + +def _loudness_meter_dominance(low_band: np.ndarray | None, bar_length: int) -> float: + """Downbeat dominance of the per-beat low-band loudness folded at bar_length. + + The onset-count accent stream is loudness-blind: a kick on every beat + yields identical counts at every bar position no matter which beat is + accented (the measured failure on the odd-meter corpus clips). Folding + the kick-band loudness recovers the accent. Maximized over bar phase + (unlike the count stream, which trusts tick 1) because the loudness + fold is about periodicity, not phase — the downbeat phase is resolved + separately by ``_compute_downbeat_phase``. + + Returns 1.0 (neutral — no evidence either way) when the signal is + missing, too short (< _TIME_SIG_LOUDNESS_MIN_BARS bars), or degenerate. + Clamped to _TIME_SIG_LOUDNESS_DOMINANCE_CAP: a near-silent off-beat + position (broken-kick patterns) makes the ratio arbitrarily large + without carrying more meter information than "clearly dominant". + """ + if low_band is None: + return 1.0 + arr = np.asarray(low_band, dtype=np.float64) + arr = np.where(np.isfinite(arr), arr, 0.0) + n_bars = arr.size // bar_length + if bar_length < 2 or n_bars < _TIME_SIG_LOUDNESS_MIN_BARS: + return 1.0 + folded = arr[: n_bars * bar_length].reshape(n_bars, bar_length).mean(axis=0) + best = 1.0 + for position in range(bar_length): + others_mean = float(np.mean(np.delete(folded, position))) + if others_mean <= 0.0: + # Off-positions are dead silent: maximal dominance if this + # position carries anything at all (synthetic-clean case). + if float(folded[position]) > 0.0: + best = _TIME_SIG_LOUDNESS_DOMINANCE_CAP + continue + best = max(best, float(folded[position]) / others_mean) + return float(min(best, _TIME_SIG_LOUDNESS_DOMINANCE_CAP)) + + +_TIME_SIG_HARMONIC_ECHO_PROMINENCE = 0.15 + + +def _is_harmonic_of_shorter_bar( + low_band: np.ndarray | None, bar_length: int, divisor: int +) -> bool: + """True when the accent profile at bar_length is really divisor-periodic. + + A 3/4 accent pattern folded at 6 repeats its accent at the divisor + offset — the fold scores high but describes the shorter bar twice, not + a 6-beat bar. A genuine 6/8 fold accents one position only. The test is + the echo's PROMINENCE — how far the position at (peak + divisor) rises + above the remaining positions, relative to the peak's own rise — not + its raw ratio to the peak: baseline noise puts echo/peak near 0.7 for + BOTH cases, while prominence measures 0.26 (true 3/4) vs 0.00 (true + 6/8) on the corpus clips. + """ + if low_band is None or divisor <= 1 or bar_length % divisor != 0: + return False + arr = np.asarray(low_band, dtype=np.float64) + arr = np.where(np.isfinite(arr), arr, 0.0) + n_bars = arr.size // bar_length + if n_bars < _TIME_SIG_LOUDNESS_MIN_BARS: + return False + folded = arr[: n_bars * bar_length].reshape(n_bars, bar_length).mean(axis=0) + peak_position = int(np.argmax(folded)) + echo_position = (peak_position + divisor) % bar_length + baseline = float(np.mean(np.delete(folded, [peak_position, echo_position]))) + peak_rise = float(folded[peak_position]) - baseline + if peak_rise <= 0.0: + return False + echo_rise = float(folded[echo_position]) - baseline + return echo_rise / peak_rise >= _TIME_SIG_HARMONIC_ECHO_PROMINENCE def analyze_time_signature( rhythm_data: dict | None, mono: np.ndarray | None = None, sample_rate: int = 44100, + beat_data: dict | None = None, ) -> dict: """Phase 1.C #0 — onset-accent autocorrelation for meter detection. @@ -1305,8 +1386,18 @@ def analyze_time_signature( 3. For each candidate bar length B in (3, 4, 5, 6, 7), reshape the per-beat onset counts into bars of B and compute "downbeat dominance" = mean accent at position 1 / mean accent at positions 2..B. - 4. The candidate with the highest dominance wins, but only overrides 4/4 - when it beats 4/4's dominance by ``_TIME_SIG_MARGIN_THRESHOLD`` (20%). + 4. (PR-G4) When ``beat_data`` is provided, fold its per-beat low-band + loudness at each B for a second, loudness-aware accent stream + (``_loudness_meter_dominance``) — onset counts alone are blind to a + kick that plays every beat but accents the downbeat, the measured + odd-meter failure. Harmonics collapse (a 3-periodic accent folded at + 6 scores high but is not a 6-beat bar), and the decision runs on the + product of both streams. + 5. The candidate with the highest combined score wins, but only + overrides 4/4 by ``_TIME_SIG_ACCENT_MARGIN_THRESHOLD`` (15%) when the + loudness stream contributed, or the original + ``_TIME_SIG_MARGIN_THRESHOLD`` (20%) count-only margin otherwise — + legacy callers without beat_data (including --fast) are byte-identical. Falls back cleanly to the previous "assumed 4/4" behavior when: - rhythm_data is missing @@ -1315,10 +1406,11 @@ def analyze_time_signature( - onset detection fails Also emits ``timeSignatureCandidates`` — the per-candidate accent evidence - (dominance + per-bar-position onset means) that was previously computed - and discarded. Additive, full-mode-only at the top level; downstream meter - work (and fundamentalsQuality's meter evidence) reads it instead of - recomputing. Empty list on every fallback branch. + (dominance + per-bar-position onset means, plus ``loudnessDominance``) + that was previously computed and discarded. Additive, full-mode-only at + the top level; downstream meter work (and fundamentalsQuality's meter + evidence) reads it instead of recomputing. Empty list on every fallback + branch. """ def _result( @@ -1401,19 +1493,40 @@ def _result( # We couldn't even score 4/4 — fall back to the assumption. return _result("4/4", "assumed_four_four", 0.0) + # PR-G4: fold the per-beat low-band loudness (when the caller has it) + # into a second accent stream. Harmonic folds collapse to neutral — + # their evidence belongs to the shorter bar they repeat. + low_band = ( + np.asarray(beat_data.get("lowBand", []), dtype=np.float64) + if isinstance(beat_data, dict) + else None + ) + loudness_dominance: dict[int, float] = {} + for B in scores: + dominance = _loudness_meter_dominance(low_band, B) + if dominance > 1.0 and any( + d in scores and d < B and B % d == 0 and _is_harmonic_of_shorter_bar(low_band, B, d) + for d in scores + ): + dominance = 1.0 + loudness_dominance[B] = dominance + loudness_contributed = any(v != 1.0 for v in loudness_dominance.values()) + combined = {B: scores[B] * loudness_dominance[B] for B in scores} + # Surface the previously discarded evidence, strongest first. candidates = [ { "timeSignature": _TIME_SIG_LABELS.get(B, f"{B}/4"), "dominance": round(float(scores[B]), 3), + "loudnessDominance": round(float(loudness_dominance[B]), 3), "positionMeans": per_position_means[B], } - for B in sorted(scores, key=scores.get, reverse=True) + for B in sorted(combined, key=combined.get, reverse=True) ] - baseline = scores[4] - best_B = max(scores, key=scores.get) - best_score = scores[best_B] + baseline = combined[4] + best_B = max(combined, key=combined.get) + best_score = combined[best_B] winner_label = _TIME_SIG_LABELS.get(best_B, "4/4") if best_B == 4: @@ -1422,8 +1535,13 @@ def _result( return _result("4/4", "onset_autocorrelation", round(float(confidence), 2), candidates) # Non-4/4 candidate won. Require a margin over 4/4 to override. + margin_threshold = ( + _TIME_SIG_ACCENT_MARGIN_THRESHOLD + if loudness_contributed + else _TIME_SIG_MARGIN_THRESHOLD + ) margin = (best_score - baseline) / max(baseline, 0.1) - if margin < _TIME_SIG_MARGIN_THRESHOLD: + if margin < margin_threshold: confidence = max(0.0, min(1.0, (baseline - 1.0) / 2.0)) return _result("4/4", "onset_autocorrelation_low_margin", round(float(confidence), 2), candidates) diff --git a/apps/backend/scripts/build_synthetic_corpus.py b/apps/backend/scripts/build_synthetic_corpus.py index e5c7a624..2ebc9e33 100755 --- a/apps/backend/scripts/build_synthetic_corpus.py +++ b/apps/backend/scripts/build_synthetic_corpus.py @@ -538,15 +538,12 @@ def _render_spec(spec: dict[str, Any]) -> RenderedClip: # upgrades land. Only checks that actually fail at baseline belong here; the # rest of each clip's checks gate normally. _KNOWN_GAPS_BY_ID: dict[str, list[str]] = { - # analyze_time_signature's 20%-margin conservatism reads every odd meter - # as 4/4, and the bar-1 phase depends on the meter, so downbeats follow. - "grid_3_4_90": ["meter:timeSignature", "downbeats:f1"], - "grid_6_8_110": ["meter:timeSignature", "downbeats:f1"], - # 7/8 additionally confuses the tempo estimate (142.6 vs 140). The - # octave-evidence check also stays informational here: the shipped bpm - # is smeared (a meter artifact, PR-G4's problem), not octave-wrong, so - # no simple ratio of 142.6 can land on 140. - "grid_7_8_140": ["meter:timeSignature", "downbeats:f1", "tempo:bpm", "tempoOctave:preferredBpm"], + # PR-G4 (loudness-accent meter stream) closed the odd-meter meter and + # downbeat gaps on 3/4, 6/8, and 7/8. What remains on 7/8 is the tempo + # smear (142.6 vs 140 — a beat-tracker artifact on odd meters, not a + # meter read), which also keeps the octave-evidence check informational: + # no simple ratio of 142.6 lands on 140. + "grid_7_8_140": ["tempo:bpm", "tempoOctave:preferredBpm"], # RhythmExtractor halves 174 BPM to 86.9 (octave preference); the beat # grid and downbeats still score >= 0.87 against truth. "grid_4_4_174": ["tempo:bpm"], diff --git a/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json b/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json index 4edffe1b..7e306ec2 100644 --- a/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json +++ b/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json @@ -393,11 +393,7 @@ "beatF1": 0.9, "downbeatF1": 0.75, "octaveTolerance": 2.0 - }, - "knownGaps": [ - "meter:timeSignature", - "downbeats:f1" - ] + } }, { "id": "grid_6_8_110", @@ -474,11 +470,7 @@ "beatF1": 0.9, "downbeatF1": 0.75, "octaveTolerance": 2.0 - }, - "knownGaps": [ - "meter:timeSignature", - "downbeats:f1" - ] + } }, { "id": "grid_4_4_128", @@ -626,8 +618,6 @@ "octaveTolerance": 2.0 }, "knownGaps": [ - "meter:timeSignature", - "downbeats:f1", "tempo:bpm", "tempoOctave:preferredBpm" ] diff --git a/apps/backend/tests/test_time_signature_accent.py b/apps/backend/tests/test_time_signature_accent.py new file mode 100644 index 00000000..2bb30fc8 --- /dev/null +++ b/apps/backend/tests/test_time_signature_accent.py @@ -0,0 +1,61 @@ +import unittest + +import numpy as np + +from analyze_core import ( + _is_harmonic_of_shorter_bar, + _loudness_meter_dominance, + analyze_time_signature, +) + + +def _accented(bar: list[float], bars: int) -> np.ndarray: + return np.asarray(bar * bars, dtype=np.float64) + + +class LoudnessMeterDominanceTests(unittest.TestCase): + def test_accented_downbeat_dominates_at_true_bar_length(self) -> None: + low = _accented([1.0, 0.8, 0.8], 8) # 3/4, kick every beat, louder downbeat + self.assertAlmostEqual(_loudness_meter_dominance(low, 3), 1.25, places=3) + # Folded at 4 the accent smears across positions. + self.assertLess(_loudness_meter_dominance(low, 4), 1.15) + + def test_flat_signal_is_neutral(self) -> None: + low = np.full(32, 0.9) + for bar_length in (3, 4, 5, 6, 7): + self.assertAlmostEqual(_loudness_meter_dominance(low, bar_length), 1.0, places=3) + + def test_too_few_bars_is_neutral(self) -> None: + low = _accented([1.0, 0.8, 0.8, 0.8, 0.8, 0.8], 3) # 3 bars < min 4 + self.assertEqual(_loudness_meter_dominance(low, 6), 1.0) + + def test_missing_signal_is_neutral(self) -> None: + self.assertEqual(_loudness_meter_dominance(None, 4), 1.0) + + def test_near_silent_offbeats_are_capped(self) -> None: + low = _accented([1.0, 0.0, 0.0, 0.0], 8) # broken-kick: one hit per bar + self.assertEqual(_loudness_meter_dominance(low, 4), 10.0) + + +class HarmonicCollapseTests(unittest.TestCase): + def test_three_periodic_accent_collapses_the_six_fold(self) -> None: + low = _accented([1.0, 0.8, 0.8], 8) # true 3/4: fold at 6 shows two peaks + self.assertTrue(_is_harmonic_of_shorter_bar(low, 6, 3)) + + def test_genuine_six_bar_accent_does_not_collapse(self) -> None: + low = _accented([1.0, 0.8, 0.8, 0.8, 0.8, 0.8], 8) # true 6/8: one peak + self.assertFalse(_is_harmonic_of_shorter_bar(low, 6, 3)) + + +class AnalyzeTimeSignatureBeatDataTests(unittest.TestCase): + def test_without_beat_data_behavior_is_count_only(self) -> None: + # Legacy/--fast callers pass no beat_data: the loudness stream must + # be absent from the decision (all-neutral) and the 20% count-only + # margin applies. rhythm_data=None hits the earliest fallback. + result = analyze_time_signature(None) + self.assertIsNone(result["timeSignature"]) + self.assertEqual(result["timeSignatureCandidates"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/ui/src/services/backendPhase1Client.ts b/apps/ui/src/services/backendPhase1Client.ts index 6f4b6297..c8b05815 100644 --- a/apps/ui/src/services/backendPhase1Client.ts +++ b/apps/ui/src/services/backendPhase1Client.ts @@ -698,7 +698,9 @@ function parseOptionalTimeSignatureCandidates(value: unknown): TimeSignatureCand const positionMeans = Array.isArray(entry.positionMeans) ? entry.positionMeans.filter((v): v is number => typeof v === "number" && Number.isFinite(v)) : []; - candidates.push({ timeSignature, dominance, positionMeans }); + // Optional (PR-G4): absent on pre-G4 snapshots — forward as null. + const loudnessDominance = toNumber(entry.loudnessDominance); + candidates.push({ timeSignature, dominance, positionMeans, loudnessDominance }); } return candidates; } diff --git a/apps/ui/src/types/measurement.ts b/apps/ui/src/types/measurement.ts index 02a6dd15..6d0c91a7 100644 --- a/apps/ui/src/types/measurement.ts +++ b/apps/ui/src/types/measurement.ts @@ -662,6 +662,11 @@ export interface TimeSignatureCandidate { timeSignature: string; dominance: number; positionMeans: number[]; + /** + * Low-band loudness-accent dominance at this bar length (PR-G4), folded + * over bar phase. 1.0 = neutral/no evidence; null on pre-G4 snapshots. + */ + loudnessDominance?: number | null; } /** diff --git a/apps/ui/tests/fixtures/phase1FullPayload.ts b/apps/ui/tests/fixtures/phase1FullPayload.ts index 88454ecf..b1ac378d 100644 --- a/apps/ui/tests/fixtures/phase1FullPayload.ts +++ b/apps/ui/tests/fixtures/phase1FullPayload.ts @@ -169,8 +169,8 @@ export const phase1EnvelopeFixture = { timeSignatureSource: 'assumed_four_four', timeSignatureConfidence: 0, timeSignatureCandidates: [ - { timeSignature: '4/4', dominance: 1.42, positionMeans: [2.1, 1.4, 1.6, 1.5] }, - { timeSignature: '3/4', dominance: 1.11, positionMeans: [1.9, 1.7, 1.6] }, + { timeSignature: '4/4', dominance: 1.42, positionMeans: [2.1, 1.4, 1.6, 1.5], loudnessDominance: 1.31 }, + { timeSignature: '3/4', dominance: 1.11, positionMeans: [1.9, 1.7, 1.6], loudnessDominance: 1.05 }, ], durationSeconds: 184.2, sampleRate: 44100, diff --git a/apps/ui/tests/services/backendPhase1Client.test.ts b/apps/ui/tests/services/backendPhase1Client.test.ts index 8cc3cf9a..122ab32d 100644 --- a/apps/ui/tests/services/backendPhase1Client.test.ts +++ b/apps/ui/tests/services/backendPhase1Client.test.ts @@ -39,7 +39,7 @@ describe('parseBackendAnalyzeResponse', () => { phase1: { ...validPayload.phase1, timeSignatureCandidates: [ - { timeSignature: '4/4', dominance: 1.42, positionMeans: [2.1, 1.4, 1.6, 1.5] }, + { timeSignature: '4/4', dominance: 1.42, positionMeans: [2.1, 1.4, 1.6, 1.5], loudnessDominance: 1.31 }, { timeSignature: '3/4', dominance: 1.11, positionMeans: [1.9, 1.7, 'bad'] }, { dominance: 1.0 }, 'garbage', @@ -47,9 +47,11 @@ describe('parseBackendAnalyzeResponse', () => { }, }); + // loudnessDominance (PR-G4) forwards when present and reads null on + // pre-G4 snapshots that lack it. expect(parsed.phase1.timeSignatureCandidates).toEqual([ - { timeSignature: '4/4', dominance: 1.42, positionMeans: [2.1, 1.4, 1.6, 1.5] }, - { timeSignature: '3/4', dominance: 1.11, positionMeans: [1.9, 1.7] }, + { timeSignature: '4/4', dominance: 1.42, positionMeans: [2.1, 1.4, 1.6, 1.5], loudnessDominance: 1.31 }, + { timeSignature: '3/4', dominance: 1.11, positionMeans: [1.9, 1.7], loudnessDominance: null }, ]); }); From 817017c4927544cfdb8953f4b70b4ac2ca706f95 Mon Sep 17 00:00:00 2001 From: assiduous repetition Date: Mon, 13 Jul 2026 21:01:31 +1200 Subject: [PATCH 4/5] feat(rhythm): detect 16th-grid shuffle in the swing measurement (accuracy PR-G5) compute_swing_detail probes the 16th grid when the 8th grid reads straight or absent: same long/short-split logic at half scale (straight center 0.25, thresholds 0.265/0.235). UKG/2-step shuffle lives on the 16ths and was previously invisible. 8th-swung material keeps priority and is byte-identical; straight material cannot fabricate a shuffle (no split at 0.25); sparse material still abstains. Co-Authored-By: Claude Fable 5 --- apps/backend/analyze_rhythm.py | 89 ++++++++++++++++++------- apps/backend/tests/test_swing_detail.py | 46 +++++++++++++ 2 files changed, 111 insertions(+), 24 deletions(-) diff --git a/apps/backend/analyze_rhythm.py b/apps/backend/analyze_rhythm.py index 5143350d..026f44e8 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/tests/test_swing_detail.py b/apps/backend/tests/test_swing_detail.py index 5dfac2a2..fbadc00b 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() From 501c539af75deb8724d370fc0c0555eb3ed598c2 Mon Sep 17 00:00:00 2001 From: assiduous repetition Date: Mon, 13 Jul 2026 21:18:25 +1200 Subject: [PATCH 5/5] feat(eval): gate 16th-shuffle DETECTION; document the ratio-value frame-resolution gap (PR-G5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New swing:gridResolution harness check (expected.swingGrid) promotes shuffle detection to an active gate on shuffle16_130_62 — swingDetail now reads swung/16th/conf 0.992 where it read None. The ratio value stays a knownGap with its measured diagnosis: onset detection quantizes to 11.6ms hops with a one-frame-per-side inward bias, compressing 62% shuffle to a 55% read (verified not a fixture artifact: a 50ms closed hat renders byte-identical medians). Value correction without a gate would repeat the confidently-wrong pattern; it stays a future, separately gated onset-timing refinement. Co-Authored-By: Claude Fable 5 --- apps/backend/fundamentals_evaluation.py | 12 ++++++++++++ apps/backend/scripts/build_synthetic_corpus.py | 14 +++++++++----- .../fundamentals_eval_manifest.synthetic.json | 4 ++-- .../tests/test_build_synthetic_corpus.py | 2 +- .../tests/test_fundamentals_evaluation.py | 17 +++++++++++++++++ 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/apps/backend/fundamentals_evaluation.py b/apps/backend/fundamentals_evaluation.py index f06d1d7c..42e7ddea 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 2ebc9e33..4a0b256c 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 7e306ec2..9f6094d8 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 5a9cd718..69be926c 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 3dd84086..77d48632 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.