From 84dc21d1929d71454a2932db413b95bcf18e4e7b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 23:11:14 +0000 Subject: [PATCH] feat(meter): surface timeSignatureCandidates evidence (accuracy PR-B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-neutral: analyze_time_signature computed per-candidate accent evidence (dominance + per-bar-position onset means for bar lengths 3-7) and threw it away. Now it rides the payload as an additive, full-mode-only top-level field, strongest candidate first, empty on every fallback branch: timeSignatureCandidates: [{timeSignature, dominance, positionMeans[]}] fundamentalsQuality.domains.meter.evidence gains {bestCandidate, margin, candidateCount} derived from it, so the trust layer can say HOW ambiguous the meter read was rather than just that it was ambiguous. This is the evidence surface the meter improvement track (accuracy program Phases B/C — the measured weak layer) builds on; downstream must never treat a candidate as an override of timeSignature (invariant #1). Contract mirrors (tripwire #4): JSON_SCHEMA.md inventory + full-only list + field table; server_phase1._build_phase1 forwarding (caught by the golden's forwarding guard); src/types/measurement.ts + backendPhase1Client.ts reconstructor (drops malformed entries); phase1FullPayload fixture; golden re-baselined (new key, structural). Tests: meter-evidence summary (backend), parser filtering (Vitest), full-mode key set. Backend suite 1295 OK; fundamentals gate 15/15; frontend verify green (877+ unit, 51 smoke). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01T4wfz87k6kzJqkKLKZE7YL --- apps/backend/JSON_SCHEMA.md | 5 +- apps/backend/analyze.py | 2 + apps/backend/analyze_core.py | 88 ++++++++----------- apps/backend/fundamentals_quality.py | 20 ++++- apps/backend/server_phase1.py | 1 + .../tests/fixtures/golden/phase1_default.json | 5 +- apps/backend/tests/test_audio_fixture.py | 3 +- .../tests/test_fundamentals_quality.py | 25 ++++++ apps/ui/src/services/backendPhase1Client.ts | 18 ++++ apps/ui/src/types/measurement.ts | 17 ++++ apps/ui/tests/fixtures/phase1FullPayload.ts | 4 + .../services/backendPhase1Client.test.ts | 20 +++++ 12 files changed, 154 insertions(+), 54 deletions(-) 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,