mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
per output device EQ
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
+259
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user