fix android auto quirks

This commit is contained in:
Boof2015
2026-07-13 23:04:28 -04:00
parent 3ac75432a4
commit a8c1f4fd22
22 changed files with 897 additions and 27 deletions
@@ -16,3 +16,7 @@ android {
abortOnError false
}
}
dependencies {
testImplementation 'junit:junit:4.13.2'
}
@@ -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,
@@ -0,0 +1,17 @@
package expo.modules.astraaudioroute
/** Prefer Android's media-attribute prediction, then retain the legacy fallback. */
internal fun <T> selectPredictedOutputDevice(
predicted: List<T>,
connected: List<T>,
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()
}
@@ -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)
}
}
+2
View File
@@ -13,6 +13,8 @@ declare class AstraAudioRouteModuleType extends NativeModule<AstraAudioRouteEven
const native = requireOptionalNativeModule<AstraAudioRouteModuleType>('AstraAudioRoute');
export const isAstraAudioRouteAvailable = native !== null;
export const AstraAudioRoute = native ?? {
addListener: () => ({ remove: () => {} }),
removeAllListeners: () => {},
+4
View File
@@ -33,3 +33,7 @@ android {
abortOnError false
}
}
dependencies {
testImplementation 'junit:junit:4.13.2'
}
@@ -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 ->
@@ -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)
@@ -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)
}
}
+2
View File
@@ -59,6 +59,8 @@ declare class AstraScopeModuleType extends NativeModule {
setTrackGains(entries: Record<string, number>, clearExisting: boolean): void;
/** Glide to the registered gain for this URL now (mount/settings/late measurement). */
activateTrackGain(url: string): void;
/** 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.
+1
View File
@@ -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",
+232
View File
@@ -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,
};
}
+140
View File
@@ -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']);
});
+143
View File
@@ -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);
}
}
+56
View File
@@ -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',
});
});
+55
View File
@@ -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' };
}
+42
View File
@@ -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);
+22 -1
View File
@@ -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) => {
+11
View File
@@ -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
+48 -1
View File
@@ -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) {
+14 -10
View File
@@ -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);
});
+7 -7
View File
@@ -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: