mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-18 19:54:26 +02:00
fix android auto quirks
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player';
|
||||
import { DspStartupCoordinator, type DspWarmupInputs } from './dspStartupCoordinator';
|
||||
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||
import { useEQStore } from '@/stores/eqStore';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getTrackLoudnessByPaths } from '@/db/queries';
|
||||
import { factsFromRow } from '@/audio/trackAnalysis';
|
||||
import type { NormalizationSettings } from '@/audio/normalization';
|
||||
import {
|
||||
resolveFastStartupFallback,
|
||||
resolveStartupTargetGain,
|
||||
type StartupTargetGain,
|
||||
} from '@/audio/dspStartupGain';
|
||||
import {
|
||||
applyEqNativeStrict,
|
||||
assertNativeDspAvailable,
|
||||
primeTrackGainNativeStrict,
|
||||
setFallbackGainNativeStrict,
|
||||
setTrackGainNativeStrict,
|
||||
} from '@/audio/eqNative';
|
||||
import {
|
||||
dbToLinear as eqDbToLinear,
|
||||
flattenBandsForNative,
|
||||
} from '@/audio/eq';
|
||||
import { buildGraphicBands } from '@/audio/graphicEq';
|
||||
import { refreshEQRouteForPlayback } from '@/audio/eqRouteSync';
|
||||
import {
|
||||
ensureGainRegistryStarted,
|
||||
loadPersistedFallbackGain,
|
||||
} from '@/audio/gainRegistry';
|
||||
import type { AudioOutputRoute } from '@/types/audio';
|
||||
|
||||
export type DspTargetActivation = 'none' | 'immediate';
|
||||
|
||||
export interface DspPlaybackTarget {
|
||||
url: string | null;
|
||||
sourceType?: string | null;
|
||||
activation: DspTargetActivation;
|
||||
}
|
||||
|
||||
type WarmupInputs = DspWarmupInputs<NormalizationSettings, AudioOutputRoute, number | null>;
|
||||
|
||||
const ROUTE_FRESH_MS = 500;
|
||||
|
||||
function strictSyncCurrentEq(): void {
|
||||
const state = useEQStore.getState();
|
||||
const bands = state.mode === 'graphic' ? buildGraphicBands(state.graphicGains) : state.bands;
|
||||
applyEqNativeStrict(
|
||||
state.enabled,
|
||||
state.enabled ? eqDbToLinear(state.preamp) : 1,
|
||||
flattenBandsForNative(bands),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveTargetGain(
|
||||
target: DspPlaybackTarget,
|
||||
settings: NormalizationSettings,
|
||||
fallback: number,
|
||||
): Promise<StartupTargetGain> {
|
||||
if (!target.url) return resolveStartupTargetGain('none', null, settings, fallback);
|
||||
if (!settings.enabled) return resolveStartupTargetGain('local', null, settings, fallback);
|
||||
if (target.sourceType && target.sourceType !== 'local') {
|
||||
return resolveStartupTargetGain('remote', null, settings, fallback);
|
||||
}
|
||||
|
||||
const db = await openLibraryDb();
|
||||
const rows = await getTrackLoudnessByPaths(db, [target.url]);
|
||||
const facts = factsFromRow(rows.get(target.url) ?? null);
|
||||
return resolveStartupTargetGain('local', facts, settings, fallback);
|
||||
}
|
||||
|
||||
function settingsSnapshot(): NormalizationSettings {
|
||||
return useAudioSettingsStore.getState().asNormalizationSettings();
|
||||
}
|
||||
|
||||
function timingLog(
|
||||
stage: 'base' | 'target',
|
||||
reason: string,
|
||||
status: 'ready' | 'failed',
|
||||
elapsedMs: number,
|
||||
): void {
|
||||
const entry = { at: Date.now(), stage, reason, status, elapsedMs };
|
||||
if (status === 'failed') console.warn('[dsp-startup] stage', entry);
|
||||
else console.info('[dsp-startup] stage', entry);
|
||||
}
|
||||
|
||||
const coordinator = new DspStartupCoordinator<
|
||||
NormalizationSettings,
|
||||
AudioOutputRoute,
|
||||
number | null,
|
||||
DspPlaybackTarget
|
||||
>({
|
||||
loadSettings: async () => {
|
||||
await useAudioSettingsStore.getState().load();
|
||||
return settingsSnapshot();
|
||||
},
|
||||
loadEqRoute: refreshEQRouteForPlayback,
|
||||
loadPersistedFallback: loadPersistedFallbackGain,
|
||||
applyBase: ({ settings, route, persistedFallback }) => {
|
||||
assertNativeDspAvailable();
|
||||
strictSyncCurrentEq();
|
||||
const fallback = resolveFastStartupFallback(settings, persistedFallback);
|
||||
setFallbackGainNativeStrict(fallback);
|
||||
const eq = useEQStore.getState();
|
||||
console.info('[dsp-startup] base-applied', {
|
||||
at: Date.now(),
|
||||
routeKey: route.key,
|
||||
eqEnabled: eq.enabled,
|
||||
preampDb: eq.preamp,
|
||||
normalizationEnabled: settings.enabled,
|
||||
fallbackLinear: fallback,
|
||||
});
|
||||
},
|
||||
prepareTarget: async (inputs, target) => {
|
||||
const fallback = resolveFastStartupFallback(inputs.settings, inputs.persistedFallback);
|
||||
const routePromise =
|
||||
Date.now() - inputs.route.updatedAt <= ROUTE_FRESH_MS
|
||||
? Promise.resolve(inputs.route)
|
||||
: refreshEQRouteForPlayback();
|
||||
const [route, resolved] = await Promise.all([
|
||||
routePromise,
|
||||
resolveTargetGain(target, inputs.settings, fallback),
|
||||
]);
|
||||
|
||||
// Route listeners normally keep this current. Reassert synchronously here
|
||||
// so their defensive/no-op wrappers cannot release a guarded play command.
|
||||
assertNativeDspAvailable();
|
||||
strictSyncCurrentEq();
|
||||
setFallbackGainNativeStrict(fallback);
|
||||
if (target.url) {
|
||||
setTrackGainNativeStrict(target.url, resolved.linearGain);
|
||||
if (target.activation === 'immediate') primeTrackGainNativeStrict(target.url);
|
||||
}
|
||||
console.info('[dsp-startup] target-applied', {
|
||||
at: Date.now(),
|
||||
routeKey: route.key,
|
||||
gainSource: resolved.source,
|
||||
activation: target.activation,
|
||||
hasTarget: Boolean(target.url),
|
||||
});
|
||||
},
|
||||
onTiming: ({ stage, reason, status, elapsedMs }) => {
|
||||
timingLog(stage, reason, status, elapsedMs);
|
||||
},
|
||||
});
|
||||
|
||||
let monitoringStarted = false;
|
||||
|
||||
function ensureStartupMonitoring(): void {
|
||||
if (monitoringStarted) return;
|
||||
monitoringStarted = true;
|
||||
useAudioSettingsStore.subscribe((state, previous) => {
|
||||
if (
|
||||
state.normalizationEnabled !== previous.normalizationEnabled ||
|
||||
state.normalizationTargetLufs !== previous.normalizationTargetLufs ||
|
||||
state.replayGainEnabled !== previous.replayGainEnabled ||
|
||||
state.replayGainMode !== previous.replayGainMode
|
||||
) {
|
||||
void coordinator.rewarm('normalization-settings-change').catch((error) => {
|
||||
console.warn('[dsp-startup] eager settings warm failed', {
|
||||
at: Date.now(),
|
||||
error: error instanceof Error ? error.name : 'UnknownError',
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startBackgroundGainWork(): void {
|
||||
// Idempotent and intentionally launched only after the safety state is ready.
|
||||
// Its library aggregate, analysis, and whole-queue map are not play blockers.
|
||||
ensureGainRegistryStarted();
|
||||
}
|
||||
|
||||
export function startAudioProcessingWarmup(reason: string): Promise<WarmupInputs> {
|
||||
ensureStartupMonitoring();
|
||||
const warmup = coordinator.warm(reason);
|
||||
void warmup.then(startBackgroundGainWork, () => {});
|
||||
return warmup;
|
||||
}
|
||||
|
||||
export async function prepareAudioProcessingForPlayback(
|
||||
target: DspPlaybackTarget,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
ensureStartupMonitoring();
|
||||
try {
|
||||
await coordinator.prepare(target, reason);
|
||||
startBackgroundGainWork();
|
||||
} catch (error) {
|
||||
// Fail closed. Do not let an unavailable DB/route/native bridge turn into a
|
||||
// unity-gain burst; the next explicit command retries through the coordinator.
|
||||
await TrackPlayer.pause().catch(() => {});
|
||||
console.warn('[dsp-startup] playback held paused', {
|
||||
at: Date.now(),
|
||||
reason,
|
||||
error: error instanceof Error ? error.name : 'UnknownError',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-prime an already registered target after a paused queue transition. */
|
||||
export async function primePreparedTrackForPlayback(
|
||||
target: DspPlaybackTarget,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
if (!target.url) return;
|
||||
try {
|
||||
assertNativeDspAvailable();
|
||||
primeTrackGainNativeStrict(target.url);
|
||||
} catch (error) {
|
||||
await TrackPlayer.pause().catch(() => {});
|
||||
console.warn('[dsp-startup] target prime held paused', {
|
||||
at: Date.now(),
|
||||
reason,
|
||||
error: error instanceof Error ? error.name : 'UnknownError',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function dspTargetFromTrack(
|
||||
track: RntpTrack | null | undefined,
|
||||
activation: DspTargetActivation,
|
||||
): DspPlaybackTarget {
|
||||
return {
|
||||
url: typeof track?.url === 'string' && track.url.length > 0 ? track.url : null,
|
||||
sourceType: typeof track?.sourceType === 'string' ? track.sourceType : null,
|
||||
activation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DspStartupCoordinator } from './dspStartupCoordinator.ts';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('starts settings, route, and fallback reads concurrently and gates the target', async () => {
|
||||
const settings = deferred<string>();
|
||||
const route = deferred<string>();
|
||||
const fallback = deferred<number>();
|
||||
const started: string[] = [];
|
||||
const applied: string[] = [];
|
||||
|
||||
const coordinator = new DspStartupCoordinator({
|
||||
loadSettings: () => {
|
||||
started.push('settings');
|
||||
return settings.promise;
|
||||
},
|
||||
loadEqRoute: () => {
|
||||
started.push('route');
|
||||
return route.promise;
|
||||
},
|
||||
loadPersistedFallback: () => {
|
||||
started.push('fallback');
|
||||
return fallback.promise;
|
||||
},
|
||||
applyBase: ({ settings: value, route: output, persistedFallback }) => {
|
||||
applied.push(`base:${value}:${output}:${persistedFallback}`);
|
||||
},
|
||||
prepareTarget: (_inputs, target: string) => {
|
||||
applied.push(`target:${target}`);
|
||||
},
|
||||
});
|
||||
|
||||
const preparation = coordinator.prepare('track-1', 'remote-play');
|
||||
assert.deepEqual(started, ['settings', 'route', 'fallback']);
|
||||
assert.deepEqual(applied, []);
|
||||
|
||||
settings.resolve('enabled');
|
||||
route.resolve('usb');
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(applied, []);
|
||||
|
||||
fallback.resolve(0.5);
|
||||
await preparation;
|
||||
assert.deepEqual(applied, ['base:enabled:usb:0.5', 'target:track-1']);
|
||||
});
|
||||
|
||||
test('concurrent preparations share cold warm-up but prime each target', async () => {
|
||||
let settingsLoads = 0;
|
||||
let routeLoads = 0;
|
||||
let fallbackLoads = 0;
|
||||
const targets: string[] = [];
|
||||
const coordinator = new DspStartupCoordinator({
|
||||
loadSettings: async () => {
|
||||
settingsLoads += 1;
|
||||
return 'settings';
|
||||
},
|
||||
loadEqRoute: async () => {
|
||||
routeLoads += 1;
|
||||
return 'route';
|
||||
},
|
||||
loadPersistedFallback: async () => {
|
||||
fallbackLoads += 1;
|
||||
return 0.5;
|
||||
},
|
||||
applyBase: () => {},
|
||||
prepareTarget: (_inputs, target: string) => {
|
||||
targets.push(target);
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
coordinator.prepare('one', 'play'),
|
||||
coordinator.prepare('two', 'play'),
|
||||
]);
|
||||
|
||||
assert.equal(settingsLoads, 1);
|
||||
assert.equal(routeLoads, 1);
|
||||
assert.equal(fallbackLoads, 1);
|
||||
assert.deepEqual(targets.sort(), ['one', 'two']);
|
||||
});
|
||||
|
||||
test('a failed warm-up is retried by the next explicit preparation', async () => {
|
||||
let attempts = 0;
|
||||
let primed = false;
|
||||
const coordinator = new DspStartupCoordinator({
|
||||
loadSettings: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error('db unavailable');
|
||||
return 'settings';
|
||||
},
|
||||
loadEqRoute: async () => 'route',
|
||||
loadPersistedFallback: async () => 0.5,
|
||||
applyBase: () => {},
|
||||
prepareTarget: () => {
|
||||
primed = true;
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(coordinator.prepare('one', 'play'), /db unavailable/);
|
||||
assert.equal(primed, false);
|
||||
await coordinator.prepare('one', 'play-retry');
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(primed, true);
|
||||
});
|
||||
|
||||
test('invalidation makes an existing waiter join the replacement warm-up', async () => {
|
||||
const firstSettings = deferred<string>();
|
||||
let settingsLoads = 0;
|
||||
const applied: string[] = [];
|
||||
const coordinator = new DspStartupCoordinator({
|
||||
loadSettings: () => {
|
||||
settingsLoads += 1;
|
||||
return settingsLoads === 1 ? firstSettings.promise : Promise.resolve('new');
|
||||
},
|
||||
loadEqRoute: async () => 'route',
|
||||
loadPersistedFallback: async () => 0.5,
|
||||
applyBase: ({ settings }) => {
|
||||
applied.push(settings);
|
||||
},
|
||||
prepareTarget: () => {},
|
||||
});
|
||||
|
||||
const waiting = coordinator.prepare('one', 'play');
|
||||
const rewarm = coordinator.rewarm('settings-change');
|
||||
firstSettings.resolve('old');
|
||||
await Promise.all([waiting, rewarm]);
|
||||
|
||||
assert.equal(settingsLoads, 2);
|
||||
assert.deepEqual(applied, ['new']);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
export interface DspWarmupInputs<Settings, Route, PersistedFallback> {
|
||||
settings: Settings;
|
||||
route: Route;
|
||||
persistedFallback: PersistedFallback;
|
||||
}
|
||||
|
||||
export interface DspStartupTiming {
|
||||
stage: 'base' | 'target';
|
||||
reason: string;
|
||||
elapsedMs: number;
|
||||
status: 'ready' | 'failed';
|
||||
}
|
||||
|
||||
export interface DspStartupDependencies<Settings, Route, PersistedFallback, Target> {
|
||||
loadSettings: () => Promise<Settings>;
|
||||
loadEqRoute: () => Promise<Route>;
|
||||
loadPersistedFallback: () => Promise<PersistedFallback>;
|
||||
applyBase: (
|
||||
inputs: DspWarmupInputs<Settings, Route, PersistedFallback>,
|
||||
) => void | Promise<void>;
|
||||
prepareTarget: (
|
||||
inputs: DspWarmupInputs<Settings, Route, PersistedFallback>,
|
||||
target: Target,
|
||||
) => void | Promise<void>;
|
||||
now?: () => number;
|
||||
onTiming?: (timing: DspStartupTiming) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesces the cold DSP warm-up while keeping per-track priming explicit.
|
||||
*
|
||||
* The three persisted inputs deliberately start in the same turn. Invalidating
|
||||
* never releases a waiter with stale state: an in-flight generation joins the
|
||||
* replacement warm-up before it resolves.
|
||||
*/
|
||||
export class DspStartupCoordinator<Settings, Route, PersistedFallback, Target> {
|
||||
private basePromise: Promise<DspWarmupInputs<Settings, Route, PersistedFallback>> | null = null;
|
||||
private generation = 0;
|
||||
private readonly dependencies: DspStartupDependencies<
|
||||
Settings,
|
||||
Route,
|
||||
PersistedFallback,
|
||||
Target
|
||||
>;
|
||||
|
||||
constructor(
|
||||
dependencies: DspStartupDependencies<
|
||||
Settings,
|
||||
Route,
|
||||
PersistedFallback,
|
||||
Target
|
||||
>,
|
||||
) {
|
||||
this.dependencies = dependencies;
|
||||
}
|
||||
|
||||
warm(reason: string): Promise<DspWarmupInputs<Settings, Route, PersistedFallback>> {
|
||||
if (this.basePromise) return this.basePromise;
|
||||
|
||||
const generation = this.generation;
|
||||
const now = this.dependencies.now ?? Date.now;
|
||||
const startedAt = now();
|
||||
let tracked: Promise<DspWarmupInputs<Settings, Route, PersistedFallback>>;
|
||||
|
||||
const task = (async () => {
|
||||
const [settings, route, persistedFallback] = await Promise.all([
|
||||
this.dependencies.loadSettings(),
|
||||
this.dependencies.loadEqRoute(),
|
||||
this.dependencies.loadPersistedFallback(),
|
||||
]);
|
||||
|
||||
if (generation !== this.generation) return this.warm(reason);
|
||||
|
||||
const inputs = { settings, route, persistedFallback };
|
||||
await this.dependencies.applyBase(inputs);
|
||||
|
||||
if (generation !== this.generation) return this.warm(reason);
|
||||
|
||||
this.dependencies.onTiming?.({
|
||||
stage: 'base',
|
||||
reason,
|
||||
elapsedMs: now() - startedAt,
|
||||
status: 'ready',
|
||||
});
|
||||
return inputs;
|
||||
})();
|
||||
|
||||
tracked = task.catch((error) => {
|
||||
if (this.basePromise === tracked) this.basePromise = null;
|
||||
this.dependencies.onTiming?.({
|
||||
stage: 'base',
|
||||
reason,
|
||||
elapsedMs: now() - startedAt,
|
||||
status: 'failed',
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
this.basePromise = tracked;
|
||||
return tracked;
|
||||
}
|
||||
|
||||
async prepare(target: Target, reason: string): Promise<void> {
|
||||
const now = this.dependencies.now ?? Date.now;
|
||||
const startedAt = now();
|
||||
|
||||
try {
|
||||
// Settings can change while a track lookup is in flight. Repeat against
|
||||
// the newest generation rather than releasing playback with stale gain.
|
||||
while (true) {
|
||||
const generation = this.generation;
|
||||
const inputs = await this.warm(reason);
|
||||
if (generation !== this.generation) continue;
|
||||
await this.dependencies.prepareTarget(inputs, target);
|
||||
if (generation !== this.generation) continue;
|
||||
break;
|
||||
}
|
||||
this.dependencies.onTiming?.({
|
||||
stage: 'target',
|
||||
reason,
|
||||
elapsedMs: now() - startedAt,
|
||||
status: 'ready',
|
||||
});
|
||||
} catch (error) {
|
||||
this.dependencies.onTiming?.({
|
||||
stage: 'target',
|
||||
reason,
|
||||
elapsedMs: now() - startedAt,
|
||||
status: 'failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.generation += 1;
|
||||
this.basePromise = null;
|
||||
}
|
||||
|
||||
rewarm(reason: string): Promise<DspWarmupInputs<Settings, Route, PersistedFallback>> {
|
||||
this.invalidate();
|
||||
return this.warm(reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { dbToLinear, type LoudnessFacts, type NormalizationSettings } from './normalization.ts';
|
||||
import {
|
||||
resolveFastStartupFallback,
|
||||
resolveStartupTargetGain,
|
||||
} from './dspStartupGain.ts';
|
||||
|
||||
const enabled: NormalizationSettings = {
|
||||
enabled: true,
|
||||
targetLufs: -12,
|
||||
replayGainEnabled: false,
|
||||
replayGainMode: 'auto',
|
||||
};
|
||||
|
||||
const emptyFacts: LoudnessFacts = {
|
||||
loudnessLufs: null,
|
||||
samplePeak: null,
|
||||
replayGainTrackDb: null,
|
||||
replayGainAlbumDb: null,
|
||||
replayGainTrackPeak: null,
|
||||
replayGainAlbumPeak: null,
|
||||
};
|
||||
|
||||
test('cold fallback is conservative and clamps a stale loud persisted value', () => {
|
||||
assert.equal(resolveFastStartupFallback(enabled, null), dbToLinear(-3));
|
||||
assert.equal(resolveFastStartupFallback(enabled, 1), dbToLinear(-3));
|
||||
});
|
||||
|
||||
test('normalization off reaches unity only from loaded disabled settings', () => {
|
||||
const disabled = { ...enabled, enabled: false };
|
||||
assert.deepEqual(resolveStartupTargetGain('local', emptyFacts, disabled, 0.5), {
|
||||
linearGain: 1,
|
||||
source: 'disabled',
|
||||
});
|
||||
assert.equal(resolveFastStartupFallback(disabled, 0.5), 1);
|
||||
});
|
||||
|
||||
test('an analyzed local track uses exact stored gain', () => {
|
||||
const facts = { ...emptyFacts, loudnessLufs: -8 };
|
||||
assert.deepEqual(resolveStartupTargetGain('local', facts, enabled, 0.5), {
|
||||
linearGain: dbToLinear(-4),
|
||||
source: 'stored',
|
||||
});
|
||||
});
|
||||
|
||||
test('an unanalyzed local track uses fallback while remote tracks stay unity', () => {
|
||||
assert.deepEqual(resolveStartupTargetGain('local', emptyFacts, enabled, 0.5), {
|
||||
linearGain: 0.5,
|
||||
source: 'fallback',
|
||||
});
|
||||
assert.deepEqual(resolveStartupTargetGain('remote', null, enabled, 0.5), {
|
||||
linearGain: 1,
|
||||
source: 'remote',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
FALLBACK_CEILING_DB,
|
||||
NORM_MIN_GAIN_DB,
|
||||
dbToLinear,
|
||||
hasUsableReplayGain,
|
||||
resolveFallbackGain,
|
||||
resolveNormalizationGain,
|
||||
type LoudnessFacts,
|
||||
type NormalizationSettings,
|
||||
} from './normalization.ts';
|
||||
|
||||
export interface StartupTargetGain {
|
||||
linearGain: number;
|
||||
source: 'none' | 'disabled' | 'remote' | 'stored' | 'fallback';
|
||||
}
|
||||
|
||||
const EMPTY_STATS = {
|
||||
lufsCount: 0,
|
||||
medianLufs: null,
|
||||
rgCount: 0,
|
||||
medianRgTrackDb: null,
|
||||
};
|
||||
|
||||
export function resolveFastStartupFallback(
|
||||
settings: NormalizationSettings,
|
||||
persisted: number | null,
|
||||
): number {
|
||||
if (!settings.enabled) return 1;
|
||||
|
||||
if (persisted != null && Number.isFinite(persisted) && persisted > 0) {
|
||||
const quietest = dbToLinear(NORM_MIN_GAIN_DB);
|
||||
const loudest = dbToLinear(FALLBACK_CEILING_DB);
|
||||
return Math.max(quietest, Math.min(loudest, persisted));
|
||||
}
|
||||
|
||||
return resolveFallbackGain(EMPTY_STATS, settings).linearGain;
|
||||
}
|
||||
|
||||
export function resolveStartupTargetGain(
|
||||
kind: 'none' | 'remote' | 'local',
|
||||
facts: LoudnessFacts | null,
|
||||
settings: NormalizationSettings,
|
||||
fallback: number,
|
||||
): StartupTargetGain {
|
||||
if (kind === 'none') return { linearGain: 1, source: 'none' };
|
||||
if (!settings.enabled) return { linearGain: 1, source: 'disabled' };
|
||||
if (kind === 'remote') return { linearGain: 1, source: 'remote' };
|
||||
if (facts && (facts.loudnessLufs != null || hasUsableReplayGain(facts, settings))) {
|
||||
return {
|
||||
linearGain: resolveNormalizationGain(facts, settings).linearGain,
|
||||
source: 'stored',
|
||||
};
|
||||
}
|
||||
return { linearGain: fallback, source: 'fallback' };
|
||||
}
|
||||
@@ -12,12 +12,54 @@ type NativeEq = {
|
||||
setTrackGain?: (url: string, linear: number) => void;
|
||||
setTrackGains?: (entries: Record<string, number>, clearExisting: boolean) => void;
|
||||
activateTrackGain?: (url: string) => void;
|
||||
primeTrackGain?: (url: string) => void;
|
||||
setFallbackGain?: (linear: number) => void;
|
||||
setActivePostEq?: (active: boolean) => void;
|
||||
};
|
||||
|
||||
const native = AstraScope as unknown as NativeEq;
|
||||
|
||||
function requireNativeMethod<K extends keyof NativeEq>(name: K): NonNullable<NativeEq[K]> {
|
||||
const method = native[name];
|
||||
if (typeof method !== 'function') {
|
||||
throw new Error(`Native DSP method unavailable: ${String(name)}`);
|
||||
}
|
||||
return method as NonNullable<NativeEq[K]>;
|
||||
}
|
||||
|
||||
/** Fail-closed capability check used before any guarded playback release. */
|
||||
export function assertNativeDspAvailable(): void {
|
||||
requireNativeMethod('setEqEnabled');
|
||||
requireNativeMethod('setEqPreamp');
|
||||
requireNativeMethod('setEqBands');
|
||||
requireNativeMethod('setTrackGain');
|
||||
requireNativeMethod('primeTrackGain');
|
||||
requireNativeMethod('setFallbackGain');
|
||||
}
|
||||
|
||||
/** Strict startup setters: unlike the UI wrappers below, failures must block play. */
|
||||
export function applyEqNativeStrict(
|
||||
enabled: boolean,
|
||||
preampLinear: number,
|
||||
bandParams: number[],
|
||||
): void {
|
||||
(requireNativeMethod('setEqEnabled') as (value: boolean) => void)(enabled);
|
||||
(requireNativeMethod('setEqPreamp') as (value: number) => void)(preampLinear);
|
||||
(requireNativeMethod('setEqBands') as (value: number[]) => void)(bandParams);
|
||||
}
|
||||
|
||||
export function setFallbackGainNativeStrict(linear: number): void {
|
||||
(requireNativeMethod('setFallbackGain') as (value: number) => void)(linear);
|
||||
}
|
||||
|
||||
export function setTrackGainNativeStrict(url: string, linear: number): void {
|
||||
(requireNativeMethod('setTrackGain') as (key: string, value: number) => void)(url, linear);
|
||||
}
|
||||
|
||||
export function primeTrackGainNativeStrict(url: string): void {
|
||||
(requireNativeMethod('primeTrackGain') as (key: string) => void)(url);
|
||||
}
|
||||
|
||||
export function setEqEnabledNative(enabled: boolean): void {
|
||||
try {
|
||||
native.setEqEnabled?.(enabled);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { AstraAudioRoute } from '../../modules/astra-audio-route';
|
||||
import {
|
||||
AstraAudioRoute,
|
||||
isAstraAudioRouteAvailable,
|
||||
} from '../../modules/astra-audio-route';
|
||||
import { useEQStore } from '@/stores/eqStore';
|
||||
import type { AudioOutputRoute } from '@/types/audio';
|
||||
|
||||
type Subscription = { remove: () => void };
|
||||
|
||||
@@ -14,6 +18,23 @@ async function applyCurrentRoute(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict one-shot refresh for the guarded play path. The normal listener is
|
||||
* defensive; this version must surface missing native routing or a failed EQ
|
||||
* profile restore so playback can remain paused.
|
||||
*/
|
||||
export async function refreshEQRouteForPlayback(): Promise<AudioOutputRoute> {
|
||||
if (!isAstraAudioRouteAvailable) {
|
||||
throw new Error('Native audio-route module unavailable');
|
||||
}
|
||||
await useEQStore.getState().load();
|
||||
const route = AstraAudioRoute.getCurrentRoute();
|
||||
if (!route) throw new Error('Current media output route unavailable');
|
||||
if (route.kind === 'unknown') throw new Error('Current media output route unresolved');
|
||||
await useEQStore.getState().setOutputRoute(route);
|
||||
return route;
|
||||
}
|
||||
|
||||
async function startEQRouteSync(): Promise<void> {
|
||||
if (!subscription) {
|
||||
subscription = AstraAudioRoute.addListener('onAudioRouteChanged', (route) => {
|
||||
|
||||
@@ -39,6 +39,17 @@ 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';
|
||||
|
||||
/** Single-setting cold-start read; no library aggregate or track analysis. */
|
||||
export async function loadPersistedFallbackGain(): Promise<number | null> {
|
||||
const db = await openLibraryDb();
|
||||
const raw = await getSetting(db, FALLBACK_DB_KEY);
|
||||
if (raw === null) return null;
|
||||
const gainDb = Number(raw);
|
||||
if (!Number.isFinite(gainDb)) return null;
|
||||
const linear = dbToLinear(gainDb);
|
||||
return Number.isFinite(linear) && linear > 0 ? linear : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The queue can change rapidly (drag-reorder); coalesce re-registrations. Kept
|
||||
* past the start-of-playback transition: registering a large queue marshals a
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
queueLoadSettled,
|
||||
setQueueLoadErrorHandler,
|
||||
} from './queueLoader';
|
||||
import {
|
||||
dspTargetFromTrack,
|
||||
prepareAudioProcessingForPlayback,
|
||||
primePreparedTrackForPlayback,
|
||||
} from './audioProcessingStartup';
|
||||
|
||||
// If a background queue fill dies partway, the mirror no longer matches the
|
||||
// native queue — re-read the truth.
|
||||
@@ -321,10 +326,13 @@ async function playTracksInternal(
|
||||
ordered = [...tracks.slice(0, startIndex + 1), ...shuffleArray(tracks.slice(startIndex + 1))];
|
||||
}
|
||||
const queueTracks = ordered.map(toRntpTrack);
|
||||
const playbackTarget = dspTargetFromTrack(queueTracks[startIndex], 'none');
|
||||
useQueueStore.getState().setSnapshot(queueTracks, startIndex);
|
||||
setOptimisticTrack(queueTracks[startIndex], 'loading');
|
||||
try {
|
||||
await prepareAudioProcessingForPlayback(playbackTarget, 'queue-play');
|
||||
await loadQueueChunked(queueTracks, startIndex);
|
||||
await primePreparedTrackForPlayback(playbackTarget, 'queue-play');
|
||||
await TrackPlayer.play();
|
||||
usePlayerStore.getState().setPlaybackState('playing');
|
||||
} catch (err) {
|
||||
@@ -342,10 +350,13 @@ export async function shuffleTracks(tracks: Track[]): Promise<void> {
|
||||
originalOrder = tracks.map((t) => t.id);
|
||||
usePlayerStore.getState().setShuffle(true);
|
||||
const queueTracks = shuffleArray(tracks).map(toRntpTrack);
|
||||
const playbackTarget = dspTargetFromTrack(queueTracks[0], 'none');
|
||||
useQueueStore.getState().setSnapshot(queueTracks, 0);
|
||||
setOptimisticTrack(queueTracks[0], 'loading');
|
||||
try {
|
||||
await prepareAudioProcessingForPlayback(playbackTarget, 'shuffle-play');
|
||||
await loadQueueChunked(queueTracks, 0);
|
||||
await primePreparedTrackForPlayback(playbackTarget, 'shuffle-play');
|
||||
await TrackPlayer.play();
|
||||
usePlayerStore.getState().setPlaybackState('playing');
|
||||
} catch (err) {
|
||||
@@ -361,18 +372,24 @@ export async function playSample(): Promise<void> {
|
||||
await ensurePlayerReady({ materializeRestored: false });
|
||||
await queueLoadSettled();
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
let playbackTarget: ReturnType<typeof dspTargetFromTrack>;
|
||||
if (queue.length === 0) {
|
||||
const sampleQueue = SAMPLE_TRACKS.map(toRntpTrack);
|
||||
playbackTarget = dspTargetFromTrack(sampleQueue[0], 'none');
|
||||
await prepareAudioProcessingForPlayback(playbackTarget, 'sample-play');
|
||||
await TrackPlayer.add(sampleQueue);
|
||||
originalOrder = SAMPLE_TRACKS.map((t) => t.id);
|
||||
useQueueStore.getState().setSnapshot(sampleQueue, 0);
|
||||
setOptimisticTrack(sampleQueue[0], 'loading');
|
||||
} else {
|
||||
const activeIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
playbackTarget = dspTargetFromTrack(queue[activeIndex ?? 0], 'immediate');
|
||||
await prepareAudioProcessingForPlayback(playbackTarget, 'sample-resume');
|
||||
useQueueStore.getState().setSnapshot(queue, activeIndex);
|
||||
setOptimisticTrack(queue[activeIndex ?? 0], 'loading');
|
||||
}
|
||||
try {
|
||||
await primePreparedTrackForPlayback(playbackTarget, 'sample-play');
|
||||
await TrackPlayer.play();
|
||||
usePlayerStore.getState().setPlaybackState('playing');
|
||||
} catch (err) {
|
||||
@@ -383,9 +400,14 @@ export async function playSample(): Promise<void> {
|
||||
|
||||
export async function play(): Promise<void> {
|
||||
selectPhonePlaybackTarget();
|
||||
usePlayerStore.getState().setPlaybackState('playing');
|
||||
try {
|
||||
const activeTrack = await TrackPlayer.getActiveTrack();
|
||||
await prepareAudioProcessingForPlayback(
|
||||
dspTargetFromTrack(activeTrack, 'immediate'),
|
||||
'controller-play',
|
||||
);
|
||||
await TrackPlayer.play();
|
||||
usePlayerStore.getState().setPlaybackState('playing');
|
||||
} catch (err) {
|
||||
await reconcilePlayerFromNative();
|
||||
throw err;
|
||||
@@ -431,6 +453,17 @@ export async function togglePlay(): Promise<void> {
|
||||
|
||||
export async function skipToNext(): Promise<void> {
|
||||
await ensurePlayerReady();
|
||||
const [nativeQueue, nativeIndex] = await Promise.all([
|
||||
TrackPlayer.getQueue(),
|
||||
TrackPlayer.getActiveTrackIndex(),
|
||||
]);
|
||||
await prepareAudioProcessingForPlayback(
|
||||
dspTargetFromTrack(
|
||||
nativeIndex == null ? undefined : nativeQueue[nativeIndex + 1],
|
||||
'none',
|
||||
),
|
||||
'skip-next',
|
||||
);
|
||||
const { tracks, activeIndex } = useQueueStore.getState();
|
||||
const nextIndex = activeIndex >= 0 ? activeIndex + 1 : -1;
|
||||
if (nextIndex >= 0 && nextIndex < tracks.length) {
|
||||
@@ -448,6 +481,17 @@ export async function skipToNext(): Promise<void> {
|
||||
|
||||
export async function skipToPrevious(): Promise<void> {
|
||||
await ensurePlayerReady();
|
||||
const [nativeQueue, nativeIndex] = await Promise.all([
|
||||
TrackPlayer.getQueue(),
|
||||
TrackPlayer.getActiveTrackIndex(),
|
||||
]);
|
||||
await prepareAudioProcessingForPlayback(
|
||||
dspTargetFromTrack(
|
||||
nativeIndex == null ? undefined : nativeQueue[nativeIndex - 1],
|
||||
'none',
|
||||
),
|
||||
'skip-previous',
|
||||
);
|
||||
const { tracks, activeIndex } = useQueueStore.getState();
|
||||
const previousIndex = activeIndex > 0 ? activeIndex - 1 : -1;
|
||||
if (previousIndex >= 0 && previousIndex < tracks.length) {
|
||||
@@ -627,6 +671,7 @@ export async function jumpToQueueIndex(index: number): Promise<void> {
|
||||
// a shifted native index while the head is still prepending) — translate,
|
||||
// waiting out the fill only when the target isn't loaded.
|
||||
const queuedTrack = useQueueStore.getState().tracks[index];
|
||||
const playbackTarget = dspTargetFromTrack(queuedTrack, 'none');
|
||||
useQueueStore.getState().setActiveIndex(index);
|
||||
setOptimisticTrack(queuedTrack, 'playing');
|
||||
let nativeIndex = absoluteIndexToNative(index);
|
||||
@@ -635,8 +680,10 @@ export async function jumpToQueueIndex(index: number): Promise<void> {
|
||||
nativeIndex = absoluteIndexToNative(index);
|
||||
}
|
||||
try {
|
||||
await prepareAudioProcessingForPlayback(playbackTarget, 'queue-jump');
|
||||
await TrackPlayer.skip(nativeIndex);
|
||||
useQueueStore.getState().setActiveIndex(index);
|
||||
await primePreparedTrackForPlayback(playbackTarget, 'queue-jump');
|
||||
await TrackPlayer.play();
|
||||
usePlayerStore.getState().setPlaybackState('playing');
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import TrackPlayer, { Event } from 'react-native-track-player';
|
||||
import { syncCarNowPlayingFromTrackPlayer } from './carSync';
|
||||
import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync';
|
||||
import { applyNormalizationForActiveTrack } from './applyNormalization';
|
||||
import { ensureGainRegistryStarted } from './gainRegistry';
|
||||
import { ensureEQRouteSyncStarted } from './eqRouteSync';
|
||||
import { startAudioProcessingWarmup } from './audioProcessingStartup';
|
||||
import { playForCar, skipToNext, skipToPrevious } from './playbackController';
|
||||
import { nativeIndexToAbsolute } from './queueLoader';
|
||||
import { useQueueStore } from '@/stores/queueStore';
|
||||
|
||||
@@ -13,11 +13,13 @@ import { useQueueStore } from '@/stores/queueStore';
|
||||
* 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();
|
||||
void ensureEQRouteSyncStarted().catch((error) => {
|
||||
console.warn('[eq-route] headless init failed', error);
|
||||
// Begin the small fail-closed warm-up before a car/Bluetooth play command can
|
||||
// arrive. Full-queue registration and analysis start only after it is safe.
|
||||
void startAudioProcessingWarmup('playback-service-start').catch((error) => {
|
||||
console.warn('[dsp-startup] headless warm failed', {
|
||||
at: Date.now(),
|
||||
error: error instanceof Error ? error.name : 'UnknownError',
|
||||
});
|
||||
});
|
||||
|
||||
const syncNowPlaying = () =>
|
||||
@@ -72,7 +74,9 @@ export async function PlaybackService(): Promise<void> {
|
||||
scheduleSync();
|
||||
});
|
||||
TrackPlayer.addEventListener(Event.RemotePlay, () => {
|
||||
void TrackPlayer.play().finally(scheduleSync);
|
||||
void playForCar()
|
||||
.catch(() => {})
|
||||
.finally(scheduleSync);
|
||||
});
|
||||
TrackPlayer.addEventListener(Event.RemotePause, () => {
|
||||
void TrackPlayer.pause().finally(scheduleSync);
|
||||
@@ -81,12 +85,12 @@ export async function PlaybackService(): Promise<void> {
|
||||
void TrackPlayer.stop().finally(scheduleSync);
|
||||
});
|
||||
TrackPlayer.addEventListener(Event.RemoteNext, () => {
|
||||
void TrackPlayer.skipToNext()
|
||||
void skipToNext()
|
||||
.catch(() => {})
|
||||
.finally(scheduleSync);
|
||||
});
|
||||
TrackPlayer.addEventListener(Event.RemotePrevious, () => {
|
||||
void TrackPlayer.skipToPrevious()
|
||||
void skipToPrevious()
|
||||
.catch(() => {})
|
||||
.finally(scheduleSync);
|
||||
});
|
||||
|
||||
@@ -15,9 +15,8 @@ import { buildArtistList, filterTracksByArtist } from '@/library/artistGrouping'
|
||||
import { dbTrackToTrack } from '@/library/trackAdapter';
|
||||
import { playForCar, playTracksForCar, pause, seekTo, skipToNext, skipToPrevious } from '@/audio/playbackController';
|
||||
import { syncCarNowPlayingFromTrackPlayer } from '@/audio/carSync';
|
||||
import { ensureEQRouteSyncStarted } from '@/audio/eqRouteSync';
|
||||
import { startAudioProcessingWarmup } from '@/audio/audioProcessingStartup';
|
||||
import TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player';
|
||||
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
||||
@@ -57,10 +56,6 @@ async function initializeForCar(): Promise<void> {
|
||||
await useLibraryStore.getState().initialize();
|
||||
await usePlaylistStore.getState().refresh();
|
||||
await useRemoteSourcesStore.getState().init();
|
||||
await Promise.all([
|
||||
ensureEQRouteSyncStarted(),
|
||||
useAudioSettingsStore.getState().load(),
|
||||
]);
|
||||
})().catch((err) => {
|
||||
initPromise = null;
|
||||
throw err;
|
||||
@@ -71,13 +66,17 @@ async function initializeForCar(): Promise<void> {
|
||||
|
||||
export async function handleAstraCarCommand(payload: CarCommandPayload): Promise<void> {
|
||||
try {
|
||||
await initializeForCar();
|
||||
// Warm DSP independently of catalog/library startup. Transport commands do
|
||||
// not need to wait for a full library initialize; media-id/search commands do.
|
||||
void startAudioProcessingWarmup('car-command').catch(() => {});
|
||||
|
||||
switch (payload.command) {
|
||||
case 'playMediaId':
|
||||
await initializeForCar();
|
||||
if (payload.media) await playMedia(payload.media);
|
||||
break;
|
||||
case 'playSearch':
|
||||
await initializeForCar();
|
||||
await playSearch(payload);
|
||||
break;
|
||||
case 'play':
|
||||
@@ -96,6 +95,7 @@ export async function handleAstraCarCommand(payload: CarCommandPayload): Promise
|
||||
if (typeof payload.position === 'number') await seekTo(payload.position);
|
||||
break;
|
||||
case 'toggleFavorite':
|
||||
await initializeForCar();
|
||||
await handleFavoriteCommand();
|
||||
break;
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user