From 6aa4b544fc0c1af638bef1976038df962ac73b0e Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:21:09 -0400 Subject: [PATCH] onboarding + eq fixes --- app.json | 1 + .../android/src/main/AndroidManifest.xml | 13 +- .../AstraLibraryScannerModule.kt | 14 + .../ScanForegroundService.kt | 198 +++++++ modules/astra-library-scanner/index.ts | 17 + package.json | 1 + src/app/(tabs)/eq.tsx | 4 +- src/app/_layout.tsx | 54 +- src/app/settings/experimental.tsx | 22 +- src/audio/eq.test.mts | 102 ++++ src/audio/eq.ts | 99 +++- src/components/eq/BandDetailPanel.tsx | 28 +- src/components/onboarding/OnboardingFlow.tsx | 544 ++++++++++++++++++ src/library/scanService.ts | 98 ++++ src/stores/libraryStore.ts | 9 +- src/stores/onboardingStore.ts | 53 ++ vendor/kotlinaudio/kotlin-audio/build.gradle | 1 + .../kotlinaudio/scope/EqAudioProcessor.kt | 78 +-- .../kotlinaudio/scope/EqCoefficients.kt | 82 +++ .../kotlinaudio/scope/EqCoefficientsTest.kt | 62 ++ 20 files changed, 1349 insertions(+), 131 deletions(-) create mode 100644 modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/ScanForegroundService.kt create mode 100644 src/audio/eq.test.mts create mode 100644 src/components/onboarding/OnboardingFlow.tsx create mode 100644 src/library/scanService.ts create mode 100644 src/stores/onboardingStore.ts create mode 100644 vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/EqCoefficients.kt create mode 100644 vendor/kotlinaudio/kotlin-audio/src/test/java/com/doublesymmetry/kotlinaudio/scope/EqCoefficientsTest.kt diff --git a/app.json b/app.json index 411a30a..781a6e7 100644 --- a/app.json +++ b/app.json @@ -3,6 +3,7 @@ "name": "Astra", "slug": "astra-mobile", "version": "0.1.0", + "platforms": ["android"], "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "astra", diff --git a/modules/astra-library-scanner/android/src/main/AndroidManifest.xml b/modules/astra-library-scanner/android/src/main/AndroidManifest.xml index bdae66c..7777de2 100644 --- a/modules/astra-library-scanner/android/src/main/AndroidManifest.xml +++ b/modules/astra-library-scanner/android/src/main/AndroidManifest.xml @@ -1,2 +1,13 @@ - + + + + + + + + + diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt index fee6281..9d4a98b 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt @@ -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 = diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/ScanForegroundService.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/ScanForegroundService.kt new file mode 100644 index 0000000..429ccab --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/ScanForegroundService.kt @@ -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) } + } + } +} diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index e98577a..06a9166 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -91,6 +91,23 @@ declare class AstraLibraryScannerModuleType extends NativeModule; releasePersistedUriPermission(uri: string): Promise; + /** + * 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 = diff --git a/package.json b/package.json index 1fd8010..c44bee5 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index 48ae781..3ed9f74 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -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), diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index fbcc1f2..154aa72 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -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() { - - - - - - - - + {/* 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 ? ( + <> + + + + + + + + + + ) : null} - - + {onboardingComplete ? ( + <> + + + + ) : ( + // First-run gate: opaque full-screen wizard over the (hidden) navigator. + // markComplete flips the flag → this unmounts, revealing the app. + + { + void useOnboardingStore.getState().markComplete(); + }} + /> + + )} ); diff --git a/src/app/settings/experimental.tsx b/src/app/settings/experimental.tsx index 224f1e6..ec4ba15 100644 --- a/src/app/settings/experimental.tsx +++ b/src/app/settings/experimental.tsx @@ -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)} /> + + DEVELOPER + ); } diff --git a/src/audio/eq.test.mts b/src/audio/eq.test.mts new file mode 100644 index 0000000..d69ef54 --- /dev/null +++ b/src/audio/eq.test.mts @@ -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 { + 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) + ); +}); diff --git a/src/audio/eq.ts b/src/audio/eq.ts index 379a816..9bdd1eb 100644 --- a/src/audio/eq.ts +++ b/src/audio/eq.ts @@ -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(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); diff --git a/src/components/eq/BandDetailPanel.tsx b/src/components/eq/BandDetailPanel.tsx index 21b4784..b49c2ff 100644 --- a/src/components/eq/BandDetailPanel.tsx +++ b/src/components/eq/BandDetailPanel.tsx @@ -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 ( @@ -96,16 +98,18 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit onValuePress={() => onEditValue('gain')} disabled={isPass} /> - v.toFixed(2)} - onChange={(v) => onUpdate({ Q: v })} - onValuePress={() => onEditValue('Q')} - /> + {!isShelf ? ( + v.toFixed(2)} + onChange={(v) => onUpdate({ Q: v })} + onValuePress={() => onEditValue('Q')} + /> + ) : null} ); } diff --git a/src/components/onboarding/OnboardingFlow.tsx b/src/components/onboarding/OnboardingFlow.tsx new file mode 100644 index 0000000..b3003fa --- /dev/null +++ b/src/components/onboarding/OnboardingFlow.tsx @@ -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['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 ( + + + + + + + + + {/* 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' ? : null} + + + {step === 'welcome' ? : null} + {step === 'library' ? : null} + {step === 'theme' ? : null} + {step === 'done' ? : null} + + + + + + {STEP_ORDER.map((id, i) => ( + + ))} + + + {canGoBack ? ( + + + Back + + + ) : null} + + + {primaryLabel} + + + + + + + ); +} + +function WelcomeStep() { + const styles = useStyles(); + const colors = useColors(); + return ( + + + + + + + Welcome to Astra + + + Your music, beautifully played. Set up your library in a few taps. + + + + ); +} + +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 ( + + + void addFolder()} + accessibilityRole="button" + > + + + {folders.length > 0 ? 'Add another folder' : 'Choose music folder'} + + + + + + {isScanning ? ( + + Scanning keeps running in the background — continue whenever you like, or add + more folders. + + ) : folders.length > 0 ? ( + + + + {formatFolderCount(folders.length)} · {formatTrackCount(totalTrackCount)} + + + ) : ( + + You can skip this and add folders later from Settings › Library. + + )} + + ); +} + +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 ( + + + + {options.map((option) => { + const selected = option.id === baseTheme; + return ( + void setBaseTheme(option.id)} + style={[styles.themePill, selected && styles.themePillSelected]} + accessibilityRole="radio" + accessibilityState={{ selected }} + > + + {option.title} + + + ); + })} + + {accentApplies ? ( + + void setAccent(id)} /> + + ) : null} + + ); +} + +function DoneStep() { + const styles = useStyles(); + const colors = useColors(); + const totalTrackCount = useLibraryStore((s) => s.totalTrackCount); + return ( + + + + + + + All set + + + {totalTrackCount > 0 + ? `${formatTrackCount(totalTrackCount)} ready to play.` + : 'Add music anytime from Settings › Library.'} + + + + ); +} + +function StepHeader({ + icon, + title, + subtitle, +}: { + icon: IoniconName; + title: string; + subtitle: string; +}) { + const styles = useStyles(); + const colors = useColors(); + return ( + + + + + + {title} + + + {subtitle} + + + ); +} + +/** 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 ( + + + + Scanning your library{detail ? ` · ${detail}` : '…'} + + + ); +} + +/** 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 ; +} + +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; diff --git a/src/library/scanService.ts b/src/library/scanService.ts new file mode 100644 index 0000000..23b8fdf --- /dev/null +++ b/src/library/scanService.ts @@ -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 { + if (notifPermRequested) return; + notifPermRequested = true; + const permission = (PermissionsAndroid.PERMISSIONS as Record) + .POST_NOTIFICATIONS; + if (!permission) return; + try { + await PermissionsAndroid.request(permission as Parameters[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 { + 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(); +} diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index 5db5931..67837e8 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -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 | null = null; export const useLibraryStore = create((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) => { @@ -127,6 +133,7 @@ export const useLibraryStore = create((set, get) => { } finally { await get().refresh(); set({ isScanning: false, scanProgress: { ...IDLE_PROGRESS } }); + endScanService(); } }; diff --git a/src/stores/onboardingStore.ts b/src/stores/onboardingStore.ts new file mode 100644 index 0000000..e3c944d --- /dev/null +++ b/src/stores/onboardingStore.ts @@ -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; + markComplete: () => Promise; + /** Dev affordance: re-arm the wizard (see Experimental settings). */ + reset: () => Promise; +} + +export const useOnboardingStore = create((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'); + }, +})); diff --git a/vendor/kotlinaudio/kotlin-audio/build.gradle b/vendor/kotlinaudio/kotlin-audio/build.gradle index c7440d9..13ace50 100644 --- a/vendor/kotlinaudio/kotlin-audio/build.gradle +++ b/vendor/kotlinaudio/kotlin-audio/build.gradle @@ -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') diff --git a/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/EqAudioProcessor.kt b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/EqAudioProcessor.kt index e5fbcc7..b1eb84a 100644 --- a/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/EqAudioProcessor.kt +++ b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/EqAudioProcessor.kt @@ -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() - } } diff --git a/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/EqCoefficients.kt b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/EqCoefficients.kt new file mode 100644 index 0000000..c4421d2 --- /dev/null +++ b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/EqCoefficients.kt @@ -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() + } +} diff --git a/vendor/kotlinaudio/kotlin-audio/src/test/java/com/doublesymmetry/kotlinaudio/scope/EqCoefficientsTest.kt b/vendor/kotlinaudio/kotlin-audio/src/test/java/com/doublesymmetry/kotlinaudio/scope/EqCoefficientsTest.kt new file mode 100644 index 0000000..14899ab --- /dev/null +++ b/vendor/kotlinaudio/kotlin-audio/src/test/java/com/doublesymmetry/kotlinaudio/scope/EqCoefficientsTest.kt @@ -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) + } + } +}