mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
fix android auto quirks
This commit is contained in:
@@ -16,3 +16,7 @@ android {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
}
|
||||
|
||||
+19
-8
@@ -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,
|
||||
|
||||
+17
@@ -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()
|
||||
}
|
||||
+37
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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