diff --git a/modules/astra-audio-route/android/build.gradle b/modules/astra-audio-route/android/build.gradle index 2fe7ec9..bc1dc18 100644 --- a/modules/astra-audio-route/android/build.gradle +++ b/modules/astra-audio-route/android/build.gradle @@ -16,3 +16,7 @@ android { abortOnError false } } + +dependencies { + testImplementation 'junit:junit:4.13.2' +} diff --git a/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/AstraAudioRouteModule.kt b/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/AstraAudioRouteModule.kt index 8af8893..5680603 100644 --- a/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/AstraAudioRouteModule.kt +++ b/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/AstraAudioRouteModule.kt @@ -1,6 +1,7 @@ package expo.modules.astraaudioroute import android.content.Context +import android.media.AudioAttributes import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo import android.media.AudioManager @@ -162,20 +163,30 @@ class AstraAudioRouteModule : Module() { } catch (_: Throwable) { emptyList() } - if (outputs.isEmpty()) return null - return outputs.firstOrNull { kindForType(it.type) == "bluetooth" } - ?: outputs.firstOrNull { kindForType(it.type) == "wired" } - ?: outputs.firstOrNull { kindForType(it.type) == "usb" } - ?: outputs.firstOrNull { kindForType(it.type) == "hdmi" } - ?: outputs.firstOrNull { kindForType(it.type) == "speaker" } - ?: outputs.first() + val predicted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + try { + val mediaAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + audioManager().getAudioDevicesForAttributes(mediaAttributes).filter { it.isSink } + } catch (_: Throwable) { + emptyList() + } + } else { + emptyList() + } + return selectPredictedOutputDevice(predicted, outputs) { kindForType(it.type) } } private fun kindForType(type: Int): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { when (type) { AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, - AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "bluetooth" + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + AudioDeviceInfo.TYPE_BLE_HEADSET, + AudioDeviceInfo.TYPE_BLE_SPEAKER, + AudioDeviceInfo.TYPE_BLE_BROADCAST -> "bluetooth" AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET -> "wired" AudioDeviceInfo.TYPE_USB_ACCESSORY, diff --git a/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/OutputDeviceSelector.kt b/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/OutputDeviceSelector.kt new file mode 100644 index 0000000..f175ee4 --- /dev/null +++ b/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/OutputDeviceSelector.kt @@ -0,0 +1,17 @@ +package expo.modules.astraaudioroute + +/** Prefer Android's media-attribute prediction, then retain the legacy fallback. */ +internal fun selectPredictedOutputDevice( + predicted: List, + connected: List, + kindFor: (T) -> String, +): T? { + predicted.firstOrNull()?.let { return it } + if (connected.isEmpty()) return null + return connected.firstOrNull { kindFor(it) == "bluetooth" } + ?: connected.firstOrNull { kindFor(it) == "wired" } + ?: connected.firstOrNull { kindFor(it) == "usb" } + ?: connected.firstOrNull { kindFor(it) == "hdmi" } + ?: connected.firstOrNull { kindFor(it) == "speaker" } + ?: connected.first() +} diff --git a/modules/astra-audio-route/android/src/test/java/expo/modules/astraaudioroute/OutputDeviceSelectorTest.kt b/modules/astra-audio-route/android/src/test/java/expo/modules/astraaudioroute/OutputDeviceSelectorTest.kt new file mode 100644 index 0000000..94f081e --- /dev/null +++ b/modules/astra-audio-route/android/src/test/java/expo/modules/astraaudioroute/OutputDeviceSelectorTest.kt @@ -0,0 +1,37 @@ +package expo.modules.astraaudioroute + +import org.junit.Assert.assertEquals +import org.junit.Test + +class OutputDeviceSelectorTest { + private data class Device(val name: String, val kind: String) + + @Test + fun predictedMediaDeviceWinsOverConnectedBluetoothPriority() { + val bluetooth = Device("car bluetooth", "bluetooth") + val usb = Device("android auto usb", "usb") + + val selected = selectPredictedOutputDevice( + predicted = listOf(usb), + connected = listOf(bluetooth, usb), + kindFor = Device::kind, + ) + + assertEquals(usb, selected) + } + + @Test + fun legacyFallbackKeepsExistingPriorityWithoutPrediction() { + val speaker = Device("phone", "speaker") + val usb = Device("dac", "usb") + val bluetooth = Device("headphones", "bluetooth") + + val selected = selectPredictedOutputDevice( + predicted = emptyList(), + connected = listOf(speaker, usb, bluetooth), + kindFor = Device::kind, + ) + + assertEquals(bluetooth, selected) + } +} diff --git a/modules/astra-audio-route/index.ts b/modules/astra-audio-route/index.ts index e249a5f..9addd33 100644 --- a/modules/astra-audio-route/index.ts +++ b/modules/astra-audio-route/index.ts @@ -13,6 +13,8 @@ declare class AstraAudioRouteModuleType extends NativeModule('AstraAudioRoute'); +export const isAstraAudioRouteAvailable = native !== null; + export const AstraAudioRoute = native ?? { addListener: () => ({ remove: () => {} }), removeAllListeners: () => {}, diff --git a/modules/astra-scope/android/build.gradle b/modules/astra-scope/android/build.gradle index 9b82df4..5684311 100644 --- a/modules/astra-scope/android/build.gradle +++ b/modules/astra-scope/android/build.gradle @@ -33,3 +33,7 @@ android { abortOnError false } } + +dependencies { + testImplementation 'junit:junit:4.13.2' +} diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt index 40661db..ebe3301 100644 --- a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt @@ -79,6 +79,12 @@ class AstraScopeModule : Module() { GainBridge.activateSmoothFor(url) } + // Hard-prime while paused; unlike activateTrackGain this must not spend the + // first audible second gliding down from the process default (unity). + Function("primeTrackGain") { url: String -> + GainBridge.primeFor(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 -> diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/GainBridge.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/GainBridge.kt index 53c99f4..a38af58 100644 --- a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/GainBridge.kt +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/GainBridge.kt @@ -89,6 +89,15 @@ object GainBridge { setTarget(gains[url] ?: fallbackGain, CORRECTION_RAMP_MS) } + /** + * Prime a paused player before play is released. There is no live signal to + * declick here, so publishing a zero-duration target guarantees the first + * processed frame is already at the requested gain. + */ + fun primeFor(url: String) { + setTarget(gains[url] ?: fallbackGain, 0) + } + /** Glide to an explicit gain (unity paths: no track / remote track / disabled). */ fun setGainSmooth(linear: Float) { setTarget(linear, CORRECTION_RAMP_MS) diff --git a/modules/astra-scope/android/src/test/java/expo/modules/astrascope/GainBridgeTest.kt b/modules/astra-scope/android/src/test/java/expo/modules/astrascope/GainBridgeTest.kt new file mode 100644 index 0000000..9a8daab --- /dev/null +++ b/modules/astra-scope/android/src/test/java/expo/modules/astrascope/GainBridgeTest.kt @@ -0,0 +1,26 @@ +package expo.modules.astrascope + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GainBridgeTest { + @Test + fun pausedPrimePublishesTargetWithoutCorrectionRamp() { + GainBridge.putGain("test-track", 0.25f) + + GainBridge.primeFor("test-track") + + assertEquals(0.25f, GainBridge.targetGain, 0.0001f) + assertEquals(0, GainBridge.rampMs) + } + + @Test + fun pausedPrimeUsesFallbackForUnanalyzedTrack() { + GainBridge.fallbackGain = 0.5f + + GainBridge.primeFor("missing-track") + + assertEquals(0.5f, GainBridge.targetGain, 0.0001f) + assertEquals(0, GainBridge.rampMs) + } +} diff --git a/modules/astra-scope/index.ts b/modules/astra-scope/index.ts index 99c084f..18b2aea 100644 --- a/modules/astra-scope/index.ts +++ b/modules/astra-scope/index.ts @@ -59,6 +59,8 @@ declare class AstraScopeModuleType extends NativeModule { setTrackGains(entries: Record, clearExisting: boolean): void; /** Glide to the registered gain for this URL now (mount/settings/late measurement). */ activateTrackGain(url: string): void; + /** Apply the registered gain immediately while playback is still paused. */ + primeTrackGain(url: string): 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. diff --git a/package.json b/package.json index d475eda..dceef5e 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/services/desktopSyncPolicy.test.mts src/shared/sync/conflictPreview.test.mts", "test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts", "test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts", + "test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts", "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts", "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts", "test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts", diff --git a/src/audio/audioProcessingStartup.ts b/src/audio/audioProcessingStartup.ts new file mode 100644 index 0000000..8de4532 --- /dev/null +++ b/src/audio/audioProcessingStartup.ts @@ -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; + +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 { + 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 { + ensureStartupMonitoring(); + const warmup = coordinator.warm(reason); + void warmup.then(startBackgroundGainWork, () => {}); + return warmup; +} + +export async function prepareAudioProcessingForPlayback( + target: DspPlaybackTarget, + reason: string, +): Promise { + 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 { + 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, + }; +} diff --git a/src/audio/dspStartupCoordinator.test.mts b/src/audio/dspStartupCoordinator.test.mts new file mode 100644 index 0000000..72a3529 --- /dev/null +++ b/src/audio/dspStartupCoordinator.test.mts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DspStartupCoordinator } from './dspStartupCoordinator.ts'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((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(); + const route = deferred(); + const fallback = deferred(); + 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(); + 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']); +}); diff --git a/src/audio/dspStartupCoordinator.ts b/src/audio/dspStartupCoordinator.ts new file mode 100644 index 0000000..3fb8449 --- /dev/null +++ b/src/audio/dspStartupCoordinator.ts @@ -0,0 +1,143 @@ +export interface DspWarmupInputs { + settings: Settings; + route: Route; + persistedFallback: PersistedFallback; +} + +export interface DspStartupTiming { + stage: 'base' | 'target'; + reason: string; + elapsedMs: number; + status: 'ready' | 'failed'; +} + +export interface DspStartupDependencies { + loadSettings: () => Promise; + loadEqRoute: () => Promise; + loadPersistedFallback: () => Promise; + applyBase: ( + inputs: DspWarmupInputs, + ) => void | Promise; + prepareTarget: ( + inputs: DspWarmupInputs, + target: Target, + ) => void | Promise; + 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 { + private basePromise: Promise> | 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> { + if (this.basePromise) return this.basePromise; + + const generation = this.generation; + const now = this.dependencies.now ?? Date.now; + const startedAt = now(); + let tracked: Promise>; + + 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 { + 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> { + this.invalidate(); + return this.warm(reason); + } +} diff --git a/src/audio/dspStartupGain.test.mts b/src/audio/dspStartupGain.test.mts new file mode 100644 index 0000000..4c8e999 --- /dev/null +++ b/src/audio/dspStartupGain.test.mts @@ -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', + }); +}); diff --git a/src/audio/dspStartupGain.ts b/src/audio/dspStartupGain.ts new file mode 100644 index 0000000..0e95906 --- /dev/null +++ b/src/audio/dspStartupGain.ts @@ -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' }; +} diff --git a/src/audio/eqNative.ts b/src/audio/eqNative.ts index 586892c..6d275f6 100644 --- a/src/audio/eqNative.ts +++ b/src/audio/eqNative.ts @@ -12,12 +12,54 @@ type NativeEq = { setTrackGain?: (url: string, linear: number) => void; setTrackGains?: (entries: Record, 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(name: K): NonNullable { + const method = native[name]; + if (typeof method !== 'function') { + throw new Error(`Native DSP method unavailable: ${String(name)}`); + } + return method as NonNullable; +} + +/** 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); diff --git a/src/audio/eqRouteSync.ts b/src/audio/eqRouteSync.ts index ae60563..a421dcc 100644 --- a/src/audio/eqRouteSync.ts +++ b/src/audio/eqRouteSync.ts @@ -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 { } } +/** + * 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 { + 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 { if (!subscription) { subscription = AstraAudioRoute.addListener('onAudioRouteChanged', (route) => { diff --git a/src/audio/gainRegistry.ts b/src/audio/gainRegistry.ts index 3875df3..2ca0974 100644 --- a/src/audio/gainRegistry.ts +++ b/src/audio/gainRegistry.ts @@ -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 { + 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 diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts index 676efce..ab58b69 100644 --- a/src/audio/playbackController.ts +++ b/src/audio/playbackController.ts @@ -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 { 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 { await ensurePlayerReady({ materializeRestored: false }); await queueLoadSettled(); const queue = await TrackPlayer.getQueue(); + let playbackTarget: ReturnType; 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 { export async function play(): Promise { 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 { export async function skipToNext(): Promise { 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 { export async function skipToPrevious(): Promise { 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 { // 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 { 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) { diff --git a/src/audio/playbackService.ts b/src/audio/playbackService.ts index a15e5cb..ffc0ef0 100644 --- a/src/audio/playbackService.ts +++ b/src/audio/playbackService.ts @@ -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 { - // 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 { 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 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); }); diff --git a/src/car/carPlayback.ts b/src/car/carPlayback.ts index c292698..8b79c9d 100644 --- a/src/car/carPlayback.ts +++ b/src/car/carPlayback.ts @@ -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 { 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 { export async function handleAstraCarCommand(payload: CarCommandPayload): Promise { 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: