diff --git a/apps/backend/JSON_SCHEMA.md b/apps/backend/JSON_SCHEMA.md index f3e1cc02..d85bff56 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`, `tuningFrequency`, `tuningCents`, `timeSignature`, `timeSignatureSource`, `timeSignatureConfidence`, `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`, `key`, `keyConfidence`, `keyProfile`, `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`, and `pitchDetail`. 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`, and `timeSignatureCandidates`. 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` @@ -158,6 +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`. | | `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 d79798a3..fa476652 100644 --- a/apps/backend/analyze.py +++ b/apps/backend/analyze.py @@ -1900,6 +1900,8 @@ def main(): "timeSignature": result.get("timeSignature"), "timeSignatureSource": result.get("timeSignatureSource"), "timeSignatureConfidence": result.get("timeSignatureConfidence"), + # Full-only (like keyProfile/tuning*): per-candidate meter evidence. + "timeSignatureCandidates": result.get("timeSignatureCandidates", []), "durationSeconds": result.get("durationSeconds"), "sampleRate": result.get("sampleRate"), "lufsIntegrated": result.get("lufsIntegrated"), diff --git a/apps/backend/analyze_core.py b/apps/backend/analyze_core.py index d35d7057..ba0e78f2 100644 --- a/apps/backend/analyze_core.py +++ b/apps/backend/analyze_core.py @@ -1263,14 +1263,30 @@ def analyze_time_signature( - fewer than 16 beats are detected - mono is not provided (legacy callers) - 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. """ + + def _result( + label: str | None, + source: str | None, + confidence: float | None, + candidates: list[dict] | None = None, + ) -> dict: + return { + "timeSignature": label, + "timeSignatureSource": source, + "timeSignatureConfidence": confidence, + "timeSignatureCandidates": candidates or [], + } + try: if rhythm_data is None: - return { - "timeSignature": None, - "timeSignatureSource": None, - "timeSignatureConfidence": None, - } + return _result(None, None, None) raw_ticks = rhythm_data.get("ticks") # Defensive: rhythm_data["ticks"] may already be a numpy array. @@ -1283,39 +1299,23 @@ def analyze_time_signature( if mono is None or ticks.size < 16: # Not enough information to disambiguate — preserve the # "assumed 4/4" contract that consumers expect today. - return { - "timeSignature": "4/4", - "timeSignatureSource": "assumed_four_four", - "timeSignatureConfidence": 0.0, - } + return _result("4/4", "assumed_four_four", 0.0) # Lazy import — analyze_rhythm is loaded after analyze_core at the # module level. Importing at function-call time avoids the cycle. try: from analyze_rhythm import _detect_onset_times except Exception: - return { - "timeSignature": "4/4", - "timeSignatureSource": "assumed_four_four", - "timeSignatureConfidence": 0.0, - } + return _result("4/4", "assumed_four_four", 0.0) onset_times = _detect_onset_times(mono, sample_rate) if onset_times.size < 16: - return { - "timeSignature": "4/4", - "timeSignatureSource": "assumed_four_four", - "timeSignatureConfidence": 0.0, - } + return _result("4/4", "assumed_four_four", 0.0) beat_diffs = np.diff(ticks) beat_diffs = beat_diffs[beat_diffs > 0] if beat_diffs.size == 0: - return { - "timeSignature": "4/4", - "timeSignatureSource": "assumed_four_four", - "timeSignatureConfidence": 0.0, - } + return _result("4/4", "assumed_four_four", 0.0) beat_duration = float(np.median(beat_diffs)) half_beat = beat_duration / 2.0 @@ -1349,11 +1349,17 @@ def analyze_time_signature( if 4 not in scores: # We couldn't even score 4/4 — fall back to the assumption. - return { - "timeSignature": "4/4", - "timeSignatureSource": "assumed_four_four", - "timeSignatureConfidence": 0.0, + return _result("4/4", "assumed_four_four", 0.0) + + # Surface the previously discarded evidence, strongest first. + candidates = [ + { + "timeSignature": _TIME_SIG_LABELS.get(B, f"{B}/4"), + "dominance": round(float(scores[B]), 3), + "positionMeans": per_position_means[B], } + for B in sorted(scores, key=scores.get, reverse=True) + ] baseline = scores[4] best_B = max(scores, key=scores.get) @@ -1363,32 +1369,16 @@ def analyze_time_signature( if best_B == 4: # 4/4 won outright; confidence rises with downbeat dominance. confidence = max(0.0, min(1.0, (baseline - 1.0) / 2.0)) - return { - "timeSignature": "4/4", - "timeSignatureSource": "onset_autocorrelation", - "timeSignatureConfidence": round(float(confidence), 2), - } + 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 = (best_score - baseline) / max(baseline, 0.1) if margin < _TIME_SIG_MARGIN_THRESHOLD: confidence = max(0.0, min(1.0, (baseline - 1.0) / 2.0)) - return { - "timeSignature": "4/4", - "timeSignatureSource": "onset_autocorrelation_low_margin", - "timeSignatureConfidence": round(float(confidence), 2), - } + return _result("4/4", "onset_autocorrelation_low_margin", round(float(confidence), 2), candidates) confidence = max(0.0, min(1.0, margin)) - return { - "timeSignature": winner_label, - "timeSignatureSource": "onset_autocorrelation", - "timeSignatureConfidence": round(float(confidence), 2), - } + return _result(winner_label, "onset_autocorrelation", round(float(confidence), 2), candidates) except Exception as exc: print(f"[warn] Time signature estimation failed: {exc}", file=sys.stderr) - return { - "timeSignature": None, - "timeSignatureSource": None, - "timeSignatureConfidence": None, - } + return _result(None, None, None) diff --git a/apps/backend/fundamentals_quality.py b/apps/backend/fundamentals_quality.py index 30d92eb0..1c4d7d53 100644 --- a/apps/backend/fundamentals_quality.py +++ b/apps/backend/fundamentals_quality.py @@ -203,6 +203,24 @@ def _meter_quality(payload: dict[str, Any]) -> dict[str, Any]: ) assumed = source == "assumed_four_four" or _confidence(confidence) == 0.0 status = STATUS_AMBIGUOUS if assumed else _status_from_confidence(confidence, high=0.5) + evidence: dict[str, Any] = {"timeSignature": meter} + candidates = payload.get("timeSignatureCandidates") + if isinstance(candidates, list) and candidates: + # Candidates are emitted strongest-first by analyze_time_signature. + best = candidates[0] if isinstance(candidates[0], dict) else {} + best_dominance = best.get("dominance") + runner_up = candidates[1] if len(candidates) > 1 and isinstance(candidates[1], dict) else {} + runner_dominance = runner_up.get("dominance") + margin = None + if isinstance(best_dominance, (int, float)) and isinstance(runner_dominance, (int, float)) and runner_dominance > 0: + margin = round((float(best_dominance) - float(runner_dominance)) / float(runner_dominance), 3) + evidence.update( + { + "bestCandidate": best.get("timeSignature"), + "margin": margin, + "candidateCount": len(candidates), + } + ) return _domain( status=status, plain=( @@ -212,7 +230,7 @@ def _meter_quality(payload: dict[str, Any]) -> dict[str, Any]: ), source=str(source) if source else "time_signature", confidence=confidence, - evidence={"timeSignature": meter}, + evidence=evidence, ) diff --git a/apps/backend/server_phase1.py b/apps/backend/server_phase1.py index ce8e0007..1ad2cf4a 100644 --- a/apps/backend/server_phase1.py +++ b/apps/backend/server_phase1.py @@ -201,6 +201,7 @@ def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]: "timeSignature": _coerce_string(payload.get("timeSignature"), "4/4"), "timeSignatureSource": _coerce_nullable_string(payload.get("timeSignatureSource")), "timeSignatureConfidence": _coerce_nullable_number(payload.get("timeSignatureConfidence")), + "timeSignatureCandidates": payload.get("timeSignatureCandidates"), "durationSeconds": _coerce_number(payload.get("durationSeconds")), "sampleRate": payload.get("sampleRate"), "lufsIntegrated": _coerce_number(payload.get("lufsIntegrated")), diff --git a/apps/backend/tests/fixtures/golden/phase1_default.json b/apps/backend/tests/fixtures/golden/phase1_default.json index aa57a9ac..992a49c9 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.1, + "lufsShortTermMax": -5.2, "monoCompatible": true, "plr": 5.6, "sampleRate": 44100, @@ -487,6 +487,7 @@ "textureScore": "number" }, "timeSignature": "str", + "timeSignatureCandidates": "list", "timeSignatureConfidence": "number", "timeSignatureSource": "str", "transcriptionDetail": "null", @@ -607,6 +608,7 @@ "synthesisCharacter", "textureCharacter", "timeSignature", + "timeSignatureCandidates", "timeSignatureConfidence", "timeSignatureSource", "transcriptionDetail", @@ -676,6 +678,7 @@ "synthesisCharacter": "dict", "textureCharacter": "dict", "timeSignature": "str", + "timeSignatureCandidates": "list", "timeSignatureConfidence": "number", "timeSignatureSource": "str", "transcriptionDetail": "null", diff --git a/apps/backend/tests/test_audio_fixture.py b/apps/backend/tests/test_audio_fixture.py index 05d28056..0566cd02 100644 --- a/apps/backend/tests/test_audio_fixture.py +++ b/apps/backend/tests/test_audio_fixture.py @@ -16,7 +16,8 @@ "bpmDoubletime", "bpmSource", "bpmRawOriginal", "key", "keyConfidence", "keyProfile", "tuningFrequency", "tuningCents", "timeSignature", "timeSignatureSource", - "timeSignatureConfidence", "durationSeconds", "sampleRate", + "timeSignatureConfidence", "timeSignatureCandidates", + "durationSeconds", "sampleRate", "lufsIntegrated", "lufsRange", "lufsMomentaryMax", "lufsShortTermMax", "lufsCurve", "truePeak", "plr", "crestFactor", diff --git a/apps/backend/tests/test_fundamentals_quality.py b/apps/backend/tests/test_fundamentals_quality.py index 13b35e58..6a807404 100644 --- a/apps/backend/tests/test_fundamentals_quality.py +++ b/apps/backend/tests/test_fundamentals_quality.py @@ -52,6 +52,31 @@ def test_marks_strong_local_measurements_as_authoritative(self) -> None: self.assertEqual(quality["domains"]["percussion"]["status"], "authoritative") self.assertEqual(quality["domains"]["transcription"]["status"], "authoritative") + def test_meter_evidence_summarizes_candidates(self) -> None: + payload = { + "bpm": 128.0, + "bpmConfidence": 0.9, + "timeSignature": "4/4", + "timeSignatureSource": "onset_autocorrelation", + "timeSignatureConfidence": 0.6, + "timeSignatureCandidates": [ + {"timeSignature": "4/4", "dominance": 1.5, "positionMeans": [2.0, 1.2, 1.4, 1.3]}, + {"timeSignature": "3/4", "dominance": 1.2, "positionMeans": [1.8, 1.5, 1.5]}, + ], + } + quality = build_fundamentals_quality(payload, analysis_mode="full") + evidence = quality["domains"]["meter"]["evidence"] + self.assertEqual(evidence["bestCandidate"], "4/4") + self.assertEqual(evidence["candidateCount"], 2) + self.assertAlmostEqual(evidence["margin"], 0.25, places=3) + + # No candidates (fast mode / fallback) — evidence stays minimal. + quality = build_fundamentals_quality( + {"bpm": 128.0, "timeSignature": "4/4", "timeSignatureSource": "assumed_four_four"}, + analysis_mode="fast", + ) + self.assertNotIn("bestCandidate", quality["domains"]["meter"]["evidence"]) + def test_tempo_cross_check_agreement_settles_mid_confidence(self) -> None: # Two independent estimators agreeing IS the settling evidence — a # mid-range extractor confidence must not demote a cross-confirmed BPM. diff --git a/apps/ui/src/services/backendPhase1Client.ts b/apps/ui/src/services/backendPhase1Client.ts index e7f4e25a..86d966f1 100644 --- a/apps/ui/src/services/backendPhase1Client.ts +++ b/apps/ui/src/services/backendPhase1Client.ts @@ -13,6 +13,7 @@ import { DynamicCharacter, FundamentalsQuality, FundamentalsQualityStatus, + TimeSignatureCandidate, GenreDetail, KickDetail, Phase1Result, @@ -595,6 +596,7 @@ export function parsePhase1Result(value: unknown): Phase1Result { timeSignature: expectString(phase1, "timeSignature"), timeSignatureSource: toOptionalStringOrNull(phase1.timeSignatureSource), timeSignatureConfidence: toNumber(phase1.timeSignatureConfidence), + timeSignatureCandidates: parseOptionalTimeSignatureCandidates(phase1.timeSignatureCandidates), durationSeconds: expectNumber(phase1, "durationSeconds"), sampleRate: toNumber(phase1.sampleRate), lufsIntegrated, @@ -679,6 +681,22 @@ const FUNDAMENTALS_QUALITY_STATUSES = new Set([ "not_run", ]); +function parseOptionalTimeSignatureCandidates(value: unknown): TimeSignatureCandidate[] | null { + if (!Array.isArray(value)) return null; + const candidates: TimeSignatureCandidate[] = []; + for (const entry of value) { + if (!isRecord(entry)) continue; + const timeSignature = typeof entry.timeSignature === "string" ? entry.timeSignature : null; + const dominance = toNumber(entry.dominance); + if (timeSignature === null || dominance === null) continue; + const positionMeans = Array.isArray(entry.positionMeans) + ? entry.positionMeans.filter((v): v is number => typeof v === "number" && Number.isFinite(v)) + : []; + candidates.push({ timeSignature, dominance, positionMeans }); + } + return candidates; +} + function parseOptionalFundamentalsQuality(value: unknown): FundamentalsQuality | null { if (value === undefined || value === null) return null; if (!isRecord(value)) return null; diff --git a/apps/ui/src/types/measurement.ts b/apps/ui/src/types/measurement.ts index afa160d5..c8e15b68 100644 --- a/apps/ui/src/types/measurement.ts +++ b/apps/ui/src/types/measurement.ts @@ -627,6 +627,17 @@ export interface GenreDetail { topScores: Array<{ genre: string; score: number }>; } +/** + * One meter candidate from analyze_time_signature's accent autocorrelation. + * `dominance` = mean accent at bar position 1 / mean of the other positions; + * `positionMeans` = the per-bar-position onset-count means behind it. + */ +export interface TimeSignatureCandidate { + timeSignature: string; + dominance: number; + positionMeans: number[]; +} + export type FundamentalsQualityStatus = "authoritative" | "ambiguous" | "failed" | "not_run"; export interface FundamentalsQualityDomain { @@ -668,6 +679,12 @@ export interface Phase1Result { timeSignature: string; timeSignatureSource?: string | null; timeSignatureConfidence?: number | null; + /** + * Full mode only. Per-candidate meter evidence from the onset-accent + * autocorrelation, strongest first (accuracy program PR-B1). Evidence + * surface — never an override of `timeSignature`. + */ + timeSignatureCandidates?: TimeSignatureCandidate[] | null; durationSeconds: number; sampleRate?: number | null; lufsIntegrated: number; diff --git a/apps/ui/tests/fixtures/phase1FullPayload.ts b/apps/ui/tests/fixtures/phase1FullPayload.ts index 206276f8..645387d1 100644 --- a/apps/ui/tests/fixtures/phase1FullPayload.ts +++ b/apps/ui/tests/fixtures/phase1FullPayload.ts @@ -145,6 +145,10 @@ export const phase1EnvelopeFixture = { timeSignature: '4/4', 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] }, + ], durationSeconds: 184.2, sampleRate: 44100, lufsIntegrated: -8.4, diff --git a/apps/ui/tests/services/backendPhase1Client.test.ts b/apps/ui/tests/services/backendPhase1Client.test.ts index e16592ae..8cc3cf9a 100644 --- a/apps/ui/tests/services/backendPhase1Client.test.ts +++ b/apps/ui/tests/services/backendPhase1Client.test.ts @@ -33,6 +33,26 @@ afterEach(() => { }); describe('parseBackendAnalyzeResponse', () => { + it('parses timeSignatureCandidates and drops malformed entries', () => { + const parsed = parseBackendAnalyzeResponse({ + ...validPayload, + phase1: { + ...validPayload.phase1, + 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, 'bad'] }, + { dominance: 1.0 }, + 'garbage', + ], + }, + }); + + 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] }, + ]); + }); + it('accepts a valid backend payload', () => { const parsed = parseBackendAnalyzeResponse({ ...validPayload,