per output device EQ

This commit is contained in:
Boof2015
2026-07-07 16:22:30 -04:00
parent ed6d587f5f
commit c4212f81e0
13 changed files with 884 additions and 10 deletions
@@ -0,0 +1,18 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'expo.modules.astraaudioroute'
version = '0.1.0'
android {
namespace "expo.modules.astraaudioroute"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
@@ -0,0 +1,259 @@
package expo.modules.astraaudioroute
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.media.MediaRouter
import android.os.Build
import android.os.Handler
import android.os.Looper
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class AstraAudioRouteModule : Module() {
private val mainHandler = Handler(Looper.getMainLooper())
private var deviceCallback: AudioDeviceCallback? = null
private var routeCallback: MediaRouter.Callback? = null
private var listening = false
override fun definition() = ModuleDefinition {
Name("AstraAudioRoute")
Events("onAudioRouteChanged")
Function("getCurrentRoute") {
snapshotRoute()
}
Function("start") {
startListening()
}
Function("stop") {
stopListening()
}
OnDestroy {
stopListening()
}
}
private fun requireContext(): Context =
appContext.reactContext ?: throw Exceptions.ReactContextLost()
private fun audioManager(): AudioManager =
requireContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager
private fun mediaRouter(): MediaRouter =
requireContext().getSystemService(Context.MEDIA_ROUTER_SERVICE) as MediaRouter
private fun startListening() {
if (listening) return
listening = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val callback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
emitCurrentRoute()
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
emitCurrentRoute()
}
}
deviceCallback = callback
audioManager().registerAudioDeviceCallback(callback, mainHandler)
}
val callback = object : MediaRouter.SimpleCallback() {
override fun onRouteSelected(router: MediaRouter, type: Int, info: MediaRouter.RouteInfo) {
emitCurrentRoute()
}
override fun onRouteUnselected(router: MediaRouter, type: Int, info: MediaRouter.RouteInfo) {
emitCurrentRoute()
}
override fun onRouteChanged(router: MediaRouter, info: MediaRouter.RouteInfo) {
emitCurrentRoute()
}
override fun onRouteAdded(router: MediaRouter, info: MediaRouter.RouteInfo) {
emitCurrentRoute()
}
override fun onRouteRemoved(router: MediaRouter, info: MediaRouter.RouteInfo) {
emitCurrentRoute()
}
}
routeCallback = callback
mediaRouter().addCallback(MediaRouter.ROUTE_TYPE_LIVE_AUDIO, callback)
emitCurrentRoute()
}
private fun stopListening() {
if (!listening) return
listening = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
deviceCallback?.let {
try {
audioManager().unregisterAudioDeviceCallback(it)
} catch (_: Throwable) {
// Treat stop as idempotent.
}
}
}
deviceCallback = null
routeCallback?.let {
try {
mediaRouter().removeCallback(it)
} catch (_: Throwable) {
// Treat stop as idempotent.
}
}
routeCallback = null
}
private fun emitCurrentRoute() {
mainHandler.post {
sendEvent("onAudioRouteChanged", snapshotRoute())
}
}
private fun snapshotRoute(): Map<String, Any?> {
val selectedRouteName = selectedRouteName()
val device = selectOutputDevice()
val kind = device?.let { kindForType(it.type) } ?: "unknown"
val label = displayLabel(kind, device, selectedRouteName)
val key = routeKey(kind, label)
return mapOf(
"key" to key,
"label" to label,
"kind" to kind,
"nativeType" to device?.type,
"nativeId" to device?.id,
"selectedRouteName" to selectedRouteName,
"updatedAt" to System.currentTimeMillis(),
)
}
private fun selectedRouteName(): String? =
try {
mediaRouter()
.getSelectedRoute(MediaRouter.ROUTE_TYPE_LIVE_AUDIO)
?.name
?.toString()
?.trim()
?.ifEmpty { null }
} catch (_: Throwable) {
null
}
private fun selectOutputDevice(): AudioDeviceInfo? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return null
val outputs = try {
audioManager().getDevices(AudioManager.GET_DEVICES_OUTPUTS).filter { it.isSink }
} 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()
}
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_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "wired"
AudioDeviceInfo.TYPE_USB_ACCESSORY,
AudioDeviceInfo.TYPE_USB_DEVICE,
AudioDeviceInfo.TYPE_USB_HEADSET -> "usb"
AudioDeviceInfo.TYPE_HDMI,
AudioDeviceInfo.TYPE_HDMI_ARC,
AudioDeviceInfo.TYPE_HDMI_EARC -> "hdmi"
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "speaker"
else -> "unknown"
}
} else {
"unknown"
}
private fun displayLabel(kind: String, device: AudioDeviceInfo?, selectedRouteName: String?): String {
val productName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
device?.productName?.toString()?.trim()?.ifEmpty { null }
} else {
null
}
val routeName = selectedRouteName?.takeIf { isUsefulRouteLabel(kind, it) }
val deviceName = productName?.takeIf { isUsefulRouteLabel(kind, it) }
return routeName ?: deviceName ?: defaultLabel(kind)
}
private fun defaultLabel(kind: String): String =
when (kind) {
"speaker" -> "Phone speaker"
"wired" -> "Wired headphones"
"bluetooth" -> "Bluetooth"
"usb" -> "USB audio"
"hdmi" -> "HDMI audio"
else -> "Unknown output"
}
private fun isUsefulRouteLabel(kind: String, label: String): Boolean {
val normalized = label.trim().lowercase()
if (normalized.isEmpty()) return false
val generic = setOf(
"audio",
"bluetooth",
"bluetooth audio",
"headphones",
"headset",
"phone",
"phone speaker",
"speaker",
"speakers",
"this device",
"wired headphones",
)
if (normalized in generic) return false
return kind == "bluetooth" || kind == "usb" || kind == "hdmi"
}
private fun routeKey(kind: String, label: String): String {
if (kind == "bluetooth") {
val slug = slug(label)
if (slug.isNotEmpty() && slug != "bluetooth" && slug != "bluetooth-audio") {
return "bluetooth:$slug"
}
}
return when (kind) {
"speaker" -> "speaker"
"wired" -> "wired"
"bluetooth" -> "bluetooth"
"usb" -> "usb"
"hdmi" -> "hdmi"
else -> "unknown"
}
}
private fun slug(value: String): String =
value
.trim()
.lowercase()
.replace(Regex("[^a-z0-9]+"), "-")
.trim('-')
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.astraaudioroute.AstraAudioRouteModule"]
}
}
+22
View File
@@ -0,0 +1,22 @@
import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core';
import type { AudioOutputRoute } from '../../src/types/audio';
type AstraAudioRouteEvents = {
onAudioRouteChanged: (route: AudioOutputRoute | null) => void;
};
declare class AstraAudioRouteModuleType extends NativeModule<AstraAudioRouteEvents> {
getCurrentRoute(): AudioOutputRoute | null;
start(): void;
stop(): void;
}
const native = requireOptionalNativeModule<AstraAudioRouteModuleType>('AstraAudioRoute');
export const AstraAudioRoute = native ?? {
addListener: () => ({ remove: () => {} }),
removeAllListeners: () => {},
getCurrentRoute: () => null,
start: () => {},
stop: () => {},
};
+20 -1
View File
@@ -111,6 +111,7 @@ export default function EQScreen() {
const activeBand = eq.bands.find((b) => b.id === eq.activeBandId) ?? null;
const activeBandNumber = eq.bands.findIndex((b) => b.id === eq.activeBandId) + 1;
const presetName = eq.presets.find((p) => p.id === eq.activePresetId)?.name ?? 'Custom';
const outputRouteLabel = eq.activeOutputRoute?.label ?? 'This phone';
const defaultPresetName = `Preset ${eq.presets.filter((p) => p.isCustom).length + 1}`;
const valueEditConfig = activeBand && editingValue ? getValueEditConfig(editingValue, activeBand) : null;
@@ -306,7 +307,17 @@ export default function EQScreen() {
{ paddingLeft: spacing.lg + insets.left, paddingRight: spacing.lg + insets.right },
]}
>
<Text variant="heading">Equalizer</Text>
<View style={styles.headerTitle}>
<Text variant="heading">Equalizer</Text>
<Text
variant="caption"
color={colors.textSecondary}
numberOfLines={1}
style={styles.routeLabel}
>
{`Tuning ${outputRouteLabel}`}
</Text>
</View>
<View style={styles.headerActions}>
<Pressable style={styles.iconButton} onPress={() => setSheet('save')} hitSlop={8}>
<Ionicons name="save-outline" size={20} color={colors.textSecondary} />
@@ -597,6 +608,14 @@ const useStyles = createThemedStyles((colors) => ({
justifyContent: 'space-between',
paddingTop: spacing.md,
paddingBottom: spacing.sm,
gap: spacing.md,
},
headerTitle: {
flex: 1,
minWidth: 0,
},
routeLabel: {
marginTop: 2,
},
headerActions: {
flexDirection: 'row',
+2 -5
View File
@@ -20,7 +20,7 @@ import { usePlaybackSync } from '@/audio/usePlaybackSync';
import { QuickSearchOverlay } from '@/components/search/QuickSearchOverlay';
import { useScopeLifecycle } from '@/scope/useScopeLifecycle';
import { useLibraryStore } from '@/stores/libraryStore';
import { useEQStore } from '@/stores/eqStore';
import { ensureEQRouteSyncStarted } from '@/audio/eqRouteSync';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
@@ -301,10 +301,7 @@ export default function RootLayout() {
.getState()
.initialize()
.catch((err) => console.error('[library] init failed', err));
useEQStore
.getState()
.load()
.catch((err) => console.error('[eq] load failed', err));
ensureEQRouteSyncStarted().catch((err) => console.error('[eq-route] init failed', err));
useAudioSettingsStore
.getState()
.load()
+133
View File
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AudioOutputRoute, EQBand } from '../types/audio.ts';
import {
buildAudioOutputRouteKey,
createEQRouteProfile,
normalizeAudioOutputRoute,
parseEQRouteProfilesJson,
restoreEQRouteProfile,
stringifyEQRouteProfiles,
} from './eqRouteProfiles.ts';
let idCounter = 0;
function nextId(): string {
idCounter += 1;
return `route-eq-${idCounter}`;
}
function band(overrides: Partial<EQBand> = {}): EQBand {
return {
id: overrides.id ?? nextId(),
type: overrides.type ?? 'peaking',
frequency: overrides.frequency ?? 1000,
gain: overrides.gain ?? 0,
Q: overrides.Q ?? 1,
enabled: overrides.enabled ?? true,
};
}
function route(overrides: Partial<AudioOutputRoute> = {}): AudioOutputRoute {
return {
key: overrides.key ?? buildAudioOutputRouteKey(overrides.kind ?? 'speaker', overrides.label ?? 'Phone speaker'),
label: overrides.label ?? 'Phone speaker',
kind: overrides.kind ?? 'speaker',
nativeType: overrides.nativeType ?? null,
nativeId: overrides.nativeId ?? null,
selectedRouteName: overrides.selectedRouteName ?? null,
updatedAt: overrides.updatedAt ?? 1,
};
}
test('normalizes route keys for named and unnamed Bluetooth outputs', () => {
assert.equal(
normalizeAudioOutputRoute({ kind: 'bluetooth', label: 'Sony WH-1000XM5' })?.key,
'bluetooth:sony-wh-1000xm5'
);
assert.equal(
normalizeAudioOutputRoute({ kind: 'bluetooth', label: 'Bluetooth audio' })?.key,
'bluetooth'
);
});
test('normalizes class routes for wired, usb, speaker, and unknown outputs', () => {
assert.equal(normalizeAudioOutputRoute({ kind: 'wired', label: '3.5mm' })?.key, 'wired');
assert.equal(normalizeAudioOutputRoute({ kind: 'usb', label: 'USB DAC' })?.key, 'usb');
assert.equal(normalizeAudioOutputRoute({ kind: 'speaker', label: 'Pixel speaker' })?.key, 'speaker');
assert.equal(normalizeAudioOutputRoute({ kind: 'nonsense', label: '' })?.key, 'unknown');
});
test('recovers from corrupt route profile storage', () => {
assert.deepEqual(parseEQRouteProfilesJson('{not-json', nextId), {});
assert.deepEqual(
parseEQRouteProfilesJson(
JSON.stringify({
version: 1,
profiles: {
speaker: { version: 999, routeKey: 'speaker' },
wired: { version: 1, routeKey: 'wired', bands: [] },
},
}),
nextId
),
{}
);
});
test('round-trips full per-route EQ state independently', () => {
const speaker = route({ key: 'speaker', label: 'Phone speaker', kind: 'speaker' });
const wired = route({ key: 'wired', label: 'Wired headphones', kind: 'wired' });
const bluetooth = route({ key: 'bluetooth:sony-wh-1000xm5', label: 'Sony WH-1000XM5', kind: 'bluetooth' });
const profiles = {
[speaker.key]: createEQRouteProfile(speaker, {
enabled: true,
preamp: -2,
mode: 'parametric',
bands: [band({ frequency: 80, gain: 4 })],
graphicGains: [0, 0, 0, 0, 0],
activePresetId: 'bass-boost',
}),
[wired.key]: createEQRouteProfile(wired, {
enabled: true,
preamp: -6,
mode: 'graphic',
bands: [band({ frequency: 1000, gain: -3 })],
graphicGains: [-1, 0, 2, 1, -2],
activePresetId: null,
}),
[bluetooth.key]: createEQRouteProfile(bluetooth, {
enabled: false,
preamp: 0,
mode: 'parametric',
bands: [band({ frequency: 4000, gain: 5, enabled: false })],
graphicGains: [0, 0, 0, 0, 0],
activePresetId: 'vocal',
}),
};
const parsed = parseEQRouteProfilesJson(stringifyEQRouteProfiles(profiles), nextId);
assert.equal(restoreEQRouteProfile(parsed.speaker, () => true).bands[0]?.gain, 4);
assert.equal(restoreEQRouteProfile(parsed.wired, () => true).mode, 'graphic');
assert.deepEqual(restoreEQRouteProfile(parsed.wired, () => true).graphicGains, [-1, 0, 2, 1, -2]);
assert.equal(restoreEQRouteProfile(parsed['bluetooth:sony-wh-1000xm5'], () => true).enabled, false);
assert.equal(restoreEQRouteProfile(parsed['bluetooth:sony-wh-1000xm5'], () => true).bands[0]?.enabled, false);
});
test('drops deleted preset references but keeps the stored EQ shape', () => {
const profile = createEQRouteProfile(route({ key: 'speaker' }), {
enabled: true,
preamp: -1,
mode: 'parametric',
bands: [band({ frequency: 1200, gain: 2.5 })],
graphicGains: [0, 0, 0, 0, 0],
activePresetId: 'deleted-custom',
});
const restored = restoreEQRouteProfile(profile, () => false);
assert.equal(restored.activePresetId, null);
assert.equal(restored.bands[0]?.frequency, 1200);
assert.equal(restored.bands[0]?.gain, 2.5);
});
+259
View File
@@ -0,0 +1,259 @@
import type {
AudioOutputRoute,
AudioOutputRouteKind,
EQBand,
EQMode,
} from '../types/audio.ts';
import {
EQ_GRAPHIC_BAND_COUNT,
EQ_MAX_BANDS,
clampPreamp,
createNormalizedEQBand,
} from './eq.ts';
export const EQ_ROUTE_PROFILE_VERSION = 1;
export const DEFAULT_AUDIO_OUTPUT_ROUTE_KEY = 'default';
export interface EQRouteProfile {
version: typeof EQ_ROUTE_PROFILE_VERSION;
routeKey: string;
routeLabel: string;
routeKind: AudioOutputRouteKind;
enabled: boolean;
preamp: number;
mode: EQMode;
bands: EQBand[];
graphicGains: number[];
activePresetId: string | null;
updatedAt: number;
}
export interface EQRouteProfileEnvelope {
version: typeof EQ_ROUTE_PROFILE_VERSION;
profiles: Record<string, EQRouteProfile>;
}
export interface EQRouteProfileState {
enabled: boolean;
preamp: number;
mode: EQMode;
bands: EQBand[];
graphicGains: number[];
activePresetId: string | null;
}
function isRouteKind(value: unknown): value is AudioOutputRouteKind {
return (
value === 'speaker' ||
value === 'wired' ||
value === 'bluetooth' ||
value === 'usb' ||
value === 'hdmi' ||
value === 'unknown'
);
}
function trim(value: unknown): string | null {
if (typeof value !== 'string') return null;
const out = value.trim();
return out.length > 0 ? out : null;
}
function finiteNumber(value: unknown): number | null {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function slug(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function defaultRouteLabel(kind: AudioOutputRouteKind): string {
switch (kind) {
case 'speaker':
return 'Phone speaker';
case 'wired':
return 'Wired headphones';
case 'bluetooth':
return 'Bluetooth';
case 'usb':
return 'USB audio';
case 'hdmi':
return 'HDMI audio';
default:
return 'Unknown output';
}
}
function isGenericBluetoothLabel(label: string): boolean {
const normalized = slug(label);
return (
normalized.length === 0 ||
normalized === 'bluetooth' ||
normalized === 'bluetooth-audio' ||
normalized === 'headphones' ||
normalized === 'headset'
);
}
export function buildAudioOutputRouteKey(kind: AudioOutputRouteKind, label: string | null): string {
if (kind === 'bluetooth' && label && !isGenericBluetoothLabel(label)) {
return `bluetooth:${slug(label)}`;
}
switch (kind) {
case 'speaker':
return 'speaker';
case 'wired':
return 'wired';
case 'bluetooth':
return 'bluetooth';
case 'usb':
return 'usb';
case 'hdmi':
return 'hdmi';
default:
return 'unknown';
}
}
export function normalizeAudioOutputRoute(value: unknown): AudioOutputRoute | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const raw = value as Partial<AudioOutputRoute>;
const kind = isRouteKind(raw.kind) ? raw.kind : 'unknown';
const label = trim(raw.label) ?? defaultRouteLabel(kind);
const key = trim(raw.key) ?? buildAudioOutputRouteKey(kind, label);
const nativeType = finiteNumber(raw.nativeType);
const nativeId = finiteNumber(raw.nativeId);
return {
key,
label,
kind,
nativeType: nativeType === null ? null : Math.trunc(nativeType),
nativeId: nativeId === null ? null : Math.trunc(nativeId),
selectedRouteName: trim(raw.selectedRouteName),
updatedAt: Math.max(0, Math.trunc(finiteNumber(raw.updatedAt) ?? Date.now())),
};
}
function normalizeBands(value: unknown, createId: () => string): EQBand[] | null {
if (!Array.isArray(value) || value.length === 0) return null;
return value
.slice(0, EQ_MAX_BANDS)
.map((band) =>
createNormalizedEQBand(
band && typeof band === 'object' && !Array.isArray(band) ? (band as object) : {},
createId()
)
);
}
function normalizeGraphicGains(value: unknown): number[] | null {
if (!Array.isArray(value) || value.length !== EQ_GRAPHIC_BAND_COUNT) return null;
const out: number[] = [];
for (const raw of value) {
const gain = finiteNumber(raw);
if (gain === null) return null;
out.push(Math.max(-12, Math.min(12, gain)));
}
return out;
}
function parseProfile(value: unknown, createId: () => string): EQRouteProfile | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const raw = value as Partial<EQRouteProfile>;
if (raw.version !== EQ_ROUTE_PROFILE_VERSION) return null;
const routeKey = trim(raw.routeKey);
if (!routeKey) return null;
const routeKind = isRouteKind(raw.routeKind) ? raw.routeKind : 'unknown';
const routeLabel = trim(raw.routeLabel) ?? defaultRouteLabel(routeKind);
const bands = normalizeBands(raw.bands, createId);
if (!bands) return null;
const mode: EQMode = raw.mode === 'graphic' ? 'graphic' : 'parametric';
const graphicGains = normalizeGraphicGains(raw.graphicGains);
if (!graphicGains) return null;
return {
version: EQ_ROUTE_PROFILE_VERSION,
routeKey,
routeLabel,
routeKind,
enabled: raw.enabled === true,
preamp: clampPreamp(finiteNumber(raw.preamp) ?? 0),
mode,
bands,
graphicGains,
activePresetId: trim(raw.activePresetId),
updatedAt: Math.max(0, Math.trunc(finiteNumber(raw.updatedAt) ?? Date.now())),
};
}
export function parseEQRouteProfiles(value: unknown, createId: () => string): Record<string, EQRouteProfile> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const raw = value as Partial<EQRouteProfileEnvelope> & { profiles?: unknown };
const source = raw.profiles && typeof raw.profiles === 'object' && !Array.isArray(raw.profiles)
? raw.profiles
: value;
const out: Record<string, EQRouteProfile> = {};
for (const [key, profileValue] of Object.entries(source)) {
const profile = parseProfile(profileValue, createId);
if (!profile) continue;
out[key] = { ...profile, routeKey: key };
}
return out;
}
export function parseEQRouteProfilesJson(json: string | null, createId: () => string): Record<string, EQRouteProfile> {
if (!json) return {};
try {
return parseEQRouteProfiles(JSON.parse(json), createId);
} catch {
return {};
}
}
export function stringifyEQRouteProfiles(profiles: Record<string, EQRouteProfile>): string {
const envelope: EQRouteProfileEnvelope = {
version: EQ_ROUTE_PROFILE_VERSION,
profiles,
};
return JSON.stringify(envelope);
}
export function createEQRouteProfile(
route: AudioOutputRoute,
state: EQRouteProfileState,
updatedAt: number = Date.now()
): EQRouteProfile {
return {
version: EQ_ROUTE_PROFILE_VERSION,
routeKey: route.key,
routeLabel: route.label,
routeKind: route.kind,
enabled: state.enabled,
preamp: clampPreamp(state.preamp),
mode: state.mode,
bands: state.bands.slice(0, EQ_MAX_BANDS).map((band) => ({ ...band })),
graphicGains: state.graphicGains.slice(0, EQ_GRAPHIC_BAND_COUNT),
activePresetId: state.activePresetId,
updatedAt,
};
}
export function restoreEQRouteProfile(
profile: EQRouteProfile,
presetExists: (presetId: string) => boolean
): EQRouteProfileState {
const activePresetId =
profile.activePresetId && presetExists(profile.activePresetId) ? profile.activePresetId : null;
return {
enabled: profile.enabled,
preamp: profile.preamp,
mode: profile.mode,
bands: profile.bands.map((band) => ({ ...band })),
graphicGains: [...profile.graphicGains],
activePresetId,
};
}
+56
View File
@@ -0,0 +1,56 @@
import { AstraAudioRoute } from '../../modules/astra-audio-route';
import { useEQStore } from '@/stores/eqStore';
type Subscription = { remove: () => void };
let startPromise: Promise<void> | null = null;
let subscription: Subscription | null = null;
async function applyCurrentRoute(): Promise<void> {
try {
await useEQStore.getState().setOutputRoute(AstraAudioRoute.getCurrentRoute());
} catch (error) {
console.warn('[eq-route] apply failed', error);
}
}
async function startEQRouteSync(): Promise<void> {
if (!subscription) {
subscription = AstraAudioRoute.addListener('onAudioRouteChanged', (route) => {
void useEQStore.getState().setOutputRoute(route).catch((error) => {
console.warn('[eq-route] route change failed', error);
});
});
}
await useEQStore.getState().load();
try {
AstraAudioRoute.start();
} catch (error) {
console.warn('[eq-route] native start failed', error);
}
await applyCurrentRoute();
}
export function ensureEQRouteSyncStarted(): Promise<void> {
if (!startPromise) {
startPromise = startEQRouteSync().catch((error) => {
startPromise = null;
throw error;
});
}
return startPromise;
}
export function stopEQRouteSync(): void {
subscription?.remove();
subscription = null;
startPromise = null;
try {
AstraAudioRoute.stop();
} catch {
/* no-op */
}
}
+4
View File
@@ -3,6 +3,7 @@ import { syncCarNowPlayingFromTrackPlayer } from './carSync';
import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync';
import { applyNormalizationForActiveTrack } from './applyNormalization';
import { ensureGainRegistryStarted } from './gainRegistry';
import { ensureEQRouteSyncStarted } from './eqRouteSync';
/**
* RNTP playback service — registered in `index.js`. Runs in a headless context
@@ -13,6 +14,9 @@ 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);
});
const syncNowPlaying = () =>
Promise.allSettled([
+2 -2
View File
@@ -15,9 +15,9 @@ 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 TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useEQStore } from '@/stores/eqStore';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
@@ -58,7 +58,7 @@ async function initializeForCar(): Promise<void> {
await usePlaylistStore.getState().refresh();
await useRemoteSourcesStore.getState().init();
await Promise.all([
useEQStore.getState().load(),
ensureEQRouteSyncStarted(),
useAudioSettingsStore.getState().load(),
]);
})().catch((err) => {
+91 -2
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { EQBand, EQMode, EQPreset } from '@/types/audio';
import type { AudioOutputRoute, EQBand, EQMode, EQPreset } from '@/types/audio';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import {
@@ -19,6 +19,14 @@ import {
deriveGraphicGains,
parseGraphicGains,
} from '@/audio/graphicEq';
import {
createEQRouteProfile,
normalizeAudioOutputRoute,
parseEQRouteProfilesJson,
restoreEQRouteProfile,
stringifyEQRouteProfiles,
type EQRouteProfile,
} from '@/audio/eqRouteProfiles';
import { setEqBandsNative, setEqEnabledNative, setEqPreampNative } from '@/audio/eqNative';
/**
@@ -34,6 +42,7 @@ const ACTIVE_PRESET_KEY = 'eq_active_preset';
const CUSTOM_PRESETS_KEY = 'eq_custom_presets';
const MODE_KEY = 'eq_mode';
const GRAPHIC_GAINS_KEY = 'eq_graphic_gains';
const ROUTE_PROFILES_KEY = 'eq_route_profiles_v1';
const PERSIST_DEBOUNCE_MS = 250;
@@ -97,6 +106,7 @@ interface EQStore {
presets: EQPreset[]; // built-in + custom
activePresetId: string | null; // null = manually edited ("Custom")
activeBandId: string | null; // UI selection shared by curve / strip / panel
activeOutputRoute: AudioOutputRoute | null;
loaded: boolean;
load: () => Promise<void>;
@@ -114,11 +124,13 @@ interface EQStore {
saveCustomPreset: (name: string) => void;
deleteCustomPreset: (presetId: string) => void;
importPreset: (preset: EQPreset) => void;
setOutputRoute: (route: AudioOutputRoute | null) => Promise<void>;
_syncToNative: () => void;
}
let persistTimer: ReturnType<typeof setTimeout> | null = null;
let routeProfilesByKey: Record<string, EQRouteProfile> = {};
export const useEQStore = create<EQStore>((set, get) => {
function syncToNative(): void {
@@ -129,7 +141,24 @@ export const useEQStore = create<EQStore>((set, get) => {
setEqBandsNative(flattenBandsForNative(activeBands));
}
function captureRouteProfile(route: AudioOutputRoute | null = get().activeOutputRoute): void {
if (!route) return;
const { enabled, preamp, mode, bands, graphicGains, activePresetId } = get();
routeProfilesByKey = {
...routeProfilesByKey,
[route.key]: createEQRouteProfile(route, {
enabled,
preamp,
mode,
bands,
graphicGains,
activePresetId,
}),
};
}
function schedulePersist(): void {
captureRouteProfile();
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
persistTimer = null;
@@ -138,6 +167,7 @@ export const useEQStore = create<EQStore>((set, get) => {
}
async function persistNow(): Promise<void> {
captureRouteProfile();
const { enabled, preamp, bands, mode, graphicGains, activePresetId, presets } = get();
const custom = presets.filter((p) => p.isCustom);
try {
@@ -149,6 +179,7 @@ export const useEQStore = create<EQStore>((set, get) => {
setSetting(db, MODE_KEY, mode),
setSetting(db, GRAPHIC_GAINS_KEY, JSON.stringify(graphicGains)),
setSetting(db, ACTIVE_PRESET_KEY, activePresetId ?? ''),
setSetting(db, ROUTE_PROFILES_KEY, stringifyEQRouteProfiles(routeProfilesByKey)),
setSetting(
db,
CUSTOM_PRESETS_KEY,
@@ -184,12 +215,13 @@ export const useEQStore = create<EQStore>((set, get) => {
presets: createBuiltInPresets(),
activePresetId: FLAT_PRESET_ID,
activeBandId: null,
activeOutputRoute: null,
loaded: false,
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const [enabledRaw, preampRaw, bandsRaw, modeRaw, gainsRaw, activeRaw, customRaw] = await Promise.all([
const [enabledRaw, preampRaw, bandsRaw, modeRaw, gainsRaw, activeRaw, customRaw, routeProfilesRaw] = await Promise.all([
getSetting(db, ENABLED_KEY),
getSetting(db, PREAMP_KEY),
getSetting(db, BANDS_KEY),
@@ -197,11 +229,13 @@ export const useEQStore = create<EQStore>((set, get) => {
getSetting(db, GRAPHIC_GAINS_KEY),
getSetting(db, ACTIVE_PRESET_KEY),
getSetting(db, CUSTOM_PRESETS_KEY),
getSetting(db, ROUTE_PROFILES_KEY),
]);
const bands = parseBands(bandsRaw) ?? createDefaultBands();
const presets = [...createBuiltInPresets(), ...parseCustomPresets(customRaw)];
const storedActive = activeRaw && activeRaw.length > 0 ? activeRaw : null;
routeProfilesByKey = parseEQRouteProfilesJson(routeProfilesRaw, genEqId);
set({
enabled: enabledRaw === 'true',
@@ -392,6 +426,61 @@ export const useEQStore = create<EQStore>((set, get) => {
get().applyPreset(stored.id);
},
setOutputRoute: async (route) => {
if (!get().loaded) await get().load();
const nextRoute = normalizeAudioOutputRoute(route);
const previousRoute = get().activeOutputRoute;
if (previousRoute?.key === nextRoute?.key) {
if (
previousRoute &&
nextRoute &&
(previousRoute.label !== nextRoute.label ||
previousRoute.kind !== nextRoute.kind ||
previousRoute.nativeId !== nextRoute.nativeId ||
previousRoute.nativeType !== nextRoute.nativeType ||
previousRoute.selectedRouteName !== nextRoute.selectedRouteName)
) {
set({ activeOutputRoute: nextRoute });
schedulePersist();
}
return;
}
captureRouteProfile(previousRoute);
if (!nextRoute) {
set({ activeOutputRoute: null });
schedulePersist();
return;
}
const profile = routeProfilesByKey[nextRoute.key];
if (!profile) {
// First-seen routes inherit whatever EQ is currently audible, then diverge
// as soon as the user edits while this route is active.
set({ activeOutputRoute: nextRoute });
schedulePersist();
return;
}
const restored = restoreEQRouteProfile(profile, (presetId) =>
get().presets.some((preset) => preset.id === presetId)
);
set({
activeOutputRoute: nextRoute,
enabled: restored.enabled,
preamp: restored.preamp,
mode: restored.mode,
bands: restored.bands,
graphicGains: restored.graphicGains,
activePresetId: restored.activePresetId,
activeBandId: restored.bands[0]?.id ?? null,
});
syncToNative();
schedulePersist();
},
_syncToNative: syncToNative,
};
});
+12
View File
@@ -85,6 +85,18 @@ export interface EQPreset {
graphicGains?: number[];
}
export type AudioOutputRouteKind = 'speaker' | 'wired' | 'bluetooth' | 'usb' | 'hdmi' | 'unknown';
export interface AudioOutputRoute {
key: string;
label: string;
kind: AudioOutputRouteKind;
nativeType: number | null;
nativeId: number | null;
selectedRouteName: string | null;
updatedAt: number;
}
// Visualizer config (scopes land at M3)
export interface VisualizerConfig {
type: 'oscilloscope' | 'spectrum' | 'spectrogram' | 'vu' | 'loudness' | 'stereo';