feat(web): level playback by the user's normalization preference (M464 #4999)
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

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>
This commit is contained in:
2026-10-06 18:21:41 -04:00
co-authored by Claude Opus 5.5
parent 2a7eb3dd19
commit 38290bf8f9
7 changed files with 434 additions and 3 deletions
+66
View File
@@ -0,0 +1,66 @@
// Loudness normalization math for the web player (M464 #4999). Pure, so the
// rules are tested without an <audio> element or an AudioContext.
import type { NormalizationPrefs, ReplayGain } from '$lib/api/normalization';
import type { TrackRef } from '$lib/api/types';
// The server's gains are to the ReplayGain 2.0 reference.
const REFERENCE_LUFS = -18;
// Headroom mode raises a quiet track only until its true peak reaches this.
export const PEAK_CEILING_DBTP = -1;
// No track is raised more than this, whatever its measurement says: a
// near-silent track would otherwise come out as amplified noise.
export const MAX_BOOST_DB = 12;
/**
* Whether the track at `index` is being played as part of its album, in
* order: a neighbour in the queue is from the same album and sits on the
* right side of it. That is when album gain keeps the album's own dynamics
* (a quiet intro stays quiet); anywhere else track gain levels the mix.
*/
export function playingAsAlbum(queue: TrackRef[], index: number): boolean {
const cur = queue[index];
if (!cur) return false;
const prev = queue[index - 1];
const next = queue[index + 1];
return (
(prev !== undefined && prev.album_id === cur.album_id && order(prev) < order(cur)) ||
(next !== undefined && next.album_id === cur.album_id && order(next) > order(cur))
);
}
// Disc-major track order. Tracks without numbers compare as equal and so
// never count as in order; a same-album neighbour with no numbers is not
// evidence of album play.
function order(t: TrackRef): number {
return (t.disc_number ?? 1) * 1000 + (t.track_number ?? 0);
}
/**
* The gain to apply, in dB, for one track. 0 when leveling is off or the
* track has not been measured yet: an unmeasured track plays as mastered.
*/
export function gainDb(
prefs: NormalizationPrefs,
g: ReplayGain | undefined,
asAlbum: boolean
): number {
if (prefs.mode === 'off' || !g) return 0;
const wantAlbum = prefs.mode === 'album' || (prefs.mode === 'auto' && asAlbum);
// Album gain falls back to track gain while the album is still being
// measured; track gain never falls back to album gain.
const useAlbum = wantAlbum && g.album_gain !== undefined;
const gain = useAlbum ? g.album_gain : g.track_gain;
const peak = useAlbum ? g.album_peak : g.track_peak;
if (gain === undefined) return 0;
let db = gain + (prefs.target_lufs - REFERENCE_LUFS);
if (prefs.boost === 'headroom' && peak !== undefined && peak > 0) {
db = Math.min(db, PEAK_CEILING_DBTP - 20 * Math.log10(peak));
}
return Math.min(db, MAX_BOOST_DB);
}
export function dbToLinear(db: number): number {
return Math.pow(10, db / 20);
}