diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 2cf7fc41..66cac4e6 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -1429,6 +1429,7 @@ export default function App() { phase2ConsistencyReport={phase2ConsistencyReport} phase2StatusMessage={phase2StatusMessage} sourceFileName={audioFile?.name ?? null} + audioFile={audioFile} spectralArtifacts={analysisRun?.artifacts?.spectral ?? null} measurementAvailability={{ analysisMode: analysisRun?.requestedStages.analysisMode, diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx index 0ade057d..d845618e 100644 --- a/apps/ui/src/components/AnalysisResults.tsx +++ b/apps/ui/src/components/AnalysisResults.tsx @@ -30,6 +30,8 @@ import { downloadFile, generateMarkdown } from '../utils/exportUtils'; import { INTERPRETATION_LABEL } from '../services/phaseLabels'; import type { ValidationReport } from '../services/phase2Validator'; import { Phase2ConsistencyReport } from './Phase2ConsistencyReport'; +import { isBrowserLoudnessConfigEnabled } from '../config'; +import { BrowserLoudnessPanel } from './BrowserLoudnessPanel'; import { MeasurementDashboard } from './MeasurementDashboard'; import { SamplePlayback } from './SamplePlayback'; import { SessionMusicianPanel } from './SessionMusicianPanel'; @@ -82,6 +84,8 @@ export interface AnalysisResultsProps { phase2ConsistencyReport?: ValidationReport | null; phase2StatusMessage?: string | null; sourceFileName?: string | null; + /** The uploaded source File, retained for the in-browser loudness readout (WS3c). */ + audioFile?: File | null; spectralArtifacts?: SpectralArtifacts | null; measurementAvailability?: MeasurementAvailabilityContext; apiBaseUrl?: string; @@ -632,6 +636,7 @@ export function AnalysisResults({ phase2ConsistencyReport = null, phase2StatusMessage = null, sourceFileName = null, + audioFile = null, spectralArtifacts = null, measurementAvailability, apiBaseUrl, @@ -2559,6 +2564,9 @@ export function AnalysisResults({ measurementCompleted={Boolean(phase1)} /> )} + {isBrowserLoudnessConfigEnabled() && phase1 && ( + + )} ); } diff --git a/apps/ui/src/components/BrowserLoudnessPanel.tsx b/apps/ui/src/components/BrowserLoudnessPanel.tsx new file mode 100644 index 00000000..08a7f583 --- /dev/null +++ b/apps/ui/src/components/BrowserLoudnessPanel.tsx @@ -0,0 +1,169 @@ +import React, { useState } from "react"; + +import type { Phase1Result } from "../types"; +import { decodeWavPcm } from "../services/browserLoudness/wavDecoder"; +import { + computeLoudnessParity, + type LoudnessParityReport, +} from "../services/browserLoudness/parity"; +import { + loadBrowserLoudnessModule, + measureWithModule, +} from "../services/browserLoudness/loader"; +import { Button, DeviceRack, Pill } from "./ui"; + +type PanelState = + | { kind: "idle" } + | { kind: "measuring" } + | { kind: "done"; report: LoudnessParityReport } + | { kind: "unsupported" } // not a decodable WAV + | { kind: "unavailable" } // WASM core not built/served + | { kind: "error"; message: string }; + +function fmtLufs(value: number | null): string { + return value === null ? "—" : value.toFixed(1); +} + +export interface BrowserLoudnessPanelProps { + phase1: Phase1Result; + audioFile: File | null; + className?: string; +} + +/** + * WS3c: in-browser loudness parity readout. Decodes the uploaded WAV at its + * native rate and measures it with the asa-dsp (WASM core), comparing the LUFS + * scalars against the authoritative Phase 1 Essentia values. Additive and + * flag-gated; Phase 1 stays authoritative. WAV-only and true-peak-free by + * design (see the service modules). + */ +export function BrowserLoudnessPanel({ phase1, audioFile, className }: BrowserLoudnessPanelProps) { + const [state, setState] = useState({ kind: "idle" }); + + async function handleMeasure(): Promise { + if (!audioFile) { + setState({ kind: "error", message: "No source file is available to measure." }); + return; + } + setState({ kind: "measuring" }); + try { + const decoded = decodeWavPcm(await audioFile.arrayBuffer()); + if (!decoded) { + setState({ kind: "unsupported" }); + return; + } + const module = await loadBrowserLoudnessModule(); + if (!module) { + setState({ kind: "unavailable" }); + return; + } + const reading = measureWithModule(module, decoded); + setState({ kind: "done", report: computeLoudnessParity(reading, phase1) }); + } catch (err) { + setState({ + kind: "error", + message: err instanceof Error ? err.message : "Browser loudness measurement failed.", + }); + } + } + + return ( + +
+

+ Measures this track's LUFS in your browser (asa-dsp / WASM) and compares + it to the authoritative Phase 1 Essentia reading. A parity diagnostic — + Phase 1 stays the source of truth. WAV input only; true peak is omitted + (it diverges on broadband content). +

+ + {state.kind !== "done" && ( + + )} + + {state.kind === "unsupported" && ( +

+ Browser loudness currently decodes WAV only. This source isn't a WAV + (FLAC/MP3 decoding is a planned follow-up), so there's nothing to compare. +

+ )} + + {state.kind === "unavailable" && ( +

+ The browser loudness core isn't available. Build + packages/loudness-spectro-wasm and set + VITE_BROWSER_LOUDNESS_WASM_URL to enable it. +

+ )} + + {state.kind === "error" && ( +

+ {state.message} +

+ )} + + {state.kind === "done" && ( +
+
+ + Integrated delta (browser − Essentia): + + + {state.report.integratedDelta === null + ? "n/a" + : `${state.report.integratedDelta >= 0 ? "+" : ""}${state.report.integratedDelta.toFixed(2)} LU`} + + + tolerance ±{state.report.toleranceLu} LU + +
+ + + + + + + + + + + + {state.report.rows.map((row) => ( + + + + + + + ))} + +
MetricEssentiaBrowserΔ LU
{row.label}{fmtLufs(row.essentia)}{fmtLufs(row.browser)} + {row.delta === null ? "—" : `${row.delta >= 0 ? "+" : ""}${row.delta.toFixed(2)}`} +
+
+ )} +
+
+ ); +} diff --git a/apps/ui/src/config.ts b/apps/ui/src/config.ts index b188a849..38ade0ba 100644 --- a/apps/ui/src/config.ts +++ b/apps/ui/src/config.ts @@ -10,6 +10,12 @@ export interface AppConfig { * results panel renders any MT3 result that exists regardless of this flag. */ enableMt3: boolean; + /** + * Operator opt-in for the in-browser loudness parity readout (WS3c). Default + * OFF — it needs the loudness-spectro-wasm core built + served and only + * covers WAV input, so it's an additive diagnostic, not a shipped feature. + */ + enableBrowserLoudness: boolean; runtimeProfile: RuntimeProfile; requestHeaders: Record; } @@ -20,6 +26,7 @@ type AppConfigEnv = Partial< | 'VITE_API_BASE_URL' | 'VITE_ENABLE_PHASE2_GEMINI' | 'VITE_ENABLE_MT3' + | 'VITE_ENABLE_BROWSER_LOUDNESS' | 'VITE_RUNTIME_PROFILE' | 'VITE_API_REQUEST_HEADERS_JSON' > @@ -110,6 +117,10 @@ export function resolveAppConfig( overrides.VITE_ENABLE_MT3 ?? env.VITE_ENABLE_MT3, false, ), + enableBrowserLoudness: parseBooleanFlag( + overrides.VITE_ENABLE_BROWSER_LOUDNESS ?? env.VITE_ENABLE_BROWSER_LOUDNESS, + false, + ), requestHeaders: parseRequestHeaders( overrides.VITE_API_REQUEST_HEADERS_JSON ?? env.VITE_API_REQUEST_HEADERS_JSON, ), @@ -134,6 +145,10 @@ export function isMt3ConfigEnabled(config: AppConfig = appConfig): boolean { return config.enableMt3; } +export function isBrowserLoudnessConfigEnabled(config: AppConfig = appConfig): boolean { + return config.enableBrowserLoudness; +} + export function buildConfiguredRequestInit( init: RequestInit = {}, config: AppConfig = appConfig, diff --git a/apps/ui/src/services/browserLoudness/loader.ts b/apps/ui/src/services/browserLoudness/loader.ts new file mode 100644 index 00000000..cf47761e --- /dev/null +++ b/apps/ui/src/services/browserLoudness/loader.ts @@ -0,0 +1,100 @@ +/** + * Guarded loader + result adapter for the browser loudness WASM core (WS3c). + * + * The `@asa/loudness-spectro-wasm` package is NOT a build dependency of the UI + * (its `pkg/` is gitignored and not built in CI), so we never statically import + * it — that would break the production build. Instead we dynamically import the + * built web glue from a runtime URL (`VITE_BROWSER_LOUDNESS_WASM_URL`), guarded + * so any failure degrades to "unavailable" rather than crashing. + * + * Activation (documented, off by default): build the web target in + * `packages/loudness-spectro-wasm` (`npm run build`), serve `pkg/`, and point + * `VITE_BROWSER_LOUDNESS_WASM_URL` at its entry JS. Until then the loader + * returns null and the panel shows the unavailable state. + */ + +import type { BrowserLoudnessReading } from "./parity"; +import type { DecodedWavPcm } from "./wavDecoder"; + +/** The raw shape the WASM `measureLoudness` returns (snake_case from Rust). */ +export interface RawWasmLoudness { + integrated_lufs: number; + loudness_range: number; + momentary_max_lufs: number; + short_term_max_lufs: number; + free?: () => void; +} + +export interface LoudnessWasmModule { + /** wasm-bindgen init (idempotent). */ + default?: () => Promise; + measureLoudness: (samples: Float32Array, channels: number, sampleRate: number) => RawWasmLoudness; +} + +function toFinite(value: number): number | null { + // The WASM core returns NaN when a value is undefined (no gated block). + return Number.isFinite(value) ? value : null; +} + +/** + * Map a raw WASM result to our reading, normalizing NaN→null and releasing the + * wasm-owned object. Pure and unit-testable (the part worth pinning); the + * dynamic import below is the thin, environment-bound piece. + */ +export function adaptWasmLoudness(raw: RawWasmLoudness): BrowserLoudnessReading { + const reading: BrowserLoudnessReading = { + integrated: toFinite(raw.integrated_lufs), + range: toFinite(raw.loudness_range), + momentaryMax: toFinite(raw.momentary_max_lufs), + shortTermMax: toFinite(raw.short_term_max_lufs), + }; + raw.free?.(); + return reading; +} + +/** Measure a decoded clip with an already-loaded module (testable with a mock). */ +export function measureWithModule( + module: LoudnessWasmModule, + decoded: DecodedWavPcm, +): BrowserLoudnessReading { + const raw = module.measureLoudness(decoded.samples, decoded.channels, decoded.sampleRate); + return adaptWasmLoudness(raw); +} + +function wasmModuleUrl(): string | null { + // VITE_BROWSER_LOUDNESS_WASM_URL is declared in vite-env.d.ts, so import.meta + // is already typed here — no cast needed. + const url = import.meta.env.VITE_BROWSER_LOUDNESS_WASM_URL?.trim(); + return url ? url : null; +} + +let modulePromise: Promise | null = null; + +/** + * Dynamically load + init the WASM module, memoized. Returns null when no URL is + * configured or the import/init fails — never throws into the UI. The + * `@vite-ignore` keeps the build from trying to resolve the (unbuilt) package. + */ +export function loadBrowserLoudnessModule(): Promise { + if (modulePromise) return modulePromise; + const url = wasmModuleUrl(); + if (!url) return Promise.resolve(null); + + modulePromise = (async () => { + try { + const module = (await import(/* @vite-ignore */ url)) as LoudnessWasmModule; + if (typeof module.default === "function") { + await module.default(); + } + return typeof module.measureLoudness === "function" ? module : null; + } catch { + return null; + } + })(); + return modulePromise; +} + +/** Test hook: reset the memoized module promise. */ +export function resetBrowserLoudnessModuleForTests(): void { + modulePromise = null; +} diff --git a/apps/ui/src/services/browserLoudness/parity.ts b/apps/ui/src/services/browserLoudness/parity.ts new file mode 100644 index 00000000..c4bffa15 --- /dev/null +++ b/apps/ui/src/services/browserLoudness/parity.ts @@ -0,0 +1,108 @@ +/** + * Browser-loudness ↔ Phase 1 (Essentia) parity computation (WS3c). + * + * Compares the in-browser asa-dsp (WASM core) LUFS reading against the + * authoritative Phase 1 Essentia values and reports per-field deltas. Phase 1 + * stays authoritative (PURPOSE.md invariant #1) — this is an additive readout, + * never a replacement. + * + * Scope is the LUFS scalars only. True peak is deliberately excluded: the #129 + * parity report found asa-dsp's true peak diverges materially from Essentia's on + * broadband content (~0.6 dBTP), so exposing a browser true-peak readout would + * mislead until that divergence is gated. (See essentia-parity-report.md.) + */ + +import type { Phase1Result } from "../../types"; + +/** The four LUFS scalars the WASM core can produce. */ +export interface BrowserLoudnessReading { + integrated: number | null; + range: number | null; + momentaryMax: number | null; + shortTermMax: number | null; +} + +export interface LoudnessParityRow { + label: string; + /** Phase 1 field this row compares against (chain of custody). */ + phase1Field: string; + browser: number | null; + essentia: number | null; + /** browser − essentia, rounded; null when either side is missing. */ + delta: number | null; + /** |delta| ≤ tolerance; null when not comparable. */ + withinTolerance: boolean | null; +} + +export interface LoudnessParityReport { + rows: LoudnessParityRow[]; + /** The integrated-LUFS delta — the headline parity number. */ + integratedDelta: number | null; + integratedWithinTolerance: boolean | null; + toleranceLu: number; +} + +/** EBU integrated-loudness agreement target (mirrors the native harness gate). */ +export const PARITY_TOLERANCE_LU = 0.1; + +type Phase1Loudness = Pick< + Phase1Result, + "lufsIntegrated" | "lufsRange" | "lufsMomentaryMax" | "lufsShortTermMax" +>; + +function finite(value: number | null | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function buildRow( + label: string, + phase1Field: string, + browser: number | null, + essentia: number | null, + toleranceLu: number, +): LoudnessParityRow { + const b = finite(browser); + const e = finite(essentia); + const delta = b !== null && e !== null ? Math.round((b - e) * 1000) / 1000 : null; + return { + label, + phase1Field, + browser: b, + essentia: e, + delta, + withinTolerance: delta === null ? null : Math.abs(delta) <= toleranceLu, + }; +} + +export function computeLoudnessParity( + browser: BrowserLoudnessReading, + phase1: Phase1Loudness, + toleranceLu: number = PARITY_TOLERANCE_LU, +): LoudnessParityReport { + const rows: LoudnessParityRow[] = [ + buildRow("Integrated", "lufsIntegrated", browser.integrated, phase1.lufsIntegrated, toleranceLu), + buildRow("Range (LRA)", "lufsRange", browser.range, phase1.lufsRange ?? null, toleranceLu), + buildRow( + "Momentary max", + "lufsMomentaryMax", + browser.momentaryMax, + phase1.lufsMomentaryMax ?? null, + toleranceLu, + ), + buildRow( + "Short-term max", + "lufsShortTermMax", + browser.shortTermMax, + phase1.lufsShortTermMax ?? null, + toleranceLu, + ), + ]; + + const integrated = rows[0]; + return { + rows, + integratedDelta: integrated.delta, + integratedWithinTolerance: integrated.withinTolerance, + toleranceLu, + }; +} diff --git a/apps/ui/src/services/browserLoudness/wavDecoder.ts b/apps/ui/src/services/browserLoudness/wavDecoder.ts new file mode 100644 index 00000000..53125266 --- /dev/null +++ b/apps/ui/src/services/browserLoudness/wavDecoder.ts @@ -0,0 +1,121 @@ +/** + * Minimal WAV PCM decoder for the browser loudness readout (WS3c). + * + * Why not `AudioContext.decodeAudioData`? It resamples to the context rate, + * which alters the signal and breaks the ±0.1 LU EBU conformance the WASM core + * is held to (the loudness-spectro-wasm README warns about exactly this). So we + * read the PCM directly at the file's native rate and hand interleaved f32 to + * the measurer. + * + * Scope: uncompressed WAV only (PCM int 16/24/32 and IEEE float32, including + * WAVE_FORMAT_EXTENSIBLE). FLAC — ASA's primary format — has no decoder here; + * the panel reports "WAV only" for anything else rather than guessing. This is + * the documented WS3c limitation until a FLAC decoder lands. + */ + +export interface DecodedWavPcm { + /** Interleaved f32 PCM in [-1, 1] ([L,R,L,R,...] for stereo). */ + samples: Float32Array; + channels: number; + sampleRate: number; +} + +const WAVE_FORMAT_PCM = 0x0001; +const WAVE_FORMAT_IEEE_FLOAT = 0x0003; +const WAVE_FORMAT_EXTENSIBLE = 0xfffe; + +function readFourCC(view: DataView, offset: number): string { + return String.fromCharCode( + view.getUint8(offset), + view.getUint8(offset + 1), + view.getUint8(offset + 2), + view.getUint8(offset + 3), + ); +} + +/** + * Decode an uncompressed WAV ArrayBuffer to interleaved f32 at its native rate. + * Returns null when the buffer is not a WAV we can decode (caller shows the + * "WAV only" state rather than throwing into the UI). + */ +export function decodeWavPcm(buffer: ArrayBuffer): DecodedWavPcm | null { + if (buffer.byteLength < 44) return null; + const view = new DataView(buffer); + + if (readFourCC(view, 0) !== "RIFF" || readFourCC(view, 8) !== "WAVE") { + return null; + } + + let formatTag = 0; + let channels = 0; + let sampleRate = 0; + let bitsPerSample = 0; + let dataOffset = -1; + let dataLength = 0; + + // Walk the chunk list (fmt / data may be separated by LIST, fact, etc.). + let offset = 12; + while (offset + 8 <= buffer.byteLength) { + const chunkId = readFourCC(view, offset); + const chunkSize = view.getUint32(offset + 4, true); + const body = offset + 8; + + if (chunkId === "fmt ") { + formatTag = view.getUint16(body, true); + channels = view.getUint16(body + 2, true); + sampleRate = view.getUint32(body + 4, true); + bitsPerSample = view.getUint16(body + 14, true); + if (formatTag === WAVE_FORMAT_EXTENSIBLE && chunkSize >= 26) { + // Real format lives in the first 2 bytes of the SubFormat GUID, which + // starts at body+24 — so the chunk must hold at least bytes 24-25. + formatTag = view.getUint16(body + 24, true); + } + } else if (chunkId === "data") { + dataOffset = body; + // Clamp to the actual buffer (some encoders over-declare the size). + dataLength = Math.min(chunkSize, buffer.byteLength - body); + } + + // Chunks are word-aligned: an odd size carries a trailing pad byte. + offset = body + chunkSize + (chunkSize % 2); + } + + if ( + channels <= 0 || + sampleRate <= 0 || + dataOffset < 0 || + dataLength <= 0 || + (formatTag !== WAVE_FORMAT_PCM && formatTag !== WAVE_FORMAT_IEEE_FLOAT) + ) { + return null; + } + + const bytesPerSample = bitsPerSample / 8; + if (![2, 3, 4].includes(bytesPerSample)) return null; + const totalSamples = Math.floor(dataLength / bytesPerSample); + const out = new Float32Array(totalSamples); + + if (formatTag === WAVE_FORMAT_IEEE_FLOAT && bytesPerSample === 4) { + for (let i = 0; i < totalSamples; i++) { + out[i] = view.getFloat32(dataOffset + i * 4, true); + } + } else if (bytesPerSample === 2) { + for (let i = 0; i < totalSamples; i++) { + out[i] = view.getInt16(dataOffset + i * 2, true) / 32768; + } + } else if (bytesPerSample === 3) { + for (let i = 0; i < totalSamples; i++) { + const p = dataOffset + i * 3; + // Little-endian 24-bit, sign-extended. + let v = view.getUint8(p) | (view.getUint8(p + 1) << 8) | (view.getUint8(p + 2) << 16); + if (v & 0x800000) v |= ~0xffffff; + out[i] = v / 8388608; + } + } else if (bytesPerSample === 4) { + for (let i = 0; i < totalSamples; i++) { + out[i] = view.getInt32(dataOffset + i * 4, true) / 2147483648; + } + } + + return { samples: out, channels, sampleRate }; +} diff --git a/apps/ui/src/vite-env.d.ts b/apps/ui/src/vite-env.d.ts index 9395cac5..a085272d 100644 --- a/apps/ui/src/vite-env.d.ts +++ b/apps/ui/src/vite-env.d.ts @@ -5,6 +5,8 @@ interface ImportMetaEnv { readonly VITE_API_REQUEST_HEADERS_JSON?: string; readonly VITE_ENABLE_PHASE2_GEMINI?: string; readonly VITE_ENABLE_MT3?: string; + readonly VITE_ENABLE_BROWSER_LOUDNESS?: string; + readonly VITE_BROWSER_LOUDNESS_WASM_URL?: string; readonly VITE_RUNTIME_PROFILE?: string; readonly DISABLE_HMR?: string; } diff --git a/apps/ui/tests/services/browserLoudness/loader.test.ts b/apps/ui/tests/services/browserLoudness/loader.test.ts new file mode 100644 index 00000000..e1e4afe9 --- /dev/null +++ b/apps/ui/tests/services/browserLoudness/loader.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { + adaptWasmLoudness, + measureWithModule, +} from "../../../src/services/browserLoudness/loader"; + +describe("adaptWasmLoudness", () => { + it("maps snake_case wasm fields and releases the wasm-owned result", () => { + const free = vi.fn(); + const reading = adaptWasmLoudness({ + integrated_lufs: -14.7, + loudness_range: 6.0, + momentary_max_lufs: -12.5, + short_term_max_lufs: -13.2, + free, + }); + expect(reading).toEqual({ + integrated: -14.7, + range: 6.0, + momentaryMax: -12.5, + shortTermMax: -13.2, + }); + expect(free).toHaveBeenCalledOnce(); + }); + + it("normalizes NaN to null (the WASM core's 'no gated block' signal)", () => { + const reading = adaptWasmLoudness({ + integrated_lufs: NaN, + loudness_range: NaN, + momentary_max_lufs: -12.5, + short_term_max_lufs: NaN, + }); + expect(reading.integrated).toBeNull(); + expect(reading.range).toBeNull(); + expect(reading.momentaryMax).toBe(-12.5); + expect(reading.shortTermMax).toBeNull(); + }); +}); + +describe("measureWithModule", () => { + it("invokes measureLoudness with decoded PCM and adapts the result", () => { + const measureLoudness = vi.fn(() => ({ + integrated_lufs: -10.0, + loudness_range: 4.0, + momentary_max_lufs: -8.0, + short_term_max_lufs: -9.0, + })); + const decoded = { samples: new Float32Array([0, 0.5, -0.5, 0]), channels: 2, sampleRate: 48000 }; + + const out = measureWithModule({ measureLoudness }, decoded); + + expect(measureLoudness).toHaveBeenCalledWith(decoded.samples, 2, 48000); + expect(out.integrated).toBe(-10.0); + expect(out.shortTermMax).toBe(-9.0); + }); +}); diff --git a/apps/ui/tests/services/browserLoudness/parity.test.ts b/apps/ui/tests/services/browserLoudness/parity.test.ts new file mode 100644 index 00000000..e7578214 --- /dev/null +++ b/apps/ui/tests/services/browserLoudness/parity.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + computeLoudnessParity, + PARITY_TOLERANCE_LU, +} from "../../../src/services/browserLoudness/parity"; +import type { Phase1Result } from "../../../src/types"; + +const phase1 = { + lufsIntegrated: -9.3, + lufsRange: 5.2, + lufsMomentaryMax: -7.1, + lufsShortTermMax: -8.0, +} as unknown as Phase1Result; + +describe("computeLoudnessParity", () => { + it("builds rows for the four LUFS scalars with browser−essentia deltas", () => { + const report = computeLoudnessParity( + { integrated: -9.35, range: 5.2, momentaryMax: -7.0, shortTermMax: -8.05 }, + phase1, + ); + expect(report.rows.map((r) => r.phase1Field)).toEqual([ + "lufsIntegrated", + "lufsRange", + "lufsMomentaryMax", + "lufsShortTermMax", + ]); + const integrated = report.rows[0]; + expect(integrated.delta).toBeCloseTo(-0.05, 3); + expect(integrated.withinTolerance).toBe(true); + expect(report.integratedDelta).toBeCloseTo(-0.05, 3); + expect(report.integratedWithinTolerance).toBe(true); + }); + + it("excludes true peak (withheld per the #129 parity report)", () => { + const report = computeLoudnessParity( + { integrated: -9.3, range: null, momentaryMax: null, shortTermMax: null }, + phase1, + ); + expect(report.rows.some((r) => r.phase1Field.toLowerCase().includes("truepeak"))).toBe(false); + expect(report.rows).toHaveLength(4); + }); + + it("flags an out-of-tolerance integrated delta", () => { + const report = computeLoudnessParity( + { integrated: -9.0, range: null, momentaryMax: null, shortTermMax: null }, + phase1, + ); + expect(report.integratedDelta).toBeCloseTo(0.3, 3); // -9.0 − (-9.3) + expect(report.integratedWithinTolerance).toBe(false); + }); + + it("marks a row not-comparable when either side is null", () => { + const sparse = { + lufsIntegrated: -9.3, + lufsRange: null, + lufsMomentaryMax: null, + lufsShortTermMax: null, + } as unknown as Phase1Result; + const report = computeLoudnessParity( + { integrated: -9.3, range: 5.0, momentaryMax: null, shortTermMax: null }, + sparse, + ); + const range = report.rows[1]; + expect(range.delta).toBeNull(); + expect(range.withinTolerance).toBeNull(); + }); + + it("defaults to the ±0.1 LU tolerance", () => { + expect(PARITY_TOLERANCE_LU).toBe(0.1); + }); +}); diff --git a/apps/ui/tests/services/browserLoudness/wavDecoder.test.ts b/apps/ui/tests/services/browserLoudness/wavDecoder.test.ts new file mode 100644 index 00000000..1a53fd1f --- /dev/null +++ b/apps/ui/tests/services/browserLoudness/wavDecoder.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { decodeWavPcm } from "../../../src/services/browserLoudness/wavDecoder"; + +function writeStr(dv: DataView, offset: number, str: string): void { + for (let i = 0; i < str.length; i++) dv.setUint8(offset + i, str.charCodeAt(i)); +} + +interface BuildWavOptions { + sampleRate?: number; + channels?: number; + bits?: number; + format?: number; // 1 = PCM int, 3 = IEEE float + samples: number[]; // interleaved, in [-1, 1] + extraChunk?: { id: string; bytes: number }; // injected between fmt and data +} + +function buildWav(opts: BuildWavOptions): ArrayBuffer { + const { sampleRate = 48000, channels = 1, bits = 16, format = 1, samples, extraChunk } = opts; + const bytesPerSample = bits / 8; + const dataLen = samples.length * bytesPerSample; + const extraLen = extraChunk ? 8 + extraChunk.bytes : 0; + const buf = new ArrayBuffer(44 + extraLen + dataLen); + const dv = new DataView(buf); + + writeStr(dv, 0, "RIFF"); + dv.setUint32(4, 36 + extraLen + dataLen, true); + writeStr(dv, 8, "WAVE"); + + writeStr(dv, 12, "fmt "); + dv.setUint32(16, 16, true); + dv.setUint16(20, format, true); + dv.setUint16(22, channels, true); + dv.setUint32(24, sampleRate, true); + dv.setUint32(28, sampleRate * channels * bytesPerSample, true); + dv.setUint16(32, channels * bytesPerSample, true); + dv.setUint16(34, bits, true); + + let offset = 36; + if (extraChunk) { + writeStr(dv, offset, extraChunk.id.padEnd(4)); + dv.setUint32(offset + 4, extraChunk.bytes, true); + offset += 8 + extraChunk.bytes; + } + + writeStr(dv, offset, "data"); + dv.setUint32(offset + 4, dataLen, true); + let p = offset + 8; + for (const s of samples) { + if (format === 3) { + dv.setFloat32(p, s, true); + p += 4; + } else if (bits === 16) { + dv.setInt16(p, Math.round(s * 32767), true); + p += 2; + } else if (bits === 24) { + const v = Math.round(s * 8388607); + dv.setUint8(p, v & 0xff); + dv.setUint8(p + 1, (v >> 8) & 0xff); + dv.setUint8(p + 2, (v >> 16) & 0xff); + p += 3; + } else if (bits === 32) { + dv.setInt32(p, Math.round(s * 2147483647), true); + p += 4; + } + } + return buf; +} + +// WAVE_FORMAT_EXTENSIBLE has a 40-byte fmt chunk whose real format tag lives in +// the first 2 bytes of the SubFormat GUID at body+24 (so chunkSize must be >=26 +// for the decoder to read it). buildWav can't express that, so build it here. +function buildExtensibleWav(opts: { + sampleRate?: number; + channels?: number; + bits?: number; + subFormat?: number; // real format in the GUID: 1 = PCM int, 3 = IEEE float + samples: number[]; +}): ArrayBuffer { + const { sampleRate = 48000, channels = 1, bits = 16, subFormat = 1, samples } = opts; + const bytesPerSample = bits / 8; + const dataLen = samples.length * bytesPerSample; + const fmtBody = 40; + const buf = new ArrayBuffer(12 + 8 + fmtBody + 8 + dataLen); + const dv = new DataView(buf); + + writeStr(dv, 0, "RIFF"); + dv.setUint32(4, 4 + (8 + fmtBody) + (8 + dataLen), true); + writeStr(dv, 8, "WAVE"); + + writeStr(dv, 12, "fmt "); + dv.setUint32(16, fmtBody, true); + dv.setUint16(20, 0xfffe, true); // WAVE_FORMAT_EXTENSIBLE + dv.setUint16(22, channels, true); + dv.setUint32(24, sampleRate, true); + dv.setUint32(28, sampleRate * channels * bytesPerSample, true); + dv.setUint16(32, channels * bytesPerSample, true); + dv.setUint16(34, bits, true); + dv.setUint16(36, 22, true); // cbSize + dv.setUint16(38, bits, true); // wValidBitsPerSample + dv.setUint32(40, 0, true); // dwChannelMask + dv.setUint16(44, subFormat, true); // SubFormat GUID — first 2 bytes carry the real tag + + const dataHeader = 12 + 8 + fmtBody; + writeStr(dv, dataHeader, "data"); + dv.setUint32(dataHeader + 4, dataLen, true); + let p = dataHeader + 8; + for (const s of samples) { + dv.setInt16(p, Math.round(s * 32767), true); + p += 2; + } + return buf; +} + +describe("decodeWavPcm", () => { + it("decodes 16-bit PCM mono at the native rate", () => { + const wav = buildWav({ sampleRate: 44100, channels: 1, bits: 16, samples: [0, 0.5, -0.5, 1.0] }); + const out = decodeWavPcm(wav); + expect(out).not.toBeNull(); + expect(out!.channels).toBe(1); + expect(out!.sampleRate).toBe(44100); + expect(out!.samples.length).toBe(4); + expect(out!.samples[1]).toBeCloseTo(0.5, 2); + expect(out!.samples[2]).toBeCloseTo(-0.5, 2); + }); + + it("decodes 32-bit float stereo as interleaved samples", () => { + const wav = buildWav({ + sampleRate: 48000, + channels: 2, + bits: 32, + format: 3, + samples: [0.1, -0.1, 0.2, -0.2], + }); + const out = decodeWavPcm(wav); + expect(out!.channels).toBe(2); + expect(out!.sampleRate).toBe(48000); + expect(out!.samples.length).toBe(4); + expect(out!.samples[0]).toBeCloseTo(0.1, 5); + expect(out!.samples[3]).toBeCloseTo(-0.2, 5); + }); + + it("decodes 24-bit PCM", () => { + const wav = buildWav({ channels: 1, bits: 24, samples: [0.25, -0.75] }); + const out = decodeWavPcm(wav); + expect(out!.samples[0]).toBeCloseTo(0.25, 3); + expect(out!.samples[1]).toBeCloseTo(-0.75, 3); + }); + + it("walks past an unknown chunk to find data", () => { + const wav = buildWav({ + channels: 1, + bits: 16, + samples: [0.5, -0.5], + extraChunk: { id: "LIST", bytes: 6 }, + }); + const out = decodeWavPcm(wav); + expect(out).not.toBeNull(); + expect(out!.samples.length).toBe(2); + expect(out!.samples[0]).toBeCloseTo(0.5, 2); + }); + + it("decodes WAVE_FORMAT_EXTENSIBLE by reading the SubFormat tag (chunkSize 40 >= 26)", () => { + const wav = buildExtensibleWav({ channels: 1, bits: 16, subFormat: 1, samples: [0.5, -0.5, 0.25] }); + const out = decodeWavPcm(wav); + expect(out).not.toBeNull(); + expect(out!.channels).toBe(1); + expect(out!.sampleRate).toBe(48000); + expect(out!.samples.length).toBe(3); + expect(out!.samples[0]).toBeCloseTo(0.5, 2); + expect(out!.samples[1]).toBeCloseTo(-0.5, 2); + }); + + it("returns null for non-WAV / truncated buffers", () => { + expect(decodeWavPcm(new ArrayBuffer(10))).toBeNull(); + const notRiff = new ArrayBuffer(64); + writeStr(new DataView(notRiff), 0, "OggS"); + expect(decodeWavPcm(notRiff)).toBeNull(); + }); +});