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`, `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`

Expand Down Expand Up @@ -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. |

Expand Down
6 changes: 6 additions & 0 deletions apps/backend/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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"),
Expand Down
167 changes: 167 additions & 0 deletions apps/backend/analyze_rhythm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions apps/backend/fundamentals_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading
Loading