better normalization scheduling and logic

This commit is contained in:
Boof2015
2026-07-02 11:15:32 -04:00
parent cab67fbc05
commit c6c24cf1a5
12 changed files with 535 additions and 51 deletions
+3 -1
View File
@@ -56,7 +56,9 @@ export async function applyNormalizationForActiveTrack(): Promise<void> {
const resolved = resolveNormalizationGain(facts, settings);
// Register by URL (the key the native player swaps on at the media transition) and
// activate it now, since no transition fires for the already-current track.
// activate it now, since no transition fires for the already-current track. The
// track has been playing at the fallback "temp" gain (gainRegistry) meanwhile, and
// activation glides natively — no burst, no step.
setTrackGainNative(url, resolved.linearGain);
activateTrackGainNative(url);
}
+25 -6
View File
@@ -10,8 +10,9 @@ type NativeEq = {
setEqBands?: (params: number[]) => void;
setNormalizationGain?: (linear: number) => void;
setTrackGain?: (url: string, linear: number) => void;
setTrackGains?: (entries: Record<string, number>, clearExisting: boolean) => void;
activateTrackGain?: (url: string) => void;
clearTrackGains?: () => void;
setFallbackGain?: (linear: number) => void;
setActivePostEq?: (active: boolean) => void;
};
@@ -41,7 +42,7 @@ export function setEqBandsNative(params: number[]): void {
}
}
/** Set the active normalization/ReplayGain gain directly (linear). 1 = unity. */
/** Glide the active normalization gain to an explicit linear value. 1 = unity. */
export function setNormalizationGainNative(linear: number): void {
try {
native.setNormalizationGain?.(linear);
@@ -62,7 +63,22 @@ export function setTrackGainNative(url: string, linear: number): void {
}
}
/** Activate the registered gain for this URL now (current track on mount/settings). */
/**
* Bulk-register queued tracks' gains (url -> linear) in one bridge call. With
* `clearExisting` the native map is cleared first (bounds it to the live queue).
*/
export function setTrackGainsNative(
entries: Record<string, number>,
clearExisting: boolean
): void {
try {
native.setTrackGains?.(entries, clearExisting);
} catch {
/* no-op */
}
}
/** Glide to the registered gain for this URL now (mount/settings/late measurement). */
export function activateTrackGainNative(url: string): void {
try {
native.activateTrackGain?.(url);
@@ -71,10 +87,13 @@ export function activateTrackGainNative(url: string): void {
}
}
/** Drop all registered per-track gains. */
export function clearTrackGainsNative(): void {
/**
* Conservative temp gain applied natively when a media-item transition hits a URL
* with no registered gain (unanalyzed track). Pinned to 1 while normalization is off.
*/
export function setFallbackGainNative(linear: number): void {
try {
native.clearTrackGains?.();
native.setFallbackGain?.(linear);
} catch {
/* no-op */
}
+165
View File
@@ -0,0 +1,165 @@
// Whole-queue normalization gain registry. Registers every queued track's resolved
// gain natively by URL (one batched DB read + one bridge call), so the player finds
// the right gain at ANY media-item transition — skips beyond the prefetch window,
// fresh queues, headless starts — with no JS in the loop. Also maintains the native
// fallback ("temp") gain used when a transition hits a track with no facts yet:
// deliberately a touch quiet (Poweramp-style), so the late correction is a small
// upward glide instead of a loud burst then a duck.
//
// The play path only does lookups, never work: analysis/decoding stays in
// useNormalizationSync's prefetch + ensureTrackLoudness (fire-and-forget).
//
// IMPORTANT (queueLoader): this module reads ONLY the JS queue mirror
// (useQueueStore.tracks) and makes ZERO TrackPlayer.* calls, so it needs no
// queueLoader settle-gating — the mirror holds the full playback context from
// setSnapshot before the chunked native load even starts. Keep it that way.
//
// Not a hook — started idempotently from both the UI (useNormalizationSync) and the
// headless PlaybackService, so Android Auto / Bluetooth starts are covered with the
// app UI never mounted.
import { useQueueStore } from '@/stores/queueStore';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { openLibraryDb } from '@/db/database';
import {
getLibraryLoudnessStats,
getSetting,
getTrackLoudnessByPaths,
setSetting,
} from '@/db/queries';
import {
dbToLinear,
hasUsableReplayGain,
resolveFallbackGain,
resolveNormalizationGain,
} from '@/audio/normalization';
import { factsFromRow } from '@/audio/trackAnalysis';
import { setFallbackGainNative, setTrackGainsNative } from '@/audio/eqNative';
/** Persisted fallback gain (dB) — pushed before the stats aggregate on cold start. */
const FALLBACK_DB_KEY = 'normalization_fallback_db';
/** The queue can change rapidly (drag-reorder); coalesce re-registrations. */
const REGISTER_DEBOUNCE_MS = 250;
let started = false;
let generation = 0;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let fallbackDirty = false;
/** Idempotent; safe to call from both the UI hook and the headless service. */
export function ensureGainRegistryStarted(): void {
if (started) return;
started = true;
useQueueStore.subscribe((state, prev) => {
// Identity change covers set/reorder/add/remove; activeIndex-only changes are
// irrelevant — the native map is URL-keyed, not position-keyed.
if (state.tracks !== prev.tracks) scheduleRegister(false);
});
useAudioSettingsStore.subscribe((state, prev) => {
if (
state.normalizationEnabled !== prev.normalizationEnabled ||
state.normalizationTargetLufs !== prev.normalizationTargetLufs ||
state.replayGainEnabled !== prev.replayGainEnabled ||
state.replayGainMode !== prev.replayGainMode
) {
scheduleRegister(true);
}
});
// Cover app relaunch with a persisted queue and headless service start.
void refreshFallbackGain().catch(() => {});
void registerQueueGains().catch(() => {});
}
function scheduleRegister(refreshFallback: boolean): void {
// Settings changes (e.g. dragging the target-LUFS slider) can fire per tick; the
// stats aggregate + whole-queue recompute ride the same debounce.
if (refreshFallback) fallbackDirty = true;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
const doFallback = fallbackDirty;
fallbackDirty = false;
void (async () => {
if (doFallback) await refreshFallbackGain().catch(() => {});
await registerQueueGains().catch(() => {});
})();
}, REGISTER_DEBOUNCE_MS);
}
async function registerQueueGains(): Promise<void> {
const gen = ++generation;
await useAudioSettingsStore.getState().load();
const settings = useAudioSettingsStore.getState().asNormalizationSettings();
const tracks = useQueueStore.getState().tracks;
const entries: Record<string, number> = {};
const localUrls: string[] = [];
const seen = new Set<string>();
for (const track of tracks) {
const url = typeof track.url === 'string' ? track.url : null;
if (!url || url.length === 0 || seen.has(url)) continue;
seen.add(url);
const sourceType = typeof track.sourceType === 'string' ? track.sourceType : undefined;
if (sourceType && sourceType !== 'local') {
// Remote tracks key by their resolved stream URL and have no local facts:
// explicit unity so a map miss never applies the fallback attenuation.
entries[url] = 1;
} else if (!settings.enabled) {
entries[url] = 1;
} else {
localUrls.push(url); // local url === tracks.path (the DB key)
}
}
if (settings.enabled && localUrls.length > 0) {
const db = await openLibraryDb();
const rows = await getTrackLoudnessByPaths(db, localUrls);
if (gen !== generation) return; // a newer registration superseded this one
for (const url of localUrls) {
const row = rows.get(url);
if (!row) continue; // not in the library — leave unregistered (fallback)
const facts = factsFromRow(row);
if (facts.loudnessLufs != null || hasUsableReplayGain(facts, settings)) {
entries[url] = resolveNormalizationGain(facts, settings).linearGain;
}
// No usable facts yet: deliberately NOT registered, so the transition
// activates the fallback gain. (resolveNormalizationGain returns unity for
// fact-less tracks — registering that would reintroduce the loud burst.)
}
}
if (gen !== generation) return;
// One bridge call; clearing bounds the native map to the live queue and drops
// entries computed under old settings.
setTrackGainsNative(entries, true);
}
/**
* Compute + push the native fallback gain from library-wide loudness stats.
* Pinned to unity while normalization is off (that keeps map misses at full
* volume with the feature disabled).
*/
async function refreshFallbackGain(): Promise<void> {
await useAudioSettingsStore.getState().load();
const settings = useAudioSettingsStore.getState().asNormalizationSettings();
if (!settings.enabled) {
setFallbackGainNative(1);
return;
}
const db = await openLibraryDb();
// Push the last persisted value first — closes the cold-start window where a
// headless (Android Auto) start could hit a transition before the aggregate lands.
const persistedRaw = await getSetting(db, FALLBACK_DB_KEY).catch(() => null);
const persistedDb = persistedRaw === null ? NaN : Number(persistedRaw);
if (Number.isFinite(persistedDb)) setFallbackGainNative(dbToLinear(persistedDb));
const stats = await getLibraryLoudnessStats(db);
const resolved = resolveFallbackGain(stats, settings);
setFallbackGainNative(resolved.linearGain);
await setSetting(db, FALLBACK_DB_KEY, String(resolved.gainDb)).catch(() => {});
}
+59 -1
View File
@@ -44,7 +44,7 @@ function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
function dbToLinear(db: number): number {
export function dbToLinear(db: number): number {
return Math.pow(10, db / 20);
}
@@ -131,3 +131,61 @@ export function resolveNormalizationGain(
peakLimited: limited.peakLimited,
};
}
// --- Fallback gain for tracks with no facts yet (Poweramp-style temp attenuation) ---
//
// A track that reaches a media-item transition with no registered gain plays at this
// fallback instead of unity: deliberately a touch QUIET, so the later correction is
// a small upward glide (natural) instead of a blast-then-duck (jarring). Derived
// from the library's median loudness when enough tracks are analyzed; otherwise
// assumes an unknown track is a loud modern master.
/** Temp gain never louder than this: errs quiet by construction. */
export const FALLBACK_CEILING_DB = -3;
/** Assumed integrated loudness of an unknown modern master. */
export const FALLBACK_ASSUMED_LUFS = -9;
/** Below this many analyzed tracks, library stats are noise — use the assumption. */
export const FALLBACK_MIN_SAMPLE = 10;
export interface LibraryLoudnessStats {
/** Tracks with a measured loudness_lufs. */
lufsCount: number;
medianLufs: number | null;
/** Tracks with a ReplayGain track-gain tag. */
rgCount: number;
medianRgTrackDb: number | null;
}
/**
* Resolve the conservative fallback gain applied natively when a transition hits a
* track with no registered gain. Unity when normalization is off (that is the
* mechanism keeping map misses at full volume with the feature disabled).
*/
export function resolveFallbackGain(
stats: LibraryLoudnessStats,
settings: NormalizationSettings
): { gainDb: number; linearGain: number } {
if (!settings.enabled) return { gainDb: 0, linearGain: 1 };
let candidateDb: number;
if (
settings.replayGainEnabled &&
stats.rgCount >= FALLBACK_MIN_SAMPLE &&
stats.medianRgTrackDb != null &&
Number.isFinite(stats.medianRgTrackDb)
) {
// RG tags are already "gain to apply" — the median IS the typical gain.
candidateDb = stats.medianRgTrackDb;
} else if (
stats.lufsCount >= FALLBACK_MIN_SAMPLE &&
stats.medianLufs != null &&
Number.isFinite(stats.medianLufs)
) {
candidateDb = settings.targetLufs - stats.medianLufs;
} else {
candidateDb = settings.targetLufs - FALLBACK_ASSUMED_LUFS;
}
const gainDb = clamp(candidateDb, NORM_MIN_GAIN_DB, FALLBACK_CEILING_DB);
return { gainDb, linearGain: dbToLinear(gainDb) };
}
+5
View File
@@ -2,6 +2,7 @@ import TrackPlayer, { Event } from 'react-native-track-player';
import { syncCarNowPlayingFromTrackPlayer } from './carSync';
import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync';
import { applyNormalizationForActiveTrack } from './applyNormalization';
import { ensureGainRegistryStarted } from './gainRegistry';
/**
* RNTP playback service — registered in `index.js`. Runs in a headless context
@@ -9,6 +10,10 @@ import { applyNormalizationForActiveTrack } from './applyNormalization';
* controls to the player. Must not depend on React or the JS UI tree.
*/
export async function PlaybackService(): Promise<void> {
// Whole-queue gain registration + fallback gain, headless-safe (Android Auto /
// Bluetooth starts with the app UI never mounted must still normalize).
ensureGainRegistryStarted();
const syncNowPlaying = () =>
Promise.allSettled([
syncWidgetNowPlayingFromTrackPlayer(),
+2 -1
View File
@@ -13,7 +13,8 @@ import { getTrackLoudness, setTrackLoudness, setTrackReplayGain, type TrackLoudn
import { hasUsableReplayGain, type LoudnessFacts } from '@/audio/normalization';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
function factsFromRow(row: TrackLoudness | null): LoudnessFacts {
/** Map a loudness DB row (or a miss) to the resolver's facts shape. */
export function factsFromRow(row: TrackLoudness | null): LoudnessFacts {
return {
loudnessLufs: row?.loudness_lufs ?? null,
samplePeak: row?.sample_peak ?? null,
+28 -15
View File
@@ -1,9 +1,12 @@
// Owns per-track normalization gain. It reads each track's loudness facts from
// SQLite, resolves the gain, and registers it natively keyed by URL — for the current
// track AND the next few queued tracks. The player then swaps to the matching gain
// natively at the real media-item transition (no JS round-trip on track change). The
// current track is also activated directly here, since on mount / settings change no
// transition fires. Renders nothing — mount once near the root.
// Fire-and-forget analysis half of normalization. Bulk registration of every
// ALREADY-ANALYZED queued track's gain lives in gainRegistry.ts (started below);
// this hook covers what the registry can't know synchronously: it measures the
// current track + the next few queued tracks on demand (decode-ahead) and registers
// each late-arriving result natively by URL, so the player picks it up at the media
// transition — or, for the current track, via a smooth native glide (the track is
// already playing at the fallback "temp" gain from sample zero; activation never
// yanks the volume). Also owns the oscilloscope's per-track display gain. Renders
// nothing — mount once near the root.
import { useEffect } from 'react';
import { usePlayerStore } from '@/stores/playerStore';
@@ -18,6 +21,7 @@ import {
} from '@/audio/eqNative';
import { useScopeStore } from '@/scope/scopeStore';
import { computeOscilloscopeGain, DEFAULT_OSC_GAIN } from '@/scope/oscilloscopeGain';
import { ensureGainRegistryStarted } from '@/audio/gainRegistry';
const EMPTY_FACTS: LoudnessFacts = {
loudnessLufs: null,
@@ -34,6 +38,9 @@ const PREFETCH_AHEAD = 5;
export function useNormalizationSync(): void {
useEffect(() => {
// Idempotent (also started from the headless PlaybackService).
ensureGainRegistryStarted();
let cancelled = false;
async function recompute(): Promise<void> {
@@ -55,7 +62,9 @@ export function useNormalizationSync(): void {
}
// ensureTrackLoudness is cheap when already analyzed (single DB read) and
// decodes+stores on a miss (lazy backfill for pre-scan tracks).
// decodes+stores on a miss (lazy backfill for pre-scan tracks). During the
// await the track is already playing at the conservative fallback gain
// (gainRegistry) — never at unity/full volume.
let facts = EMPTY_FACTS;
try {
facts = await ensureTrackLoudness(path);
@@ -68,7 +77,9 @@ export function useNormalizationSync(): void {
const resolved = resolveNormalizationGain(facts, settings);
// Seed the native map (so transitioning back to this track picks it up) and make
// it active now (mount / settings change fire no media-item transition).
// it active now (mount / settings change / late measurement fire no media-item
// transition). Activation glides natively (~1.2s) — usually a small upward
// correction from the fallback gain, never a hard step.
setTrackGainNative(path, resolved.linearGain);
activateTrackGainNative(path);
@@ -80,13 +91,15 @@ export function useNormalizationSync(): void {
useScopeStore.getState().setOscGain(computeOscilloscopeGain(basePeak, resolved.linearGain));
}
// Warm the next several upcoming tracks' loudness while the current one plays, and
// register each one's resolved gain natively by URL — so when the player advances,
// the gain is already in the map and gets applied at the transition with no JS in
// the loop. Looking a few ahead (not just the immediate next) means a song added
// several positions back is still measured + registered with plenty of lead time.
// Derived from the queue mirror, so it re-runs on reorder / add-next / advance.
// Deduped + DB-cached + native-semaphore-capped, so it stays cheap and gentle.
// MEASURE the next several upcoming tracks' loudness while the current one plays
// (decode-ahead for tracks with no facts yet), and register each late-arriving
// result natively by URL — so when the player advances, the gain is in the map
// and applies at the transition with no JS in the loop. Already-analyzed tracks
// are bulk-registered by gainRegistry; re-registering them here is a harmless
// cheap DB hit with the same value. Looking a few ahead (not just the immediate
// next) means a song added several positions back is still measured with plenty
// of lead time. Derived from the queue mirror, so it re-runs on reorder /
// add-next / advance. Deduped + DB-cached + native-semaphore-capped.
function prefetchUpcoming(): void {
const { tracks, activeIndex } = useQueueStore.getState();
if (activeIndex < 0) return;
+61
View File
@@ -365,6 +365,67 @@ export async function setTrackReplayGain(
);
}
/**
* Loudness facts for many track paths in one round trip (chunked IN at 500/chunk,
* same shape as deleteTracksByPaths). Used by the gain registry to register the
* whole queue's gains in a single pass. Keys of the returned map are the selected
* `path` values — string params go through encodeParams/toUtf8Latin1 and the read
* path decodes them back, so they match the input JS strings exactly.
*/
export async function getTrackLoudnessByPaths(
db: LibraryDatabase,
paths: string[]
): Promise<Map<string, TrackLoudness>> {
const out = new Map<string, TrackLoudness>();
for (let i = 0; i < paths.length; i += 500) {
const chunk = paths.slice(i, i + 500);
const placeholders = chunk.map(() => '?').join(', ');
const rows = await db.all<TrackLoudness & { path: string }>(
`SELECT path, loudness_lufs, sample_peak,
replay_gain_track_db, replay_gain_album_db,
replay_gain_track_peak, replay_gain_album_peak, rg_scanned
FROM tracks WHERE path IN (${placeholders})`,
chunk as SqlParams
);
for (const row of rows) out.set(row.path, row);
}
return out;
}
/** Library-wide loudness aggregates, feeding the fallback ("temp") gain. */
export interface LibraryLoudnessStatsRow {
lufsCount: number;
medianLufs: number | null;
rgCount: number;
medianRgTrackDb: number | null;
}
/**
* Counts + medians of measured LUFS and ReplayGain track gain across the library.
* SQLite has no MEDIAN — ORDER BY + LIMIT 1 OFFSET (COUNT-1)/2 scalar subqueries;
* empty sets yield NULL.
*/
export async function getLibraryLoudnessStats(
db: LibraryDatabase
): Promise<LibraryLoudnessStatsRow> {
const row = await db.get<LibraryLoudnessStatsRow>(
`SELECT
(SELECT COUNT(*) FROM tracks WHERE loudness_lufs IS NOT NULL) AS lufsCount,
(SELECT loudness_lufs FROM tracks WHERE loudness_lufs IS NOT NULL
ORDER BY loudness_lufs LIMIT 1
OFFSET (SELECT (COUNT(*) - 1) / 2 FROM tracks WHERE loudness_lufs IS NOT NULL)
) AS medianLufs,
(SELECT COUNT(*) FROM tracks WHERE replay_gain_track_db IS NOT NULL) AS rgCount,
(SELECT replay_gain_track_db FROM tracks WHERE replay_gain_track_db IS NOT NULL
ORDER BY replay_gain_track_db LIMIT 1
OFFSET (SELECT (COUNT(*) - 1) / 2 FROM tracks WHERE replay_gain_track_db IS NOT NULL)
) AS medianRgTrackDb`
);
return (
row ?? { lufsCount: 0, medianLufs: null, rgCount: 0, medianRgTrackDb: null }
);
}
// --- Settings (key-value preferences) ----------------------------------------
export async function getSetting(db: LibraryDatabase, key: string): Promise<string | null> {