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
@@ -57,8 +57,9 @@ class AstraScopeModule : Module() {
EqBridge.revision += 1
}
// Glide to an explicit gain (unity paths: no track / remote track / disabled).
Function("setNormalizationGain") { linear: Double ->
GainBridge.linearGain = linear.toFloat()
GainBridge.setGainSmooth(linear.toFloat())
}
// Register a queued track's gain by URL so the player can switch to it natively at
@@ -67,14 +68,21 @@ class AstraScopeModule : Module() {
GainBridge.putGain(url, linear.toFloat())
}
// Make the registered gain for this URL active now (used for the current track on
// mount / settings change, where no transition fires).
Function("activateTrackGain") { url: String ->
GainBridge.activateFor(url)
// Bulk-register the whole queue's gains in one bridge call (gainRegistry.ts).
Function("setTrackGains") { entries: Map<String, Double>, clearExisting: Boolean ->
GainBridge.putGains(entries.mapValues { it.value.toFloat() }, clearExisting)
}
Function("clearTrackGains") {
GainBridge.clearGains()
// Make the registered gain for this URL active now, as a smooth glide (mount /
// settings change / late measurement — no media-item transition fires for these).
Function("activateTrackGain") { url: String ->
GainBridge.activateSmoothFor(url)
}
// Conservative temp gain applied when a transition hits an unregistered URL
// (unanalyzed track). JS keeps this at 1 while normalization is disabled.
Function("setFallbackGain") { linear: Double ->
GainBridge.fallbackGain = linear.toFloat()
}
}
}
@@ -7,15 +7,54 @@ import java.util.concurrent.ConcurrentHashMap
* kotlin-audio `NormalizationGainProcessor` on the audio thread. Applied BEFORE the
* scope taps so the scopes see normalized levels.
*
* JS pre-registers each queued track's gain by URL ([putGain]); the player then swaps
* the active gain to the matching one natively at the real media-item transition
* ([activateFor], called from BaseAudioPlayer.onMediaItemTransition). That lands the
* gain at the actual audio boundary instead of after a JS round-trip on track change.
* JS pre-registers queued tracks' gains by URL ([putGain]/[putGains]); the player
* swaps the active gain to the matching one natively at the real media-item
* transition ([activateFor], called from BaseAudioPlayer.onMediaItemTransition).
* That lands the gain at the actual audio boundary instead of after a JS round-trip.
*
* Gains are published as a (target, rampMs) pair under a [revision] counter — the
* same pattern as [EqBridge.revision]. The processor re-arms its ramp whenever the
* revision moves: effectively instant at media boundaries ([TRANSITION_RAMP_MS]),
* an audible glide for mid-track corrections ([CORRECTION_RAMP_MS]) such as a late
* loudness measurement landing or a settings toggle.
*
* A URL with no registered gain activates at [fallbackGain] instead of unity — a
* conservative temporary attenuation (Poweramp-style) so an unanalyzed loud track
* starts slightly quiet and glides up, rather than blasting and ducking. JS keeps
* fallbackGain pinned to 1 while normalization is disabled.
*
* Publication race note: a reader could pair a fresh [targetGain] with a stale
* [rampMs] for one buffer (5-20 ms); the next buffer's revision check corrects it.
* Deliberately unfixed — no locking on the audio-thread read path.
*/
object GainBridge {
/** Active linear amplitude multiplier (1 = unity). Read on the audio thread. */
/** Declick ramp for media-item boundaries (effectively instant). */
const val TRANSITION_RAMP_MS = 30
/** Audible glide for mid-track corrections (late measurement, settings toggle). */
const val CORRECTION_RAMP_MS = 1200
/** Linear amplitude target the processor ramps toward (1 = unity). */
@Volatile
var linearGain: Float = 1f
var targetGain: Float = 1f
private set
/** Ramp duration for the most recent target change. */
@Volatile
var rampMs: Int = 0
private set
/** Bumped LAST on every target change; the processor re-arms when it moves. */
@Volatile
var revision: Int = 0
private set
/**
* Applied when a transition hits a URL with no registered gain (track not yet
* analyzed). Errs quiet by construction; JS sets 1 while normalization is off.
*/
@Volatile
var fallbackGain: Float = 1f
// url -> linear gain, seeded from JS ahead of playback.
private val gains = ConcurrentHashMap<String, Float>()
@@ -25,13 +64,45 @@ object GainBridge {
gains[url] = gain
}
/** Make the gain registered for [url] active (unity if unknown/null). */
/**
* Bulk-register the whole queue's gains in one call. With [clearExisting] the map
* is cleared first (bounds it to the live queue, drops stale-settings entries);
* a transition landing in the microsecond clear-to-putAll window would activate
* [fallbackGain] — errs quiet, acceptable.
*/
fun putGains(entries: Map<String, Float>, clearExisting: Boolean) {
if (clearExisting) gains.clear()
gains.putAll(entries)
}
/**
* Make the gain registered for [url] active at the audio boundary (declick-fast
* ramp). Misses fall back to [fallbackGain], NOT unity — that is the core fix for
* the "loud first seconds" burst on unanalyzed tracks.
*/
fun activateFor(url: String?) {
linearGain = if (url != null) gains[url] ?: 1f else 1f
setTarget(url?.let { gains[it] } ?: fallbackGain, TRANSITION_RAMP_MS)
}
/** Like [activateFor] but glides — for JS-initiated mid-track corrections. */
fun activateSmoothFor(url: String) {
setTarget(gains[url] ?: fallbackGain, CORRECTION_RAMP_MS)
}
/** Glide to an explicit gain (unity paths: no track / remote track / disabled). */
fun setGainSmooth(linear: Float) {
setTarget(linear, CORRECTION_RAMP_MS)
}
/** Drop all registered gains (e.g. on full queue reset). */
fun clearGains() {
gains.clear()
}
@Synchronized
private fun setTarget(gain: Float, durationMs: Int) {
targetGain = gain
rampMs = durationMs
revision += 1
}
}
+12 -4
View File
@@ -45,17 +45,25 @@ declare class AstraScopeModuleType extends NativeModule {
* enabled?1:0]. Native recomputes biquad coefficients at the stream rate.
*/
setEqBands(params: number[]): void;
/** Set the active per-track normalization/ReplayGain gain (linear; 1 = unity). */
/** Glide the active normalization gain to an explicit linear value (1 = unity). */
setNormalizationGain(linear: number): void;
/**
* Register a queued track's gain by URL. The player switches the active gain to the
* matching entry natively at the media-item transition (no JS round-trip).
*/
setTrackGain(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).
*/
setTrackGains(entries: Record<string, number>, clearExisting: boolean): void;
/** Glide to the registered gain for this URL now (mount/settings/late measurement). */
activateTrackGain(url: string): void;
/** Drop all registered per-track gains. */
clearTrackGains(): void;
/**
* Conservative temp gain (linear) applied when a media-item transition hits a URL
* with no registered gain (unanalyzed track). Keep at 1 while normalization is off.
*/
setFallbackGain(linear: number): void;
}
export const AstraScope = requireNativeModule<AstraScopeModuleType>('AstraScope');
+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> {
@@ -6,36 +6,97 @@ import com.google.android.exoplayer2.audio.BaseAudioProcessor
import expo.modules.astrascope.GainBridge
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Applies the per-track normalization / ReplayGain gain from [GainBridge] (set from
* JS on track/settings change). Sits FIRST in the chain — before the scope taps —
* so the visualizers see normalized levels (the user's "better for scopes" goal).
* JS on track/settings change, activated natively at media-item transitions). Sits
* FIRST in the chain — before the scope taps — so the visualizers see normalized
* levels (the user's "better for scopes" goal).
*
* Bit-exact passthrough when the gain is unity. Handles PCM float and 16-bit; the
* Gain changes ramp linearly, stepped once per FRAME (not per sample) so interleaved
* channels stay balanced: ~instant declick at media boundaries, an audible glide for
* mid-track corrections (see GainBridge.TRANSITION_RAMP_MS / CORRECTION_RAMP_MS).
* The bridge publishes (target, rampMs) under a revision counter; [syncFromBridge]
* re-arms the ramp when it moves (the EqAudioProcessor.rebuildIfNeeded pattern).
*
* [onFlush] snaps to the bridge target: a seek needs no declick (ExoPlayer flushes
* the output anyway), and a freshly created/reinitialized sink must start at the
* bridge's gain, never at a stale 1.0. Manual-skip ordering: the sink flush
* (playback thread) may run before activateFor (player thread) — flush snaps to the
* old target, then the next queueInput sees the new revision and does the 30 ms
* ramp. Correct either way.
*
* Bit-exact passthrough only at unity WITH no ramp in flight — a ramp passing
* through 1.0 keeps processing until it lands. Handles PCM float and 16-bit; the
* 16-bit path clamps to int16 range. The gain resolver already backs off so the
* post-gain peak stays <= 0.98, so clipping should not occur here in practice.
*/
class NormalizationGainProcessor : BaseAudioProcessor() {
private var floatScratch = FloatArray(0)
private var channels = 0
private var sampleRate = 0
private var lastRevision = Int.MIN_VALUE
private var currentGain = 1f
private var targetGain = 1f
private var stepPerFrame = 0f
private var rampFramesRemaining = 0
override fun onConfigure(
inputAudioFormat: AudioProcessor.AudioFormat
): AudioProcessor.AudioFormat = inputAudioFormat
): AudioProcessor.AudioFormat {
channels = inputAudioFormat.channelCount
sampleRate = inputAudioFormat.sampleRate
return inputAudioFormat
}
override fun onFlush() {
// Position discontinuity / sink (re)init: adopt the bridge state with no ramp.
lastRevision = GainBridge.revision
currentGain = GainBridge.targetGain
targetGain = currentGain
stepPerFrame = 0f
rampFramesRemaining = 0
}
private fun syncFromBridge() {
val rev = GainBridge.revision
if (rev == lastRevision) return
lastRevision = rev
targetGain = GainBridge.targetGain
if (targetGain == currentGain) {
rampFramesRemaining = 0
return
}
val frames = max(1, GainBridge.rampMs * sampleRate / 1000)
rampFramesRemaining = frames
stepPerFrame = (targetGain - currentGain) / frames
}
/** Advance the ramp by one frame; lands exactly on the target (no float drift). */
private fun stepFrame() {
if (rampFramesRemaining == 0) return
rampFramesRemaining -= 1
currentGain = if (rampFramesRemaining == 0) targetGain else currentGain + stepPerFrame
}
override fun queueInput(inputBuffer: ByteBuffer) {
val remaining = inputBuffer.remaining()
if (remaining <= 0) return
val gain = GainBridge.linearGain
if (gain == 1f) {
syncFromBridge()
// Bit-exact passthrough only when settled at unity (never mid-ramp).
if (rampFramesRemaining == 0 && currentGain == 1f) {
val out = replaceOutputBuffer(remaining)
out.put(inputBuffer)
out.flip()
return
}
val ch = channels.coerceAtLeast(1)
when (inputAudioFormat.encoding) {
C.ENCODING_PCM_FLOAT -> {
val fb = inputBuffer.asFloatBuffer()
@@ -44,10 +105,16 @@ class NormalizationGainProcessor : BaseAudioProcessor() {
if (floatScratch.size < n) floatScratch = FloatArray(n)
fb.get(floatScratch, 0, n)
inputBuffer.position(inputBuffer.limit())
var c = 0
var i = 0
while (i < n) {
floatScratch[i] = floatScratch[i] * gain
floatScratch[i] = floatScratch[i] * currentGain
i++
c++
if (c == ch) {
c = 0
stepFrame()
}
}
val out = replaceOutputBuffer(n * 4).order(ByteOrder.nativeOrder())
out.asFloatBuffer().put(floatScratch, 0, n)
@@ -60,11 +127,17 @@ class NormalizationGainProcessor : BaseAudioProcessor() {
if (n <= 0) return
val out = replaceOutputBuffer(n * 2).order(ByteOrder.nativeOrder())
val osb = out.asShortBuffer()
var c = 0
var i = 0
while (i < n) {
val v = (sb.get(i) * gain).roundToInt().coerceIn(-32768, 32767)
val v = (sb.get(i) * currentGain).roundToInt().coerceIn(-32768, 32767)
osb.put(v.toShort())
i++
c++
if (c == ch) {
c = 0
stepFrame()
}
}
inputBuffer.position(inputBuffer.limit())
out.position(n * 2)