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