mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
onboarding + eq fixes
This commit is contained in:
@@ -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>
|
||||
|
||||
+14
@@ -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 =
|
||||
|
||||
+198
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
}));
|
||||
@@ -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')
|
||||
|
||||
+3
-75
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/EqCoefficients.kt
Vendored
+82
@@ -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()
|
||||
}
|
||||
}
|
||||
+62
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user