From c4212f81e040991d3cd55f3b6395c3df3016772b Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:22:30 -0400 Subject: [PATCH] per output device EQ --- .../astra-audio-route/android/build.gradle | 18 ++ .../astraaudioroute/AstraAudioRouteModule.kt | 259 ++++++++++++++++++ .../astra-audio-route/expo-module.config.json | 6 + modules/astra-audio-route/index.ts | 22 ++ src/app/(tabs)/eq.tsx | 21 +- src/app/_layout.tsx | 7 +- src/audio/eqRouteProfiles.test.mts | 133 +++++++++ src/audio/eqRouteProfiles.ts | 259 ++++++++++++++++++ src/audio/eqRouteSync.ts | 56 ++++ src/audio/playbackService.ts | 4 + src/car/carPlayback.ts | 4 +- src/stores/eqStore.ts | 93 ++++++- src/types/audio.ts | 12 + 13 files changed, 884 insertions(+), 10 deletions(-) create mode 100644 modules/astra-audio-route/android/build.gradle create mode 100644 modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/AstraAudioRouteModule.kt create mode 100644 modules/astra-audio-route/expo-module.config.json create mode 100644 modules/astra-audio-route/index.ts create mode 100644 src/audio/eqRouteProfiles.test.mts create mode 100644 src/audio/eqRouteProfiles.ts create mode 100644 src/audio/eqRouteSync.ts diff --git a/modules/astra-audio-route/android/build.gradle b/modules/astra-audio-route/android/build.gradle new file mode 100644 index 0000000..2fe7ec9 --- /dev/null +++ b/modules/astra-audio-route/android/build.gradle @@ -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 + } +} diff --git a/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/AstraAudioRouteModule.kt b/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/AstraAudioRouteModule.kt new file mode 100644 index 0000000..8af8893 --- /dev/null +++ b/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/AstraAudioRouteModule.kt @@ -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) { + emitCurrentRoute() + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + 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 { + 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('-') +} diff --git a/modules/astra-audio-route/expo-module.config.json b/modules/astra-audio-route/expo-module.config.json new file mode 100644 index 0000000..5c6b2c3 --- /dev/null +++ b/modules/astra-audio-route/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.astraaudioroute.AstraAudioRouteModule"] + } +} diff --git a/modules/astra-audio-route/index.ts b/modules/astra-audio-route/index.ts new file mode 100644 index 0000000..e249a5f --- /dev/null +++ b/modules/astra-audio-route/index.ts @@ -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 { + getCurrentRoute(): AudioOutputRoute | null; + start(): void; + stop(): void; +} + +const native = requireOptionalNativeModule('AstraAudioRoute'); + +export const AstraAudioRoute = native ?? { + addListener: () => ({ remove: () => {} }), + removeAllListeners: () => {}, + getCurrentRoute: () => null, + start: () => {}, + stop: () => {}, +}; diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index af24d46..48ae781 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -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 }, ]} > - Equalizer + + Equalizer + + {`Tuning ${outputRouteLabel}`} + + setSheet('save')} hitSlop={8}> @@ -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', diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 00d14c9..fbcc1f2 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -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() diff --git a/src/audio/eqRouteProfiles.test.mts b/src/audio/eqRouteProfiles.test.mts new file mode 100644 index 0000000..16aee03 --- /dev/null +++ b/src/audio/eqRouteProfiles.test.mts @@ -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 { + 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 { + 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); +}); diff --git a/src/audio/eqRouteProfiles.ts b/src/audio/eqRouteProfiles.ts new file mode 100644 index 0000000..841caf2 --- /dev/null +++ b/src/audio/eqRouteProfiles.ts @@ -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; +} + +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; + 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; + 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const raw = value as Partial & { profiles?: unknown }; + const source = raw.profiles && typeof raw.profiles === 'object' && !Array.isArray(raw.profiles) + ? raw.profiles + : value; + const out: Record = {}; + 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 { + if (!json) return {}; + try { + return parseEQRouteProfiles(JSON.parse(json), createId); + } catch { + return {}; + } +} + +export function stringifyEQRouteProfiles(profiles: Record): 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, + }; +} diff --git a/src/audio/eqRouteSync.ts b/src/audio/eqRouteSync.ts new file mode 100644 index 0000000..ae60563 --- /dev/null +++ b/src/audio/eqRouteSync.ts @@ -0,0 +1,56 @@ +import { AstraAudioRoute } from '../../modules/astra-audio-route'; +import { useEQStore } from '@/stores/eqStore'; + +type Subscription = { remove: () => void }; + +let startPromise: Promise | null = null; +let subscription: Subscription | null = null; + +async function applyCurrentRoute(): Promise { + try { + await useEQStore.getState().setOutputRoute(AstraAudioRoute.getCurrentRoute()); + } catch (error) { + console.warn('[eq-route] apply failed', error); + } +} + +async function startEQRouteSync(): Promise { + 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 { + 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 */ + } +} diff --git a/src/audio/playbackService.ts b/src/audio/playbackService.ts index 82c7b70..253f787 100644 --- a/src/audio/playbackService.ts +++ b/src/audio/playbackService.ts @@ -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 { // 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([ diff --git a/src/car/carPlayback.ts b/src/car/carPlayback.ts index d6ad055..c292698 100644 --- a/src/car/carPlayback.ts +++ b/src/car/carPlayback.ts @@ -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 { await usePlaylistStore.getState().refresh(); await useRemoteSourcesStore.getState().init(); await Promise.all([ - useEQStore.getState().load(), + ensureEQRouteSyncStarted(), useAudioSettingsStore.getState().load(), ]); })().catch((err) => { diff --git a/src/stores/eqStore.ts b/src/stores/eqStore.ts index 22853ca..38c16cb 100644 --- a/src/stores/eqStore.ts +++ b/src/stores/eqStore.ts @@ -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; @@ -114,11 +124,13 @@ interface EQStore { saveCustomPreset: (name: string) => void; deleteCustomPreset: (presetId: string) => void; importPreset: (preset: EQPreset) => void; + setOutputRoute: (route: AudioOutputRoute | null) => Promise; _syncToNative: () => void; } let persistTimer: ReturnType | null = null; +let routeProfilesByKey: Record = {}; export const useEQStore = create((set, get) => { function syncToNative(): void { @@ -129,7 +141,24 @@ export const useEQStore = create((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((set, get) => { } async function persistNow(): Promise { + 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((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((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((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((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, }; }); diff --git a/src/types/audio.ts b/src/types/audio.ts index 1b03df2..d9e17a2 100644 --- a/src/types/audio.ts +++ b/src/types/audio.ts @@ -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';