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