Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/backend/JSON_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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. |
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
88 changes: 39 additions & 49 deletions apps/backend/analyze_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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)
20 changes: 19 additions & 1 deletion apps/backend/fundamentals_quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand All @@ -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,
)


Expand Down
1 change: 1 addition & 0 deletions apps/backend/server_phase1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
5 changes: 4 additions & 1 deletion apps/backend/tests/fixtures/golden/phase1_default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -487,6 +487,7 @@
"textureScore": "number"
},
"timeSignature": "str",
"timeSignatureCandidates": "list",
"timeSignatureConfidence": "number",
"timeSignatureSource": "str",
"transcriptionDetail": "null",
Expand Down Expand Up @@ -607,6 +608,7 @@
"synthesisCharacter",
"textureCharacter",
"timeSignature",
"timeSignatureCandidates",
"timeSignatureConfidence",
"timeSignatureSource",
"transcriptionDetail",
Expand Down Expand Up @@ -676,6 +678,7 @@
"synthesisCharacter": "dict",
"textureCharacter": "dict",
"timeSignature": "str",
"timeSignatureCandidates": "list",
"timeSignatureConfidence": "number",
"timeSignatureSource": "str",
"transcriptionDetail": "null",
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/tests/test_audio_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions apps/backend/tests/test_fundamentals_quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions apps/ui/src/services/backendPhase1Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
DynamicCharacter,
FundamentalsQuality,
FundamentalsQualityStatus,
TimeSignatureCandidate,
GenreDetail,
KickDetail,
Phase1Result,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -679,6 +681,22 @@ const FUNDAMENTALS_QUALITY_STATUSES = new Set<FundamentalsQualityStatus>([
"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;
Expand Down
Loading
Loading