WS3c: in-browser loudness parity readout (flag-gated, WAV-only) - #133
Conversation
Adds an additive browser-side loudness readout that measures the uploaded WAV with the asa-dsp (WASM core) and compares the LUFS scalars against the authoritative Phase 1 Essentia values. Phase 1 stays the source of truth (PURPOSE.md invariant #1); this is a diagnostic, default-off behind VITE_ENABLE_BROWSER_LOUDNESS. Services (pure, fully unit-tested): - wavDecoder.ts: decodes uncompressed WAV (PCM 16/24/32 + float32, incl. WAVE_FORMAT_EXTENSIBLE) to interleaved f32 at the file's NATIVE rate — no AudioContext.decodeAudioData resampling, which would break ±0.1 LU EBU conformance. WAV only; FLAC/MP3 report "WAV only" rather than guessing. - parity.ts: computeLoudnessParity → per-field deltas vs Phase 1, gated at ±0.1 LU. True peak is deliberately excluded (asa-dsp diverges on broadband per #129). - loader.ts: guarded dynamic import of the WASM module from a runtime URL (VITE_BROWSER_LOUDNESS_WASM_URL) with @vite-ignore so the build never tries to resolve the (unbuilt, gitignored) package; returns null → "unavailable" on any failure. The result adapter (NaN→null, free()) is unit-tested. UI: BrowserLoudnessPanel (flag-gated mount in AnalysisResults) reuses the retained App `audioFile`; graceful states for non-WAV, wasm-unavailable, error. Verification limits (honest): the WAV decoder + parity math + result adapter are unit-tested; the actual WASM run and browser behavior are NOT verified here (no browser, and building web-wasm needs wasm-bindgen-cli). Activation requires building packages/loudness-spectro-wasm and serving pkg/ — documented in the unavailable state. 729 unit tests pass; build clean (no static wasm import). https://claude.ai/code/session_01Wq1vE1D7o3W9xZKSHXz43Q
slittycode
left a comment
There was a problem hiding this comment.
Verdict: APPROVE (submitted as COMMENT — can't self-approve)
Summary
Adds a flag-gated, default-off in-browser LUFS parity diagnostic that decodes WAV at native sample rate, runs the WASM core, and shows per-field deltas against the authoritative Phase 1 Essentia values. Phase 1 is read-only throughout; no mutations, no re-derivation. 13 new unit tests, all pass. Full suite (746 tests) passes clean. Frontend and WASM CI checks are green.
Findings
Worth considering:
wavDecoder.ts — the WAVE_FORMAT_EXTENSIBLE guard is chunkSize >= 24, but the SubFormat GUID starts at byte offset 24 of the chunk body, so safely reading 2 bytes there requires chunkSize >= 26. Off by 2. In any real EXTENSIBLE file chunkSize is 40, so this never fires. If it did (malformed file), DataView.getUint16 throws a RangeError that propagates to the panel's catch handler and shows the error state — no crash, just a confusing error message for a file that wasn't going to decode correctly anyway. Worth fixing to >= 26 to be accurate.
loader.ts — wasmModuleUrl casts import.meta to a looser type to reach env, but VITE_BROWSER_LOUDNESS_WASM_URL is already declared in vite-env.d.ts, so import.meta.env.VITE_BROWSER_LOUDNESS_WASM_URL works directly. The cast is harmless (lint accepts it with deps installed) but it's extra noise.
Test results
746 passed, 4 skipped (pre-existing fixture-gated tests), 0 failed. New browser-loudness suite: 13/13 — parity math, WAV decoder (including EXTENSIBLE, LIST chunk walk, 24-bit sign extension, null paths), and WASM adapter adapter all have meaningful tests.
Phase boundary check
Clean. parity.ts uses Pick<Phase1Result, …> in read-only fashion; the parity report is a new type that never feeds back into any Phase 1 field. BrowserLoudnessPanel reads phase1 for display only. AnalysisResults gains audioFile as a prop but Phase 1 state is unaffected.
Generated by Claude Code
…a cast - wavDecoder: WAVE_FORMAT_EXTENSIBLE guard needs chunkSize >= 26 (not 24) to safely read the 2-byte SubFormat tag at body+24; add a test that decodes a real 40-byte EXTENSIBLE fmt chunk (the branch was previously untested). - loader: VITE_BROWSER_LOUDNESS_WASM_URL is declared in vite-env.d.ts, so the import.meta cast is unnecessary — read import.meta.env directly. https://claude.ai/code/session_01Wq1vE1D7o3W9xZKSHXz43Q
…ard (#135) Two defects surfaced by an independent review of the loudness-v2 program (#128–#133): patchSmith read spectralBalance bands as "dB above/below the mix average", but the analyzer emits absolute band energy (10·log10(mean_energy), ~ -100..0). On real tracks the sub oscillator never engaged (guard subBass<=1.0 against a real ~ -41 dB reading) and the acid cutoff pinned to the 200 Hz floor, while the cited rationale misstated the measurement (invariant #2). Fix locally: add meanBandDb() and read each band as a prominence relative to the 7-band mix average — the analyzer contract is unchanged (mixDoctor/dashboard rely on the absolute scale). Rationales are now true; tests use realistic absolute dB with a regression case that fails on the old logic. phase1Version was emitted but never consumed, so persisted pre-#128 runs (stored verbatim in analysis_runtime, never migrated) replayed v1-unit values into v2 consumers: a linear truePeak of 0.98 read as a +0.98 dBTP over (false TRUE_PEAK_OVER / MISSING_LOUDNESS_ACTION) and a raw bpmConfidence of 3.2 rendered as 320%. Add _migrate_v1_loudness_units() at the top of _build_phase1 (the single chokepoint every persisted snapshot passes through): a payload lacking "phase1.v2" gets truePeak linear→dBTP, bpmConfidence raw→0-1, plr dropped and recomputed, then stamped v2. No-op for v2 payloads. Verified: tsc clean; 750 frontend unit tests; vite build; 1102 backend unittests (skipped=3). Playwright smoke could not run (chromium download blocked by the sandbox network allowlist); both changes are pure logic covered by the suites that ran. https://claude.ai/code/session_01C2VkJk4JAUGtz3b9BWqVGE Co-authored-by: Claude <noreply@anthropic.com>
What this is
An additive, default-off in-browser loudness readout: it measures the uploaded WAV with the asa-dsp (WASM core) and compares the LUFS scalars against the authoritative Phase 1 Essentia values. Phase 1 stays the source of truth (PURPOSE.md invariant #1) — this is a parity diagnostic, gated behind
VITE_ENABLE_BROWSER_LOUDNESS.Services (pure, fully unit-tested)
wavDecoder.ts— decodes uncompressed WAV (PCM 16/24/32 + float32, incl.WAVE_FORMAT_EXTENSIBLE) to interleaved f32 at the file's native rate. Deliberately notAudioContext.decodeAudioData, which resamples to the context rate and breaks ±0.1 LU EBU conformance. WAV-only; FLAC/MP3 report "WAV only" rather than guessing (FLAC decoding is a documented follow-up).parity.ts—computeLoudnessParity→ per-field deltas vs Phase 1, gated at ±0.1 LU. True peak is excluded (asa-dsp diverges on broadband per the WS3a: asa-dsp ↔ Essentia loudness parity harness (checkpoint) #129 parity report).loader.ts— guarded dynamic import of the WASM module from a runtime URL (VITE_BROWSER_LOUDNESS_WASM_URL) with@vite-ignore, so the production build never tries to resolve the unbuilt, gitignored package. Returns null → "unavailable" on any failure. The result adapter (NaN→null,free()) is unit-tested.UI
BrowserLoudnessPanel(flag-gated mount inAnalysisResults) reuses the already-retained AppaudioFile, with graceful states for non-WAV, wasm-unavailable, and error. Default-off → no Chromatic snapshot churn.How the wasm-artifact decision was resolved
The plan flagged that CI gitignores
pkg/and doesn't build wasm. Rather than commit a binary or add a wasm toolchain to the frontend CI, the loader dynamically imports at runtime — so the build stays clean with no new CI step and no committed binary. Activation is a documented runtime step (buildpackages/loudness-spectro-wasm, servepkg/, set the URL).Verification limits (honest)
The WAV decoder, parity math, and result adapter are unit-tested (13 new tests). The actual WASM run and browser behavior are not verified here — no browser in CI, and building web-wasm needs
wasm-bindgen-cli. The unavailable state documents activation.npm run lint+ 729 unit tests + build all pass.Notes
main.VITE_ENABLE_BROWSER_LOUDNESS(gate) +VITE_BROWSER_LOUDNESS_WASM_URL(module URL).https://claude.ai/code/session_01Wq1vE1D7o3W9xZKSHXz43Q
Generated by Claude Code