From 944a0d19c4041856f102f77ae440022d4fb7dfc9 Mon Sep 17 00:00:00 2001 From: assiduous repetition Date: Mon, 13 Jul 2026 17:47:37 +1200 Subject: [PATCH 1/2] 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/2] 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",