Files
minstrel/web/src/lib/player/gain.test.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

87 lines
3.5 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import { dbToLinear, gainDb, playingAsAlbum, MAX_BOOST_DB } from './gain';
import type { NormalizationPrefs } from '$lib/api/normalization';
import type { TrackRef } from '$lib/api/types';
import { makeTrack } from '$test-utils/fixtures/track';
const prefs = (over: Partial<NormalizationPrefs> = {}): NormalizationPrefs => ({
mode: 'auto',
target_lufs: -18,
boost: 'headroom',
...over
});
const track = (id: string, album: string, n?: number, disc?: number): TrackRef =>
makeTrack({ id, album_id: album, track_number: n, disc_number: disc });
describe('gainDb', () => {
const g = { track_gain: -6, track_peak: 1, album_gain: -4, album_peak: 1 };
test('off and unmeasured tracks play as mastered', () => {
expect(gainDb(prefs({ mode: 'off' }), g, false)).toBe(0);
expect(gainDb(prefs(), undefined, false)).toBe(0);
expect(gainDb(prefs(), {}, false)).toBe(0);
});
test('mode picks the gain; auto follows album play', () => {
expect(gainDb(prefs({ mode: 'track' }), g, true)).toBe(-6);
expect(gainDb(prefs({ mode: 'album' }), g, false)).toBe(-4);
expect(gainDb(prefs({ mode: 'auto' }), g, true)).toBe(-4);
expect(gainDb(prefs({ mode: 'auto' }), g, false)).toBe(-6);
});
test('album gain falls back to track gain until the album is measured', () => {
expect(gainDb(prefs({ mode: 'album' }), { track_gain: -6, track_peak: 1 }, true)).toBe(-6);
});
test('a louder target raises every gain by the difference', () => {
expect(gainDb(prefs({ mode: 'track', target_lufs: -14 }), g, false)).toBe(-2);
});
test('headroom stops a boost 1 dB under the true peak; the limiter lets it through', () => {
// +8 dB wanted; the peak at 0.5 (about -6 dBTP) leaves 5 dB of room.
const quiet = { track_gain: 8, track_peak: 0.5 };
expect(gainDb(prefs({ mode: 'track' }), quiet, false)).toBeCloseTo(-1 - 20 * Math.log10(0.5), 5);
expect(gainDb(prefs({ mode: 'track', boost: 'limiter' }), quiet, false)).toBe(8);
});
test('no boost passes the cap, limiter or not', () => {
const silent = { track_gain: 30, track_peak: 0.001 };
expect(gainDb(prefs({ mode: 'track', boost: 'limiter' }), silent, false)).toBe(MAX_BOOST_DB);
});
test('cuts are never limited by the peak', () => {
expect(gainDb(prefs({ mode: 'track' }), { track_gain: -9, track_peak: 1.4 }, false)).toBe(-9);
});
});
describe('playingAsAlbum', () => {
test('in-order neighbours from the same album', () => {
const q = [track('1', 'x', 1), track('2', 'x', 2), track('3', 'x', 3)];
expect(playingAsAlbum(q, 0)).toBe(true);
expect(playingAsAlbum(q, 1)).toBe(true);
expect(playingAsAlbum(q, 2)).toBe(true);
});
test('a shuffled album, a mix and a lone track are not album play', () => {
expect(playingAsAlbum([track('3', 'x', 3), track('1', 'x', 1)], 0)).toBe(false);
expect(playingAsAlbum([track('1', 'x', 1), track('2', 'y', 2)], 0)).toBe(false);
expect(playingAsAlbum([track('1', 'x', 1)], 0)).toBe(false);
});
test('disc order counts: disc 2 track 1 follows disc 1 track 12', () => {
const q = [track('a', 'x', 12, 1), track('b', 'x', 1, 2)];
expect(playingAsAlbum(q, 1)).toBe(true);
});
test('neighbours without track numbers are not evidence of order', () => {
expect(playingAsAlbum([track('1', 'x'), track('2', 'x')], 0)).toBe(false);
});
});
test('dbToLinear', () => {
expect(dbToLinear(0)).toBe(1);
expect(dbToLinear(-20)).toBeCloseTo(0.1, 6);
expect(dbToLinear(6)).toBeCloseTo(1.995, 3);
});