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
7 changes: 4 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ apps/ui/.env
apps/ui/.env*
!apps/ui/.env.example

apps/backend/__pycache__/
apps/backend/*.py[cod]
apps/backend/*.pyo
# Python bytecode (repo-wide — covers scripts/, apps/backend/, etc.)
__pycache__/
*.py[cod]
*.pyo
apps/backend/*.egg-info/
apps/backend/build/
apps/backend/dist/
Expand Down
7 changes: 4 additions & 3 deletions apps/backend/JSON_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Conventions:

Top-level keys:

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

**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.

Expand Down Expand Up @@ -149,8 +149,9 @@ Current server behavior that affects schema expectations:

| Field | Type | Description | Units / Scale | LLM interpretation note |
|---|---|---|---|---|
| `phase1Version` | `string` | Phase 1 JSON schema version, e.g. `"phase1.v2"`. | identifier | Lets consumers detect the schema generation. v2 changed `truePeak` (now dBTP) and `bpmConfidence` (now 0-1) units vs v1. |
| `bpm` | `float \| null` | Primary tempo estimate from `RhythmExtractor2013`. | beats per minute | Main tempo anchor for Ableton project tempo and clip warp assumptions. |
| `bpmConfidence` | `float \| null` | Confidence output from `RhythmExtractor2013` for primary BPM. | unbounded float (RhythmExtractor2013-specific; observed values typically 1.0-4.0 on real material) | Not normalised to 0-1. Higher values indicate stronger rhythmic periodicity. Values above 2.0 generally indicate reliable tempo detection. Low values (below 1.0) suggest ambiguous pulse or half/double-time content. |
| `bpmConfidence` | `float \| null` | Tempo confidence from `RhythmExtractor2013`, normalized to 0-1. | 0-1 (Phase 1 v2: raw Essentia confidence ~0-5.32 divided by 5.0 and clamped) | Higher = stronger rhythmic periodicity. Below 0.4 (raw < 2.0) suggests an ambiguous pulse or half/double-time content; hedge tempo claims there. |
| `key` | `string \| null` | Global key label from `KeyExtractor` (`edma` profile), e.g. `"A Minor"`. | categorical | Starting point for harmonic reconstruction; validate by ear against bass/chord roots. |
| `keyConfidence` | `float \| null` | Confidence/strength of global key estimate. | 0-1 (approx) | Low values indicate ambiguous tonality or modal/atonal content. |
| `timeSignature` | `string \| null` | Time signature estimate (currently defaults to `"4/4"` when rhythm exists). | string | Treat as prior; verify manually on odd-metre material. |
Expand Down Expand Up @@ -182,7 +183,7 @@ Current server behavior that affects schema expectations:
|---|---|---|---|---|
| `lufsIntegrated` | `float \| null` | Integrated loudness via `LoudnessEBUR128`. | LUFS | Global loudness target reference for gain staging and master chain matching. |
| `lufsRange` | `float \| null` | Loudness range via `LoudnessEBUR128`. | LU | Indicates macro-dynamic movement across sections. |
| `truePeak` | `float \| null` | Max true peak across stereo channels. | linear amplitude proxy (rounded) | Helps detect clipping risk and required headroom when rebuilding. |
| `truePeak` | `float \| null` | Max true peak across stereo channels. | dBTP (Phase 1 v2: was a linear amplitude proxy in v1) | 0.0 dBTP == full scale; > 0.0 == inter-sample over; `null` for digital silence (no defined dBTP). Helps detect clipping risk and required headroom when rebuilding. |
| `crestFactor` | `float \| null` | Peak-to-RMS ratio over mono signal. | dB | Higher crest means stronger transients/less compression; lower crest suggests denser limiting/compression. |
| `lufsMomentaryMax` | `float \| null` | Maximum momentary loudness (400 ms window) via `LoudnessEBUR128`. | LUFS | Peak short-burst loudness; useful for detecting loud transient moments. |
| `lufsShortTermMax` | `float \| null` | Maximum short-term loudness (3 s window) via `LoudnessEBUR128`. | LUFS | Peak sustained loudness; gap between this and integrated LUFS indicates dynamic range use. |
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -1469,6 +1469,7 @@ def main():
)
fast_plr = analyze_plr(result.get("lufsIntegrated"), result.get("truePeak")).get("plr")
output = {
"phase1Version": "phase1.v2",
"bpm": result.get("bpm"),
"bpmConfidence": result.get("bpmConfidence"),
"bpmPercival": result.get("bpmPercival"),
Expand Down Expand Up @@ -1872,6 +1873,7 @@ def main():

# Build final output in the exact requested key order
output = {
"phase1Version": "phase1.v2",
"bpm": result.get("bpm"),
"bpmConfidence": result.get("bpmConfidence"),
"bpmPercival": result.get("bpmPercival"),
Expand Down
30 changes: 23 additions & 7 deletions apps/backend/analyze_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ def analyze_bpm(

if rhythm_data is not None:
bpm = round(float(rhythm_data["bpm"]), 1)
bpm_confidence = round(float(rhythm_data["confidence"]), 2)
# Phase 1 schema v2: normalize Essentia RhythmExtractor2013 confidence
# (raw ~0-5.32) to 0-1 by dividing by 5.0 and clamping. Consistent with
# every other *Confidence field; raw < 2.0 ("ambiguous" per the detector)
# maps to < 0.4, the standard low-confidence hedge threshold.
bpm_confidence = round(min(max(float(rhythm_data["confidence"]), 0.0) / 5.0, 1.0), 3)

percival_cls = getattr(es, "PercivalBpmEstimator", None)
if percival_cls is not None:
Expand Down Expand Up @@ -248,8 +252,13 @@ def analyze_true_peak(stereo: np.ndarray) -> dict:
peaks.append(float(np.max(peak_value)) if len(peak_value) > 0 else 0.0)
else:
peaks.append(float(peak_value))
true_peak = max(peaks) if peaks else 0.0
return {"truePeak": round(true_peak, 1)}
true_peak_linear = max(peaks) if peaks else 0.0
# Phase 1 schema v2: emit dBTP (v1 emitted a linear amplitude proxy).
# 0.0 dBTP == full scale; > 0.0 dBTP == inter-sample over. A silent
# signal (linear 0) has no defined dBTP, so emit None.
if true_peak_linear <= 0.0:
return {"truePeak": None}
return {"truePeak": round(20.0 * float(np.log10(true_peak_linear)), 1)}
except Exception as e:
print(f"[warn] True peak detection failed: {e}", file=sys.stderr)
return {"truePeak": None}
Expand Down Expand Up @@ -621,13 +630,20 @@ def analyze_spectral_balance(
return {"spectralBalance": None, "spectralBalanceTimeSeries": None}


def analyze_plr(lufs_integrated: float | None, true_peak: float | None) -> dict:
"""Peak-to-loudness ratio (PLR): truePeak - LUFS integrated."""
def analyze_plr(lufs_integrated: float | None, true_peak_dbtp: float | None) -> dict:
"""Peak-to-loudness ratio (PLR) in LU: truePeak (dBTP) - integrated loudness (LUFS).

As of Phase 1 schema v2, ``truePeak`` is expressed in dBTP (see
``analyze_true_peak``), so PLR is a direct dB-domain subtraction. (In v1
``truePeak`` was a linear amplitude proxy and this subtraction was unit-
incoherent — the v2 migration fixes that at the source.) Returns None for
missing or non-finite inputs.
"""
try:
if lufs_integrated is None or true_peak is None:
if lufs_integrated is None or true_peak_dbtp is None:
return {"plr": None}
lufs_value = float(lufs_integrated)
true_peak_value = float(true_peak)
true_peak_value = float(true_peak_dbtp)
if not np.isfinite(lufs_value) or not np.isfinite(true_peak_value):
return {"plr": None}
return {"plr": round(true_peak_value - lufs_value, 2)}
Expand Down
17 changes: 13 additions & 4 deletions apps/backend/analyze_fast.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ def analyze_fast(mono: np.ndarray, sample_rate: int = 44100) -> dict:
rhythm_extractor = es.RhythmExtractor2013(method="multifeature")
bpm, beats, bpm_confidence, _, _ = rhythm_extractor(mono)
result["bpm"] = round(float(bpm), 2) if bpm is not None else None
result["bpmConfidence"] = round(float(bpm_confidence), 3) if bpm_confidence is not None else None
# Phase 1 v2: normalize RhythmExtractor2013 confidence (~0-5.32) to 0-1.
result["bpmConfidence"] = (
round(min(max(float(bpm_confidence), 0.0) / 5.0, 1.0), 3)
if bpm_confidence is not None
else None
)
except Exception as e:
print(f"[warn] Fast mode BPM analysis failed: {e}", file=sys.stderr)
result["bpm"] = None
Expand Down Expand Up @@ -106,11 +111,15 @@ def analyze_fast(mono: np.ndarray, sample_rate: int = 44100) -> dict:
result["lufsIntegrated"] = None
result["lufsRange"] = None

# True peak (from stereo)
# True peak (from stereo), emitted in dBTP (Phase 1 v2). NOTE: fast mode uses
# a plain sample peak (np.max(np.abs)), NOT the oversampled inter-sample true
# peak the standard path computes via Essentia's TruePeakDetector — so this is
# a sample-peak approximation and may read lower than the standard path on
# material with real inter-sample overs.
try:
if stereo is not None:
max_peak = np.max(np.abs(stereo))
result["truePeak"] = round(float(max_peak), 6) if max_peak > 0 else None
max_peak = float(np.max(np.abs(stereo)))
result["truePeak"] = round(20.0 * np.log10(max_peak), 1) if max_peak > 0 else None
else:
result["truePeak"] = None
except Exception as e:
Expand Down
8 changes: 7 additions & 1 deletion apps/backend/loudness_rec_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,10 @@ def measure_loudness_true_peak(

loudness = analyze_core.analyze_loudness(stereo, sample_rate=sample_rate)
true_peak = analyze_core.analyze_true_peak(stereo)
return loudness.get("lufsIntegrated"), true_peak.get("truePeak")
# Phase 1 v2 emits truePeak in dBTP; this eval works in linear amplitude
# (limiter-ceiling reachability), so convert back to a linear peak.
true_peak_dbtp = true_peak.get("truePeak")
true_peak_linear = (
10.0 ** (float(true_peak_dbtp) / 20.0) if true_peak_dbtp is not None else None
)
return loudness.get("lufsIntegrated"), true_peak_linear
1 change: 1 addition & 0 deletions apps/backend/phase1_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ def _evaluate_plr_consistency(payload: dict[str, Any]) -> FixtureCheck:
passed=False,
message=f"non-numeric values plr={plr} truePeak={true_peak} lufsIntegrated={lufs}",
)
# truePeak is dBTP (Phase 1 v2), so PLR is a direct dB-domain subtraction.
expected = float(true_peak) - float(lufs)
delta = abs(float(plr) - expected)
passed = delta <= 0.11
Expand Down
8 changes: 7 additions & 1 deletion apps/backend/server_phase1.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]:
if plr is None:
lufs_integrated = _coerce_nullable_number(payload.get("lufsIntegrated"))
true_peak = _coerce_nullable_number(payload.get("truePeak"))
# truePeak is dBTP (Phase 1 v2); PLR is a direct dB-domain subtraction
# (mirrors analyze_core.analyze_plr).
if lufs_integrated is not None and true_peak is not None:
plr = round(true_peak - lufs_integrated, 2)

Expand All @@ -146,6 +148,7 @@ def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]:
mono_compatible = sub_bass_mono if isinstance(sub_bass_mono, bool) else None

return {
"phase1Version": _coerce_nullable_string(payload.get("phase1Version")),
"bpm": _coerce_number(payload.get("bpm")),
"bpmConfidence": _coerce_number(payload.get("bpmConfidence")),
"bpmPercival": _coerce_nullable_number(payload.get("bpmPercival")),
Expand All @@ -168,7 +171,10 @@ def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]:
"lufsMomentaryMax": _coerce_nullable_number(payload.get("lufsMomentaryMax")),
"lufsShortTermMax": _coerce_nullable_number(payload.get("lufsShortTermMax")),
"lufsCurve": payload.get("lufsCurve"),
"truePeak": _coerce_number(payload.get("truePeak")),
# truePeak is dBTP (Phase 1 v2) and is null for digital silence (no
# defined dBTP). Preserve that null rather than coercing it to 0.0,
# which would falsely report a full-scale peak for a silent track.
"truePeak": _coerce_nullable_number(payload.get("truePeak")),
"plr": plr,
"crestFactor": _coerce_nullable_number(payload.get("crestFactor")),
"dynamicSpread": _coerce_nullable_number(payload.get("dynamicSpread")),
Expand Down
6 changes: 4 additions & 2 deletions apps/backend/tests/fixtures/golden/phase1_default.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"lufsRange": 0.8,
"lufsShortTermMax": -5.2,
"monoCompatible": true,
"plr": 6.6,
"plr": 5.6,
"sampleRate": 44100,
"spectralBalance.brilliance": -64.7,
"spectralBalance.highs": -62.2,
Expand All @@ -25,7 +25,7 @@
"stereoDetail.subBassMono": true,
"timeSignature": "4/4",
"timeSignatureSource": "assumed_four_four",
"truePeak": 1.0
"truePeak": 0.0
},
"topLevelKeys": [
"acidDetail",
Expand Down Expand Up @@ -62,6 +62,7 @@
"melodyDetail",
"monoCompatible",
"perceptual",
"phase1Version",
"pitchDetail",
"plr",
"reverbDetail",
Expand Down Expand Up @@ -129,6 +130,7 @@
"melodyDetail": "dict",
"monoCompatible": "bool",
"perceptual": "dict",
"phase1Version": "str",
"pitchDetail": "null",
"plr": "number",
"reverbDetail": "dict",
Expand Down
8 changes: 4 additions & 4 deletions apps/backend/tests/fixtures/phase1_eval_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"thresholds": {
"bpm": { "target": 120.1, "tolerance": 0.2 },
"lufsIntegrated": { "target": -23.9, "tolerance": 0.5 },
"truePeak": { "target": 0.9, "tolerance": 0.2 },
"plr": { "target": 24.8, "tolerance": 0.7 },
"truePeak": { "target": -0.9, "tolerance": 0.2 },
"plr": { "target": 23.2, "tolerance": 0.7 },
"monoCompatible": { "equals": true }
}
},
Expand All @@ -31,8 +31,8 @@
"thresholds": {
"bpm": { "target": 112.2, "tolerance": 0.2 },
"lufsIntegrated": { "target": -6.9, "tolerance": 0.5 },
"truePeak": { "target": 0.5, "tolerance": 0.2 },
"plr": { "target": 7.4, "tolerance": 0.7 },
"truePeak": { "target": -6.0, "tolerance": 0.2 },
"plr": { "target": 1.0, "tolerance": 0.7 },
"monoCompatible": { "equals": true }
}
}
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/tests/test_analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
# here would force the field to always appear in CLI output and break the
# "absent when off" contract. See JSON_SCHEMA.md "Optional MT3 Namespace".
EXPECTED_TOP_LEVEL_KEYS = {
"phase1Version",
"bpm", "bpmConfidence", "bpmPercival", "bpmAgreement",
"bpmDoubletime", "bpmSource", "bpmRawOriginal",
"key", "keyConfidence", "timeSignature", "timeSignatureSource",
Expand All @@ -57,6 +58,7 @@

# Fields fast mode populates with real values.
FAST_MODE_POPULATED_FIELDS = {
"phase1Version",
"bpm", "bpmConfidence", "bpmPercival", "bpmAgreement",
"bpmDoubletime", "bpmSource", "bpmRawOriginal",
"key", "keyConfidence", "timeSignature", "timeSignatureSource",
Expand Down
10 changes: 7 additions & 3 deletions apps/backend/tests/test_audio_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@


EXPECTED_TOP_LEVEL_KEYS = {
"phase1Version",
"bpm", "bpmConfidence", "bpmPercival", "bpmAgreement",
"bpmDoubletime", "bpmSource", "bpmRawOriginal",
"key", "keyConfidence", "keyProfile", "tuningFrequency", "tuningCents",
Expand Down Expand Up @@ -106,9 +107,12 @@ class AudioFixtureSmokeTest(unittest.TestCase):
EXPECTED_LUFS_TOLERANCE = 0.5
EXPECTED_LUFS_RANGE = 0.8
EXPECTED_LUFS_RANGE_TOLERANCE = 0.5
EXPECTED_TRUE_PEAK = 1.0
EXPECTED_TRUE_PEAK_TOLERANCE = 0.1
EXPECTED_PLR = 9.9
# Phase 1 v2: truePeak is dBTP. The clipped fixture peaks at ~full scale,
# so truePeak ~ 0.0 dBTP (was 1.0 as a linear amplitude proxy in v1).
EXPECTED_TRUE_PEAK = 0.0
EXPECTED_TRUE_PEAK_TOLERANCE = 0.2
# PLR (LU) = truePeak(dBTP) - lufsIntegrated = ~0.0 - (-8.9) = ~8.9.
EXPECTED_PLR = 8.9
EXPECTED_PLR_TOLERANCE = 0.7
EXPECTED_CREST_FACTOR = 11.0
EXPECTED_CREST_FACTOR_TOLERANCE = 0.5
Expand Down
Loading
Loading