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 }, ]); });