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
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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user