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
1 change: 1 addition & 0 deletions apps/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions apps/ui/src/components/AnalysisResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -632,6 +636,7 @@ export function AnalysisResults({
phase2ConsistencyReport = null,
phase2StatusMessage = null,
sourceFileName = null,
audioFile = null,
spectralArtifacts = null,
measurementAvailability,
apiBaseUrl,
Expand Down Expand Up @@ -2559,6 +2564,9 @@ export function AnalysisResults({
measurementCompleted={Boolean(phase1)}
/>
)}
{isBrowserLoudnessConfigEnabled() && phase1 && (
<BrowserLoudnessPanel phase1={phase1} audioFile={audioFile} className="mt-6" />
)}
</motion.div>
);
}
169 changes: 169 additions & 0 deletions apps/ui/src/components/BrowserLoudnessPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<PanelState>({ kind: "idle" });

async function handleMeasure(): Promise<void> {
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 (
<DeviceRack
name="BROWSER LOUDNESS"
subtitle="· parity (experimental)"
status={state.kind === "done" ? "active" : "idle"}
aria-label="Browser loudness parity"
className={className}
>
<div className="space-y-3">
<p className="max-w-xl font-mono text-[11px] leading-snug text-text-secondary">
Measures this track&apos;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).
</p>

{state.kind !== "done" && (
<Button
variant="primary"
size="md"
ledIndicator
onClick={handleMeasure}
disabled={state.kind === "measuring" || !audioFile}
>
{state.kind === "measuring" ? "Measuring…" : "Measure in browser"}
</Button>
)}

{state.kind === "unsupported" && (
<p className="font-mono text-[11px] text-text-secondary">
Browser loudness currently decodes WAV only. This source isn&apos;t a WAV
(FLAC/MP3 decoding is a planned follow-up), so there&apos;s nothing to compare.
</p>
)}

{state.kind === "unavailable" && (
<p className="font-mono text-[11px] text-text-secondary">
The browser loudness core isn&apos;t available. Build
<span className="text-text-primary"> packages/loudness-spectro-wasm</span> and set
<span className="text-text-primary"> VITE_BROWSER_LOUDNESS_WASM_URL</span> to enable it.
</p>
)}

{state.kind === "error" && (
<p className="font-mono text-[11px] text-error" role="alert">
{state.message}
</p>
)}

{state.kind === "done" && (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[11px] text-text-secondary">
Integrated delta (browser − Essentia):
</span>
<Pill
tone={state.report.integratedWithinTolerance ? "success" : "warning"}
variant="outline"
>
{state.report.integratedDelta === null
? "n/a"
: `${state.report.integratedDelta >= 0 ? "+" : ""}${state.report.integratedDelta.toFixed(2)} LU`}
</Pill>
<span className="font-mono text-[10px] text-text-secondary/70">
tolerance ±{state.report.toleranceLu} LU
</span>
</div>

<table className="w-full font-mono text-[11px]">
<thead>
<tr className="text-text-secondary/60">
<th className="text-left font-normal">Metric</th>
<th className="text-right font-normal">Essentia</th>
<th className="text-right font-normal">Browser</th>
<th className="text-right font-normal">Δ LU</th>
</tr>
</thead>
<tbody>
{state.report.rows.map((row) => (
<tr key={row.phase1Field}>
<td className="text-text-primary">{row.label}</td>
<td className="text-right text-text-secondary">{fmtLufs(row.essentia)}</td>
<td className="text-right text-accent">{fmtLufs(row.browser)}</td>
<td
className={`text-right ${
row.withinTolerance === false ? "text-warning" : "text-text-secondary"
}`}
>
{row.delta === null ? "—" : `${row.delta >= 0 ? "+" : ""}${row.delta.toFixed(2)}`}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</DeviceRack>
);
}
15 changes: 15 additions & 0 deletions apps/ui/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}
Expand All @@ -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'
>
Expand Down Expand Up @@ -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,
),
Expand All @@ -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,
Expand Down
100 changes: 100 additions & 0 deletions apps/ui/src/services/browserLoudness/loader.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
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<LoudnessWasmModule | null> | 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<LoudnessWasmModule | null> {
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;
}
Loading
Loading