Files
minstrel/web/src/lib/api/normalization.ts
T
bvandeusenandClaude Opus 5.5 38290bf8f9
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m26s
release / android (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
feat(web): level playback by the user's normalization preference (M464 #4999)
Cuts go through element.volume. Boosts route the element through a Web
Audio GainNode and a DynamicsCompressor (a -1 dBFS limiter in limiter
mode, a pass-through otherwise), built only when a track wants a boost
and only once an AudioContext is confirmed running; iOS never gets the
graph. Auto mode takes album gain when a queue neighbour is from the
same album in track order. Gains are fetched for the next 50 tracks as
the queue moves, with a 10s deadline. The prefetch element is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:21:41 -04:00

63 lines
2.3 KiB
TypeScript

import { api, apiFetch } from './client';
// Loudness normalization preference (M464 #4998). One per user, stored on
// the server so the web player, the Android app and casts agree.
export type NormalizationMode = 'off' | 'auto' | 'track' | 'album';
export type NormalizationTarget = -18 | -16 | -14;
export type NormalizationBoost = 'headroom' | 'limiter';
export type NormalizationPrefs = {
mode: NormalizationMode;
target_lufs: NormalizationTarget;
boost: NormalizationBoost;
};
// Must match library.DefaultNormalizationPrefs on the server.
export const DEFAULT_NORMALIZATION: NormalizationPrefs = {
mode: 'auto',
target_lufs: -18,
boost: 'headroom'
};
export const NORMALIZATION_MODES: NormalizationMode[] = ['off', 'auto', 'track', 'album'];
export const NORMALIZATION_TARGETS: NormalizationTarget[] = [-18, -16, -14];
export function isNormalizationPrefs(v: unknown): v is NormalizationPrefs {
if (typeof v !== 'object' || v === null) return false;
const p = v as Record<string, unknown>;
return (
NORMALIZATION_MODES.includes(p.mode as NormalizationMode) &&
NORMALIZATION_TARGETS.includes(p.target_lufs as NormalizationTarget) &&
(p.boost === 'headroom' || p.boost === 'limiter')
);
}
export function getNormalization(): Promise<NormalizationPrefs> {
return api.get<NormalizationPrefs>('/api/me/normalization');
}
// The whole preference is sent every time; the server keeps the last write.
export function putNormalization(p: NormalizationPrefs): Promise<NormalizationPrefs> {
return api.put<NormalizationPrefs>('/api/me/normalization', p);
}
// Per-track gains (#4997): dB to the -18 LUFS ReplayGain reference and linear
// peaks. A field is absent until the server has measured it.
export type ReplayGain = {
track_gain?: number;
track_peak?: number;
album_gain?: number;
album_peak?: number;
};
/**
* Gains for up to 200 tracks; an id missing from items has none yet. Given
* a deadline: the player waits on it, and a request that never answers
* would leave those tracks unleveled for the rest of the session.
*/
export function getReplayGain(ids: string[]): Promise<{ items: Record<string, ReplayGain> }> {
return apiFetch(`/api/tracks/replay-gain?ids=${ids.map(encodeURIComponent).join(',')}`, {
signal: AbortSignal.timeout(10_000)
}) as Promise<{ items: Record<string, ReplayGain> }>;
}