mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
better normalization scheduling and logic
This commit is contained in:
@@ -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
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user