onboarding + eq fixes

This commit is contained in:
Boof2015
2026-07-07 20:21:09 -04:00
parent c072a1df16
commit 6aa4b544fc
20 changed files with 1349 additions and 131 deletions
+1
View File
@@ -3,6 +3,7 @@
"name": "Astra",
"slug": "astra-mobile",
"version": "0.1.0",
"platforms": ["android"],
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "astra",
@@ -1,2 +1,13 @@
<manifest>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<service
android:name="expo.modules.astralibraryscanner.ScanForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -165,6 +165,20 @@ class AstraLibraryScannerModule : Module() {
// Already released or never persisted — nothing to do.
}
}
// Foreground-service keepalive around a scan (see ScanForegroundService) so a big
// scan survives backgrounding / screen-off. Fire-and-forget; JS owns the lifecycle.
Function("startScanService") { title: String, text: String ->
ScanForegroundService.start(requireContext(), title, text)
}
Function("updateScanNotification") { title: String, text: String, subText: String?, current: Int, total: Int, indeterminate: Boolean ->
ScanForegroundService.update(title, text, subText, current, total, indeterminate)
}
Function("stopScanService") {
ScanForegroundService.stop(requireContext())
}
}
private fun requireContext(): Context =
@@ -0,0 +1,198 @@
package expo.modules.astralibraryscanner
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/**
* Keeps the process alive + the CPU awake while a (JS-orchestrated) library scan
* runs, so a big scan finishes even when the user backgrounds Astra or the screen
* sleeps — and shows a progress notification. Started from JS while the app is
* foregrounded, then survives backgrounding as a `dataSync` foreground service.
*
* The scan loop itself lives on the app's JS thread (see src/library/scanner.ts);
* this service just provides the wakelock + FGS keepalive + the visible progress.
*/
class ScanForegroundService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
// A start may still be racing us; the OS requires startForeground() to have
// been called before we can tear down, so promote-then-stop if needed.
if (instance == null) promote(DEFAULT_TITLE, DEFAULT_TEXT, null, 0, 0, true)
stopSelfSafely()
}
else -> {
val title = intent?.getStringExtra(EXTRA_TITLE) ?: DEFAULT_TITLE
val text = intent?.getStringExtra(EXTRA_TEXT) ?: DEFAULT_TEXT
promote(title, text, null, 0, 0, true)
acquireWakeLock()
}
}
// Never auto-restart: the scan lives in JS, so a killed process has no scan to resume.
return START_NOT_STICKY
}
/** Refresh the ongoing notification. NotificationManager.notify is thread-safe. */
fun update(title: String, text: String, subText: String?, current: Int, total: Int, indeterminate: Boolean) {
notificationManager().notify(
NOTIFICATION_ID,
buildNotification(title, text, subText, current, total, indeterminate)
)
}
private fun promote(
title: String,
text: String,
subText: String?,
current: Int,
total: Int,
indeterminate: Boolean
) {
val ok = runCatching {
val notification = buildNotification(title, text, subText, current, total, indeterminate)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIFICATION_ID, notification)
}
instance = this
}.isSuccess
// If promotion was refused (e.g. FGS-start not allowed), bail rather than let the
// system kill the whole process with "did not call startForeground in time".
if (!ok) stopSelfSafely()
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val pm = getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG).apply {
setReferenceCounted(false)
runCatching { acquire(MAX_WAKELOCK_MS) }
}
}
private fun releaseWakeLock() {
runCatching { if (wakeLock?.isHeld == true) wakeLock?.release() }
wakeLock = null
}
private fun stopSelfSafely() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
stopSelf()
}
override fun onDestroy() {
if (instance === this) instance = null
releaseWakeLock()
super.onDestroy()
}
private fun buildNotification(
title: String,
text: String,
subText: String?,
current: Int,
total: Int,
indeterminate: Boolean
): Notification {
ensureChannel()
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(launchIntent())
if (!subText.isNullOrBlank()) builder.setSubText(subText)
if (indeterminate || total <= 0) {
builder.setProgress(0, 0, true)
} else {
builder.setProgress(total, current.coerceIn(0, total), false)
}
return builder.build()
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = notificationManager()
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
// DEFAULT (not LOW) so the scan announces itself once when it starts; the ongoing
// progress updates stay quiet via setOnlyAlertOnce.
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Library scan", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Progress while Astra scans your music folders"
setShowBadge(false)
}
)
}
private fun launchIntent(): PendingIntent? {
val intent = packageManager.getLaunchIntentForPackage(packageName) ?: return null
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getActivity(this, 0, intent, flags)
}
private fun notificationManager(): NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
companion object {
@Volatile
private var instance: ScanForegroundService? = null
private const val CHANNEL_ID = "astra_library_scan"
private const val NOTIFICATION_ID = 0x5CA4
private const val WAKELOCK_TAG = "astra:library-scan"
private const val MAX_WAKELOCK_MS = 60L * 60L * 1000L // 1h safety cap
private const val DEFAULT_TITLE = "Scanning your library"
private const val DEFAULT_TEXT = "Preparing…"
private const val EXTRA_TITLE = "title"
private const val EXTRA_TEXT = "text"
private const val ACTION_STOP = "expo.modules.astralibraryscanner.action.STOP_SCAN"
fun start(context: Context, title: String, text: String) {
val intent = Intent(context, ScanForegroundService::class.java)
.putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_TEXT, text)
runCatching { ContextCompat.startForegroundService(context.applicationContext, intent) }
}
fun update(
title: String,
text: String,
subText: String?,
current: Int,
total: Int,
indeterminate: Boolean
) {
instance?.update(title, text, subText, current, total, indeterminate)
}
fun stop(context: Context) {
// Route through onStartCommand (main thread) so teardown never races the promote.
val intent = Intent(context, ScanForegroundService::class.java).setAction(ACTION_STOP)
runCatching { ContextCompat.startForegroundService(context.applicationContext, intent) }
}
}
}
+17
View File
@@ -91,6 +91,23 @@ declare class AstraLibraryScannerModuleType extends NativeModule<AstraLibrarySca
getPersistedTreeUris(): string[];
takePersistableUriPermission(uri: string): Promise<boolean>;
releasePersistedUriPermission(uri: string): Promise<void>;
/**
* Scan keepalive (Android). `startScanService` promotes a `dataSync` foreground
* service + partial wakelock so a JS-orchestrated scan keeps running when the app
* is backgrounded / the screen sleeps, and shows a progress notification;
* `updateScanNotification` refreshes it; `stopScanService` tears it down. The
* wakelock/keepalive work even if the notification itself is not permitted.
*/
startScanService(title: string, text: string): void;
updateScanNotification(
title: string,
text: string,
subText: string | null,
current: number,
total: number,
indeterminate: boolean
): void;
stopScanService(): void;
}
export const AstraLibraryScanner =
+1
View File
@@ -70,6 +70,7 @@
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
"test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/shared/sync/conflictPreview.test.mts",
"test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts",
"test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts",
"typecheck": "tsc --noEmit",
"postinstall": "patch-package"
},
+3 -1
View File
@@ -51,7 +51,8 @@ import {
EQ_MIN_FREQUENCY,
EQ_MIN_PREAMP_DB,
EQ_MIN_Q,
isPassEQBandType
isPassEQBandType,
isShelfEQBandType
} from '@/audio/eq';
import { parseAutoEQ } from '@/audio/autoEQParser';
import { buildGraphicBands } from '@/audio/graphicEq';
@@ -516,6 +517,7 @@ function getValueEditConfig(kind: EQEditableValue, band: EQBand) {
parseValue: parseDb,
};
case 'Q':
if (isShelfEQBandType(band.type)) return null;
return {
title: 'Edit Q',
initialValue: band.Q.toFixed(2),
+42 -12
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { AppState } from 'react-native';
import { AppState, StyleSheet, View } from 'react-native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
@@ -41,6 +41,8 @@ import { fetchDesktopRemoteIdentity } from '@/services/desktopRemoteClient';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { SyncConflictPrompt } from '@/components/sync/SyncConflictPrompt';
import { useThemeStore } from '@/stores/themeStore';
import { useOnboardingStore } from '@/stores/onboardingStore';
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
import { useTheme } from '@/theme/themed';
// Anchor the root stack at the tabs so a deep link straight to a top-level route (the
@@ -280,8 +282,10 @@ export default function RootLayout() {
// persisted theme (no flash). The failsafe path paints the default theme
// and snaps once the SQLite read lands — accepted degradation.
const themeLoaded = useThemeStore((s) => s.loaded);
const onboardingLoaded = useOnboardingStore((s) => s.loaded);
const onboardingComplete = useOnboardingStore((s) => s.onboardingComplete);
const theme = useTheme();
const ready = (fontsLoaded && themeLoaded) || splashTimedOut;
const ready = (fontsLoaded && themeLoaded && onboardingLoaded) || splashTimedOut;
useEffect(() => {
if (ready) {
@@ -297,6 +301,10 @@ export default function RootLayout() {
.getState()
.load()
.catch((err) => console.error('[theme] load failed', err));
useOnboardingStore
.getState()
.load()
.catch((err) => console.error('[onboarding] load failed', err));
useLibraryStore
.getState()
.initialize()
@@ -326,14 +334,22 @@ export default function RootLayout() {
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary }}>
<SafeAreaProvider>
<StatusBar style={theme.statusBarStyle} />
<ThemeSystemSync />
<PlaybackSync />
<ScopeLifecycle />
<NormalizationSync />
<LastFmScrobbler />
<PlaybackTargetSync />
<DesktopRemoteMediaSessionSync />
<DesktopSyncAutoTrigger />
{/* The navigator stays mounted whatever the onboarding state (expo-router
needs a root navigator), but the playback/sync/desktop side-effects and
overlays are gated off during the wizard — no LAN-discovery bursts or
scrobbler running mid-onboarding. */}
{onboardingComplete ? (
<>
<ThemeSystemSync />
<PlaybackSync />
<ScopeLifecycle />
<NormalizationSync />
<LastFmScrobbler />
<PlaybackTargetSync />
<DesktopRemoteMediaSessionSync />
<DesktopSyncAutoTrigger />
</>
) : null}
<Stack
screenOptions={{
headerShown: false,
@@ -351,8 +367,22 @@ export default function RootLayout() {
}}
/>
</Stack>
<QuickSearchOverlay />
<SyncConflictPrompt />
{onboardingComplete ? (
<>
<QuickSearchOverlay />
<SyncConflictPrompt />
</>
) : (
// First-run gate: opaque full-screen wizard over the (hidden) navigator.
// markComplete flips the flag → this unmounts, revealing the app.
<View style={StyleSheet.absoluteFill}>
<OnboardingFlow
onDone={() => {
void useOnboardingStore.getState().markComplete();
}}
/>
</View>
)}
</SafeAreaProvider>
</GestureHandlerRootView>
);
+21 -1
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { InteractionManager } from 'react-native';
import { Alert, InteractionManager } from 'react-native';
import { useRouter } from 'expo-router';
import {
SettingsNavRow,
@@ -10,6 +10,7 @@ import { formatRelativeTime } from '@/lib/format';
import { useColors } from '@/theme/themed';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { useOnboardingStore } from '@/stores/onboardingStore';
export default function ExperimentalSettingsScreen() {
const colors = useColors();
@@ -31,6 +32,17 @@ export default function ExperimentalSettingsScreen() {
const desktopRemoteSubtitle = desktopRemoteConnection
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'}: ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
: 'Pair with Astra Desktop to control playback from this phone.';
const replayOnboarding = () => {
Alert.alert(
'Replay onboarding?',
'The first-run setup wizard will show again the next time you return to the home screen. Your library and settings are kept.',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Replay', onPress: () => void useOnboardingStore.getState().reset() },
]
);
};
const desktopSyncSubtitle = !desktopRemoteConnection
? 'Sync favorites and playlists with Astra Desktop.'
: desktopSyncConflictCount > 0
@@ -57,6 +69,14 @@ export default function ExperimentalSettingsScreen() {
subtitleColor={desktopSyncConflictCount > 0 ? colors.warning : undefined}
onPress={() => router.push('/desktop-sync' as never)}
/>
<SettingsSectionLabel>DEVELOPER</SettingsSectionLabel>
<SettingsNavRow
icon="refresh-outline"
title="Replay onboarding"
subtitle="Show the first-run setup wizard again."
onPress={replayOnboarding}
/>
</SettingsSectionScreen>
);
}
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { EQBand } from '../types/audio.ts';
import {
computeCombinedEQMagnitude,
computeEQFilterCoefficients,
computeEQFilterMagnitude,
type EQFilterCoefficients,
} from './eq.ts';
function band(overrides: Partial<EQBand> = {}): EQBand {
return {
id: overrides.id ?? 'band-1',
type: overrides.type ?? 'peaking',
frequency: overrides.frequency ?? 1000,
gain: overrides.gain ?? 0,
Q: overrides.Q ?? 1,
enabled: overrides.enabled ?? true,
};
}
function assertClose(actual: number, expected: number, tolerance = 1e-6): void {
assert.ok(
Math.abs(actual - expected) <= tolerance,
`expected ${actual} to be within ${tolerance} of ${expected}`
);
}
function assertCoefficientsClose(
actual: EQFilterCoefficients,
expected: EQFilterCoefficients,
tolerance = 1e-9
): void {
assertClose(actual.b0, expected.b0, tolerance);
assertClose(actual.b1, expected.b1, tolerance);
assertClose(actual.b2, expected.b2, tolerance);
assertClose(actual.a1, expected.a1, tolerance);
assertClose(actual.a2, expected.a2, tolerance);
}
test('peaking filter reaches requested gain at center frequency', () => {
const boost = band({ type: 'peaking', frequency: 1200, gain: 5.5, Q: 1.25 });
assertClose(computeEQFilterMagnitude(boost, 1200, 48000), 5.5, 1e-6);
});
test('shelf filters ignore Q like Web Audio BiquadFilterNode', () => {
const lowLoose = band({ type: 'lowshelf', frequency: 100, gain: 6, Q: 0.1 });
const lowTight = band({ type: 'lowshelf', frequency: 100, gain: 6, Q: 18 });
const highLoose = band({ type: 'highshelf', frequency: 8000, gain: -4, Q: 0.1 });
const highTight = band({ type: 'highshelf', frequency: 8000, gain: -4, Q: 18 });
assertCoefficientsClose(
computeEQFilterCoefficients(lowLoose, 48000),
computeEQFilterCoefficients(lowTight, 48000)
);
assertCoefficientsClose(
computeEQFilterCoefficients(highLoose, 48000),
computeEQFilterCoefficients(highTight, 48000)
);
assertClose(
computeEQFilterMagnitude(lowLoose, 40, 48000),
computeEQFilterMagnitude(lowTight, 40, 48000)
);
assertClose(
computeEQFilterMagnitude(highLoose, 12000, 48000),
computeEQFilterMagnitude(highTight, 12000, 48000)
);
});
test('lowpass and highpass coefficients use Web Audio Q-in-dB semantics', () => {
assertCoefficientsClose(
computeEQFilterCoefficients(band({ type: 'lowpass', frequency: 1000, Q: 6 }), 48000),
{
b0: 0.004142085705,
b1: 0.00828417141,
b2: 0.004142085705,
a1: -1.920085584611,
a2: 0.936653927431,
}
);
assertCoefficientsClose(
computeEQFilterCoefficients(band({ type: 'highpass', frequency: 1000, Q: 6 }), 48000),
{
b0: 0.964184878011,
b1: -1.928369756021,
b2: 0.964184878011,
a1: -1.920085584611,
a2: 0.936653927431,
}
);
});
test('combined response skips disabled bands', () => {
const enabled = band({ id: 'enabled', frequency: 1000, gain: 3, Q: 1, enabled: true });
const disabled = band({ id: 'disabled', frequency: 1000, gain: 9, Q: 1, enabled: false });
assertClose(
computeCombinedEQMagnitude([enabled, disabled], 1000, 48000),
computeEQFilterMagnitude(enabled, 1000, 48000)
);
});
+71 -28
View File
@@ -1,7 +1,7 @@
// Parametric EQ math + helpers — ported from desktop `src/renderer/utils/eq.ts`.
// The biquad cookbook (Audio EQ Cookbook) magnitude math drives the response curve
// in the EQ screen. Coefficients themselves are computed natively (Kotlin) at the
// real stream sample rate — here we only flatten band params for the native bridge.
// Web Audio BiquadFilterNode-compatible math drives the response curve in the EQ
// screen. Native playback computes matching coefficients in Kotlin at the real
// stream sample rate — here we also flatten band params for the native bridge.
import type { EQBand, EQBandType, EQMode, EQPreset } from '../types/audio';
@@ -88,6 +88,10 @@ export function isPassEQBandType(type: EQBandType): boolean {
return type === 'highpass' || type === 'lowpass';
}
export function isShelfEQBandType(type: EQBandType): boolean {
return type === 'lowshelf' || type === 'highshelf';
}
/** Pass filters carry no gain — force it to 0. */
export function normalizeEQBand<T extends EQBand>(band: T): T {
if (!isPassEQBandType(band.type) || band.gain === 0) {
@@ -181,18 +185,49 @@ export function serializeEQPresetData(
}
// ---------------------------------------------------------------------------
// Response curve magnitude (Audio EQ Cookbook) — for the Skia response curve.
// Response curve magnitude (Web Audio BiquadFilterNode) — for the Skia response curve.
// ---------------------------------------------------------------------------
export function computeEQFilterMagnitude(band: EQBand, testFreq: number, sampleRate: number): number {
if (sampleRate <= 0) return 0;
export interface EQFilterCoefficients {
b0: number;
b1: number;
b2: number;
a1: number;
a2: number;
}
const MIN_FILTER_Q = 0.0001;
function normalizeCoefficientSet(
b0: number,
b1: number,
b2: number,
a0: number,
a1: number,
a2: number
): EQFilterCoefficients {
const invA0 = 1 / a0;
return {
b0: b0 * invA0,
b1: b1 * invA0,
b2: b2 * invA0,
a1: a1 * invA0,
a2: a2 * invA0,
};
}
export function computeEQFilterCoefficients(band: EQBand, sampleRate: number): EQFilterCoefficients {
if (sampleRate <= 0) {
return { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 };
}
const w0 = (2 * Math.PI * band.frequency) / sampleRate;
const w = (2 * Math.PI * testFreq) / sampleRate;
const A = Math.pow(10, band.gain / 40);
const sinW0 = Math.sin(w0);
const cosW0 = Math.cos(w0);
const alpha = sinW0 / (2 * band.Q);
const alphaQ = sinW0 / (2 * Math.max(band.Q, MIN_FILTER_Q));
const alphaQDb = sinW0 / (2 * Math.pow(10, band.Q / 20));
const alphaShelf = (sinW0 / 2) * Math.SQRT2;
let b0 = 1;
let b1 = 0;
@@ -203,60 +238,68 @@ export function computeEQFilterMagnitude(band: EQBand, testFreq: number, sampleR
switch (band.type) {
case 'peaking':
b0 = 1 + alpha * A;
b0 = 1 + alphaQ * A;
b1 = -2 * cosW0;
b2 = 1 - alpha * A;
a0 = 1 + alpha / A;
b2 = 1 - alphaQ * A;
a0 = 1 + alphaQ / A;
a1 = -2 * cosW0;
a2 = 1 - alpha / A;
a2 = 1 - alphaQ / A;
break;
case 'lowshelf': {
const sqrtA = Math.sqrt(A);
b0 = A * (A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alpha);
b0 = A * (A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alphaShelf);
b1 = 2 * A * (A - 1 - (A + 1) * cosW0);
b2 = A * (A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alpha);
a0 = A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alpha;
b2 = A * (A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alphaShelf);
a0 = A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alphaShelf;
a1 = -2 * (A - 1 + (A + 1) * cosW0);
a2 = A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alpha;
a2 = A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alphaShelf;
break;
}
case 'highshelf': {
const sqrtA = Math.sqrt(A);
b0 = A * (A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alpha);
b0 = A * (A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alphaShelf);
b1 = -2 * A * (A - 1 + (A + 1) * cosW0);
b2 = A * (A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alpha);
a0 = A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alpha;
b2 = A * (A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alphaShelf);
a0 = A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alphaShelf;
a1 = 2 * (A - 1 - (A + 1) * cosW0);
a2 = A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alpha;
a2 = A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alphaShelf;
break;
}
case 'lowpass':
b0 = (1 - cosW0) / 2;
b1 = 1 - cosW0;
b2 = (1 - cosW0) / 2;
a0 = 1 + alpha;
a0 = 1 + alphaQDb;
a1 = -2 * cosW0;
a2 = 1 - alpha;
a2 = 1 - alphaQDb;
break;
case 'highpass':
b0 = (1 + cosW0) / 2;
b1 = -(1 + cosW0);
b2 = (1 + cosW0) / 2;
a0 = 1 + alpha;
a0 = 1 + alphaQDb;
a1 = -2 * cosW0;
a2 = 1 - alpha;
a2 = 1 - alphaQDb;
break;
}
return normalizeCoefficientSet(b0, b1, b2, a0, a1, a2);
}
export function computeEQFilterMagnitude(band: EQBand, testFreq: number, sampleRate: number): number {
if (sampleRate <= 0) return 0;
const w = (2 * Math.PI * testFreq) / sampleRate;
const { b0, b1, b2, a1, a2 } = computeEQFilterCoefficients(band, sampleRate);
const cosW = Math.cos(w);
const sinW = Math.sin(w);
const cos2W = Math.cos(2 * w);
const sin2W = Math.sin(2 * w);
const numReal = b0 / a0 + (b1 / a0) * cosW + (b2 / a0) * cos2W;
const numImag = -(b1 / a0) * sinW - (b2 / a0) * sin2W;
const denReal = 1 + (a1 / a0) * cosW + (a2 / a0) * cos2W;
const denImag = -(a1 / a0) * sinW - (a2 / a0) * sin2W;
const numReal = b0 + b1 * cosW + b2 * cos2W;
const numImag = -b1 * sinW - b2 * sin2W;
const denReal = 1 + a1 * cosW + a2 * cos2W;
const denImag = -a1 * sinW - a2 * sin2W;
const numMag = Math.sqrt(numReal * numReal + numImag * numImag);
const denMag = Math.sqrt(denReal * denReal + denImag * denImag);
+16 -12
View File
@@ -18,7 +18,8 @@ import {
EQ_MAX_Q,
EQ_MIN_FREQUENCY,
EQ_MIN_Q,
isPassEQBandType
isPassEQBandType,
isShelfEQBandType
} from '@/audio/eq';
import { EQSlider } from './EQSlider';
import {
@@ -39,7 +40,7 @@ interface BandDetailPanelProps {
export type EQEditableValue = 'frequency' | 'gain' | 'Q';
/** "Band N" + type dropdown + On toggle + Frequency / Gain / Q sliders. */
/** "Band N" + type dropdown + On toggle + audible parameter sliders. */
export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEditValue }: BandDetailPanelProps) {
const styles = useStyles();
const colors = useColors();
@@ -54,6 +55,7 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit
}
const isPass = isPassEQBandType(band.type);
const isShelf = isShelfEQBandType(band.type);
return (
<View style={styles.card}>
@@ -96,16 +98,18 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit
onValuePress={() => onEditValue('gain')}
disabled={isPass}
/>
<EQSlider
label="Q"
value={band.Q}
min={EQ_MIN_Q}
max={EQ_MAX_Q}
log
format={(v) => v.toFixed(2)}
onChange={(v) => onUpdate({ Q: v })}
onValuePress={() => onEditValue('Q')}
/>
{!isShelf ? (
<EQSlider
label="Q"
value={band.Q}
min={EQ_MIN_Q}
max={EQ_MAX_Q}
log
format={(v) => v.toFixed(2)}
onChange={(v) => onUpdate({ Q: v })}
onValuePress={() => onEditValue('Q')}
/>
) : null}
</View>
);
}
@@ -0,0 +1,544 @@
import { useEffect, useState, type ComponentProps } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
View,
useWindowDimensions,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, {
FadeIn,
FadeInDown,
FadeOutUp,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { Canvas, LinearGradient, Rect, vec } from '@shopify/react-native-skia';
import { Ionicons } from '@expo/vector-icons';
import { AstraLogo } from '@/components/AstraLogo';
import { Text } from '@/components/Text';
import { ScanProgress } from '@/components/library/ScanProgress';
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
import { formatFolderCount, formatTrackCount } from '@/components/settings/SettingsPanels';
import { radius, spacing } from '@/theme';
import { motion } from '@/theme/motion';
import { createThemedStyles, useColors } from '@/theme/themed';
import type { BaseThemeId } from '@/theme/resolve';
import { useLibraryStore } from '@/stores/libraryStore';
import { useThemeStore } from '@/stores/themeStore';
type IoniconName = ComponentProps<typeof Ionicons>['name'];
type StepId = 'welcome' | 'library' | 'theme' | 'done';
const STEP_ORDER: StepId[] = ['welcome', 'library', 'theme', 'done'];
const WIZARD_THEME_OPTIONS: { id: BaseThemeId; title: string }[] = [
{ id: 'system', title: 'System' },
{ id: 'midnight', title: 'Midnight' },
{ id: 'dark', title: 'Dark' },
{ id: 'amoled', title: 'AMOLED' },
{ id: 'light', title: 'Light' },
{ id: 'materialYou', title: 'Material You' },
];
/**
* First-run wizard. Rendered by the root layout instead of the app tree while
* `onboarding_complete` is unset. Purely presentational — it drives the existing
* library + theme stores and calls `onDone` (→ markComplete) at the end.
*/
export function OnboardingFlow({ onDone }: { onDone: () => void }) {
const styles = useStyles();
const colors = useColors();
const insets = useSafeAreaInsets();
const { width, height } = useWindowDimensions();
const [stepIndex, setStepIndex] = useState(0);
const step = STEP_ORDER[stepIndex];
const foldersCount = useLibraryStore((s) => s.folders.length);
const isScanning = useLibraryStore((s) => s.isScanning);
const goNext = () => {
if (stepIndex < STEP_ORDER.length - 1) setStepIndex((i) => i + 1);
else onDone();
};
const goBack = () => setStepIndex((i) => Math.max(0, i - 1));
const canGoBack = step === 'library' || step === 'theme';
const primaryLabel =
step === 'welcome'
? 'Get started'
: step === 'library'
? // A scan is orchestrated by the store (not this component), so it keeps
// running after "Continue" — never call the forward action "Skip" while
// it is actively working.
foldersCount > 0 || isScanning
? 'Continue'
: 'Skip for now'
: step === 'theme'
? 'Continue'
: 'Start listening';
return (
<View style={styles.root}>
<Canvas style={StyleSheet.absoluteFill}>
<Rect x={0} y={0} width={width} height={height}>
<LinearGradient
start={vec(0, 0)}
end={vec(width * 0.5, height)}
colors={[colors.accentGlow, colors.bgPrimary, colors.bgPrimary]}
positions={[0, 0.55, 1]}
/>
</Rect>
</Canvas>
<View
style={[
styles.content,
{ paddingTop: insets.top + spacing.lg, paddingBottom: insets.bottom + spacing.lg },
]}
>
{/* Once past the library step, a subtle banner keeps reminding the user the
scan they started is still running in the background (it was not stopped
by continuing). */}
{step !== 'library' ? <ScanBanner /> : null}
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<Animated.View key={step} entering={FadeIn.duration(220)} style={styles.stepWrap}>
{step === 'welcome' ? <WelcomeStep /> : null}
{step === 'library' ? <LibraryStep /> : null}
{step === 'theme' ? <ThemeStep /> : null}
{step === 'done' ? <DoneStep /> : null}
</Animated.View>
</ScrollView>
<View style={styles.footer}>
<View style={styles.dots}>
{STEP_ORDER.map((id, i) => (
<Dot key={id} active={i === stepIndex} />
))}
</View>
<View style={styles.navRow}>
{canGoBack ? (
<Pressable
onPress={goBack}
style={styles.secondaryButton}
accessibilityRole="button"
accessibilityLabel="Go back"
>
<Text variant="label" color={colors.textSecondary}>
Back
</Text>
</Pressable>
) : null}
<Pressable
onPress={goNext}
style={styles.primaryButton}
accessibilityRole="button"
accessibilityLabel={primaryLabel}
>
<Text variant="label" color={colors.bgPrimary} style={styles.primaryButtonText}>
{primaryLabel}
</Text>
</Pressable>
</View>
</View>
</View>
</View>
);
}
function WelcomeStep() {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.centered}>
<Animated.View entering={FadeInDown.duration(500)}>
<AstraLogo size={96} />
</Animated.View>
<Animated.View entering={FadeInDown.delay(120).duration(500)} style={styles.centeredText}>
<Text variant="title" style={styles.centeredTitle}>
Welcome to Astra
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.centeredSubtitle}>
Your music, beautifully played. Set up your library in a few taps.
</Text>
</Animated.View>
</View>
);
}
function LibraryStep() {
const styles = useStyles();
const colors = useColors();
const folders = useLibraryStore((s) => s.folders);
const totalTrackCount = useLibraryStore((s) => s.totalTrackCount);
const isScanning = useLibraryStore((s) => s.isScanning);
const addFolder = useLibraryStore((s) => s.addFolder);
return (
<View style={styles.stepBody}>
<StepHeader
icon="musical-notes-outline"
title="Add your music"
subtitle="Point Astra at the folders where your music lives. It scans them into your library — files on disk are never modified."
/>
<Pressable
style={[styles.choiceButton, isScanning && styles.disabled]}
disabled={isScanning}
onPress={() => void addFolder()}
accessibilityRole="button"
>
<Ionicons name="folder-open-outline" size={20} color={colors.accent} />
<Text variant="body" color={colors.textPrimary}>
{folders.length > 0 ? 'Add another folder' : 'Choose music folder'}
</Text>
</Pressable>
<ScanProgress />
{isScanning ? (
<Text variant="caption" color={colors.textTertiary} style={styles.hint}>
Scanning keeps running in the background continue whenever you like, or add
more folders.
</Text>
) : folders.length > 0 ? (
<View style={styles.summaryCard}>
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
<Text variant="body" color={colors.textPrimary}>
{formatFolderCount(folders.length)} · {formatTrackCount(totalTrackCount)}
</Text>
</View>
) : (
<Text variant="caption" color={colors.textTertiary} style={styles.hint}>
You can skip this and add folders later from Settings Library.
</Text>
)}
</View>
);
}
function ThemeStep() {
const styles = useStyles();
const colors = useColors();
const baseTheme = useThemeStore((s) => s.baseTheme);
const materialYouAvailable = useThemeStore((s) => s.materialYouAvailable);
const resolvedId = useThemeStore((s) => s.theme.id);
const accentId = useThemeStore((s) => s.accentId);
const setBaseTheme = useThemeStore((s) => s.setBaseTheme);
const setAccent = useThemeStore((s) => s.setAccent);
const options = WIZARD_THEME_OPTIONS.filter(
(option) => option.id !== 'materialYou' || materialYouAvailable
);
const accentApplies = !resolvedId.startsWith('materialYou');
return (
<View style={styles.stepBody}>
<StepHeader
icon="color-palette-outline"
title="Make it yours"
subtitle="Pick a theme. You can change it anytime in Settings."
/>
<View style={styles.themeGrid}>
{options.map((option) => {
const selected = option.id === baseTheme;
return (
<Pressable
key={option.id}
onPress={() => void setBaseTheme(option.id)}
style={[styles.themePill, selected && styles.themePillSelected]}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
<Text
variant="label"
color={selected ? colors.accentTextStrong : colors.textSecondary}
>
{option.title}
</Text>
</Pressable>
);
})}
</View>
{accentApplies ? (
<View style={styles.accentBlock}>
<AccentSwatchRow value={accentId} onChange={(id) => void setAccent(id)} />
</View>
) : null}
</View>
);
}
function DoneStep() {
const styles = useStyles();
const colors = useColors();
const totalTrackCount = useLibraryStore((s) => s.totalTrackCount);
return (
<View style={styles.centered}>
<Animated.View entering={FadeInDown.duration(400)} style={styles.doneBadge}>
<Ionicons name="checkmark" size={44} color={colors.bgPrimary} />
</Animated.View>
<Animated.View entering={FadeInDown.delay(100).duration(400)} style={styles.centeredText}>
<Text variant="title" style={styles.centeredTitle}>
All set
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.centeredSubtitle}>
{totalTrackCount > 0
? `${formatTrackCount(totalTrackCount)} ready to play.`
: 'Add music anytime from Settings Library.'}
</Text>
</Animated.View>
</View>
);
}
function StepHeader({
icon,
title,
subtitle,
}: {
icon: IoniconName;
title: string;
subtitle: string;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.stepHeader}>
<View style={styles.stepIconWrap}>
<Ionicons name={icon} size={26} color={colors.accent} />
</View>
<Text variant="heading" style={styles.centeredTitle}>
{title}
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.centeredSubtitle}>
{subtitle}
</Text>
</View>
);
}
/** Subtle "still scanning" pill shown at the top of steps after the library step. */
function ScanBanner() {
const styles = useStyles();
const colors = useColors();
const isScanning = useLibraryStore((s) => s.isScanning);
const progress = useLibraryStore((s) => s.scanProgress);
if (!isScanning) return null;
const detail =
(progress.phase === 'extracting' || progress.phase === 'analyzing') && progress.total > 0
? `${progress.processed}/${progress.total}`
: null;
return (
<Animated.View
entering={FadeInDown.duration(220)}
exiting={FadeOutUp.duration(160)}
style={styles.scanBanner}
>
<ActivityIndicator size="small" color={colors.accent} />
<Text
variant="caption"
color={colors.textSecondary}
numberOfLines={1}
style={styles.scanBannerText}
>
Scanning your library{detail ? ` · ${detail}` : '…'}
</Text>
</Animated.View>
);
}
/** Page indicator dot — widens + brightens when active. Animated View, not an icon. */
function Dot({ active }: { active: boolean }) {
const styles = useStyles();
const progress = useSharedValue(active ? 1 : 0);
useEffect(() => {
progress.value = withTiming(active ? 1 : 0, motion.snap);
}, [active, progress]);
const animatedStyle = useAnimatedStyle(() => ({
width: 8 + progress.value * 14,
opacity: 0.3 + progress.value * 0.7,
}));
return <Animated.View style={[styles.dot, animatedStyle]} />;
}
const useStyles = createThemedStyles((colors) => ({
root: {
flex: 1,
backgroundColor: colors.bgPrimary,
},
content: {
flex: 1,
paddingHorizontal: spacing.lg,
},
scanBanner: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: spacing.sm,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
scanBannerText: {
maxWidth: 240,
},
scroll: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
paddingVertical: spacing.xl,
},
stepWrap: {
width: '100%',
},
centered: {
alignItems: 'center',
gap: spacing.xl,
},
centeredText: {
alignItems: 'center',
gap: spacing.sm,
},
centeredTitle: {
textAlign: 'center',
},
centeredSubtitle: {
textAlign: 'center',
maxWidth: 340,
},
doneBadge: {
width: 88,
height: 88,
borderRadius: 44,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
},
stepBody: {
width: '100%',
gap: spacing.lg,
},
stepHeader: {
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.xs,
},
stepIconWrap: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.glassBg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.xs,
},
choiceButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
minHeight: 52,
paddingVertical: spacing.md + 2,
paddingHorizontal: spacing.lg,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
disabled: {
opacity: 0.5,
},
summaryCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
hint: {
textAlign: 'center',
},
themeGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
justifyContent: 'center',
},
themePill: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
themePillSelected: {
borderColor: colors.accent,
backgroundColor: colors.accentGlow,
},
accentBlock: {
alignItems: 'center',
marginTop: spacing.sm,
},
footer: {
gap: spacing.lg,
paddingTop: spacing.md,
},
dots: {
flexDirection: 'row',
gap: spacing.sm,
justifyContent: 'center',
alignItems: 'center',
},
dot: {
height: 8,
borderRadius: 4,
backgroundColor: colors.accent,
},
navRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
secondaryButton: {
minHeight: 52,
paddingHorizontal: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
primaryButton: {
flex: 1,
minHeight: 52,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.md,
backgroundColor: colors.accent,
},
primaryButtonText: {
fontSize: 15,
},
}));
export default OnboardingFlow;
+98
View File
@@ -0,0 +1,98 @@
// Foreground-service keepalive around a library scan. The scan loop runs on the JS
// thread (see scanner.ts), so without a foreground service + wakelock Android throttles
// it the moment the app is backgrounded or the screen sleeps — a big scan would stall.
// This starts the FGS on the first progress tick and tears it down when the scan ends.
// All no-ops on non-Android and on native binaries built before the FGS methods existed.
import { PermissionsAndroid, Platform } from 'react-native';
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
import type { ScanProgress } from './scanner';
const supported =
Platform.OS === 'android' &&
typeof (AstraLibraryScanner as { startScanService?: unknown }).startScanService === 'function';
// runScan guarantees scans never overlap, so single-scan module state is safe.
let active = false;
let notifPermRequested = false;
/**
* POST_NOTIFICATIONS is Android 13+ (API 33); PermissionsAndroid resolves it granted
* automatically below that. Requested contextually on the first scan. The FGS +
* wakelock still keep the scan alive without it — only the visible notification needs it.
*/
async function ensureNotificationPermission(): Promise<void> {
if (notifPermRequested) return;
notifPermRequested = true;
const permission = (PermissionsAndroid.PERMISSIONS as Record<string, string | undefined>)
.POST_NOTIFICATIONS;
if (!permission) return;
try {
await PermissionsAndroid.request(permission as Parameters<typeof PermissionsAndroid.request>[0]);
} catch {
// Denied/unavailable — the scan still runs, the notification just won't show.
}
}
interface ScanNotification {
title: string;
text: string;
subText: string | null;
current: number;
total: number;
indeterminate: boolean;
}
const n = (value: number) => value.toLocaleString();
function notificationFor(progress: ScanProgress): ScanNotification {
const folder = progress.folderName?.trim() || null;
if (progress.phase === 'extracting') {
return {
title: 'Scanning your library',
text: progress.total > 0 ? `${n(progress.processed)} of ${n(progress.total)} files` : 'Reading files…',
subText: folder,
current: progress.processed,
total: progress.total,
indeterminate: progress.total <= 0,
};
}
if (progress.phase === 'analyzing') {
return {
title: 'Analyzing audio',
text: progress.total > 0 ? `${n(progress.processed)} of ${n(progress.total)} tracks` : 'Analyzing…',
subText: folder,
current: progress.processed,
total: progress.total,
indeterminate: progress.total <= 0,
};
}
return {
title: 'Finding your music',
text: progress.total > 0 ? `${n(progress.total)} files found so far…` : 'Looking through your folders…',
subText: folder,
current: 0,
total: 0,
indeterminate: true,
};
}
/** Report a scan progress tick — starts the FGS on the first call, updates it after. */
export async function reportScanProgress(progress: ScanProgress): Promise<void> {
if (!supported) return;
const { title, text, subText, current, total, indeterminate } = notificationFor(progress);
if (!active) {
active = true;
await ensureNotificationPermission();
if (!active) return; // scan ended while we awaited the permission dialog
AstraLibraryScanner.startScanService(title, text);
}
AstraLibraryScanner.updateScanNotification(title, text, subText, current, total, indeterminate);
}
/** Tear down the scan foreground service when a scan finishes (or errors). */
export function endScanService(): void {
if (!supported || !active) return;
active = false;
AstraLibraryScanner.stopScanService();
}
+8 -1
View File
@@ -21,6 +21,7 @@ import {
type ScanProgress,
type ScanResult,
} from '@/library/scanner';
import { endScanService, reportScanProgress } from '@/library/scanService';
import { ALBUM_SORT_LABELS, type AlbumSort } from '@/lib/albumSort';
import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort';
import { TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort';
@@ -114,7 +115,12 @@ interface LibraryStore {
let initPromise: Promise<void> | null = null;
export const useLibraryStore = create<LibraryStore>((set, get) => {
const onProgress = (progress: ScanProgress) => set({ scanProgress: progress });
const onProgress = (progress: ScanProgress) => {
set({ scanProgress: progress });
// Mirror progress into the foreground-service notification (starts it on the
// first tick) so a big scan keeps running + stays visible when backgrounded.
void reportScanProgress(progress);
};
/** Shared scan wrapper: progress/error state + refresh, scans never overlap. */
const runScan = async (scan: () => Promise<ScanResult | null>) => {
@@ -127,6 +133,7 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
} finally {
await get().refresh();
set({ isScanning: false, scanProgress: { ...IDLE_PROGRESS } });
endScanService();
}
};
+53
View File
@@ -0,0 +1,53 @@
import { create } from 'zustand';
import { openLibraryDb } from '@/db/database';
import { getFolders, getSetting, setSetting } from '@/db/queries';
/**
* First-run wizard gate. SQLite (settings table) is the source of truth, mirrored
* in memory like the other pref stores. The wizard shows once on a fresh install
* and never again once `onboardingComplete` is persisted.
*/
const ONBOARDING_COMPLETE_KEY = 'onboarding_complete';
interface OnboardingStore {
onboardingComplete: boolean;
loaded: boolean;
load: () => Promise<void>;
markComplete: () => Promise<void>;
/** Dev affordance: re-arm the wizard (see Experimental settings). */
reset: () => Promise<void>;
}
export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
onboardingComplete: false,
loaded: false,
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const value = await getSetting(db, ONBOARDING_COMPLETE_KEY);
if (value !== null) {
set({ onboardingComplete: value === 'true', loaded: true });
return;
}
// Flag never set: an install that already has library folders predates this
// wizard — treat it as onboarded (and persist) so the wizard never ambushes
// an upgrading user. A genuinely fresh install has no folders → show it.
const folders = await getFolders(db);
const complete = folders.length > 0;
if (complete) await setSetting(db, ONBOARDING_COMPLETE_KEY, 'true');
set({ onboardingComplete: complete, loaded: true });
},
markComplete: async () => {
set({ onboardingComplete: true });
const db = await openLibraryDb();
await setSetting(db, ONBOARDING_COMPLETE_KEY, 'true');
},
reset: async () => {
set({ onboardingComplete: false });
const db = await openLibraryDb();
await setSetting(db, ONBOARDING_COMPLETE_KEY, 'false');
},
}));
+1
View File
@@ -36,6 +36,7 @@ dependencies {
api 'com.google.android.exoplayer:exoplayer:2.19.0'
api 'com.google.android.exoplayer:extension-mediasession:2.19.0'
api 'com.jakewharton.timber:timber:5.0.1'
testImplementation 'junit:junit:4.13.2'
// The PCM tap forwards to expo.modules.astrascope.ScopeBridge.
implementation project(':astra-scope')
@@ -6,17 +6,12 @@ import com.google.android.exoplayer2.audio.BaseAudioProcessor
import expo.modules.astrascope.EqBridge
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Parametric EQ as an ExoPlayer AudioProcessor (M4). Reads raw band params from
* [EqBridge] (set from JS) and computes Audio-EQ-Cookbook biquad coefficients at
* the real stream sample rate — mirroring Web Audio's BiquadFilterNode on desktop.
* [EqBridge] (set from JS) and computes Web Audio BiquadFilterNode-compatible
* coefficients at the real stream sample rate, matching desktop Astra.
* A cascade of transposed-direct-form-II biquads runs per channel after a preamp.
*
* Passthrough (bit-exact) when the EQ is disabled or has no active bands and unity
@@ -100,7 +95,7 @@ class EqAudioProcessor : BaseAudioProcessor() {
var bi = 0
for (i in 0 until total) {
if (params[i * 5 + 4] == 0f) continue
computeCoeffs(
EqCoefficients.compute(
params[i * 5].toInt(),
params[i * 5 + 1],
params[i * 5 + 2],
@@ -188,71 +183,4 @@ class EqAudioProcessor : BaseAudioProcessor() {
}
}
/**
* Audio-EQ-Cookbook biquad coefficients (a0-normalized) into out[off..off+4].
* Type ordinals match EQ_BAND_TYPE_ORDINAL in src/audio/eq.ts:
* 0 lowshelf, 1 peaking, 2 highshelf, 3 highpass, 4 lowpass.
*/
private fun computeCoeffs(
type: Int,
freq: Float,
gainDb: Float,
q: Float,
sr: Float,
out: FloatArray,
off: Int
) {
if (sr <= 0f) {
out[off] = 1f; out[off + 1] = 0f; out[off + 2] = 0f; out[off + 3] = 0f; out[off + 4] = 0f
return
}
val w0 = 2.0 * PI * freq / sr
val cosW0 = cos(w0)
val sinW0 = sin(w0)
val a = 10.0.pow(gainDb / 40.0)
val alpha = sinW0 / (2.0 * q.coerceAtLeast(0.0001f))
var b0 = 1.0; var b1 = 0.0; var b2 = 0.0
var a0 = 1.0; var a1 = 0.0; var a2 = 0.0
when (type) {
1 -> { // peaking
b0 = 1 + alpha * a; b1 = -2 * cosW0; b2 = 1 - alpha * a
a0 = 1 + alpha / a; a1 = -2 * cosW0; a2 = 1 - alpha / a
}
0 -> { // lowshelf
val sqrtA = sqrt(a)
b0 = a * (a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alpha)
b1 = 2 * a * (a - 1 - (a + 1) * cosW0)
b2 = a * (a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alpha)
a0 = a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alpha
a1 = -2 * (a - 1 + (a + 1) * cosW0)
a2 = a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alpha
}
2 -> { // highshelf
val sqrtA = sqrt(a)
b0 = a * (a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alpha)
b1 = -2 * a * (a - 1 + (a + 1) * cosW0)
b2 = a * (a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alpha)
a0 = a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alpha
a1 = 2 * (a - 1 - (a + 1) * cosW0)
a2 = a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alpha
}
4 -> { // lowpass
b0 = (1 - cosW0) / 2; b1 = 1 - cosW0; b2 = (1 - cosW0) / 2
a0 = 1 + alpha; a1 = -2 * cosW0; a2 = 1 - alpha
}
3 -> { // highpass
b0 = (1 + cosW0) / 2; b1 = -(1 + cosW0); b2 = (1 + cosW0) / 2
a0 = 1 + alpha; a1 = -2 * cosW0; a2 = 1 - alpha
}
}
val inv = 1.0 / a0
out[off] = (b0 * inv).toFloat()
out[off + 1] = (b1 * inv).toFloat()
out[off + 2] = (b2 * inv).toFloat()
out[off + 3] = (a1 * inv).toFloat()
out[off + 4] = (a2 * inv).toFloat()
}
}
@@ -0,0 +1,82 @@
package com.doublesymmetry.kotlinaudio.scope
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Web Audio BiquadFilterNode coefficients, a0-normalized as b0,b1,b2,a1,a2.
* Type ordinals match EQ_BAND_TYPE_ORDINAL in src/audio/eq.ts:
* 0 lowshelf, 1 peaking, 2 highshelf, 3 highpass, 4 lowpass.
*/
internal object EqCoefficients {
private const val MIN_FILTER_Q = 0.0001
fun compute(
type: Int,
freq: Float,
gainDb: Float,
q: Float,
sr: Float,
out: FloatArray,
off: Int
) {
if (sr <= 0f) {
out[off] = 1f; out[off + 1] = 0f; out[off + 2] = 0f; out[off + 3] = 0f; out[off + 4] = 0f
return
}
val w0 = 2.0 * PI * freq / sr
val cosW0 = cos(w0)
val sinW0 = sin(w0)
val a = 10.0.pow(gainDb / 40.0)
val alphaQ = sinW0 / (2.0 * q.coerceAtLeast(MIN_FILTER_Q.toFloat()))
val alphaQDb = sinW0 / (2.0 * 10.0.pow(q / 20.0))
val alphaShelf = (sinW0 / 2.0) * sqrt(2.0)
var b0 = 1.0; var b1 = 0.0; var b2 = 0.0
var a0 = 1.0; var a1 = 0.0; var a2 = 0.0
when (type) {
1 -> { // peaking
b0 = 1 + alphaQ * a; b1 = -2 * cosW0; b2 = 1 - alphaQ * a
a0 = 1 + alphaQ / a; a1 = -2 * cosW0; a2 = 1 - alphaQ / a
}
0 -> { // lowshelf
val sqrtA = sqrt(a)
b0 = a * (a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alphaShelf)
b1 = 2 * a * (a - 1 - (a + 1) * cosW0)
b2 = a * (a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alphaShelf)
a0 = a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alphaShelf
a1 = -2 * (a - 1 + (a + 1) * cosW0)
a2 = a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alphaShelf
}
2 -> { // highshelf
val sqrtA = sqrt(a)
b0 = a * (a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alphaShelf)
b1 = -2 * a * (a - 1 + (a + 1) * cosW0)
b2 = a * (a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alphaShelf)
a0 = a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alphaShelf
a1 = 2 * (a - 1 - (a + 1) * cosW0)
a2 = a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alphaShelf
}
4 -> { // lowpass
b0 = (1 - cosW0) / 2; b1 = 1 - cosW0; b2 = (1 - cosW0) / 2
a0 = 1 + alphaQDb; a1 = -2 * cosW0; a2 = 1 - alphaQDb
}
3 -> { // highpass
b0 = (1 + cosW0) / 2; b1 = -(1 + cosW0); b2 = (1 + cosW0) / 2
a0 = 1 + alphaQDb; a1 = -2 * cosW0; a2 = 1 - alphaQDb
}
}
val inv = 1.0 / a0
out[off] = (b0 * inv).toFloat()
out[off + 1] = (b1 * inv).toFloat()
out[off + 2] = (b2 * inv).toFloat()
out[off + 3] = (a1 * inv).toFloat()
out[off + 4] = (a2 * inv).toFloat()
}
}
@@ -0,0 +1,62 @@
package com.doublesymmetry.kotlinaudio.scope
import org.junit.Assert.assertEquals
import org.junit.Test
class EqCoefficientsTest {
@Test
fun passFiltersUseWebAudioQDbSemantics() {
assertCoefficients(
coeffs(type = 4, freq = 1000f, gainDb = 0f, q = 6f),
doubleArrayOf(
0.004142085705,
0.008284171410,
0.004142085705,
-1.920085584611,
0.936653927431
)
)
assertCoefficients(
coeffs(type = 3, freq = 1000f, gainDb = 0f, q = 6f),
doubleArrayOf(
0.964184878011,
-1.928369756021,
0.964184878011,
-1.920085584611,
0.936653927431
)
)
}
@Test
fun shelfFiltersIgnoreQ() {
assertCoefficients(
coeffs(type = 0, freq = 100f, gainDb = 6f, q = 0.1f),
coeffs(type = 0, freq = 100f, gainDb = 6f, q = 18f)
)
assertCoefficients(
coeffs(type = 2, freq = 8000f, gainDb = -4f, q = 0.1f),
coeffs(type = 2, freq = 8000f, gainDb = -4f, q = 18f)
)
}
private fun coeffs(type: Int, freq: Float, gainDb: Float, q: Float): FloatArray {
val out = FloatArray(5)
EqCoefficients.compute(type, freq, gainDb, q, 48000f, out, 0)
return out
}
private fun assertCoefficients(actual: FloatArray, expected: DoubleArray) {
assertEquals("coefficient count", expected.size, actual.size)
for (i in expected.indices) {
assertEquals("coefficient $i", expected[i], actual[i].toDouble(), 1e-6)
}
}
private fun assertCoefficients(actual: FloatArray, expected: FloatArray) {
assertEquals("coefficient count", expected.size, actual.size)
for (i in expected.indices) {
assertEquals("coefficient $i", expected[i].toDouble(), actual[i].toDouble(), 0.0)
}
}
}