From 00dded1c1d9a91312bed577556a31ee20e52e221 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 18 May 2026 12:00:58 -0700 Subject: [PATCH] Preparing for next release which will add a lot of new features. Mainly, playback capture mode which allows the visualizer to work with any external media through microphone capture. This will also be the first android release upcoming. --- .gitignore | 1 + Cargo.toml | 6 +- ...otlin-compiler-13414114603619315478.salive | 0 android/app/build.gradle.kts | 16 +- android/app/src/main/AndroidManifest.xml | 13 + .../org/elseif/yrxtals/CaptureController.kt | 63 +++ .../org/elseif/yrxtals/IcedSurfaceView.kt | 66 ++- .../java/org/elseif/yrxtals/MainActivity.kt | 103 ++++- .../org/elseif/yrxtals/MediaSessionReader.kt | 209 +++++++++ .../java/org/elseif/yrxtals/NativeBridge.kt | 35 ++ include/yr_xtals.h | 29 ++ ios/Info.plist | 2 + ios/src/CaptureController.swift | 46 ++ ios/src/CaptureSession.swift | 167 +++++++ ios/src/IcedViewportView.swift | 22 + ios/src/YrXtalsApp.swift | 5 +- scripts/android/release-playstore.sh | 62 +++ src/analyzer.rs | 126 ++++++ src/analyzer_worker.rs | 86 +++- src/android.rs | 238 +++++++++- src/ios.rs | 80 ++++ src/lib.rs | 3 + src/mic_input.rs | 88 ++++ src/ui/app.rs | 407 ++++++++++++++++-- src/ui/main_menu.rs | 91 ++++ src/ui/mod.rs | 2 + src/ui/player.rs | 165 ++++++- src/viewport.rs | 96 ++++- src/visualizer/state.rs | 9 + xtask/src/main.rs | 7 +- 30 files changed, 2173 insertions(+), 70 deletions(-) delete mode 100644 android/.kotlin/sessions/kotlin-compiler-13414114603619315478.salive create mode 100644 android/app/src/main/java/org/elseif/yrxtals/CaptureController.kt create mode 100644 android/app/src/main/java/org/elseif/yrxtals/MediaSessionReader.kt create mode 100644 ios/src/CaptureController.swift create mode 100644 ios/src/CaptureSession.swift create mode 100755 scripts/android/release-playstore.sh create mode 100644 src/mic_input.rs create mode 100644 src/ui/main_menu.rs diff --git a/.gitignore b/.gitignore index 85d5d92..b2a9db7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ build_android/ build_ios/ build_macos/ build_windows/ +.kotlin icons *.png vamp-plugin/ diff --git a/Cargo.toml b/Cargo.toml index 6974ce4..812980e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,10 @@ bytemuck = { version = "1", features = ["derive"] } rayon = "1.10" arc-swap = "1.7" +# settings persistence +serde = { version = "1", features = ["derive"] } +serde_json = "1" + # desktop shell deps [target.'cfg(all(not(target_os = "ios"), not(target_os = "android")))'.dependencies] winit = "0.30" @@ -62,7 +66,7 @@ rfd = "0.15" # android shell deps [target.'cfg(target_os = "android")'.dependencies] jni = "0.21" -ndk = "0.9" +ndk = { version = "0.9", features = ["api-level-28"] } ndk-context = "0.1" android_logger = "0.14" log = "0.4" diff --git a/android/.kotlin/sessions/kotlin-compiler-13414114603619315478.salive b/android/.kotlin/sessions/kotlin-compiler-13414114603619315478.salive deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index eb5687e..4dffd99 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -23,6 +23,15 @@ android { getByName("debug") { storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore") } + create("release") { + val ks = System.getenv("YRXTALS_KEYSTORE") + if (ks != null) { + storeFile = file(ks) + storePassword = System.getenv("YRXTALS_STORE_PASSWORD") + keyAlias = System.getenv("YRXTALS_KEY_ALIAS") ?: "yrxtals" + keyPassword = System.getenv("YRXTALS_KEY_PASSWORD") + } + } } buildTypes { @@ -32,7 +41,11 @@ android { } getByName("release") { isMinifyEnabled = false - signingConfig = signingConfigs.getByName("debug") + signingConfig = if (System.getenv("YRXTALS_KEYSTORE") != null) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -42,6 +55,7 @@ android { initWith(getByName("release")) isDebuggable = true isJniDebuggable = true + signingConfig = signingConfigs.getByName("debug") matchingFallbacks += listOf("release") } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 06e9480..6b4afd3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + @@ -23,6 +26,16 @@ + + + + + + diff --git a/android/app/src/main/java/org/elseif/yrxtals/CaptureController.kt b/android/app/src/main/java/org/elseif/yrxtals/CaptureController.kt new file mode 100644 index 0000000..57a1cb8 --- /dev/null +++ b/android/app/src/main/java/org/elseif/yrxtals/CaptureController.kt @@ -0,0 +1,63 @@ +package org.elseif.yrxtals + +import android.Manifest +import android.content.pm.PackageManager +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat + +private const val TAG = "YrXtalsCaptureCtrl" + +/// gates mic-input start on RECORD_AUDIO and forwards the start/stop calls into the rust AAudio input stream. +class CaptureController(private val activity: ComponentActivity) { + + var view: IcedSurfaceView? = null + + /// true while the rust AAudio mic input stream is running. + var isCapturing: Boolean = false + private set + + private val recordPermissionLauncher: ActivityResultLauncher = + activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + startMicNative() + } else { + Log.w(TAG, "RECORD_AUDIO permission denied") + } + } + + /// requests RECORD_AUDIO if missing; starts the rust AAudio mic input stream on grant or when already granted. + fun start() { + if (!hasPermission(Manifest.permission.RECORD_AUDIO)) { + recordPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + return + } + startMicNative() + } + + /// stops the rust AAudio mic input stream. + fun stop() { + synchronized(NativeBridge.viewportLock) { + val h = NativeBridge.currentViewportHandle + if (h != 0L) { + NativeBridge.viewportStopMicCapture(h) + } + } + isCapturing = false + } + + private fun startMicNative() { + synchronized(NativeBridge.viewportLock) { + val h = NativeBridge.currentViewportHandle + if (h != 0L) { + NativeBridge.viewportStartMicCapture(h) + } + } + isCapturing = true + } + + private fun hasPermission(name: String): Boolean = + ContextCompat.checkSelfPermission(activity, name) == PackageManager.PERMISSION_GRANTED +} diff --git a/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt b/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt index 81140cf..9f7f45c 100644 --- a/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt +++ b/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt @@ -1,6 +1,8 @@ package org.elseif.yrxtals +import android.app.Activity import android.content.Context +import android.os.Build import android.util.AttributeSet import android.view.Choreographer import android.view.KeyEvent @@ -16,6 +18,7 @@ class IcedSurfaceView @JvmOverloads constructor( ) : SurfaceView(context, attrs, defStyleAttr), SurfaceHolder.Callback, Choreographer.FrameCallback { var controller: LibraryController? = null + var captureController: CaptureController? = null private var handle: Long = 0 private var frameCallbackPosted: Boolean = false @@ -31,15 +34,29 @@ class IcedSurfaceView @JvmOverloads constructor( isFocusableInTouchMode = true } - /// allocates the rust viewport bound to the new surface, sized in logical dp at the screen density. + /// allocates the rust viewport bound to the new surface and restores Playback capture state when a session is active. override fun surfaceCreated(holder: SurfaceHolder) { val s = density val wDp = holder.surfaceFrame.width() / s val hDp = holder.surfaceFrame.height() / s - handle = NativeBridge.viewportCreate(holder.surface, wDp, hDp, s) - if (handle == 0L) { + val created = NativeBridge.viewportCreate(holder.surface, wDp, hDp, s) + if (created == 0L) { return } + synchronized(NativeBridge.viewportLock) { + handle = created + NativeBridge.currentViewportHandle = created + } + if (captureController?.isCapturing == true) { + NativeBridge.viewportRestoreCaptureSession(created) + NativeBridge.viewportStartMicCapture(created) + } + val act = context as? MainActivity + act?.pushPersistedSettings() + act?.pushNotificationAccess() + if (MediaSessionReader.hasAccess(context)) { + MediaSessionReader.instance?.refreshNow() + } startFrames() requestFocus() } @@ -51,17 +68,28 @@ class IcedSurfaceView @JvmOverloads constructor( NativeBridge.viewportResize(handle, width / s, height / s, s) } - /// drops the viewport on surface detach. + /// drops the viewport on surface detach and atomically clears the shared handle ahead of FFI destroy. override fun surfaceDestroyed(holder: SurfaceHolder) { stopFrames() - if (handle != 0L) { - NativeBridge.viewportDestroy(handle) + val toDestroy: Long + synchronized(NativeBridge.viewportLock) { + toDestroy = handle handle = 0 + if (NativeBridge.currentViewportHandle == toDestroy) { + NativeBridge.currentViewportHandle = 0L + } + } + if (toDestroy != 0L) { + NativeBridge.viewportDestroy(toDestroy) } } - /// pauses the choreographer loop while the activity is backgrounded. + /// pauses the choreographer loop while the activity is backgrounded, skipping the pause inside PiP. fun onActivityPause() { + val act = context as? Activity + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && act?.isInPictureInPictureMode == true) { + return + } paused = true stopFrames() } @@ -84,7 +112,7 @@ class IcedSurfaceView @JvmOverloads constructor( frameCallbackPosted = false } - /// renders one frame and surfaces any pending picker request to the controller. + /// renders one frame and surfaces any pending picker or capture-action request to the matching controller. override fun doFrame(frameTimeNanos: Long) { frameCallbackPosted = false if (handle == 0L || paused) return @@ -93,6 +121,28 @@ class IcedSurfaceView @JvmOverloads constructor( if (pending != 0) { controller?.presentPicker(pending) } + val captureAction = NativeBridge.viewportTakePendingCaptureAction(handle) + when (captureAction) { + 1 -> captureController?.start() + 2 -> captureController?.stop() + } + if (NativeBridge.viewportTakePendingPipRequest(handle)) { + (context as? MainActivity)?.requestPip() + } + val transport = NativeBridge.viewportTakePendingCaptureTransport(handle) + if (transport != 0) { + MediaSessionReader.instance?.transportCommand(transport) + } + val seek = NativeBridge.viewportTakePendingCaptureSeek(handle) + if (seek >= 0f) { + MediaSessionReader.instance?.seekFraction(seek) + } + if (NativeBridge.viewportTakePendingOpenListenerSettings(handle)) { + (context as? MainActivity)?.openListenerSettings() + } + if (NativeBridge.viewportTakePendingPersistSettings(handle)) { + (context as? MainActivity)?.persistSettings() + } startFrames() } diff --git a/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt b/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt index 7094ee2..4f54ec5 100644 --- a/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt +++ b/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt @@ -1,6 +1,13 @@ package org.elseif.yrxtals +import android.app.PictureInPictureParams +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.os.Build import android.os.Bundle +import android.util.Log +import android.util.Rational import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.core.view.WindowCompat @@ -12,6 +19,7 @@ class MainActivity : ComponentActivity() { private lateinit var surfaceView: IcedSurfaceView private lateinit var controller: LibraryController + private lateinit var captureController: CaptureController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -21,8 +29,13 @@ class MainActivity : ComponentActivity() { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) controller = LibraryController(this) - surfaceView = IcedSurfaceView(this).also { it.controller = controller } + captureController = CaptureController(this) + surfaceView = IcedSurfaceView(this).also { + it.controller = controller + it.captureController = captureController + } controller.view = surfaceView + captureController.view = surfaceView setContentView(surfaceView) WindowInsetsControllerCompat(window, surfaceView).let { c -> @@ -34,10 +47,98 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() surfaceView.onActivityResume() + if (MediaSessionReader.hasAccess(this)) { + if (MediaSessionReader.instance == null) { + MediaSessionReader.requestBind(this) + } else { + MediaSessionReader.instance?.refreshNow() + } + } + pushNotificationAccess() + } + + /// publishes the current notification-listener access flag into the rust viewport under the shared lock. + fun pushNotificationAccess() { + val granted = MediaSessionReader.hasAccess(this) + synchronized(NativeBridge.viewportLock) { + val h = NativeBridge.currentViewportHandle + if (h != 0L) { + NativeBridge.viewportSetNotificationAccess(h, granted) + } + } + } + + private val prefs by lazy { getSharedPreferences("yrxtals", Context.MODE_PRIVATE) } + + /// pushes the persisted settings JSON into the rust viewport at surface create. silent no-op if no value stored yet. + fun pushPersistedSettings() { + val json = prefs.getString("settings_json", null) ?: return + synchronized(NativeBridge.viewportLock) { + val h = NativeBridge.currentViewportHandle + if (h != 0L) { + NativeBridge.viewportSetSettingsJson(h, json) + } + } + } + + /// reads the current settings JSON out of rust and writes it to SharedPreferences. + fun persistSettings() { + val json = synchronized(NativeBridge.viewportLock) { + val h = NativeBridge.currentViewportHandle + if (h != 0L) NativeBridge.viewportGetSettingsJson(h) else null + } + if (json != null && json.isNotEmpty()) { + prefs.edit().putString("settings_json", json).apply() + } + } + + /// opens the system Notification Listener access screen. + fun openListenerSettings() { + try { + startActivity(Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) + } catch (e: Throwable) { + Log.w("YrXtals", "open notification listener settings failed: ${e.message}") + } } override fun onPause() { super.onPause() surfaceView.onActivityPause() } + + /// enters Picture-in-Picture mode while a capture session runs. + override fun onUserLeaveHint() { + super.onUserLeaveHint() + if (!captureController.isCapturing) return + enterPipIfSupported() + } + + /// fires Picture-in-Picture entry from the in-app toggle. + fun requestPip() { + enterPipIfSupported() + } + + /// shared PiP entry path used by both the user-leave hint and the manual in-app toggle. + private fun enterPipIfSupported() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + try { + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(16, 9)) + .build() + enterPictureInPictureMode(params) + } catch (e: IllegalStateException) { + Log.w("YrXtals", "enterPictureInPictureMode failed: ${e.message}") + } + } + + /// keeps the surface view rendering after a PiP transition. + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration, + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (isInPictureInPictureMode) { + surfaceView.onActivityResume() + } + } } diff --git a/android/app/src/main/java/org/elseif/yrxtals/MediaSessionReader.kt b/android/app/src/main/java/org/elseif/yrxtals/MediaSessionReader.kt new file mode 100644 index 0000000..182640a --- /dev/null +++ b/android/app/src/main/java/org/elseif/yrxtals/MediaSessionReader.kt @@ -0,0 +1,209 @@ +package org.elseif.yrxtals + +import android.content.ComponentName +import android.content.Context +import android.media.MediaMetadata +import android.media.session.MediaController +import android.media.session.MediaSessionManager +import android.media.session.PlaybackState +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.service.notification.NotificationListenerService +import android.util.Log + +private const val TAG = "YrXtalsMediaReader" + +/// notification-listener that mirrors the active foreign media session into the rust viewport. +class MediaSessionReader : NotificationListenerService() { + + private var sessionManager: MediaSessionManager? = null + private var primary: MediaController? = null + private val callback = object : MediaController.Callback() { + override fun onMetadataChanged(metadata: MediaMetadata?) { + primary?.let { pushMetadata(it, metadata) } + } + override fun onPlaybackStateChanged(state: PlaybackState?) { + pushPlaybackState(state) + } + override fun onSessionDestroyed() { + detachPrimary() + refreshActiveSessions() + } + } + + private val sessionsListener = MediaSessionManager.OnActiveSessionsChangedListener { _ -> + refreshActiveSessions() + } + + override fun onListenerConnected() { + super.onListenerConnected() + Log.i(TAG, "listener connected") + instance = this + val mgr = getSystemService(Context.MEDIA_SESSION_SERVICE) as? MediaSessionManager ?: return + sessionManager = mgr + val component = ComponentName(this, MediaSessionReader::class.java) + try { + mgr.addOnActiveSessionsChangedListener( + sessionsListener, + component, + Handler(Looper.getMainLooper()), + ) + refreshActiveSessions() + } catch (e: SecurityException) { + Log.w(TAG, "MediaSessionManager listener registration denied: ${e.message}") + } + } + + override fun onListenerDisconnected() { + super.onListenerDisconnected() + Log.i(TAG, "listener disconnected") + sessionManager?.removeOnActiveSessionsChangedListener(sessionsListener) + detachPrimary() + pushClear() + if (instance === this) instance = null + } + + private fun refreshActiveSessions() { + val mgr = sessionManager ?: return + val component = ComponentName(this, MediaSessionReader::class.java) + val active = try { + mgr.getActiveSessions(component) + } catch (e: SecurityException) { + Log.w(TAG, "getActiveSessions denied: ${e.message}") + emptyList() + } + val pick = active.firstOrNull { it.playbackState?.state == PlaybackState.STATE_PLAYING } + ?: active.firstOrNull() + attachPrimary(pick) + } + + private fun attachPrimary(controller: MediaController?) { + if (controller === primary) { + if (controller != null) { + pushMetadata(controller, controller.metadata) + pushPlaybackState(controller.playbackState) + } + return + } + detachPrimary() + primary = controller + if (controller == null) { + Log.i(TAG, "no active controller, clearing capture session") + pushClear() + return + } + Log.i(TAG, "attached primary controller: ${controller.packageName}") + controller.registerCallback(callback, Handler(Looper.getMainLooper())) + pushMetadata(controller, controller.metadata) + pushPlaybackState(controller.playbackState) + } + + private fun detachPrimary() { + primary?.unregisterCallback(callback) + primary = null + } + + private fun pushMetadata(controller: MediaController, metadata: MediaMetadata?) { + val title = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE) + ?: metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE) + val artist = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST) + ?: metadata?.getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST) + ?: metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE) + val durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.coerceAtLeast(0L) ?: 0L + val positionMs = controller.playbackState?.position?.coerceAtLeast(0L) ?: 0L + synchronized(NativeBridge.viewportLock) { + val h = NativeBridge.currentViewportHandle + if (h != 0L) { + NativeBridge.viewportSetCaptureMetadata(h, title, artist, positionMs, durationMs) + } + } + } + + private fun pushPlaybackState(state: PlaybackState?) { + val playing = state?.state == PlaybackState.STATE_PLAYING + val positionMs = state?.position?.coerceAtLeast(0L) ?: 0L + synchronized(NativeBridge.viewportLock) { + val h = NativeBridge.currentViewportHandle + if (h != 0L) { + NativeBridge.viewportSetCapturePlaybackState(h, playing, positionMs) + } + } + } + + private fun pushClear() { + synchronized(NativeBridge.viewportLock) { + val h = NativeBridge.currentViewportHandle + if (h != 0L) { + NativeBridge.viewportClearCaptureSession(h) + } + } + } + + /// re-pushes the currently attached MediaController state. invoked by the host after a surface re-create. + fun refreshNow() { + val controller = primary + if (controller == null) { + refreshActiveSessions() + return + } + pushMetadata(controller, controller.metadata) + pushPlaybackState(controller.playbackState) + } + + /// forwards a transport command code (1 toggle, 2 prev, 3 next) to the current foreign controller. + fun transportCommand(code: Int) { + val controls = primary?.transportControls ?: return + when (code) { + 1 -> { + if (primary?.playbackState?.state == PlaybackState.STATE_PLAYING) { + controls.pause() + } else { + controls.play() + } + } + 2 -> controls.skipToPrevious() + 3 -> controls.skipToNext() + } + } + + /// seeks the current foreign controller to the given normalised position. + fun seekFraction(fraction: Float) { + val duration = primary?.metadata + ?.getLong(MediaMetadata.METADATA_KEY_DURATION) + ?.coerceAtLeast(0L) ?: 0L + if (duration <= 0L) return + val target = (fraction.coerceIn(0f, 1f) * duration).toLong() + primary?.transportControls?.seekTo(target) + } + + companion object { + @Volatile + @JvmStatic + var instance: MediaSessionReader? = null + private set + + /// returns true when YrXtals' notification-listener has been granted notification access. + fun hasAccess(context: Context): Boolean { + val enabled = Settings.Secure.getString( + context.contentResolver, + "enabled_notification_listeners", + ) ?: return false + val self = ComponentName(context, MediaSessionReader::class.java).flattenToString() + return enabled.split(':').any { it == self } + } + + /// asks the system to (re)bind the notification listener so onListenerConnected fires. + fun requestBind(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return + try { + NotificationListenerService.requestRebind( + ComponentName(context, MediaSessionReader::class.java), + ) + } catch (e: Throwable) { + Log.w(TAG, "requestRebind failed: ${e.message}") + } + } + } +} diff --git a/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt b/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt index 4655180..b12a515 100644 --- a/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt +++ b/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt @@ -9,6 +9,15 @@ object NativeBridge { System.loadLibrary("yr_crystals") } + /// live viewport handle observed by audio threads. zero indicates no current surface. + @Volatile + @JvmField + var currentViewportHandle: Long = 0L + + /// guards every FFI call against viewport destruction on the main thread. + @JvmField + val viewportLock: Any = Any() + external fun initContext(activity: Context) external fun viewportCreate(surface: Surface, width: Float, height: Float, scale: Float): Long @@ -30,4 +39,30 @@ object NativeBridge { external fun viewportSetTrackArt(handle: Long, idx: Int, bytes: ByteArray) external fun viewportTakePendingPick(handle: Long): Int + + external fun viewportSetPlaybackMode(handle: Long, mode: Int) + external fun viewportPushCapturePcm(handle: Long, samples: FloatArray, sampleRate: Int, channels: Int) + external fun viewportSetCaptureMetadata( + handle: Long, + title: String?, + artist: String?, + positionMs: Long, + durationMs: Long, + ) + external fun viewportTakePendingCaptureAction(handle: Long): Int + external fun viewportTakePendingPipRequest(handle: Long): Boolean + external fun viewportRestoreCaptureSession(handle: Long) + external fun viewportSetCapturePlaybackState(handle: Long, playing: Boolean, positionMs: Long) + external fun viewportClearCaptureSession(handle: Long) + external fun viewportTakePendingCaptureTransport(handle: Long): Int + external fun viewportTakePendingCaptureSeek(handle: Long): Float + external fun viewportSetNotificationAccess(handle: Long, granted: Boolean) + external fun viewportTakePendingOpenListenerSettings(handle: Long): Boolean + + external fun viewportStartMicCapture(handle: Long) + external fun viewportStopMicCapture(handle: Long) + + external fun viewportGetSettingsJson(handle: Long): String? + external fun viewportSetSettingsJson(handle: Long, json: String) + external fun viewportTakePendingPersistSettings(handle: Long): Boolean } diff --git a/include/yr_xtals.h b/include/yr_xtals.h index e3c001c..256eed5 100644 --- a/include/yr_xtals.h +++ b/include/yr_xtals.h @@ -33,6 +33,9 @@ void viewport_key_event(struct ViewportHandle *handle, /// drains the pending-picker flag (0 none, 1 folder, 2 file) and returns the prior value. uint8_t viewport_take_pending_pick(struct ViewportHandle *handle); +/// drains the pending playback-capture action flag (0 idle, 1 start, 2 stop). +uint8_t viewport_take_pending_capture_action(struct ViewportHandle *handle); + void viewport_apply_picked_folder(struct ViewportHandle *handle, const char *path); void viewport_apply_picked_file(struct ViewportHandle *handle, const char *path); @@ -66,6 +69,32 @@ void viewport_set_track_art(struct ViewportHandle *handle, const uint8_t *bytes, size_t len); +/// switches the top-level playback mode: 0 = Local file playback, 1 = Playback capture from the OS. +void viewport_set_playback_mode(struct ViewportHandle *handle, uint32_t mode); + +/// pushes a chunk of interleaved f32 PCM captured by the shell into the playback-capture pipeline. +void viewport_push_capture_pcm(struct ViewportHandle *handle, + const float *samples, + size_t sample_count, + uint32_t sample_rate, + uint32_t channels); + +/// updates the now-playing metadata for Playback capture mode. clears fields on null pointers. +void viewport_set_capture_metadata(struct ViewportHandle *handle, + const char *title, + const char *artist, + uint64_t position_ms, + uint64_t duration_ms); + +/// serializes both settings slots as a JSON string. caller frees via viewport_free_string. +char *viewport_get_settings_json(struct ViewportHandle *handle); + +/// applies a JSON settings blob loaded by the shell at startup. silent no-op on parse failure. +void viewport_set_settings_json(struct ViewportHandle *handle, const char *json); + +/// drains the pending-persist flag set after a settings change. +bool viewport_take_pending_persist_settings(struct ViewportHandle *handle); + void viewport_free_string(char *s); #endif diff --git a/ios/Info.plist b/ios/Info.plist index 2110b22..1374fe7 100644 --- a/ios/Info.plist +++ b/ios/Info.plist @@ -48,6 +48,8 @@ 17.0 NSAppleMusicUsageDescription Yr Xtals plays audio files from your library or Files app for offline visualization. + NSMicrophoneUsageDescription + Yr Xtals listens through the microphone in Playback capture mode so the visualizer reacts to whatever audio is playing around you. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/ios/src/CaptureController.swift b/ios/src/CaptureController.swift new file mode 100644 index 0000000..ea96940 --- /dev/null +++ b/ios/src/CaptureController.swift @@ -0,0 +1,46 @@ +import AVFoundation + +/// gates mic capture on AVCaptureDevice audio permission and exposes start/stop to the iced viewport. +class CaptureController { + + private let session = CaptureSession() + weak var view: IcedViewportView? + + /// true while AVAudioEngine is running through CaptureSession. + var isCapturing: Bool { session.isCapturing } + + /// requests AVCaptureDevice audio permission if missing; starts the mic session on grant or when already granted. + func start() { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + startSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in + DispatchQueue.main.async { + if granted { self?.startSession() } + else { print("[YrXtals] mic permission denied") } + } + } + case .denied, .restricted: + print("[YrXtals] mic permission previously denied") + @unknown default: + print("[YrXtals] mic permission status unknown") + } + } + + /// stops the mic session. + func stop() { + session.stop() + } + + private func startSession() { + session.viewportHandleProvider = { [weak view = self.view] in + view?.viewportHandle + } + do { + try session.start() + } catch { + print("[YrXtals] CaptureSession start failed: \(error)") + } + } +} diff --git a/ios/src/CaptureSession.swift b/ios/src/CaptureSession.swift new file mode 100644 index 0000000..c6d8919 --- /dev/null +++ b/ios/src/CaptureSession.swift @@ -0,0 +1,167 @@ +import AVFoundation +import UIKit + +/// drives an AVAudioEngine input tap on the device mic and pushes mono float PCM into the rust capture pipeline. +class CaptureSession { + + private var engine: AVAudioEngine? + private(set) var isCapturing = false + private var observers: [NSObjectProtocol] = [] + + /// supplies the live viewport handle each tap. captured weakly to avoid extending the view's lifetime. + var viewportHandleProvider: (() -> OpaquePointer?)? + + /// configures AVAudioSession for low-latency mic capture, installs a tap, and starts the engine. + func start() throws { + if isCapturing { return } + + try configureSession() + try startEngine() + registerObservers() + isCapturing = true + print("[YrXtals] CaptureSession started") + } + + /// removes the tap, stops the engine, and deactivates the audio session. + func stop() { + if !isCapturing { return } + unregisterObservers() + teardownEngine() + isCapturing = false + try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) + print("[YrXtals] CaptureSession stopped") + } + + /// applies the low-latency mic AVAudioSession configuration. + private func configureSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory( + .playAndRecord, + mode: .measurement, + options: [.defaultToSpeaker, .mixWithOthers], + ) + try session.setPreferredSampleRate(48_000) + try session.setPreferredIOBufferDuration(0.005) + try session.setActive(true, options: []) + } + + /// allocates a fresh AVAudioEngine, taps the input bus, and starts the engine. + private func startEngine() throws { + let engine = AVAudioEngine() + let input = engine.inputNode + let format = input.outputFormat(forBus: 0) + let sampleRate = UInt32(format.sampleRate) + let channels = UInt32(format.channelCount) + + input.installTap(onBus: 0, bufferSize: 256, format: format) { [weak self] buffer, _ in + guard let self = self, + let handle = self.viewportHandleProvider?() else { return } + self.push(buffer: buffer, handle: handle, sampleRate: sampleRate, channels: channels) + } + + try engine.start() + self.engine = engine + } + + /// removes the tap and stops the AVAudioEngine. safe to call repeatedly. + private func teardownEngine() { + engine?.inputNode.removeTap(onBus: 0) + engine?.stop() + engine = nil + } + + /// re-establishes the engine after an interruption or a return to the foreground. keeps the session active. + private func bounceEngine() { + teardownEngine() + do { + try AVAudioSession.sharedInstance().setActive(true, options: []) + try startEngine() + print("[YrXtals] CaptureSession bounced") + } catch { + print("[YrXtals] CaptureSession bounce failed: \(error)") + } + } + + /// listens for interruption, foreground activation, and media-services-reset notifications. + private func registerObservers() { + let center = NotificationCenter.default + observers.append(center.addObserver( + forName: AVAudioSession.interruptionNotification, + object: nil, + queue: .main, + ) { [weak self] note in + self?.handleInterruption(note) + }) + observers.append(center.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main, + ) { [weak self] _ in + guard let self = self, self.isCapturing else { return } + self.bounceEngine() + }) + observers.append(center.addObserver( + forName: AVAudioSession.mediaServicesWereResetNotification, + object: nil, + queue: .main, + ) { [weak self] _ in + guard let self = self, self.isCapturing else { return } + do { try self.configureSession() } catch { + print("[YrXtals] post-reset session reconfigure failed: \(error)") + } + self.bounceEngine() + }) + } + + private func unregisterObservers() { + let center = NotificationCenter.default + for o in observers { center.removeObserver(o) } + observers.removeAll() + } + + /// reacts to AVAudioSession interruptions: stops the engine on .began, restarts it on .ended. + private func handleInterruption(_ note: Notification) { + guard let info = note.userInfo, + let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: raw) else { return } + switch type { + case .began: + teardownEngine() + print("[YrXtals] audio interruption began") + case .ended: + if isCapturing { bounceEngine() } + print("[YrXtals] audio interruption ended") + @unknown default: + break + } + } + + /// interleaves the per-channel float buffers and forwards the chunk through the FFI. + private func push(buffer: AVAudioPCMBuffer, handle: OpaquePointer, sampleRate: UInt32, channels: UInt32) { + guard let channelData = buffer.floatChannelData else { return } + let frames = Int(buffer.frameLength) + if frames == 0 { return } + let chCount = Int(channels) + + if chCount == 1 { + viewport_push_capture_pcm(handle, channelData[0], frames, sampleRate, channels) + return + } + + let total = frames * chCount + var interleaved = [Float](repeating: 0, count: total) + interleaved.withUnsafeMutableBufferPointer { dst in + for c in 0..&2 +YRXTALS_KEYSTORE not set. + +create a release keystore once with: + keytool -genkey -v -keystore ~/.android/yrxtals-release.jks \\ + -keyalg RSA -keysize 4096 -validity 25000 -alias yrxtals + +then export: + export YRXTALS_KEYSTORE=\$HOME/.android/yrxtals-release.jks + export YRXTALS_KEY_ALIAS=yrxtals + export YRXTALS_STORE_PASSWORD= + export YRXTALS_KEY_PASSWORD= +EOF + exit 2 +fi + +if [[ -z "${YRXTALS_STORE_PASSWORD:-}" || -z "${YRXTALS_KEY_PASSWORD:-}" ]]; then + echo "YRXTALS_STORE_PASSWORD and YRXTALS_KEY_PASSWORD must be set" >&2 + exit 2 +fi + +if [[ ! -f "$YRXTALS_KEYSTORE" ]]; then + echo "keystore not found at $YRXTALS_KEYSTORE" >&2 + exit 2 +fi + +export YRXTALS_KEY_ALIAS="${YRXTALS_KEY_ALIAS:-yrxtals}" + +bash "$SCRIPT_DIR/generate-icons.sh" + +JNILIBS="$REPO_ROOT/android/app/src/main/jniLibs" +mkdir -p "$JNILIBS/arm64-v8a" + +echo "→ cargo ndk -t arm64-v8a -P 28 -o $JNILIBS build --profile release" +( cd "$REPO_ROOT" && cargo ndk -t arm64-v8a -P 28 -o "$JNILIBS" build --profile release ) + +echo "→ gradlew :app:bundleRelease" +( cd "$REPO_ROOT/android" && ./gradlew :app:bundleRelease ) + +AAB="$REPO_ROOT/android/app/build/outputs/bundle/release/app-release.aab" +if [[ ! -f "$AAB" ]]; then + echo "expected aab missing at $AAB" >&2 + exit 1 +fi + +echo +echo "signed aab: $AAB" +echo "applicationId: org.elseif.yrxtals" +echo "upload at https://play.google.com/console" diff --git a/src/analyzer.rs b/src/analyzer.rs index bbc4594..681647e 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -1,5 +1,6 @@ +use std::collections::VecDeque; use std::sync::Arc; use num_complex::Complex64; @@ -35,6 +36,18 @@ pub struct Analyzer { track: Option>, last_frames: Vec, + + /// most recently observed live-PCM sample rate. + live_sample_rate: u32, + + /// interleaved-stereo PCM accumulated by push_live_pcm and drained one hop at a time by step_live. + live_buffer: VecDeque, + + /// soft cap on the live buffer length. + live_buffer_max: usize, + + /// smoothed AGC gain applied to incoming live PCM before it enters the analyzer buffer. + live_gain: f64, } impl Analyzer { @@ -87,6 +100,10 @@ impl Analyzer { last_hilbert_sample: 0, track: None, last_frames: Vec::new(), + live_sample_rate: 0, + live_buffer: VecDeque::with_capacity(192_000), + live_buffer_max: 192_000, + live_gain: 1.0, } } @@ -222,6 +239,115 @@ impl Analyzer { } } + /// appends interleaved PCM into the live-mode buffer with stereo conversion. propagates sample-rate changes and caps the buffer length. + pub fn push_live_pcm(&mut self, samples: &[f32], sample_rate: u32, channels: u32) { + if samples.is_empty() || channels == 0 { + return; + } + if sample_rate != self.live_sample_rate { + self.live_sample_rate = sample_rate; + for p in self + .main + .iter_mut() + .chain(self.transient.iter_mut()) + .chain(self.deep.iter_mut()) + { + p.set_sample_rate(sample_rate); + } + self.hilbert_needs_reset = true; + } + + // chunk-RMS AGC: keep mic levels near a fixed target so a quiet phone-mic still drives the visualizer. + const TARGET_RMS: f64 = 0.15; + const MIN_RMS: f64 = 0.001; + const MAX_GAIN: f64 = 50.0; + const MIN_GAIN: f64 = 0.01; + const ATTACK: f64 = 0.30; + const RELEASE: f64 = 0.02; + + let mut sum_sq = 0.0_f64; + for &s in samples { + sum_sq += (s as f64) * (s as f64); + } + let rms = (sum_sq / samples.len() as f64).sqrt(); + let target_gain = if rms > MIN_RMS { + (TARGET_RMS / rms).clamp(MIN_GAIN, MAX_GAIN) + } else { + self.live_gain.clamp(MIN_GAIN, MAX_GAIN) + }; + let alpha = if target_gain < self.live_gain { ATTACK } else { RELEASE }; + self.live_gain += alpha * (target_gain - self.live_gain); + let gain = self.live_gain as f32; + + let ch = channels as usize; + match ch { + 2 => { + for &s in samples { + self.live_buffer.push_back(s * gain); + } + } + 1 => { + for &s in samples { + let scaled = s * gain; + self.live_buffer.push_back(scaled); + self.live_buffer.push_back(scaled); + } + } + _ => { + let mut i = 0; + while i + ch <= samples.len() { + self.live_buffer.push_back(samples[i] * gain); + self.live_buffer.push_back(samples[i + 1] * gain); + i += ch; + } + } + } + while self.live_buffer.len() > self.live_buffer_max { + self.live_buffer.pop_front(); + self.live_buffer.pop_front(); + } + } + + /// consumes one hop's worth of interleaved stereo from the live buffer and publishes a frame pair. + pub fn step_live(&mut self) -> Option<&[FrameData]> { + let hop = self.hilbert_hop_size; + let fft = self.hilbert_fft_size; + if hop == 0 || fft == 0 { + return None; + } + if self.hilbert_needs_reset { + let warmup_blocks = fft / hop; + let warmup_stereo = warmup_blocks * hop * 2; + if self.live_buffer.len() < warmup_stereo + hop * 2 { + return None; + } + self.hilbert.reinit(fft); + for _ in 0..warmup_blocks { + let mut left = Vec::with_capacity(hop); + let mut right = Vec::with_capacity(hop); + for _ in 0..hop { + left.push(self.live_buffer.pop_front().unwrap() as f64); + right.push(self.live_buffer.pop_front().unwrap() as f64); + } + let _ = self.hilbert.process(&left, &right); + } + self.hilbert_needs_reset = false; + } + if self.live_buffer.len() < hop * 2 { + return None; + } + let mut left = Vec::with_capacity(hop); + let mut right = Vec::with_capacity(hop); + for _ in 0..hop { + left.push(self.live_buffer.pop_front().unwrap() as f64); + right.push(self.live_buffer.pop_front().unwrap() as f64); + } + let (cl, cr) = self.hilbert.process(&left, &right); + self.push_to_processors(&cl, &cr); + self.compute_and_publish(); + Some(&self.last_frames) + } + /// distributes one analytic-signal hop into all three band processors per channel. fn push_to_processors(&mut self, complex_l: &[Complex64], complex_r: &[Complex64]) { self.main[0].push_data(complex_l); diff --git a/src/analyzer_worker.rs b/src/analyzer_worker.rs index f9a007f..be63ed7 100644 --- a/src/analyzer_worker.rs +++ b/src/analyzer_worker.rs @@ -11,6 +11,13 @@ use crossbeam_channel::{bounded, Receiver, RecvTimeoutError, Sender}; use crate::analyzer::{Analyzer, FrameData}; use crate::track::TrackData; +/// analyzer pipeline mode: track-anchored playback vs. live captured stream. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AnalyzerMode { + Track, + Live, +} + /// command messages routed from the ui thread into the analyzer thread. enum Cmd { SetTrack(Arc), @@ -18,6 +25,8 @@ enum Cmd { SetNumBins(usize), SetSmoothing { granularity: i32, detail: i32, strength: f32 }, SetGpuBlend(f32), + SetMode(AnalyzerMode), + PushLivePcm { samples: Vec, sample_rate: u32, channels: u32 }, Shutdown, } @@ -30,6 +39,19 @@ pub struct AnalyzerWorker { join: Option>, } +/// cloneable handle that pushes live PCM into the analyzer worker without holding a reference to it. +#[derive(Clone)] +pub struct PcmSender { + tx: Sender, +} + +impl PcmSender { + /// queues a chunk of interleaved live PCM into the analyzer worker. drops the chunk on a full channel. + pub fn push(&self, samples: Vec, sample_rate: u32, channels: u32) { + let _ = self.tx.try_send(Cmd::PushLivePcm { samples, sample_rate, channels }); + } +} + impl AnalyzerWorker { /// launches the background analyzer thread and returns an owning handle. pub fn spawn(device: wgpu::Device, queue: wgpu::Queue) -> Self { @@ -87,6 +109,16 @@ impl AnalyzerWorker { let _ = self.cmd_tx.send(Cmd::SetGpuBlend(blend)); } + /// queues a switch between Track and Live analyzer modes. + pub fn set_mode(&self, mode: AnalyzerMode) { + let _ = self.cmd_tx.send(Cmd::SetMode(mode)); + } + + /// queues a live-PCM chunk for the analyzer, dropping the chunk on a full worker channel. + pub fn push_live_pcm(&self, samples: Vec, sample_rate: u32, channels: u32) { + let _ = self.cmd_tx.try_send(Cmd::PushLivePcm { samples, sample_rate, channels }); + } + /// stores the audio thread's normalised playhead atomically. pub fn publish_playhead(&self, normalised: f32) { let total = self.total_frames.load(Ordering::Acquire); @@ -101,6 +133,11 @@ impl AnalyzerWorker { pub fn latest_frames(&self) -> Arc> { self.frames.load_full() } + + /// builds a cloneable PcmSender that bypasses the AnalyzerWorker handle and pushes directly into the command channel. + pub fn pcm_sender(&self) -> PcmSender { + PcmSender { tx: self.cmd_tx.clone() } + } } impl Drop for AnalyzerWorker { @@ -122,6 +159,7 @@ fn run( queue: wgpu::Queue, ) { let mut analyzer = Analyzer::new(device, queue); + let mut mode = AnalyzerMode::Track; let interval = Duration::from_micros(16_667); let mut next_tick = Instant::now() + interval; @@ -132,6 +170,14 @@ fn run( match cmd_rx.recv_timeout(timeout) { Ok(Cmd::Shutdown) => break, + Ok(Cmd::SetMode(m)) => { + mode = m; + continue; + } + Ok(Cmd::PushLivePcm { samples, sample_rate, channels }) => { + analyzer.push_live_pcm(&samples, sample_rate, channels); + continue; + } Ok(cmd) => { apply(&mut analyzer, cmd); continue; @@ -141,18 +187,39 @@ fn run( } while let Ok(cmd) = cmd_rx.try_recv() { - if matches!(cmd, Cmd::Shutdown) { - return; + match cmd { + Cmd::Shutdown => return, + Cmd::SetMode(m) => mode = m, + Cmd::PushLivePcm { samples, sample_rate, channels } => { + analyzer.push_live_pcm(&samples, sample_rate, channels); + } + other => apply(&mut analyzer, other), } - apply(&mut analyzer, cmd); } - let total = total_frames.load(Ordering::Acquire); - if total > 0 { - let frame = playhead_frame.load(Ordering::Acquire); - let pos = (frame as f64) / (total as f64); - if let Some(latest) = analyzer.step(pos) { - frames.store(Arc::new(latest.to_vec())); + match mode { + AnalyzerMode::Track => { + let total = total_frames.load(Ordering::Acquire); + if total > 0 { + let frame = playhead_frame.load(Ordering::Acquire); + let pos = (frame as f64) / (total as f64); + if let Some(latest) = analyzer.step(pos) { + frames.store(Arc::new(latest.to_vec())); + } + } + } + AnalyzerMode::Live => { + // drains all hops queued in the live buffer per tick so small hops (e.g. 64) don't fall behind the audio thread; publishes only the most recent frame pair. + let mut latest_owned: Option> = None; + for _ in 0..200 { + match analyzer.step_live() { + Some(latest) => latest_owned = Some(latest.to_vec()), + None => break, + } + } + if let Some(f) = latest_owned { + frames.store(Arc::new(f)); + } } } @@ -177,6 +244,7 @@ fn apply(analyzer: &mut Analyzer, cmd: Cmd) { strength, } => analyzer.set_smoothing_params(granularity, detail, strength), Cmd::SetGpuBlend(b) => analyzer.set_gpu_blend(b), + Cmd::SetMode(_) | Cmd::PushLivePcm { .. } => {} Cmd::Shutdown => {} } } diff --git a/src/android.rs b/src/android.rs index e701d14..c9f8dd3 100644 --- a/src/android.rs +++ b/src/android.rs @@ -1,4 +1,4 @@ -use jni::objects::{JByteArray, JClass, JIntArray, JObject, JObjectArray, JString}; +use jni::objects::{JByteArray, JClass, JFloatArray, JIntArray, JObject, JObjectArray, JString}; use jni::sys::{jboolean, jbyte, jfloat, jint, jlong, jobject}; use jni::JNIEnv; use ndk::native_window::NativeWindow; @@ -9,6 +9,7 @@ use std::ffi::c_void; use std::ptr::NonNull; use std::sync::Once; +use crate::mic_input::MicInput; use crate::viewport::ViewportHandle; macro_rules! dbg_log { @@ -18,10 +19,11 @@ macro_rules! dbg_log { }; } -/// owns the inner viewport plus the ANativeWindow whose lifetime backs the wgpu surface. +/// owns the inner viewport, the ANativeWindow whose lifetime backs the wgpu surface, and the AAudio mic input stream. struct AndroidViewport { inner: ViewportHandle, _window: NativeWindow, + mic_input: Option, } /// initializes android logger and the ndk_context singleton consumed by cpal's AAudio backend. @@ -99,6 +101,7 @@ pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportCreate<'a>( let boxed = Box::new(AndroidViewport { inner, _window: window, + mic_input: None, }); Box::into_raw(boxed) as jlong } @@ -345,6 +348,168 @@ pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingP av.inner.take_pending_pick() as jint } +/// drains the pending playback-capture action flag (0 idle, 1 start, 2 stop). +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingCaptureAction<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) -> jint { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return 0 }; + av.inner.take_pending_capture_action() as jint +} + +/// drains the pending Picture-in-Picture entry-request flag. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingPipRequest<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) -> jboolean { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return 0 }; + if av.inner.take_pending_pip_request() { 1 } else { 0 } +} + +/// re-applies Playback capture mode onto a freshly created viewport without firing the start action. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportRestoreCaptureSession<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.inner.restore_capture_session(); +} + +/// stores the foreign-session playback flag and live position pushed by the host MediaController. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetCapturePlaybackState<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + playing: jboolean, + position_ms: jlong, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.inner.set_capture_playback_state(playing != 0, position_ms.max(0) as u64); +} + +/// resets every Playback capture metadata field after the host loses its primary MediaController. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportClearCaptureSession<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.inner.clear_capture_session(); +} + +/// stores the notification-listener access flag pushed by the host shell. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetNotificationAccess<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + granted: jboolean, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.inner.set_notification_access(granted != 0); +} + +/// drains the pending request to open the system Notification Listener settings. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingOpenListenerSettings<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) -> jboolean { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return 0 }; + if av.inner.take_pending_open_listener_settings() { 1 } else { 0 } +} + +/// drains the pending capture-mode transport command (0 idle, 1 toggle, 2 prev, 3 next). +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingCaptureTransport<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) -> jint { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return 0 }; + av.inner.take_pending_capture_transport() as jint +} + +/// drains the pending capture-mode seek fraction. negative result means idle. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingCaptureSeek<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) -> jfloat { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return -1.0 }; + av.inner.take_pending_capture_seek() +} + +/// switches the top-level playback mode: 0=Local file playback, 1=Playback capture from the OS. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetPlaybackMode<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + mode: jint, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.inner.set_playback_mode(mode.max(0) as u32); +} + +/// pushes a chunk of interleaved f32 PCM captured by the shell into the playback-capture pipeline. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportPushCapturePcm<'a>( + env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + samples: JFloatArray<'a>, + sample_rate: jint, + channels: jint, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + let Ok(len) = env.get_array_length(&samples) else { return }; + if len <= 0 { return; } + let mut buf = vec![0f32; len as usize]; + if env.get_float_array_region(&samples, 0, &mut buf).is_err() { return; } + av.inner.push_capture_pcm(&buf, sample_rate.max(0) as u32, channels.max(0) as u32); +} + +/// updates the now-playing metadata for Playback capture mode. clears fields on null Java strings. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetCaptureMetadata<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + title: JString<'a>, + artist: JString<'a>, + position_ms: jlong, + duration_ms: jlong, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + let title_str = if JObjectExt::is_null(&title) { + None + } else { + env.get_string(&title).ok().map(String::from) + }; + let artist_str = if JObjectExt::is_null(&artist) { + None + } else { + env.get_string(&artist).ok().map(String::from) + }; + av.inner.set_capture_metadata( + title_str, + artist_str, + position_ms.max(0) as u64, + duration_ms.max(0) as u64, + ); +} + /// dup2s stdout and stderr onto a pipe and pumps each line into logcat under the `yr_crystals.io` tag. fn redirect_stdio_to_logcat() { use std::io::{BufRead, BufReader}; @@ -372,6 +537,75 @@ fn redirect_stdio_to_logcat() { .ok(); } +/// opens the AAudio mic input stream and stores the handle on the AndroidViewport. no-op if a stream is already running. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportStartMicCapture<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + if av.mic_input.is_some() { + return; + } + let pusher = av.inner.pcm_sender(); + match MicInput::start(pusher) { + Ok(mic) => av.mic_input = Some(mic), + Err(e) => { + dbg_log!("viewportStartMicCapture failed: {e}"); + } + } +} + +/// closes the AAudio mic input stream by dropping the stored handle. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportStopMicCapture<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.mic_input = None; +} + +/// serializes both settings slots as a JSON java string for the shell to persist. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportGetSettingsJson<'a>( + env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) -> jni::sys::jstring { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { + return std::ptr::null_mut(); + }; + let s = av.inner.settings_json(); + env.new_string(s).map(|j| j.into_raw()).unwrap_or(std::ptr::null_mut()) +} + +/// applies a JSON settings blob loaded by the shell at startup. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetSettingsJson<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + json: JString<'a>, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + let Ok(s) = env.get_string(&json) else { return }; + av.inner.apply_settings_json(&String::from(s)); +} + +/// drains the pending-persist flag set after a settings change. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingPersistSettings<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) -> jboolean { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return 0 }; + if av.inner.take_pending_persist_settings() { 1 } else { 0 } +} + /// nullability check on a JObject through its raw pointer. trait JObjectExt { fn is_null(&self) -> bool; diff --git a/src/ios.rs b/src/ios.rs index 91a8a73..f5744c6 100644 --- a/src/ios.rs +++ b/src/ios.rs @@ -301,6 +301,86 @@ pub extern "C" fn viewport_take_pending_pick(handle: *mut ViewportHandle) -> u8 h.take_pending_pick() } +/// drains the pending playback-capture action flag (0 idle, 1 start, 2 stop). +#[unsafe(no_mangle)] +pub extern "C" fn viewport_take_pending_capture_action(handle: *mut ViewportHandle) -> u8 { + let Some(h) = (unsafe { handle.as_mut() }) else { + return 0; + }; + h.take_pending_capture_action() +} + +/// switches the top-level playback mode: 0=Local file playback, 1=Playback capture from the OS. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_set_playback_mode(handle: *mut ViewportHandle, mode: u32) { + let Some(h) = (unsafe { handle.as_mut() }) else { return }; + h.set_playback_mode(mode); +} + +/// pushes a chunk of interleaved f32 PCM captured by the shell into the playback-capture pipeline. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_push_capture_pcm( + handle: *mut ViewportHandle, + samples: *const f32, + sample_count: usize, + sample_rate: u32, + channels: u32, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { return }; + if samples.is_null() || sample_count == 0 { + return; + } + let slice = unsafe { std::slice::from_raw_parts(samples, sample_count) }; + h.push_capture_pcm(slice, sample_rate, channels); +} + +/// updates the now-playing metadata for Playback capture mode. clears fields on null pointers. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_set_capture_metadata( + handle: *mut ViewportHandle, + title: *const c_char, + artist: *const c_char, + position_ms: u64, + duration_ms: u64, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { return }; + let title = if title.is_null() { + None + } else { + unsafe { CStr::from_ptr(title) }.to_str().ok().map(String::from) + }; + let artist = if artist.is_null() { + None + } else { + unsafe { CStr::from_ptr(artist) }.to_str().ok().map(String::from) + }; + h.set_capture_metadata(title, artist, position_ms, duration_ms); +} + +/// serializes both settings slots as JSON. caller must release the returned pointer via viewport_free_string. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_get_settings_json(handle: *mut ViewportHandle) -> *mut c_char { + let Some(h) = (unsafe { handle.as_mut() }) else { return std::ptr::null_mut() }; + let s = h.settings_json(); + CString::new(s).map(|c| c.into_raw()).unwrap_or(std::ptr::null_mut()) +} + +/// applies a JSON settings blob loaded by the shell at startup. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_set_settings_json(handle: *mut ViewportHandle, json: *const c_char) { + let Some(h) = (unsafe { handle.as_mut() }) else { return }; + if json.is_null() { return; } + let Ok(s) = unsafe { CStr::from_ptr(json) }.to_str() else { return }; + h.apply_settings_json(s); +} + +/// drains the pending-persist flag set after a settings change. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_take_pending_persist_settings(handle: *mut ViewportHandle) -> bool { + let Some(h) = (unsafe { handle.as_mut() }) else { return false }; + h.take_pending_persist_settings() +} + /// releases a CString previously handed to Swift over the FFI boundary. #[unsafe(no_mangle)] pub extern "C" fn viewport_free_string(s: *mut c_char) { diff --git a/src/lib.rs b/src/lib.rs index ef73191..f02ced0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,3 +26,6 @@ pub mod ios; #[cfg(target_os = "android")] pub mod android; + +#[cfg(target_os = "android")] +pub mod mic_input; diff --git a/src/mic_input.rs b/src/mic_input.rs new file mode 100644 index 0000000..04b0f93 --- /dev/null +++ b/src/mic_input.rs @@ -0,0 +1,88 @@ +use ndk::audio::{ + AudioCallbackResult, AudioDirection, AudioFormat, AudioInputPreset, AudioPerformanceMode, + AudioSharingMode, AudioStream, AudioStreamBuilder, +}; +use std::ffi::c_void; + +use crate::analyzer_worker::PcmSender; + +/// owns the AAudio mic-input stream that pushes raw mic PCM into the analyzer worker. +pub struct MicInput { + _stream: AudioStream, +} + +impl MicInput { + /// opens a LOW_LATENCY mic input stream with the Unprocessed preset and starts the data callback. + pub fn start(pusher: PcmSender) -> Result { + let mut frames_since_log: u64 = 0; + let mut callbacks_since_log: u64 = 0; + let mut peak_since_log: f32 = 0.0; + let callback: ndk::audio::AudioStreamDataCallback = Box::new( + move |stream: &AudioStream, audio_data: *mut c_void, num_frames: i32| { + let channels = stream.channel_count().max(1) as usize; + let frames = num_frames.max(0) as usize; + let total = frames * channels; + if total == 0 { + return AudioCallbackResult::Continue; + } + let samples = + unsafe { std::slice::from_raw_parts(audio_data as *const f32, total) }; + let sr = stream.sample_rate() as u32; + #[cfg(debug_assertions)] + { + for &s in samples { + let a = s.abs(); + if a > peak_since_log { + peak_since_log = a; + } + } + frames_since_log = frames_since_log.saturating_add(frames as u64); + callbacks_since_log = callbacks_since_log.saturating_add(1); + if frames_since_log >= sr as u64 { + log::info!( + "mic_input tick: callbacks={} frames={} sr={} ch={} peak={:.4}", + callbacks_since_log, + frames_since_log, + sr, + channels, + peak_since_log, + ); + frames_since_log = 0; + callbacks_since_log = 0; + peak_since_log = 0.0; + } + } + pusher.push(samples.to_vec(), sr, channels as u32); + AudioCallbackResult::Continue + }, + ); + + let stream = AudioStreamBuilder::new() + .map_err(|e| format!("AAudio input builder: {e:?}"))? + .direction(AudioDirection::Input) + .input_preset(AudioInputPreset::Unprocessed) + .format(AudioFormat::PCM_Float) + .channel_count(1) + .performance_mode(AudioPerformanceMode::LowLatency) + .sharing_mode(AudioSharingMode::Shared) + .data_callback(callback) + .open_stream() + .map_err(|e| format!("AAudio input open: {e:?}"))?; + + #[cfg(debug_assertions)] + log::info!( + "mic_input opened: sr={} ch={} format={:?} perf={:?} share={:?}", + stream.sample_rate(), + stream.channel_count(), + stream.format(), + stream.performance_mode(), + stream.sharing_mode(), + ); + + stream + .request_start() + .map_err(|e| format!("AAudio input start: {e:?}"))?; + + Ok(MicInput { _stream: stream }) + } +} diff --git a/src/ui/app.rs b/src/ui/app.rs index dd63182..70f1d91 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5,30 +5,101 @@ use iced_wgpu::core::{Element, Theme}; use iced_widget::scrollable::AbsoluteOffset; use crate::analyzer::FrameData; -use crate::analyzer_worker::AnalyzerWorker; +use crate::analyzer_worker::{AnalyzerMode, AnalyzerWorker}; use crate::engine::{AudioEngine, EngineEvent}; use crate::library::{self, Track}; use crate::library_worker::{LibraryUpdate, LibraryWorker}; use super::player; +/// top-level playback mode: file-based local playback vs. live audio captured from the OS. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlaybackMode { + Local, + Capture, +} + +impl PlaybackMode { + /// builds a mode from the u32 FFI wire format, falling back to Local on unknown values. + pub fn from_u32(v: u32) -> Self { + match v { + 1 => Self::Capture, + _ => Self::Local, + } + } +} + +/// metadata and counters for Playback capture mode. +#[derive(Debug, Default, Clone)] +pub struct CaptureState { + pub title: Option, + pub artist: Option, + pub position_ms: u64, + pub duration_ms: u64, + + /// foreign-session playback flag pushed by the host MediaController callback. + pub playing: bool, + + /// flips true once any media session has reported metadata. drives the transport-enable state. + pub has_session: bool, + + /// host-pushed flag: true when the user has granted notification listener access for now-playing info. + pub notification_access: bool, + + /// running total of audio frames received over the FFI capture channel since launch. + pub samples_received: u64, + + /// sample rate (Hz) of the most recent FFI capture push. + pub sample_rate: u32, +} + /// owns the library, audio engine, background workers, and every UI-toggleable setting. pub struct App { pub library: Library, pub selected_track: Option, pub playing: bool, pub immersive: bool, + pub playback_mode: PlaybackMode, + pub capture: CaptureState, + + /// flags whether App::view renders the main mode-selection menu in place of the player. + pub show_main_menu: bool, + + /// flips true once the Playback capture mode setup wizard completes. + pub capture_setup_complete: bool, pub engine: Option, pub worker: AnalyzerWorker, pub library_worker: LibraryWorker, pub frame_data: Arc>, pub current_palette: Option>>, pub settings: Settings, + + /// stash of the other mode's settings. swapped with `settings` on every mode transition. + pub settings_inactive: Settings, + pub show_settings: bool, /// shell-side picker request flag drained by the iOS host once per tick. pub pending_pick: u8, + /// pending playback-capture action flag for the host shell (0 idle, 1 start, 2 stop). + pub pending_capture_action: u8, + + /// flag drained by the Android shell to request Picture-in-Picture entry. + pub pending_pip_request: bool, + + /// pending capture-mode transport command for the host shell (0 idle, 1 toggle, 2 prev, 3 next). + pub pending_capture_transport: u8, + + /// pending capture-mode seek fraction in 0..=1, or negative to mean idle. + pub pending_capture_seek: f32, + + /// flag drained by the Android shell to open the system Notification Listener settings. + pub pending_open_listener_settings: bool, + + /// flag drained by the host shell to persist current settings (both slots) to disk. + pub pending_persist_settings: bool, + /// monotonic id stamped onto every decode request, matched against returning results. pub current_decode_id: u64, pub next_decode_id: u64, @@ -58,7 +129,7 @@ pub struct App { } /// every visualizer toggle, slider value, and DSP parameter the settings panel exposes. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] pub struct Settings { pub glass: bool, pub entropy_on: bool, @@ -87,7 +158,7 @@ impl Default for Settings { entropy_on: false, album_colors: false, mirrored: true, - inverted: false, + inverted: true, entropy_strength: 0.0, hue: 0.9, contrast: 1.0, @@ -124,6 +195,13 @@ pub enum Message { ToggleImmersive, ToggleChrome, ToggleSettings, + SetPlaybackMode(PlaybackMode), + OpenLocalMode, + OpenCaptureMode, + ReturnToMainMenu, + EnterPip, + OpenListenerSettings, + ResetSettings, NoOp, SettingsScrolled(AbsoluteOffset), SetGlass(bool), @@ -147,10 +225,49 @@ pub enum Message { PickedFiles(Vec), } +/// returns the platform-tuned (fft, hop, num_bins) triple for Playback capture mode. android/desktop keep the existing 26-bin default. +#[cfg(target_os = "ios")] +fn capture_dsp_defaults() -> (usize, usize, usize) { + (4096, 1024, 15) +} + +#[cfg(target_os = "android")] +fn capture_dsp_defaults() -> (usize, usize, usize) { + (8192, 64, 26) +} + +#[cfg(not(any(target_os = "ios", target_os = "android")))] +fn capture_dsp_defaults() -> (usize, usize, usize) { + (8192, 64, 26) +} + +/// returns a fresh Settings populated with the platform-tuned defaults for the given playback mode. +pub fn settings_for_mode(mode: PlaybackMode) -> Settings { + match mode { + PlaybackMode::Local => Settings::default(), + PlaybackMode::Capture => { + let mut s = Settings::default(); + let (fft, hop, bins) = capture_dsp_defaults(); + s.fft = fft as u32; + s.hop = hop as u32; + s.num_bins = bins as u32; + s + } + } +} + +/// json wire shape persisted by the host shell, holding one Settings per mode. +#[derive(serde::Serialize, serde::Deserialize)] +struct PersistedSettings { + local: Settings, + capture: Settings, +} + impl App { /// builds the analyzer worker, library worker, audio engine, and seeds defaults. pub fn new(device: wgpu::Device, queue: wgpu::Queue) -> Self { - let settings = Settings::default(); + let settings = settings_for_mode(PlaybackMode::Local); + let settings_inactive = settings_for_mode(PlaybackMode::Capture); let worker = AnalyzerWorker::spawn(device, queue); worker.set_num_bins(settings.num_bins as usize); worker.set_dsp_params(settings.fft as usize, settings.hop as usize); @@ -162,6 +279,10 @@ impl App { selected_track: None, playing: false, immersive: false, + playback_mode: PlaybackMode::Local, + capture: CaptureState::default(), + show_main_menu: true, + capture_setup_complete: false, engine: AudioEngine::new() .map_err(|e| eprintln!("yr_crystals: audio engine unavailable: {e}")) .ok(), @@ -170,8 +291,15 @@ impl App { frame_data: Arc::new(Vec::new()), current_palette: None, settings, + settings_inactive, show_settings: false, pending_pick: 0, + pending_capture_action: 0, + pending_pip_request: false, + pending_capture_transport: 0, + pending_capture_seek: -1.0, + pending_open_listener_settings: false, + pending_persist_settings: false, current_decode_id: 0, next_decode_id: 0, track_loading: false, @@ -185,6 +313,167 @@ impl App { } } + /// sets the top-level playback mode. swaps the active/inactive settings slots and re-applies the worker config when the mode actually changes. + pub fn set_playback_mode(&mut self, mode: PlaybackMode) { + let prev = self.playback_mode; + self.playback_mode = mode; + let analyzer_mode = match mode { + PlaybackMode::Local => AnalyzerMode::Track, + PlaybackMode::Capture => AnalyzerMode::Live, + }; + self.worker.set_mode(analyzer_mode); + if prev != mode { + std::mem::swap(&mut self.settings, &mut self.settings_inactive); + self.apply_settings_to_worker(); + self.pending_capture_action = match mode { + PlaybackMode::Capture => 1, + PlaybackMode::Local => 2, + }; + } + } + + /// pushes every worker-relevant settings value to the analyzer worker. + pub fn apply_settings_to_worker(&self) { + self.worker.set_num_bins(self.settings.num_bins as usize); + self.worker.set_dsp_params(self.settings.fft as usize, self.settings.hop as usize); + self.worker.set_smoothing( + self.settings.granularity, + self.settings.detail, + self.settings.strength, + ); + self.worker.set_gpu_blend(self.settings.gpu_blend); + } + + /// replaces the active settings slot with the platform defaults for the current mode and re-applies them. + pub fn reset_settings_to_defaults(&mut self) { + self.settings = settings_for_mode(self.playback_mode); + self.apply_settings_to_worker(); + } + + /// drains the pending-persist flag set after a settings change. + pub fn take_pending_persist_settings(&mut self) -> bool { + let v = self.pending_persist_settings; + self.pending_persist_settings = false; + v + } + + /// serializes both settings slots as JSON, keyed by mode. used by the host shell to write to disk. + pub fn settings_json(&self) -> String { + let (local, capture) = match self.playback_mode { + PlaybackMode::Local => (self.settings, self.settings_inactive), + PlaybackMode::Capture => (self.settings_inactive, self.settings), + }; + let snapshot = PersistedSettings { local, capture }; + serde_json::to_string(&snapshot).unwrap_or_default() + } + + /// applies a settings JSON blob (both mode slots) loaded by the host shell at startup. silent no-op on parse failure. + pub fn apply_settings_json(&mut self, json: &str) { + let Ok(parsed) = serde_json::from_str::(json) else { return }; + match self.playback_mode { + PlaybackMode::Local => { + self.settings = parsed.local; + self.settings_inactive = parsed.capture; + } + PlaybackMode::Capture => { + self.settings = parsed.capture; + self.settings_inactive = parsed.local; + } + } + self.apply_settings_to_worker(); + } + + /// returns the capture-action flag and clears the slot in one step. + pub fn take_pending_capture_action(&mut self) -> u8 { + let v = self.pending_capture_action; + self.pending_capture_action = 0; + v + } + + /// drains the pending Picture-in-Picture request flag for the host shell. + pub fn take_pending_pip_request(&mut self) -> bool { + let v = self.pending_pip_request; + self.pending_pip_request = false; + v + } + + /// re-applies Playback capture mode onto a freshly created viewport. skips firing the start action. + pub fn restore_capture_session(&mut self) { + self.playback_mode = PlaybackMode::Capture; + self.worker.set_mode(AnalyzerMode::Live); + self.show_main_menu = false; + self.capture_setup_complete = true; + } + + /// updates the capture stats. forwards the chunk to the analyzer worker. + pub fn push_capture_pcm(&mut self, samples: &[f32], sample_rate: u32, channels: u32) { + if samples.is_empty() || channels == 0 { + return; + } + self.capture.sample_rate = sample_rate; + let frames = (samples.len() / channels as usize) as u64; + self.capture.samples_received = self.capture.samples_received.saturating_add(frames); + self.worker.push_live_pcm(samples.to_vec(), sample_rate, channels); + } + + /// overwrites the four now-playing metadata fields in App.capture. + pub fn set_capture_metadata( + &mut self, + title: Option, + artist: Option, + position_ms: u64, + duration_ms: u64, + ) { + self.capture.title = title; + self.capture.artist = artist; + self.capture.position_ms = position_ms; + self.capture.duration_ms = duration_ms; + self.capture.has_session = true; + } + + /// stores the foreign-session playback flag and live position from the host MediaController callback. + pub fn set_capture_playback_state(&mut self, playing: bool, position_ms: u64) { + self.capture.playing = playing; + self.capture.position_ms = position_ms; + self.capture.has_session = true; + } + + /// resets every Playback capture metadata field. invoked when the host loses its primary MediaController. + pub fn clear_capture_session(&mut self) { + self.capture.title = None; + self.capture.artist = None; + self.capture.position_ms = 0; + self.capture.duration_ms = 0; + self.capture.playing = false; + self.capture.has_session = false; + } + + /// stores the notification-listener access flag pushed by the host shell. + pub fn set_notification_access(&mut self, granted: bool) { + self.capture.notification_access = granted; + } + + /// drains the pending request to open the system Notification Listener settings. + pub fn take_pending_open_listener_settings(&mut self) -> bool { + let v = self.pending_open_listener_settings; + self.pending_open_listener_settings = false; + v + } + + /// drains the pending capture-mode transport command flag. + pub fn take_pending_capture_transport(&mut self) -> u8 { + let v = self.pending_capture_transport; + self.pending_capture_transport = 0; + v + } + + /// drains the pending capture-mode seek fraction. negative means idle. + pub fn take_pending_capture_seek(&mut self) -> f32 { + let v = self.pending_capture_seek; + self.pending_capture_seek = -1.0; + v + } + /// returns the picker flag and clears the slot in one step. pub fn take_pending_pick(&mut self) -> u8 { let p = self.pending_pick; @@ -509,6 +798,26 @@ impl App { /// dispatches a UI message into the matching state mutation and worker call. pub fn update(&mut self, msg: Message) { + let settings_changed = matches!( + msg, + Message::SetGlass(_) + | Message::SetEntropy(_) + | Message::SetAlbumColors(_) + | Message::SetMirrored(_) + | Message::SetInverted(_) + | Message::SetEntropyStrength(_) + | Message::SetHue(_) + | Message::SetContrast(_) + | Message::SetBrightness(_) + | Message::SetNumBins(_) + | Message::SetFft(_) + | Message::SetHop(_) + | Message::SetGranularity(_) + | Message::SetDetail(_) + | Message::SetStrength(_) + | Message::SetGpuBlend(_) + | Message::ResetSettings, + ); match msg { Message::OpenFolder => { #[cfg(all(not(target_os = "ios"), not(target_os = "android")))] @@ -558,37 +867,49 @@ impl App { self.load_index(idx); } } - Message::TogglePlayPause => { - if self.selected_track.is_some() { - self.playing = !self.playing; + Message::TogglePlayPause => match self.playback_mode { + PlaybackMode::Local => { + if self.selected_track.is_some() { + self.playing = !self.playing; + if let Some(eng) = &self.engine { + if self.playing { eng.play(); } else { eng.pause(); } + } + } + } + PlaybackMode::Capture => self.pending_capture_transport = 1, + }, + Message::Next => match self.playback_mode { + PlaybackMode::Local => { + if let Some(i) = self.selected_track { + if i + 1 < self.library.tracks.len() { + let next = i + 1; + self.selected_track = Some(next); + self.load_index(next); + } + } + } + PlaybackMode::Capture => self.pending_capture_transport = 3, + }, + Message::Prev => match self.playback_mode { + PlaybackMode::Local => { + if let Some(i) = self.selected_track { + if i > 0 { + let prev = i - 1; + self.selected_track = Some(prev); + self.load_index(prev); + } + } + } + PlaybackMode::Capture => self.pending_capture_transport = 2, + }, + Message::Seek(pos) => match self.playback_mode { + PlaybackMode::Local => { if let Some(eng) = &self.engine { - if self.playing { eng.play(); } else { eng.pause(); } + eng.seek_normalised(pos.clamp(0.0, 1.0)); } } - } - Message::Next => { - if let Some(i) = self.selected_track { - if i + 1 < self.library.tracks.len() { - let next = i + 1; - self.selected_track = Some(next); - self.load_index(next); - } - } - } - Message::Prev => { - if let Some(i) = self.selected_track { - if i > 0 { - let prev = i - 1; - self.selected_track = Some(prev); - self.load_index(prev); - } - } - } - Message::Seek(pos) => { - if let Some(eng) = &self.engine { - eng.seek_normalised(pos.clamp(0.0, 1.0)); - } - } + PlaybackMode::Capture => self.pending_capture_seek = pos.clamp(0.0, 1.0), + }, Message::ToggleImmersive => self.immersive = !self.immersive, Message::ToggleChrome => { if self.immersive { @@ -610,6 +931,19 @@ impl App { self.restore_settings_scroll = true; } } + Message::SetPlaybackMode(mode) => self.set_playback_mode(mode), + Message::OpenLocalMode => { + self.set_playback_mode(PlaybackMode::Local); + self.show_main_menu = false; + } + Message::OpenCaptureMode => { + self.set_playback_mode(PlaybackMode::Capture); + self.show_main_menu = false; + } + Message::ReturnToMainMenu => self.show_main_menu = true, + Message::EnterPip => self.pending_pip_request = true, + Message::OpenListenerSettings => self.pending_open_listener_settings = true, + Message::ResetSettings => self.reset_settings_to_defaults(), Message::NoOp => {} Message::SettingsScrolled(off) => self.settings_scroll = off, Message::SetGlass(on) => self.settings.glass = on, @@ -668,6 +1002,9 @@ impl App { self.worker.set_gpu_blend(self.settings.gpu_blend); } } + if settings_changed { + self.pending_persist_settings = true; + } } /// queues a fresh decode for the given track, stamping a new request id. @@ -697,7 +1034,11 @@ impl App { /// builds the iced widget tree for the current frame. pub fn view(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { - player::view(self) + if self.show_main_menu { + super::main_menu::view(self) + } else { + player::view(self) + } } } diff --git a/src/ui/main_menu.rs b/src/ui/main_menu.rs new file mode 100644 index 0000000..71fd6eb --- /dev/null +++ b/src/ui/main_menu.rs @@ -0,0 +1,91 @@ +use iced_wgpu::core::{Alignment, Background, Border, Color, Element, Length, Padding, Theme}; +use iced_widget::{ + button::{self, Status as ButtonStatus}, + column, container, text, Space, +}; + +use super::app::{App, Message}; +use super::theme::palette; + +/// renders the top-level mode-selection screen. +pub fn view(_app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let title = text("YrXtls").size(56).color(palette::text()); + let subtitle = text("Choose a mode").size(16).color(palette::text_dim()); + + let local = mode_button( + "Local mode", + "Play files from your device.", + Message::OpenLocalMode, + ); + let capture = mode_button( + "Playback capture mode", + "Visualize whatever audio your system is playing.", + Message::OpenCaptureMode, + ); + + let body = column![ + title, + subtitle, + Space::new().height(Length::Fixed(28.0)), + local, + capture, + ] + .spacing(16) + .align_x(Alignment::Center); + + container(body) + .center_x(Length::Fill) + .center_y(Length::Fill) + .width(Length::Fill) + .height(Length::Fill) + .style(background_style) + .into() +} + +/// builds one of the main-menu mode buttons with a title line and a one-line description below. +fn mode_button<'a>( + title: &'a str, + subtitle: &'a str, + msg: Message, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let content = column![ + text(title).size(22).color(palette::text()), + text(subtitle).size(13).color(palette::text_dim()), + ] + .spacing(6); + + iced_widget::button(content) + .padding(Padding::from([20, 28])) + .width(Length::Fixed(420.0)) + .on_press(msg) + .style(mode_button_style) + .into() +} + +/// dark card style for the main-menu mode buttons with a brighter background on hover. +fn mode_button_style(_t: &Theme, status: ButtonStatus) -> button::Style { + let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed); + let bg = if hovered { + Color { r: 0.10, g: 0.10, b: 0.12, a: 1.0 } + } else { + Color { r: 0.05, g: 0.05, b: 0.07, a: 1.0 } + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: palette::text(), + border: Border { + color: palette::border(), + width: 1.0, + radius: 12.0.into(), + }, + ..Default::default() + } +} + +/// pure-black full-screen background for the menu container. +fn background_style(_t: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(palette::bg())), + ..Default::default() + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bd84e55..88342c8 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,7 @@ /// top-level App state, message enum, and update loop. pub mod app; +/// first-launch screen with the Local / Playback capture mode selection. +pub mod main_menu; /// iced widget tree for sidebar, transport, settings overlay, and visualizer surface. pub mod player; /// dark palette tokens and the compositor clear color. diff --git a/src/ui/player.rs b/src/ui/player.rs index 84951fe..fb097db 100644 --- a/src/ui/player.rs +++ b/src/ui/player.rs @@ -22,7 +22,7 @@ const LOADING_DEG_PER_SEC: f32 = 120.0; use crate::library::Track; -use super::app::{App, Message}; +use super::app::{App, Message, PlaybackMode}; use super::theme::palette; pub const SIDEBAR_W: f32 = 280.0; @@ -108,7 +108,8 @@ fn library_progress_strip(app: &App) -> Element<'_, Message, Theme, iced_wgpu::R /// builds the title row plus folder, file, and settings chip buttons. fn top_bar(_app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { - let title = text("Yr Xtals").size(16).color(palette::text()); + let title = mouse_area(text("Yr Xtals").size(16).color(palette::text())) + .on_press(Message::ReturnToMainMenu); let folder_btn = chip_button("Folder", Message::OpenFolder); #[cfg(target_os = "ios")] let file_btn = chip_button("Library", Message::OpenFile); @@ -277,6 +278,7 @@ fn track_row_owned( /// shader-backed visualizer surface stacked under loading and empty-state overlays. fn visualiser(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let no_track = app.selected_track.is_none(); + let needs_track_prompt = no_track && app.playback_mode == PlaybackMode::Local; let viz: Element<'_, Message, Theme, iced_wgpu::Renderer> = shader( VisualizerProgram::new( @@ -291,7 +293,7 @@ fn visualiser(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let overlay: Element<'_, Message, Theme, iced_wgpu::Renderer> = if app.track_loading { loading_overlay() - } else if no_track { + } else if needs_track_prompt { centered_overlay_text("Pick a track from the sidebar", palette::text()) } else { Space::new().width(Length::Fill).height(Length::Fill).into() @@ -401,9 +403,22 @@ pub const SETTINGS_W: f32 = 340.0; fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let s = &app.settings; + let header = row![ + text("Settings").size(15).color(palette::text()), + Space::new().width(Length::Fill), + chip_button( + match app.playback_mode { + PlaybackMode::Local => "Reset Local", + PlaybackMode::Capture => "Reset Capture", + }, + Message::ResetSettings, + ), + ] + .align_y(iced_wgpu::core::Alignment::Center); + let body = column![ Space::new().height(Length::Fixed(TOP_BAR_H + 4.0)), - text("Settings").size(15).color(palette::text()), + header, Space::new().height(Length::Fixed(10.0)), section_label("style"), toggle_row("glass", s.glass, Message::SetGlass), @@ -639,8 +654,22 @@ fn settings_panel_style(_theme: &Theme) -> container::Style { } } -/// bottom transport bar with skip, play/pause, scrub slider, and position readout. +/// bottom transport bar. file-mode renders the local skip/play/scrub set; capture mode renders the metadata strip with a PiP toggle. fn transport(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let bar: Element<'_, Message, Theme, iced_wgpu::Renderer> = match app.playback_mode { + PlaybackMode::Local => local_transport_bar(app), + PlaybackMode::Capture => capture_transport_bar(app), + }; + + container(bar) + .width(Length::Fill) + .height(Length::Fixed(TRANSPORT_H)) + .style(panel_style) + .into() +} + +/// row of skip, play/pause, scrub slider, and position readout for local file playback. +fn local_transport_bar(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let prev = transport_button(BSKIP_SVG, Message::Prev, app.selected_track.is_some()); let engine_playing = app .engine @@ -659,7 +688,7 @@ fn transport(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { .size(11) .color(palette::text_dim()); - let bar = row![ + row![ prev, Space::new().width(Length::Fixed(6.0)), play, @@ -672,13 +701,125 @@ fn transport(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { ] .padding(Padding::from([0, 16])) .align_y(iced_wgpu::core::Alignment::Center) - .height(Length::Fill); + .height(Length::Fill) + .into() +} - container(bar) - .width(Length::Fill) - .height(Length::Fixed(TRANSPORT_H)) - .style(panel_style) - .into() +/// capture-mode transport row. dispatches into the session-active layout or one of the two awareness states (no access / no session). +fn capture_transport_bar(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + #[cfg(target_os = "android")] + if !app.capture.notification_access { + return capture_no_access_row(app); + } + if !app.capture.has_session { + return capture_no_session_row(app); + } + capture_session_row(app) +} + +/// status row shown when notification listener access has not been granted yet. taps open Android's listener settings. +#[cfg(target_os = "android")] +fn capture_no_access_row<'a>(_app: &App) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let hint = text("Now-playing controls require notification access.") + .size(13) + .color(palette::text_dim()); + row![ + hint, + Space::new().width(Length::Fill), + chip_button("Enable", Message::OpenListenerSettings), + Space::new().width(Length::Fixed(8.0)), + chip_button("PiP", Message::EnterPip), + ] + .padding(Padding::from([0, 16])) + .align_y(iced_wgpu::core::Alignment::Center) + .height(Length::Fill) + .into() +} + +/// status row shown after listener access is granted while no foreign media session is publishing. +fn capture_no_session_row<'a>(_app: &App) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let hint = text("Play something nearby through your speaker to see it react.") + .size(13) + .color(palette::text_dim()); + row![ + hint, + Space::new().width(Length::Fill), + chip_button("PiP", Message::EnterPip), + ] + .padding(Padding::from([0, 16])) + .align_y(iced_wgpu::core::Alignment::Center) + .height(Length::Fill) + .into() +} + +/// full transport row used while a foreign MediaSession is active: metadata, prev/play/next, scrub, time, PiP toggle. +fn capture_session_row(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let title = app + .capture + .title + .clone() + .unwrap_or_else(|| String::from("Capturing system audio")); + let artist = app.capture.artist.clone().unwrap_or_default(); + + let title_label = text(title).size(14).color(palette::text()); + let artist_label = text(artist).size(11).color(palette::text_dim()); + let meta = column![title_label, artist_label].spacing(2).width(Length::Fixed(200.0)); + + let prev = transport_button(BSKIP_SVG, Message::Prev, true); + let play_glyph = if app.capture.playing { PAUSE_SVG } else { PLAY_SVG }; + let play = transport_button(play_glyph, Message::TogglePlayPause, true); + let next = transport_button(FSKIP_SVG, Message::Next, true); + + let dur = app.capture.duration_ms.max(1) as f32; + let pos_fraction = (app.capture.position_ms as f32 / dur).clamp(0.0, 1.0); + let scrub_enabled = app.capture.duration_ms > 0; + let scrub = if scrub_enabled { + slider(0.0..=1.0, pos_fraction, Message::Seek) + .step(0.001_f32) + .width(Length::Fill) + } else { + slider(0.0..=1.0, 0.0_f32, |_| Message::NoOp) + .step(0.001_f32) + .width(Length::Fill) + }; + let pos_label = text(format_capture_time(app.capture.position_ms, app.capture.duration_ms)) + .size(11) + .color(palette::text_dim()); + + row![ + meta, + Space::new().width(Length::Fixed(12.0)), + prev, + Space::new().width(Length::Fixed(6.0)), + play, + Space::new().width(Length::Fixed(6.0)), + next, + Space::new().width(Length::Fixed(16.0)), + scrub, + Space::new().width(Length::Fixed(12.0)), + pos_label, + Space::new().width(Length::Fixed(12.0)), + chip_button("PiP", Message::EnterPip), + ] + .padding(Padding::from([0, 16])) + .align_y(iced_wgpu::core::Alignment::Center) + .height(Length::Fill) + .into() +} + +/// formats a position/duration pair as `m:ss / m:ss`, falling back to a blank duration on unknown length. +fn format_capture_time(position_ms: u64, duration_ms: u64) -> String { + fn fmt(ms: u64) -> String { + let total_s = ms / 1000; + let m = total_s / 60; + let s = total_s % 60; + format!("{m}:{s:02}") + } + if duration_ms == 0 { + fmt(position_ms) + } else { + format!("{} / {}", fmt(position_ms), fmt(duration_ms)) + } } /// small soft accent-tinted text-label button. diff --git a/src/viewport.rs b/src/viewport.rs index f19efb5..2d42599 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -386,6 +386,99 @@ impl ViewportHandle { pub fn take_pending_pick(&mut self) -> u8 { self.state.take_pending_pick() } + + /// drains the App's pending playback-capture action flag. + pub fn take_pending_capture_action(&mut self) -> u8 { + self.state.take_pending_capture_action() + } + + /// drains the App's pending Picture-in-Picture request flag. + pub fn take_pending_pip_request(&mut self) -> bool { + self.state.take_pending_pip_request() + } + + /// re-applies Playback capture mode onto a freshly created viewport without firing the start action. + pub fn restore_capture_session(&mut self) { + self.state.restore_capture_session(); + self.needs_redraw = true; + } + + /// switches the top-level playback mode from a u32 wire value (0=Local, 1=Capture). + pub fn set_playback_mode(&mut self, mode: u32) { + self.state.set_playback_mode(crate::ui::app::PlaybackMode::from_u32(mode)); + self.needs_redraw = true; + } + + /// forwards captured PCM from the host shell into the App. + pub fn push_capture_pcm(&mut self, samples: &[f32], sample_rate: u32, channels: u32) { + self.state.push_capture_pcm(samples, sample_rate, channels); + } + + /// updates the displayed playback-capture metadata from the host shell. + pub fn set_capture_metadata( + &mut self, + title: Option, + artist: Option, + position_ms: u64, + duration_ms: u64, + ) { + self.state.set_capture_metadata(title, artist, position_ms, duration_ms); + self.needs_redraw = true; + } + + /// stores the foreign-session playback flag and live position from the host MediaController callback. + pub fn set_capture_playback_state(&mut self, playing: bool, position_ms: u64) { + self.state.set_capture_playback_state(playing, position_ms); + self.needs_redraw = true; + } + + /// resets every Playback capture metadata field. + pub fn clear_capture_session(&mut self) { + self.state.clear_capture_session(); + self.needs_redraw = true; + } + + /// stores the notification-listener access flag pushed by the host shell. + pub fn set_notification_access(&mut self, granted: bool) { + self.state.set_notification_access(granted); + self.needs_redraw = true; + } + + /// drains the pending request to open the system Notification Listener settings. + pub fn take_pending_open_listener_settings(&mut self) -> bool { + self.state.take_pending_open_listener_settings() + } + + /// drains the pending capture-mode transport command flag. + pub fn take_pending_capture_transport(&mut self) -> u8 { + self.state.take_pending_capture_transport() + } + + /// returns a cloneable handle that pushes live PCM directly into the analyzer worker. + pub fn pcm_sender(&self) -> crate::analyzer_worker::PcmSender { + self.state.worker.pcm_sender() + } + + /// serializes both settings slots as JSON for the host shell to persist. + pub fn settings_json(&self) -> String { + self.state.settings_json() + } + + /// applies a settings JSON blob loaded by the host shell at startup. + pub fn apply_settings_json(&mut self, json: &str) { + self.state.apply_settings_json(json); + self.needs_redraw = true; + } + + /// drains the pending-persist flag set after a settings change. + pub fn take_pending_persist_settings(&mut self) -> bool { + self.state.take_pending_persist_settings() + } + + /// drains the pending capture-mode seek fraction. + pub fn take_pending_capture_seek(&mut self) -> f32 { + self.state.take_pending_capture_seek() + } } /// builds a wgpu instance restricted to the platform's preferred backend and obtains a surface from the caller. @@ -509,7 +602,8 @@ fn render(handle: &mut ViewportHandle) { let animating = playing || handle.state.track_loading - || handle.state.library_progress.is_some(); + || handle.state.library_progress.is_some() + || handle.state.playback_mode == crate::ui::app::PlaybackMode::Capture; if !animating && !handle.needs_redraw && !pending { return; } diff --git a/src/visualizer/state.rs b/src/visualizer/state.rs index a863309..d2cd4a6 100644 --- a/src/visualizer/state.rs +++ b/src/visualizer/state.rs @@ -111,6 +111,15 @@ impl VisState { }); } } + + // flushes the rightmost bin to the viewport edge. + let max_x = out.iter().map(|b| b.log_x).fold(f32::NEG_INFINITY, f32::max); + if max_x.is_finite() && max_x > 0.0 { + let inv = 1.0 / max_x; + for b in out.iter_mut() { + b.log_x *= inv; + } + } } /// derives a hue from spectral midpoint and mean amplitude, smoothed by a circular running mean. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6e9bb36..0363fcb 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -17,7 +17,11 @@ fn main() -> ExitCode { } let extra_args: Vec<&String> = args.iter().skip(1).collect(); - let (action, platform) = parse(cmd); + let (action, platform) = if cmd == "release-playstore" { + ("release-playstore".to_string(), "android".to_string()) + } else { + parse(cmd) + }; let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() @@ -119,6 +123,7 @@ fn print_help() { eprintln!(" xcodeproj-ios generate ios/YrXtals.xcodeproj via xcodegen"); eprintln!(" release-ios build an App Store-signed .ipa for Transporter"); eprintln!(" release-macos build a Mac App Store-signed .pkg for Transporter"); + eprintln!(" release-playstore build a release-signed .aab for Google Play (org.elseif.yrxtals)"); eprintln!(" clean wipe build/, target/, /tmp/yr_crystals-target"); eprintln!(" clean-ios wipe iOS build dirs + generated assets (--cargo also runs cargo clean)"); eprintln!(" clean-android wipe android build dirs + generated assets (--cargo also runs cargo clean)");