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
+21 -1
View File
@@ -1,4 +1,4 @@
import { api } from './client';
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.
@@ -40,3 +40,23 @@ export function getNormalization(): Promise<NormalizationPrefs> {
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> }>;
}