commit dc9ddedd62b0bca42876995d81469f07dc40893a Author: jess Date: Fri May 8 23:47:42 2026 -0700 Init diff --git a/.android-sdk-packages b/.android-sdk-packages new file mode 100644 index 0000000..361ca98 --- /dev/null +++ b/.android-sdk-packages @@ -0,0 +1,6 @@ +# android sdk coordinates installed via `sdkmanager --install` +# scripts/android/build.sh consumes this file +platform-tools +platforms;android-35 +build-tools;35.0.1 +ndk;27.3.13750724 diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..d1a6c40 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +[alias] +xtask = "run --release --package xtask --" + +# build target lives on /tmp (internal SSD) instead of the project root (external spin disk). +[build] +target-dir = "/tmp/yr_crystals-target" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85d5d92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +build/ +build_android/ +build_ios/ +build_macos/ +build_windows/ +icons +*.png +vamp-plugin/ +libraries/loop-tempo-estimator/ +tests/ +ADC-2024/ +*.json +readme.md +vamp-plugin-sdk.cmake +*.keystore +*.jks +.yrxtls-ios-target +.yrxtls-android-target +target/ +*.so +*.a +*.o +*.dynlib + +ios/YrXtals.xcodeproj/ +ios/Assets.xcassets/AppIcon.appiconset/ +android/app/src/main/res/mipmap-hdpi/ +android/app/src/main/res/mipmap-mdpi/ +android/app/src/main/res/mipmap-xhdpi/ +android/app/src/main/res/mipmap-xxhdpi/ +android/app/src/main/res/mipmap-xxxhdpi/ +android/app/src/main/res/mipmap-anydpi-v26/ +android/app/src/main/jniLibs/ +*.xcuserstate +xcuserdata/ diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 0000000..22b8e68 --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1,4 @@ +# versions sdkman can install for this project +# usage: cd into project root, run `sdk env install` then `sdk env` +java=17.0.19-tem +gradle=8.9 diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..759460e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,76 @@ +[workspace] +members = [".", "xtask"] +default-members = ["."] +resolver = "2" + +[package] +name = "yr_crystals" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" +crate-type = ["rlib", "staticlib", "cdylib"] + +[[bin]] +name = "yr_crystals" +path = "src/main.rs" + +[dependencies] +rustfft = "6" +num-complex = "0.4" + +# core audio +cpal = "0.16" +ringbuf = "0.4" +crossbeam-channel = "0.5" +rubato = "0.14" + +# file decoding +symphonia = { version = "0.5", default-features = false, features = [ + "pcm", "wav", "flac", "ogg", "vorbis", + "mp3", "aac", "alac", "isomp4", "adpcm", +] } + +# iced/wgpu +wgpu = "27" +raw-window-handle = "0.6" +pollster = "0.4" +smol_str = "0.2" +iced_wgpu = "0.14" +iced_graphics = "0.14" +iced_runtime = "0.14" +iced_widget = { version = "0.14", features = ["wgpu", "image", "canvas", "svg", "lazy"] } + +# tag and cover-art reading +lofty = "0.22" +image = "0.25" + +# visualizer wgpu +bytemuck = { version = "1", features = ["derive"] } + +# concurrency +rayon = "1.10" +arc-swap = "1.7" + +# desktop shell deps +[target.'cfg(all(not(target_os = "ios"), not(target_os = "android")))'.dependencies] +winit = "0.30" +rfd = "0.15" + +# android shell deps +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" +ndk = "0.9" +ndk-context = "0.1" +android_logger = "0.14" +log = "0.4" + +[profile.release] +lto = "thin" +codegen-units = 1 + +[profile.release-debug] +inherits = "release" +debug-assertions = true diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..a38c8a4 --- /dev/null +++ b/LICENCE @@ -0,0 +1,12 @@ +This is free to use, without conditions. + +There is no licence here on purpose. Individuals, students, hobbyists — take what +you need, make it yours, don't think twice. You'd flatter me. + +The absence of a licence is deliberate. A licence is a legal surface. Words can be +reinterpreted, and corporations employ lawyers whose job is exactly that. Silence is +harder to exploit than language. If a company wants to use this, the lack of explicit +permission makes it just inconvenient enough to matter. + +This won't change the balance of power. But it shifts the weight, even slightly, away +from the system that co-opts open work for closed profit. That's enough for me. diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md new file mode 100644 index 0000000..1a829b7 --- /dev/null +++ b/PRIVACY_POLICY.md @@ -0,0 +1,21 @@ +# Privacy Policy + +**Yr Xtals** does not collect, store, or transmit any personal data. + +## Device Permissions + +The app may request access to the following device features solely for local, on-device functionality: + +- **Music Library** — To play audio tracks from your library. + +No data from these sources is recorded, uploaded, or shared. All processing happens entirely on your device. + +## Third-Party Services + +Yr Xtals does not use analytics, advertising, tracking, or any third-party services. + +## Contact + +If you have questions about this policy, contact: **[your email]** + +*Last updated: March 1, 2026* diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..82426b1 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +local.properties +.idea/ +*.iml +captures/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..eb5687e --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,76 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "org.elseif.yrxtals" + compileSdk = 35 + ndkVersion = "27.3.13750724" + + defaultConfig { + applicationId = "org.elseif.yrxtals" + minSdk = 28 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + ndk { + abiFilters += "arm64-v8a" + } + } + + signingConfigs { + getByName("debug") { + storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore") + } + } + + buildTypes { + getByName("debug") { + isMinifyEnabled = false + isJniDebuggable = true + } + getByName("release") { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("debug") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + create("releaseDebug") { + initWith(getByName("release")) + isDebuggable = true + isJniDebuggable = true + matchingFallbacks += listOf("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + sourceSets { + getByName("main") { + jniLibs.srcDirs("src/main/jniLibs") + } + } + + packaging { + jniLibs { + useLegacyPackaging = false + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-ktx:1.9.2") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.documentfile:documentfile:1.0.1") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..dccc446 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +-keep class org.elseif.yrxtals.NativeBridge { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..06e9480 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt b/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt new file mode 100644 index 0000000..81140cf --- /dev/null +++ b/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt @@ -0,0 +1,137 @@ +package org.elseif.yrxtals + +import android.content.Context +import android.util.AttributeSet +import android.view.Choreographer +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.SurfaceHolder +import android.view.SurfaceView + +/// hosts the rust-driven wgpu surface inside an android SurfaceView and forwards input through jni. +class IcedSurfaceView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : SurfaceView(context, attrs, defStyleAttr), SurfaceHolder.Callback, Choreographer.FrameCallback { + + var controller: LibraryController? = null + + private var handle: Long = 0 + private var frameCallbackPosted: Boolean = false + private var paused: Boolean = false + + val viewportHandle: Long get() = handle + + private val density: Float get() = resources.displayMetrics.density.coerceAtLeast(1.0f) + + init { + holder.addCallback(this) + isFocusable = true + isFocusableInTouchMode = true + } + + /// allocates the rust viewport bound to the new surface, sized in logical dp at the screen density. + 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) { + return + } + startFrames() + requestFocus() + } + + /// reconfigures the wgpu surface for a new bounds, expressed in logical dp. + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + if (handle == 0L) return + val s = density + NativeBridge.viewportResize(handle, width / s, height / s, s) + } + + /// drops the viewport on surface detach. + override fun surfaceDestroyed(holder: SurfaceHolder) { + stopFrames() + if (handle != 0L) { + NativeBridge.viewportDestroy(handle) + handle = 0 + } + } + + /// pauses the choreographer loop while the activity is backgrounded. + fun onActivityPause() { + paused = true + stopFrames() + } + + /// resumes the choreographer loop on activity foreground if a surface is alive. + fun onActivityResume() { + paused = false + if (handle != 0L) startFrames() + } + + private fun startFrames() { + if (frameCallbackPosted) return + frameCallbackPosted = true + Choreographer.getInstance().postFrameCallback(this) + } + + private fun stopFrames() { + if (!frameCallbackPosted) return + Choreographer.getInstance().removeFrameCallback(this) + frameCallbackPosted = false + } + + /// renders one frame and surfaces any pending picker request to the controller. + override fun doFrame(frameTimeNanos: Long) { + frameCallbackPosted = false + if (handle == 0L || paused) return + NativeBridge.viewportRender(handle) + val pending = NativeBridge.viewportTakePendingPick(handle) + if (pending != 0) { + controller?.presentPicker(pending) + } + startFrames() + } + + /// translates a single-pointer motion event into the rust touch lifecycle, in logical dp. + override fun onTouchEvent(event: MotionEvent): Boolean { + if (handle == 0L) return false + val s = density + val x = event.x / s + val y = event.y / s + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + NativeBridge.viewportTouchEvent(handle, x, y, true, false) + requestFocus() + } + MotionEvent.ACTION_MOVE -> { + NativeBridge.viewportTouchEvent(handle, x, y, true, true) + } + MotionEvent.ACTION_UP -> { + NativeBridge.viewportTouchEvent(handle, x, y, false, false) + } + MotionEvent.ACTION_CANCEL -> { + NativeBridge.viewportTouchEvent(handle, x, y, false, false) + } + } + return true + } + + /// forwards a hardware-keyboard key down through the rust event queue. + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (handle == 0L) return super.onKeyDown(keyCode, event) + val text = if (event.unicodeChar != 0) event.unicodeChar.toChar().toString() else null + NativeBridge.viewportKeyEvent(handle, keyCode, event.metaState, true, text) + return true + } + + /// forwards a hardware-keyboard key up through the rust event queue. + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + if (handle == 0L) return super.onKeyUp(keyCode, event) + NativeBridge.viewportKeyEvent(handle, keyCode, event.metaState, false, null) + return true + } +} diff --git a/android/app/src/main/java/org/elseif/yrxtals/LibraryController.kt b/android/app/src/main/java/org/elseif/yrxtals/LibraryController.kt new file mode 100644 index 0000000..32ebbb9 --- /dev/null +++ b/android/app/src/main/java/org/elseif/yrxtals/LibraryController.kt @@ -0,0 +1,173 @@ +package org.elseif.yrxtals + +import android.app.Activity +import android.content.Intent +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.OpenableColumns +import android.util.Log +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.ComponentActivity +import androidx.documentfile.provider.DocumentFile +import java.io.File +import java.util.UUID +import java.util.concurrent.Executors + +private const val TAG = "YrXtals" + +/// owns the file/folder pickers, copies picked content into the app cache, and forwards results into the rust viewport. +class LibraryController(private val activity: ComponentActivity) { + + var view: IcedSurfaceView? = null + + private var pendingKind: Int = 0 + private val main: Handler = Handler(Looper.getMainLooper()) + private val io = Executors.newSingleThreadExecutor() + + private val openTreeLauncher: ActivityResultLauncher = + activity.registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + pendingKind = 0 + if (uri != null) handlePickedTree(uri) + } + + private val openDocsLauncher: ActivityResultLauncher> = + activity.registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + pendingKind = 0 + if (!uris.isNullOrEmpty()) handlePickedAudio(uris) + } + + /// presents the SAF folder picker for kind 1 or the audio multi-document picker for kind 2. + fun presentPicker(kind: Int) { + if (pendingKind != 0) { + Log.w(TAG, "presentPicker($kind) ignored; pendingKind already $pendingKind") + return + } + pendingKind = kind + when (kind) { + 1 -> openTreeLauncher.launch(null) + 2 -> openDocsLauncher.launch(arrayOf("audio/*")) + else -> { pendingKind = 0 } + } + } + + /// walks a picked SAF tree, filters audio children, and feeds them through the export pipeline. + private fun handlePickedTree(treeUri: Uri) { + val handle = view?.viewportHandle ?: return + try { + activity.contentResolver.takePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (_: SecurityException) {} + io.execute { + val root = DocumentFile.fromTreeUri(activity, treeUri) ?: return@execute + val audio = mutableListOf() + collectAudio(root, audio) + if (audio.isEmpty()) return@execute + val uris = audio.map { it.uri } + main.post { handlePickedAudio(uris) } + } + } + + private fun collectAudio(dir: DocumentFile, out: MutableList) { + for (child in dir.listFiles()) { + if (child.isDirectory) { + collectAudio(child, out) + } else { + val mime = child.type ?: continue + if (mime.startsWith("audio/")) out.add(child) + } + } + } + + /// extracts metadata, seeds the sidebar, copies bytes to cache, and pushes paths and art to rust. + private fun handlePickedAudio(uris: List) { + val handle = view?.viewportHandle ?: run { + Log.w(TAG, "handlePickedAudio: no viewportHandle") + return + } + Log.i(TAG, "handlePickedAudio picked ${uris.size} item(s)") + + val total = uris.size + val titles = arrayOfNulls(total) + val trackNumbers = IntArray(total) + + io.execute { + val metas = uris.map { extractMeta(it) } + for (i in 0 until total) { + titles[i] = metas[i].title.ifEmpty { displayName(uris[i]) ?: "Track ${i + 1}" } + trackNumbers[i] = metas[i].track + } + main.post { + NativeBridge.viewportSetPendingTitles(handle, titles.map { it ?: "" }.toTypedArray(), trackNumbers) + NativeBridge.viewportSetLibraryProgress(handle, 0, total) + } + for (i in 0 until total) { + val art = metas[i].art + if (art != null && art.isNotEmpty()) { + main.post { NativeBridge.viewportSetTrackArt(handle, i, art) } + } + } + var done = 0 + for (i in 0 until total) { + val cachePath = copyToCache(uris[i]) + done += 1 + main.post { + if (cachePath != null) { + NativeBridge.viewportSetTrackPath(handle, i, cachePath) + } + NativeBridge.viewportSetLibraryProgress(handle, done, total) + if (done == total) { + NativeBridge.viewportSetLibraryProgress(handle, 0, 0) + } + } + } + } + } + + private data class TrackMeta(val title: String, val track: Int, val art: ByteArray?) + + private fun extractMeta(uri: Uri): TrackMeta { + val r = MediaMetadataRetriever() + return try { + r.setDataSource(activity, uri) + val title = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE).orEmpty() + val trackStr = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER) + val track = trackStr?.substringBefore('/')?.toIntOrNull() ?: 0 + val art = r.embeddedPicture + TrackMeta(title, track, art) + } catch (e: Exception) { + Log.w(TAG, "extractMeta failed for $uri: ${e.message}") + TrackMeta("", 0, null) + } finally { + try { r.release() } catch (_: Exception) {} + } + } + + private fun displayName(uri: Uri): String? { + val cursor = activity.contentResolver.query(uri, null, null, null, null) ?: return null + cursor.use { + val nameIdx = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIdx >= 0 && it.moveToFirst()) return it.getString(nameIdx) + } + return null + } + + private fun copyToCache(uri: Uri): String? { + val ext = displayName(uri)?.substringAfterLast('.', "")?.takeIf { it.isNotEmpty() } ?: "audio" + val out = File(activity.cacheDir, "${UUID.randomUUID()}.$ext") + return try { + activity.contentResolver.openInputStream(uri)?.use { input -> + out.outputStream().use { output -> input.copyTo(output) } + } + Log.i(TAG, "copyToCache wrote ${out.length()} bytes to ${out.absolutePath}") + out.absolutePath + } catch (e: Exception) { + Log.e(TAG, "copyToCache failed for $uri: ${e.message}") + null + } + } +} diff --git a/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt b/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt new file mode 100644 index 0000000..7094ee2 --- /dev/null +++ b/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt @@ -0,0 +1,43 @@ +package org.elseif.yrxtals + +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +/// hosts the IcedSurfaceView full-bleed and binds the LibraryController to picker callbacks. +class MainActivity : ComponentActivity() { + + private lateinit var surfaceView: IcedSurfaceView + private lateinit var controller: LibraryController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + NativeBridge.initContext(applicationContext) + + WindowCompat.setDecorFitsSystemWindows(window, false) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + controller = LibraryController(this) + surfaceView = IcedSurfaceView(this).also { it.controller = controller } + controller.view = surfaceView + setContentView(surfaceView) + + WindowInsetsControllerCompat(window, surfaceView).let { c -> + c.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + c.hide(WindowInsetsCompat.Type.systemBars()) + } + } + + override fun onResume() { + super.onResume() + surfaceView.onActivityResume() + } + + override fun onPause() { + super.onPause() + surfaceView.onActivityPause() + } +} diff --git a/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt b/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt new file mode 100644 index 0000000..0f0572d --- /dev/null +++ b/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt @@ -0,0 +1,32 @@ +package org.elseif.yrxtals + +import android.content.Context +import android.view.Surface + +/// jni surface to the rust viewport, one extern method per fn in src/android.rs. +object NativeBridge { + init { + System.loadLibrary("yr_crystals") + } + + external fun initContext(activity: Context) + + external fun viewportCreate(surface: Surface, width: Float, height: Float, scale: Float): Long + external fun viewportDestroy(handle: Long) + external fun viewportRender(handle: Long) + external fun viewportResize(handle: Long, width: Float, height: Float, scale: Float) + + external fun viewportTouchEvent(handle: Long, x: Float, y: Float, pressed: Boolean, moved: Boolean) + external fun viewportKeyEvent(handle: Long, key: Int, modifiers: Int, pressed: Boolean, text: String?) + + external fun viewportApplyPickedFolder(handle: Long, path: String) + external fun viewportApplyPickedFile(handle: Long, path: String) + external fun viewportApplyPickedFiles(handle: Long, paths: Array) + + external fun viewportSetLibraryProgress(handle: Long, current: Int, total: Int) + external fun viewportSetPendingTitles(handle: Long, titles: Array, trackNumbers: IntArray) + external fun viewportSetTrackPath(handle: Long, idx: Int, path: String) + external fun viewportSetTrackArt(handle: Long, idx: Int, bytes: ByteArray) + + external fun viewportTakePendingPick(handle: Long): Int +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..532523d --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + YrXtals + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..101c422 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..047bf2c --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "2.0.20" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..78add5b --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx2048m -XX:+UseParallelGC -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true + +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1ef00a1 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..aa5f10b --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..58848d7 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "yrxtals" +include(":app") diff --git a/assets/BSkip.svg b/assets/BSkip.svg new file mode 100644 index 0000000..738b1c4 --- /dev/null +++ b/assets/BSkip.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/FSkip.svg b/assets/FSkip.svg new file mode 100644 index 0000000..3c62c33 --- /dev/null +++ b/assets/FSkip.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/Icon.svg b/assets/Icon.svg new file mode 100644 index 0000000..8a6cd93 --- /dev/null +++ b/assets/Icon.svg @@ -0,0 +1,262 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Yr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + t + + + + + + + + + + + + + + + + + + + + + + l + + + + + + + + + + + + + + + + + Z + + + + + + + + + + + X + + + + + + + + + + + a + + + + + + + + + + \ No newline at end of file diff --git a/assets/Loading.svg b/assets/Loading.svg new file mode 100644 index 0000000..a3e7f7d --- /dev/null +++ b/assets/Loading.svg @@ -0,0 +1,11 @@ + + + + Loading indicator + Nautilus + + + Loading indicator + Cog + + \ No newline at end of file diff --git a/assets/Pause.svg b/assets/Pause.svg new file mode 100644 index 0000000..3ce6377 --- /dev/null +++ b/assets/Pause.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/Play.svg b/assets/Play.svg new file mode 100644 index 0000000..195f607 --- /dev/null +++ b/assets/Play.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/Settings.svg b/assets/Settings.svg new file mode 100644 index 0000000..0aa7a42 --- /dev/null +++ b/assets/Settings.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/androidIcon.svg b/assets/androidIcon.svg new file mode 100644 index 0000000..f1646b9 --- /dev/null +++ b/assets/androidIcon.svg @@ -0,0 +1,260 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Yr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + t + + + + + + + + + + + + + + + + + + + + + + l + + + + + + + + + + + + + + + + + Z + + + + + + + + + + + X + + + + + + + + + + + a + + + + + + + + + + \ No newline at end of file diff --git a/fonts/Inter-OFL.txt b/fonts/Inter-OFL.txt new file mode 100644 index 0000000..d05ec4b --- /dev/null +++ b/fonts/Inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fonts/Inter-Regular.ttf b/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..ce097c8 Binary files /dev/null and b/fonts/Inter-Regular.ttf differ diff --git a/include/yr_xtals.h b/include/yr_xtals.h new file mode 100644 index 0000000..b7c9596 --- /dev/null +++ b/include/yr_xtals.h @@ -0,0 +1,68 @@ +#ifndef YR_XTALS_H +#define YR_XTALS_H + +#include +#include +#include +#include + +typedef struct ViewportHandle ViewportHandle; + +/// Owns the iOS view's wgpu surface, iced renderer, and App state. +struct ViewportHandle *viewport_create(void *uiview, float width, float height, float scale); + +void viewport_destroy(struct ViewportHandle *handle); + +void viewport_render(struct ViewportHandle *handle); + +void viewport_resize(struct ViewportHandle *handle, float width, float height, float scale); + +/// pushes a single-pointer touch event with implicit-Left button into the iced runtime. +void viewport_touch_event(struct ViewportHandle *handle, + float x, + float y, + bool pressed, + bool moved); + +void viewport_key_event(struct ViewportHandle *handle, + uint32_t key, + uint32_t modifiers, + bool pressed, + const char *text); + +/// 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); + +void viewport_apply_picked_folder(struct ViewportHandle *handle, const char *path); + +void viewport_apply_picked_file(struct ViewportHandle *handle, const char *path); + +void viewport_apply_picked_files(struct ViewportHandle *handle, + const char *const *paths, + size_t count); + +/// drives the import progress bar, cleared by total=0. +void viewport_set_library_progress(struct ViewportHandle *handle, + uint32_t current, + uint32_t total); + +/// pushes parallel arrays of placeholder titles and track-number tags (0 = unknown) into the sidebar. +void viewport_set_pending_titles(struct ViewportHandle *handle, + const char *const *titles, + const uint32_t *track_numbers, + size_t count); + +/// fills in the file path of a pending track once export finishes. +void viewport_set_track_path(struct ViewportHandle *handle, + size_t idx, + const char *path); + +/// pushes JPEG/PNG artwork bytes for a track through the art decode thread. +void viewport_set_track_art(struct ViewportHandle *handle, + size_t idx, + const uint8_t *bytes, + size_t len); + +void viewport_free_string(char *s); + +#endif diff --git a/ios/Info.plist b/ios/Info.plist new file mode 100644 index 0000000..62d2ece --- /dev/null +++ b/ios/Info.plist @@ -0,0 +1,89 @@ + + + + + CFBundleDisplayName + Yr Xtals + CFBundleDocumentTypes + + + CFBundleTypeName + Audio + LSHandlerRank + Alternate + LSItemContentTypes + + public.audio + public.mp3 + public.mpeg-4-audio + public.folder + public.directory + public.content + public.data + + + + CFBundleExecutable + YrXtals + CFBundleIdentifier + org.else-if.yrxtals + CFBundleName + YrXtals + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.1 + CFBundleSupportedPlatforms + + iPhoneOS + iPhoneSimulator + + CFBundleVersion + 1.0.1 + ITSAppUsesNonExemptEncryption + + LSSupportsOpeningDocumentsInPlace + + MinimumOSVersion + 17.0 + NSAppleMusicUsageDescription + Yr Xtals plays audio files from your library or Files app for offline visualization. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + audio + + UIDeviceFamily + + 2 + 1 + + UIFileSharingEnabled + + UILaunchScreen + + UIColorName + + + UIRequiredDeviceCapabilities + + arm64 + metal + + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..2f05da4 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,53 @@ +name: YrXtals +options: + bundleIdPrefix: org.else-if + deploymentTarget: + iOS: "17.0" + developmentLanguage: en + groupSortPosition: top + generateEmptyDirectories: true + +settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: org.else-if.yrxtals + DEVELOPMENT_TEAM: Z9CXT3ZVLD + CODE_SIGN_STYLE: Automatic + SWIFT_VERSION: 5.0 + TARGETED_DEVICE_FAMILY: "2,1" + IPHONEOS_DEPLOYMENT_TARGET: "17.0" + SWIFT_OBJC_BRIDGING_HEADER: ../include/yr_xtals.h + LIBRARY_SEARCH_PATHS: + - $(PROJECT_DIR)/../target/$(PLATFORM_NAME)/release + OTHER_LDFLAGS: + - -lyr_crystals + EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64 + INFOPLIST_FILE: Info.plist + GENERATE_INFOPLIST_FILE: NO + # clears xcodegen's auto-emitted INFOPLIST_KEY_* settings. + INFOPLIST_KEY_CFBundleDisplayName: "" + INFOPLIST_KEY_LSApplicationCategoryType: "" + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: "" + INFOPLIST_KEY_UILaunchScreen_Generation: "" + INFOPLIST_KEY_UIStatusBarHidden: "" + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "" + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "" + # permits cargo writes outside PROJECT_DIR. + ENABLE_USER_SCRIPT_SANDBOXING: NO + +targets: + YrXtals: + type: application + platform: iOS + sources: + - path: src + - path: Assets.xcassets + settings: + base: + INFOPLIST_FILE: Info.plist + GENERATE_INFOPLIST_FILE: NO + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + preBuildScripts: + - name: Build Rust staticlib + script: bash "$PROJECT_DIR/../scripts/ios/xcode-cargo.sh" + basedOnDependencyAnalysis: false + dependencies: [] diff --git a/ios/src/IcedViewportRepresentable.swift b/ios/src/IcedViewportRepresentable.swift new file mode 100644 index 0000000..5147a56 --- /dev/null +++ b/ios/src/IcedViewportRepresentable.swift @@ -0,0 +1,15 @@ +import UIKit + +/// hosts the IcedViewportView as a UIKit root outside SwiftUI's hosting chain. +final class IcedViewportController: UIViewController { + let viewport = IcedViewportView(frame: .zero) + + /// installs the metal-backed iced viewport as the controller's root view. + override func loadView() { + view = viewport + } + + override var prefersStatusBarHidden: Bool { true } + override var prefersHomeIndicatorAutoHidden: Bool { true } + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .all } +} diff --git a/ios/src/IcedViewportView.swift b/ios/src/IcedViewportView.swift new file mode 100644 index 0000000..148aa11 --- /dev/null +++ b/ios/src/IcedViewportView.swift @@ -0,0 +1,207 @@ +import UIKit +import QuartzCore + +/// hosts the Rust-driven wgpu surface inside a CAMetalLayer-backed UIView and forwards UIKit events through the C FFI. +class IcedViewportView: UIView { + override class var layerClass: AnyClass { CAMetalLayer.self } + + private(set) var viewportHandle: OpaquePointer? + private var displayLink: CADisplayLink? + private var isTornDown = false + weak var controller: LibraryController? + #if DEBUG + private var dbgFrameCount: UInt64 = 0 + #endif + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + /// configures the metal layer for opaque BGRA presentation at native screen scale. + private func commonInit() { + backgroundColor = .black + isMultipleTouchEnabled = false + if let metalLayer = layer as? CAMetalLayer { + metalLayer.contentsScale = UIScreen.main.scale + metalLayer.framebufferOnly = true + metalLayer.pixelFormat = .bgra8Unorm + metalLayer.isOpaque = true + } + } + + /// creates the viewport on first window attach, halts the display link on detach. + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + if viewportHandle == nil && !isTornDown { + createViewport() + } + startDisplayLink() + becomeFirstResponder() + } else { + stopDisplayLink() + } + } + + override var canBecomeFirstResponder: Bool { true } + + /// allocates the Rust-side viewport handle bound to the view's metal layer. + private func createViewport() { + let scale = Float(window?.screen.scale ?? UIScreen.main.scale) + let w = Float(bounds.width) + let h = Float(bounds.height) + if let metalLayer = layer as? CAMetalLayer, metalLayer.device == nil { + metalLayer.device = MTLCreateSystemDefaultDevice() + } + let viewPtr = Unmanaged.passUnretained(self).toOpaque() + viewportHandle = viewport_create(viewPtr, w, h, scale) + print("[YrXtals] createViewport \(w)x\(h)@\(scale) handle=\(String(describing: viewportHandle))") + } + + /// releases the Rust handle and clears the local reference. + private func destroyViewport() { + guard let handle = viewportHandle else { return } + viewportHandle = nil + viewport_destroy(handle) + } + + /// stops the display link and frees the viewport. + func teardown() { + if isTornDown { return } + isTornDown = true + stopDisplayLink() + destroyViewport() + } + + deinit { teardown() } + + /// drives renderFrame on every vsync via the main runloop. + private func startDisplayLink() { + guard displayLink == nil else { return } + let link = CADisplayLink(target: self, selector: #selector(renderFrame)) + link.add(to: .main, forMode: .common) + displayLink = link + } + + /// invalidates the display link. + private func stopDisplayLink() { + displayLink?.invalidate() + displayLink = nil + } + + /// renders a frame and surfaces any pending picker request to the controller. + @objc private func renderFrame() { + if isTornDown { return } + guard let handle = viewportHandle else { return } + let modalUp = window?.rootViewController?.presentedViewController != nil + if !modalUp { + viewport_render(handle) + } + + let pending = viewport_take_pending_pick(handle) + if pending != 0 { + controller?.presentPicker(kind: pending) + } + #if DEBUG + dbgFrameCount &+= 1 + if dbgFrameCount % 120 == 0 { + let presented = window?.rootViewController?.presentedViewController + print("[YrXtals.dbg] renderFrame tick=\(dbgFrameCount) modalUp=\(modalUp) presented=\(String(describing: presented.map { type(of: $0) })) " + + "firstResponder=\(isFirstResponder)") + } + #endif + } + + /// keeps the metal drawable size and Rust viewport bounds in sync with the view layout. + override func layoutSubviews() { + super.layoutSubviews() + let scale = Float(window?.screen.scale ?? UIScreen.main.scale) + if let metalLayer = layer as? CAMetalLayer { + metalLayer.contentsScale = CGFloat(scale) + metalLayer.drawableSize = CGSize( + width: bounds.width * CGFloat(scale), + height: bounds.height * CGFloat(scale) + ) + } + guard let handle = viewportHandle else { return } + viewport_resize(handle, Float(bounds.width), Float(bounds.height), scale) + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let h = viewportHandle, let touch = touches.first else { return } + let p = touch.location(in: self) + #if DEBUG + let presented = window?.rootViewController?.presentedViewController + if presented != nil { + print("[YrXtals.dbg] touchesBegan at (\(p.x),\(p.y)) WHILE presented=\(type(of: presented!)) — viewport should not see this") + } + #endif + viewport_touch_event(h, Float(p.x), Float(p.y), true, false) + becomeFirstResponder() + } + + #if DEBUG + /// logs hit-tests on the iced view during modal presentation. + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let result = super.hitTest(point, with: event) + let presented = window?.rootViewController?.presentedViewController + if presented != nil { + print("[YrXtals.dbg] iced.hitTest at \(point) -> \(String(describing: result.map { type(of: $0) })) modalUp=\(type(of: presented!))") + } + return result + } + #endif + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard let h = viewportHandle, let touch = touches.first else { return } + let p = touch.location(in: self) + viewport_touch_event(h, Float(p.x), Float(p.y), true, true) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let h = viewportHandle, let touch = touches.first else { return } + let p = touch.location(in: self) + viewport_touch_event(h, Float(p.x), Float(p.y), false, false) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + guard let h = viewportHandle, let touch = touches.first else { return } + let p = touch.location(in: self) + viewport_touch_event(h, Float(p.x), Float(p.y), false, false) + } + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard let h = viewportHandle else { + super.pressesBegan(presses, with: event) + return + } + for press in presses { + forwardKey(press, pressed: true, handle: h) + } + } + + override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + guard let h = viewportHandle else { + super.pressesEnded(presses, with: event) + return + } + for press in presses { + forwardKey(press, pressed: false, handle: h) + } + } + + /// translates a UIKey into a keycode/modifier/text triple and pushes it through the FFI. + private func forwardKey(_ press: UIPress, pressed: Bool, handle: OpaquePointer) { + guard let key = press.key else { return } + let chars = pressed ? key.characters : "" + chars.withCString { cstr in + viewport_key_event(handle, UInt32(key.keyCode.rawValue), UInt32(key.modifierFlags.rawValue), pressed, cstr) + } + } +} diff --git a/ios/src/LibraryController.swift b/ios/src/LibraryController.swift new file mode 100644 index 0000000..cec3459 --- /dev/null +++ b/ios/src/LibraryController.swift @@ -0,0 +1,360 @@ +import UIKit +import UniformTypeIdentifiers +import MediaPlayer +import AVFoundation + +/// owns the document and media pickers and forwards picked results into the Rust viewport. +final class LibraryController: NSObject, + UIDocumentPickerDelegate, + MPMediaPickerControllerDelegate { + weak var view: IcedViewportView? + weak var presentationHost: UIViewController? + private var pendingKind: UInt8 = 0 + + private var scopedURL: URL? + + /// presents a folder picker for kind 1 or the music library picker for kind 2. + func presentPicker(kind: UInt8) { + guard pendingKind == 0 else { + print("[YrXtals] presentPicker(\(kind)) ignored; pendingKind already \(pendingKind)") + return + } + guard let root = presentationHost ?? topViewController() else { + print("[YrXtals] presentPicker(\(kind)) failed — no top view controller") + return + } + pendingKind = kind + print("[YrXtals] presentPicker kind=\(kind), presenting on \(type(of: root))") + #if DEBUG + dumpPresentationContext(label: "presentPicker.before", root: root) + #endif + + if kind == 1 { + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false) + picker.delegate = self + picker.allowsMultipleSelection = false + picker.shouldShowFileExtensions = true + #if DEBUG + print("[YrXtals.dbg] folder picker style=\(picker.modalPresentationStyle.rawValue) " + + "definesPresCtx=\(picker.definesPresentationContext) " + + "modalInPresentation=\(picker.isModalInPresentation) " + + "view.userInteraction=\(picker.view.isUserInteractionEnabled)") + #endif + root.present(picker, animated: true) { [weak self, weak picker] in + #if DEBUG + guard let p = picker else { print("[YrXtals.dbg] folder picker present: deallocated"); return } + self?.dumpPickerState(label: "folder.presented", picker: p) + #endif + } + } else { + let auth = MPMediaLibrary.authorizationStatus() + print("[YrXtals] media library auth = \(auth.rawValue)") + if auth == .denied || auth == .restricted { + pendingKind = 0 + showAlert(on: root, message: "Music library access is denied. Enable it in Settings → YrXtals.") + return + } + let picker = MPMediaPickerController(mediaTypes: .anyAudio) + picker.delegate = self + picker.allowsPickingMultipleItems = true + picker.showsCloudItems = true + picker.prompt = "Pick tracks (tap an album then \"Done\" to load it whole)" + #if DEBUG + print("[YrXtals.dbg] media picker style=\(picker.modalPresentationStyle.rawValue)") + #endif + root.present(picker, animated: true) { [weak self, weak picker] in + #if DEBUG + guard let p = picker else { print("[YrXtals.dbg] media picker present: deallocated"); return } + self?.dumpPickerState(label: "media.presented", picker: p) + #endif + } + } + } + + #if DEBUG + /// dumps the responder chain, key window, and presentation chain at the moment of picker request. + private func dumpPresentationContext(label: String, root: UIViewController) { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + for (si, scene) in scenes.enumerated() { + print("[YrXtals.dbg] \(label): scene[\(si)] state=\(scene.activationState.rawValue) windows=\(scene.windows.count)") + for (wi, w) in scene.windows.enumerated() { + print("[YrXtals.dbg] \(label): scene[\(si)].window[\(wi)] key=\(w.isKeyWindow) hidden=\(w.isHidden) " + + "level=\(w.windowLevel.rawValue) bounds=\(w.bounds) rootVC=\(String(describing: w.rootViewController)) " + + "userInteraction=\(w.isUserInteractionEnabled)") + } + } + var presented: UIViewController? = root + var depth = 0 + while let p = presented { + print("[YrXtals.dbg] \(label): chain[\(depth)] vc=\(type(of: p)) " + + "view.userInteraction=\(p.view.isUserInteractionEnabled) " + + "modalStyle=\(p.modalPresentationStyle.rawValue)") + presented = p.presentedViewController + depth += 1 + } + } + + /// dumps the picker view hierarchy and gesture state once present(animated:completion:) returns. + private func dumpPickerState(label: String, picker: UIViewController) { + let v = picker.view + let nav = (picker as? UINavigationController) ?? picker.children.compactMap { $0 as? UINavigationController }.first + print("[YrXtals.dbg] \(label): picker=\(type(of: picker)) view=\(String(describing: v)) " + + "frame=\(v?.frame ?? .zero) userInteraction=\(v?.isUserInteractionEnabled ?? false) " + + "alpha=\(v?.alpha ?? 0) hidden=\(v?.isHidden ?? true) " + + "window=\(String(describing: v?.window)) " + + "subviews=\(v?.subviews.count ?? 0) " + + "navStack=\(nav?.viewControllers.count ?? -1) " + + "delegate=\(String(describing: (picker as? UIDocumentPickerViewController)?.delegate))") + if let v = v { + recurseViewDump(label: label, view: v, depth: 0, maxDepth: 6) + } + if let w = v?.window { + let recogs = w.gestureRecognizers ?? [] + print("[YrXtals.dbg] \(label): window gestureRecognizers count=\(recogs.count)") + for (i, g) in recogs.enumerated() { + print("[YrXtals.dbg] \(label): window.gr[\(i)]=\(type(of: g)) enabled=\(g.isEnabled) state=\(g.state.rawValue) " + + "cancelsInView=\(g.cancelsTouchesInView) delaysBegan=\(g.delaysTouchesBegan) delaysEnded=\(g.delaysTouchesEnded)") + } + } + attachDebugTapSpy(to: picker) + } + + /// recurses subviews up to maxDepth, printing frame, interaction, and gesture counts at each level. + private func recurseViewDump(label: String, view: UIView, depth: Int, maxDepth: Int) { + let pad = String(repeating: " ", count: depth) + print("[YrXtals.dbg] \(label): \(pad)\(type(of: view)) frame=\(view.frame) " + + "userInteraction=\(view.isUserInteractionEnabled) hidden=\(view.isHidden) alpha=\(view.alpha) " + + "subviews=\(view.subviews.count) gestureRecognizers=\(view.gestureRecognizers?.count ?? 0) " + + "layer=\(type(of: view.layer))") + if depth + 1 > maxDepth { return } + for sv in view.subviews { + recurseViewDump(label: label, view: sv, depth: depth + 1, maxDepth: maxDepth) + } + } + + /// adds a passing-through UITapGestureRecognizer to the picker view. + private func attachDebugTapSpy(to picker: UIViewController) { + guard let v = picker.view else { return } + let tap = UITapGestureRecognizer(target: self, action: #selector(debugTapSpy(_:))) + tap.cancelsTouchesInView = false + tap.delaysTouchesBegan = false + tap.delaysTouchesEnded = false + v.addGestureRecognizer(tap) + print("[YrXtals.dbg] attached debug tap spy to \(type(of: picker)).view") + } + + @objc private func debugTapSpy(_ g: UITapGestureRecognizer) { + let p = g.location(in: g.view) + print("[YrXtals.dbg] debugTapSpy fired at \(p) on \(String(describing: g.view.map { type(of: $0) })) state=\(g.state.rawValue)") + } + #endif + + /// claims the security-scoped folder URL and pushes the path to the Rust library scanner. + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + let kind = pendingKind + pendingKind = 0 + print("[YrXtals] documentPicker didPick kind=\(kind) urls=\(urls)") + guard let url = urls.first else { return } + guard let h = view?.viewportHandle else { + print("[YrXtals] documentPicker: no viewportHandle, dropping pick") + return + } + + if let prev = scopedURL { + prev.stopAccessingSecurityScopedResource() + scopedURL = nil + } + let scoped = url.startAccessingSecurityScopedResource() + print("[YrXtals] startAccessingSecurityScopedResource = \(scoped) for \(url.path)") + if scoped { + scopedURL = url + } + #if DEBUG + let fm = FileManager.default + var isDir: ObjCBool = false + let exists = fm.fileExists(atPath: url.path, isDirectory: &isDir) + let reachable = (try? url.checkResourceIsReachable()) ?? false + let readable = fm.isReadableFile(atPath: url.path) + print("[YrXtals.dbg] picked path exists=\(exists) isDir=\(isDir.boolValue) reachable=\(reachable) readable=\(readable)") + if let entries = try? fm.contentsOfDirectory(atPath: url.path) { + print("[YrXtals.dbg] picked dir entries=\(entries.count) sample=\(entries.prefix(5))") + } else { + print("[YrXtals.dbg] picked dir contentsOfDirectory failed") + } + #endif + + url.path.withCString { cstr in + viewport_apply_picked_folder(h, cstr) + } + } + + /// clears the pending picker flag after the document picker dismisses without a selection. + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + pendingKind = 0 + print("[YrXtals] documentPicker cancelled") + } + + /// sorts picked MPMediaItems by track number, seeds the sidebar with placeholders, and dispatches artwork and export jobs. + func mediaPicker(_ mediaPicker: MPMediaPickerController, + didPickMediaItems collection: MPMediaItemCollection) { + pendingKind = 0 + mediaPicker.dismiss(animated: true) + guard !collection.items.isEmpty else { + print("[YrXtals] mediaPicker returned no items") + return + } + + let items = collection.items.sorted { lhs, rhs in + let l = lhs.albumTrackNumber == 0 ? Int.max : lhs.albumTrackNumber + let r = rhs.albumTrackNumber == 0 ? Int.max : rhs.albumTrackNumber + if l != r { return l < r } + return (lhs.title ?? "").localizedCaseInsensitiveCompare(rhs.title ?? "") == .orderedAscending + } + print("[YrXtals] mediaPicker picked \(items.count) item(s)") + if let h = view?.viewportHandle { + let titles = items.map { $0.title ?? "Untitled" } + let trackNumbers: [UInt32] = items.map { UInt32($0.albumTrackNumber) } + let cstrs: [UnsafeMutablePointer?] = titles.map { strdup($0) } + defer { cstrs.forEach { if let p = $0 { free(p) } } } + var pointers: [UnsafePointer?] = cstrs.map { $0.flatMap { UnsafePointer($0) } } + pointers.withUnsafeMutableBufferPointer { titlesBuf in + trackNumbers.withUnsafeBufferPointer { tnBuf in + viewport_set_pending_titles(h, titlesBuf.baseAddress, tnBuf.baseAddress, titles.count) + } + } + } + pushArtwork(for: items) + exportAndDeliver(items: items) + } + + /// pulls 256-pixel JPEG artwork off a background queue and pushes each into the viewport on the main thread. + private func pushArtwork(for items: [MPMediaItem]) { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + for (idx, item) in items.enumerated() { + guard let img = item.artwork?.image(at: CGSize(width: 256, height: 256)) else { continue } + guard let data = img.jpegData(compressionQuality: 0.85) else { continue } + DispatchQueue.main.async { [weak self] in + guard let h = self?.view?.viewportHandle else { return } + data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in + guard let base = raw.baseAddress else { return } + viewport_set_track_art(h, idx, base.assumingMemoryBound(to: UInt8.self), data.count) + } + } + } + } + } + + /// transcodes each MPMediaItem to a temporary m4a, reports progress, and forwards the resulting paths to Rust. + private func exportAndDeliver(items: [MPMediaItem]) { + let total = items.count + var skipped: [String] = [] + var done = 0 + + if let h = view?.viewportHandle { + viewport_set_library_progress(h, 0, UInt32(total)) + } + + let finish: () -> Void = { [weak self] in + guard let self = self, let h = self.view?.viewportHandle else { return } + viewport_set_library_progress(h, 0, 0) + print("[YrXtals] exports finished: \(total - skipped.count)/\(total) succeeded; skipped=\(skipped)") + if total - skipped.count == 0, let root = self.topViewController(), !skipped.isEmpty { + self.showAlert(on: root, message: "Couldn't load any of the picked tracks. \(skipped.first ?? "")") + } + } + + for (idx, item) in items.enumerated() { + guard let assetURL = item.assetURL else { + skipped.append("'\(item.title ?? "?")' is DRM-protected") + done += 1 + if done == total { finish() } + continue + } + let asset = AVURLAsset(url: assetURL) + let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("\(UUID().uuidString).m4a") + try? FileManager.default.removeItem(at: tmpURL) + + guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetAppleM4A) else { + skipped.append("AVAssetExportSession init failed for '\(item.title ?? "?")'") + done += 1 + if done == total { finish() } + continue + } + exporter.outputURL = tmpURL + exporter.outputFileType = .m4a + exporter.shouldOptimizeForNetworkUse = false + + print("[YrXtals] exporting [\(idx+1)/\(total)] \(item.title ?? "?") → \(tmpURL.lastPathComponent)") + exporter.exportAsynchronously { [weak self] in + DispatchQueue.main.async { + var landedPath: String? = nil + if exporter.status == .completed { + + let attrs = try? FileManager.default.attributesOfItem(atPath: tmpURL.path) + let size = (attrs?[.size] as? Int64) ?? -1 + print("[YrXtals] export[\(idx)] size=\(size) path=\(tmpURL.path)") + if size > 0 { + landedPath = tmpURL.path + } else { + skipped.append("'\(item.title ?? "?")' exported empty (size=\(size))") + } + } else { + skipped.append("export failed for '\(item.title ?? "?")': status=\(exporter.status.rawValue) err=\(String(describing: exporter.error))") + } + if let path = landedPath, let h = self?.view?.viewportHandle { + path.withCString { p in + viewport_set_track_path(h, idx, p) + } + } + done += 1 + if let h = self?.view?.viewportHandle { + viewport_set_library_progress(h, UInt32(done), UInt32(total)) + } + if done == total { finish() } + } + } + } + } + + /// clears the pending picker flag and dismisses the media picker on cancel. + func mediaPickerDidCancel(_ mediaPicker: MPMediaPickerController) { + pendingKind = 0 + mediaPicker.dismiss(animated: true) + print("[YrXtals] mediaPicker cancelled") + } + + deinit { + #if DEBUG + print("[YrXtals.dbg] LibraryController deinit (delegate would go nil)") + #endif + scopedURL?.stopAccessingSecurityScopedResource() + } + + /// walks the active scene's window stack down to the topmost presented controller. + private func topViewController() -> UIViewController? { + let scenes = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive } + for scene in scenes { + let candidates = scene.windows.sorted { lhs, _ in lhs.isKeyWindow } + for window in candidates { + if let root = window.rootViewController { + var top = root + while let presented = top.presentedViewController { + top = presented + } + return top + } + } + } + return nil + } + + /// presents a single-button OK alert anchored on the given root controller. + private func showAlert(on root: UIViewController, message: String) { + let alert = UIAlertController(title: "YrXtals", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + root.present(alert, animated: true) + } +} diff --git a/ios/src/YrXtalsApp.swift b/ios/src/YrXtalsApp.swift new file mode 100644 index 0000000..7a3ed1c --- /dev/null +++ b/ios/src/YrXtalsApp.swift @@ -0,0 +1,175 @@ +import UIKit +import AVFoundation +import MediaPlayer +import Darwin + +/// configures audio, stderr capture, and scene routing at launch. +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + /// configures audio session, media-library auth, and the stderr-forwarding pipe at process launch. + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + Self.captureStderr() + + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory(.playback, mode: .default, options: []) + try session.setActive(true) + } catch { + print("[YrXtals] AVAudioSession setup failed: \(error)") + } + + MPMediaLibrary.requestAuthorization { status in + print("[YrXtals] media library authorization: \(status.rawValue)") + } + #if DEBUG + Self.dumpStartupDiagnostics() + Self.startNotificationSpy() + #endif + return true + } + + #if DEBUG + /// dumps bundle id, sandbox container, entitlements, FileProvider availability, and reachable temp dirs at startup. + private static func dumpStartupDiagnostics() { + let info = Bundle.main.infoDictionary ?? [:] + print("[YrXtals.dbg] bundleId=\(Bundle.main.bundleIdentifier ?? "?") version=\(info["CFBundleShortVersionString"] ?? "?") build=\(info["CFBundleVersion"] ?? "?")") + print("[YrXtals.dbg] bundlePath=\(Bundle.main.bundlePath)") + let home = NSHomeDirectory() + print("[YrXtals.dbg] sandbox home=\(home)") + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.path ?? "?" + let tmp = NSTemporaryDirectory() + print("[YrXtals.dbg] documents=\(docs)") + print("[YrXtals.dbg] tmp=\(tmp) writable=\(FileManager.default.isWritableFile(atPath: tmp))") + if let provURL = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision") ?? URL(string: Bundle.main.bundlePath + "/embedded.mobileprovision") { + if let data = try? Data(contentsOf: provURL) { + print("[YrXtals.dbg] embedded.mobileprovision size=\(data.count)") + if let s = String(data: data, encoding: .ascii) { + if let start = s.range(of: "") { + let plistRange = start.lowerBound.. UISceneConfiguration { + let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + config.delegateClass = SceneDelegate.self + return config + } + + /// reroutes process stderr through a pipe and replays each line on stdout with a Rust prefix. + private static func captureStderr() { + let realStdout = dup(fileno(stdout)) + guard realStdout != -1 else { return } + let outFile = fdopen(realStdout, "w") + guard outFile != nil else { close(realStdout); return } + setvbuf(outFile, nil, _IONBF, 0) + + var fds: [Int32] = [0, 0] + guard pipe(&fds) == 0 else { return } + dup2(fds[1], fileno(stderr)) + setvbuf(stderr, nil, _IONBF, 0) + + DispatchQueue.global(qos: .utility).async { + guard let f = fdopen(fds[0], "r") else { return } + var line: UnsafeMutablePointer? + var cap: Int = 0 + while getline(&line, &cap, f) > 0 { + if let l = line { + fputs("[Rust] ", outFile) + fputs(l, outFile) + } + } + if let l = line { free(l) } + } + } +} + +/// installs IcedViewportController as the key window's rootViewController. +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + let libraryController = LibraryController() + + /// builds the window with IcedViewportController as the root and binds the library controller. + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + let win = UIWindow(windowScene: windowScene) + let root = IcedViewportController() + root.viewport.controller = libraryController + libraryController.view = root.viewport + libraryController.presentationHost = root + win.rootViewController = root + win.overrideUserInterfaceStyle = .dark + win.makeKeyAndVisible() + self.window = win + } +} diff --git a/macos/Info.plist b/macos/Info.plist new file mode 100644 index 0000000..4b4c816 --- /dev/null +++ b/macos/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleExecutable + yr_crystals + CFBundleIdentifier + org.else-if.yrcrystals + CFBundleName + Yr Xtals + CFBundleDisplayName + Yr Xtals + CFBundlePackageType + APPL + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1.0 + LSMinimumSystemVersion + 14.0 + LSApplicationCategoryType + public.app-category.music + CFBundleIconFile + AppIcon + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + NSMicrophoneUsageDescription + Yr Xtals does not record audio. This entry is here only because the audio framework asks for it on some macOS versions. + + diff --git a/scripts/android/_env.sh b/scripts/android/_env.sh new file mode 100755 index 0000000..54aba1f --- /dev/null +++ b/scripts/android/_env.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# resolves and exports jdk, android sdk, and ndk paths. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# jdk 17 resolution +if [[ -z "${JAVA_HOME:-}" ]]; then + for candidate in \ + "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" \ + "/usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" \ + "$HOME/.sdkman/candidates/java/current"; do + if [[ -x "$candidate/bin/java" ]]; then + export JAVA_HOME="$candidate" + break + fi + done +fi +if [[ -z "${JAVA_HOME:-}" || ! -x "$JAVA_HOME/bin/java" ]]; then + echo "JAVA_HOME not resolved. install jdk 17 (brew install openjdk@17 or sdk install java 17.0.19-tem)." >&2 + exit 2 +fi + +# android sdk resolution +if [[ -z "${ANDROID_HOME:-}" ]]; then + for candidate in \ + "/opt/homebrew/share/android-commandlinetools" \ + "/usr/local/share/android-commandlinetools" \ + "$HOME/Library/Android/sdk" \ + "$HOME/Android/Sdk"; do + if [[ -d "$candidate/platform-tools" || -d "$candidate/cmdline-tools" ]]; then + export ANDROID_HOME="$candidate" + break + fi + done +fi +if [[ -z "${ANDROID_HOME:-}" || ! -d "$ANDROID_HOME" ]]; then + echo "ANDROID_HOME not resolved. install via brew install --cask android-commandlinetools." >&2 + exit 2 +fi +export ANDROID_SDK_ROOT="$ANDROID_HOME" + +# ndk version sourced from .android-sdk-packages +NDK_COORD="$(grep -E '^ndk;' "$REPO_ROOT/.android-sdk-packages" | head -1 | tr -d ' ')" +NDK_VERSION="${NDK_COORD#ndk;}" +if [[ -z "$NDK_VERSION" ]]; then + echo ".android-sdk-packages missing an 'ndk;' entry." >&2 + exit 2 +fi +export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION" +export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME" +if [[ ! -d "$ANDROID_NDK_HOME" ]]; then + echo "NDK $NDK_VERSION missing at $ANDROID_NDK_HOME — run scripts/android/bootstrap.sh." >&2 + exit 2 +fi + +# prepends jdk and android sdk binaries to PATH +export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" + +# target device serial +ANDROID_TARGET_FILE="$REPO_ROOT/.yrxtls-android-target" +ANDROID_TARGET="${YRXTALS_ANDROID_DEVICE:-}" +if [[ -z "$ANDROID_TARGET" && -f "$ANDROID_TARGET_FILE" ]]; then + ANDROID_TARGET="$(cat "$ANDROID_TARGET_FILE")" +fi +export ANDROID_TARGET diff --git a/scripts/android/bootstrap.sh b/scripts/android/bootstrap.sh new file mode 100755 index 0000000..97b0c62 --- /dev/null +++ b/scripts/android/bootstrap.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# installs every android sdk package listed in .android-sdk-packages. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# resolves JAVA_HOME and ANDROID_HOME from common install locations +SDK_PKGS_FILE="$REPO_ROOT/.android-sdk-packages" +if [[ ! -f "$SDK_PKGS_FILE" ]]; then + echo ".android-sdk-packages missing at repo root." >&2 + exit 2 +fi + +if [[ -z "${JAVA_HOME:-}" ]]; then + for c in /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home \ + /usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home \ + "$HOME/.sdkman/candidates/java/current"; do + if [[ -x "$c/bin/java" ]]; then export JAVA_HOME="$c"; break; fi + done +fi +if [[ -z "${JAVA_HOME:-}" ]]; then + echo "JAVA_HOME not resolved. install jdk 17." >&2; exit 2 +fi +if [[ -z "${ANDROID_HOME:-}" ]]; then + for c in /opt/homebrew/share/android-commandlinetools \ + /usr/local/share/android-commandlinetools \ + "$HOME/Library/Android/sdk" \ + "$HOME/Android/Sdk"; do + if [[ -d "$c/cmdline-tools" ]]; then export ANDROID_HOME="$c"; break; fi + done +fi +if [[ -z "${ANDROID_HOME:-}" ]]; then + echo "ANDROID_HOME not resolved. install via brew install --cask android-commandlinetools." >&2; exit 2 +fi +export ANDROID_SDK_ROOT="$ANDROID_HOME" + +SDKMGR="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" +if [[ ! -x "$SDKMGR" ]]; then + echo "sdkmanager missing at $SDKMGR." >&2; exit 2 +fi + +# accepts all sdk licenses idempotently +yes 2>/dev/null | "$SDKMGR" --licenses >/dev/null || true + +mapfile -t PKGS < <(grep -Ev '^\s*(#|$)' "$SDK_PKGS_FILE") +echo "installing: ${PKGS[*]}" +"$SDKMGR" --install "${PKGS[@]}" + +echo "bootstrap done." diff --git a/scripts/android/build.sh b/scripts/android/build.sh new file mode 100755 index 0000000..c00258a --- /dev/null +++ b/scripts/android/build.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# compiles the aarch64-linux-android cdylib into jniLibs and assembles the apk via gradle. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/_env.sh" + +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +GRADLE_TYPE="${1:-release}" +case "$GRADLE_TYPE" in + debug|release|release-debug) ;; + *) echo "unknown gradle type: $GRADLE_TYPE (debug|release|release-debug)" >&2; exit 2 ;; +esac + +# YRXTALS_ANDROID_CARGO_PROFILE overrides the rust profile, defaulting to the gradle type +CARGO_PROFILE="${YRXTALS_ANDROID_CARGO_PROFILE:-}" +if [[ -z "$CARGO_PROFILE" ]]; then + case "$GRADLE_TYPE" in + debug) CARGO_PROFILE="dev" ;; + release) CARGO_PROFILE="release" ;; + release-debug) CARGO_PROFILE="release-debug" ;; + esac +fi + +bash "$SCRIPT_DIR/generate-icons.sh" + +JNILIBS="$REPO_ROOT/android/app/src/main/jniLibs" +mkdir -p "$JNILIBS/arm64-v8a" + +CARGO_NDK_FLAGS=( -t arm64-v8a -P 28 -o "$JNILIBS" build ) +[[ "$CARGO_PROFILE" != "dev" ]] && CARGO_NDK_FLAGS+=( --profile "$CARGO_PROFILE" ) + +echo "→ cargo ndk ${CARGO_NDK_FLAGS[*]} (profile=$CARGO_PROFILE)" +( cd "$REPO_ROOT" && cargo ndk "${CARGO_NDK_FLAGS[@]}" ) + +case "$GRADLE_TYPE" in + debug) GRADLE_TASK="assembleDebug"; APK_VARIANT_DIR="debug" ;; + release) GRADLE_TASK="assembleRelease"; APK_VARIANT_DIR="release" ;; + release-debug) GRADLE_TASK="assembleReleaseDebug"; APK_VARIANT_DIR="releaseDebug" ;; +esac + +echo "→ gradlew $GRADLE_TASK" +( cd "$REPO_ROOT/android" && ./gradlew "$GRADLE_TASK" ) + +APK_DIR="$REPO_ROOT/android/app/build/outputs/apk/$APK_VARIANT_DIR" +echo "apk at: $(find "$APK_DIR" -name '*.apk' | head -1)" diff --git a/scripts/android/debug.sh b/scripts/android/debug.sh new file mode 100755 index 0000000..a88092e --- /dev/null +++ b/scripts/android/debug.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# installs the apk, launches the activity, and tails logcat filtered to YrXtals and native crash channels. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/_env.sh" + +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +"$SCRIPT_DIR/install.sh" release-debug + +if [[ -z "${ANDROID_TARGET:-}" ]]; then + echo "no device selected." >&2 + exit 2 +fi + +PKG="org.elseif.yrxtals" + +adb -s "$ANDROID_TARGET" logcat -c || true +adb -s "$ANDROID_TARGET" shell am start -n "$PKG/.MainActivity" >/dev/null + +PID="$(adb -s "$ANDROID_TARGET" shell pidof "$PKG" | tr -d '\r')" +echo "running pid=$PID on $ANDROID_TARGET — tailing logcat (Ctrl+C to stop)" + +if [[ -n "$PID" ]]; then + exec adb -s "$ANDROID_TARGET" logcat --pid="$PID" YrXtals:V yr_crystals:V AndroidRuntime:E libc:F DEBUG:F '*:S' +else + exec adb -s "$ANDROID_TARGET" logcat YrXtals:V yr_crystals:V AndroidRuntime:E libc:F DEBUG:F '*:S' +fi diff --git a/scripts/android/generate-icons.sh b/scripts/android/generate-icons.sh new file mode 100755 index 0000000..b98a73a --- /dev/null +++ b/scripts/android/generate-icons.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# rasterizes assets/androidIcon.svg edge-to-edge into the android mipmap-density buckets used by the launcher. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SVG="$ROOT/assets/androidIcon.svg" +RES="$ROOT/android/app/src/main/res" + +if [[ ! -f "$SVG" ]]; then + echo "ERROR: $SVG not found" >&2 + exit 1 +fi + +if ! command -v rsvg-convert >/dev/null 2>&1; then + echo "ERROR: rsvg-convert not on PATH (brew install librsvg)" >&2 + exit 1 +fi + +# launcher icon density buckets per the android docs. +DENSITIES=( + "mdpi 48" + "hdpi 72" + "xhdpi 96" + "xxhdpi 144" + "xxxhdpi 192" +) + +for entry in "${DENSITIES[@]}"; do + bucket="${entry%% *}" + size="${entry##* }" + dir="$RES/mipmap-$bucket" + mkdir -p "$dir" + rsvg-convert --width="$size" --height="$size" "$SVG" -o "$dir/ic_launcher.png" +done + +# 512x512 play-store icon staged alongside the launcher buckets +mkdir -p "$RES/mipmap-xxxhdpi" +rsvg-convert --width=512 --height=512 "$SVG" -o "$RES/mipmap-xxxhdpi/ic_launcher_play.png" + +echo "wrote launcher icons across ${#DENSITIES[@]} density buckets + play store size" diff --git a/scripts/android/install.sh b/scripts/android/install.sh new file mode 100755 index 0000000..b1e18b8 --- /dev/null +++ b/scripts/android/install.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# builds and installs the apk to the saved or specified device serial. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/_env.sh" + +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +GRADLE_TYPE="${1:-release}" +"$SCRIPT_DIR/build.sh" "$GRADLE_TYPE" + +if [[ -z "${ANDROID_TARGET:-}" ]]; then + echo "no device selected. run cargo xtask select-android first." >&2 + exit 2 +fi + +case "$GRADLE_TYPE" in + debug) APK_VARIANT_DIR="debug" ;; + release) APK_VARIANT_DIR="release" ;; + release-debug) APK_VARIANT_DIR="releaseDebug" ;; +esac +APK="$(find "$REPO_ROOT/android/app/build/outputs/apk/$APK_VARIANT_DIR" -name '*.apk' | head -1)" +if [[ -z "$APK" ]]; then + echo "no apk built at $APK_VARIANT_DIR." >&2; exit 1 +fi + +echo "→ adb -s $ANDROID_TARGET install -r $APK" +adb -s "$ANDROID_TARGET" install -r "$APK" diff --git a/scripts/android/select.sh b/scripts/android/select.sh new file mode 100755 index 0000000..f56a758 --- /dev/null +++ b/scripts/android/select.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# lists adb devices and persists the chosen serial to .yrxtls-android-target. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/_env.sh" + +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +TARGET_FILE="$REPO_ROOT/.yrxtls-android-target" + +mapfile -t LINES < <(adb devices -l | tail -n +2 | grep -v '^\s*$' || true) +if [[ ${#LINES[@]} -eq 0 ]]; then + echo "no devices attached. plug in a phone with USB debugging enabled or start an emulator." >&2 + exit 1 +fi + +echo "attached devices:" +for i in "${!LINES[@]}"; do + printf " [%d] %s\n" "$i" "${LINES[$i]}" +done + +read -rp "pick index: " idx +chosen="${LINES[$idx]}" +serial="$(awk '{print $1}' <<<"$chosen")" +if [[ -z "$serial" ]]; then + echo "no serial parsed." >&2 + exit 1 +fi + +echo "$serial" > "$TARGET_FILE" +echo "saved $serial to $TARGET_FILE" diff --git a/scripts/ios/build.sh b/scripts/ios/build.sh new file mode 100755 index 0000000..b15a14c --- /dev/null +++ b/scripts/ios/build.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s), iOS build requires macOS" >&2; exit 1;; +esac + +TARGET="${1:-sim}" +CARGO_PROFILE="${YRXTALS_IOS_CARGO_PROFILE:-release}" + +case "$TARGET" in + sim) + RUST_TARGET="aarch64-apple-ios-sim" + SDK_NAME="iphonesimulator" + SWIFT_TARGET="arm64-apple-ios17.0-simulator" + ;; + device) + RUST_TARGET="aarch64-apple-ios" + SDK_NAME="iphoneos" + SWIFT_TARGET="arm64-apple-ios17.0" + ;; + *) + echo "usage: $0 [sim|device]" >&2 + exit 2 + ;; +esac + +BUILD="$ROOT/build" +APP="$BUILD/ios/YrXtals.app" +RUST_LIB="/tmp/yr_crystals-target/$RUST_TARGET/$CARGO_PROFILE" + +SDK="$(xcrun --sdk "$SDK_NAME" --show-sdk-path)" + +export CC=/usr/bin/clang +export CXX=/usr/bin/clang++ +export IPHONEOS_DEPLOYMENT_TARGET=17.0 + +echo "Building Rust staticlib for $RUST_TARGET (profile=$CARGO_PROFILE)..." +cargo build --profile "$CARGO_PROFILE" --target "$RUST_TARGET" --lib + +rm -f "$RUST_LIB/libyr_crystals.dylib" "$RUST_LIB/deps/libyr_crystals.dylib" + +if [ ! -f "$RUST_LIB/libyr_crystals.a" ]; then + echo "ERROR: libyr_crystals.a not found at $RUST_LIB" >&2 + exit 1 +fi + +rm -rf "$APP" +mkdir -p "$APP" +cp "$ROOT/ios/Info.plist" "$APP/Info.plist" + +bash "$ROOT/scripts/ios/generate-icons.sh" + +ACTOOL_PARTIAL="$BUILD/ios/actool-partial-info.plist" +mkdir -p "$BUILD/ios" +echo "Compiling asset catalog..." +xcrun actool "$ROOT/ios/Assets.xcassets" \ + --compile "$APP" \ + --platform "$SDK_NAME" \ + --minimum-deployment-target 17.0 \ + --app-icon AppIcon \ + --output-partial-info-plist "$ACTOOL_PARTIAL" \ + --target-device ipad \ + --target-device iphone \ + >/dev/null + +if [ -f "$ACTOOL_PARTIAL" ]; then + /usr/libexec/PlistBuddy -c "Merge $ACTOOL_PARTIAL" "$APP/Info.plist" 2>/dev/null || true +fi + +RUST_FLAGS=(-import-objc-header "$ROOT/include/yr_xtals.h" -L "$RUST_LIB" -lyr_crystals) + +SWIFT_DEBUG_FLAGS=() +case "$CARGO_PROFILE" in + *debug*) SWIFT_DEBUG_FLAGS=(-D DEBUG) ;; +esac + +echo "Compiling Swift (profile=$CARGO_PROFILE)..." +xcrun -sdk "$SDK_NAME" swiftc \ + -target "$SWIFT_TARGET" \ + -sdk "$SDK" \ + "${RUST_FLAGS[@]}" \ + "${SWIFT_DEBUG_FLAGS[@]}" \ + -framework UIKit \ + -framework SwiftUI \ + -framework QuartzCore \ + -framework Metal \ + -framework MetalKit \ + -framework AVFoundation \ + -framework AudioToolbox \ + -framework MediaPlayer \ + -framework CoreGraphics \ + -framework CoreFoundation \ + -O \ + -o "$APP/YrXtals" \ + "$ROOT"/ios/src/*.swift + +if [ "$TARGET" = "sim" ]; then + codesign --force --sign - "$APP" +else + PROFILE="${YRXTALS_IOS_PROFILE:-/Volumes/External/prvProfiles/All.mobileprovision}" + if [ ! -f "$PROFILE" ]; then + echo "ERROR: provisioning profile not found at $PROFILE" >&2 + echo " set YRXTALS_IOS_PROFILE to point at a valid .mobileprovision" >&2 + exit 1 + fi + cp "$PROFILE" "$APP/embedded.mobileprovision" + + ENT="$BUILD/ios/entitlements.plist" + security cms -D -i "$PROFILE" 2>/dev/null \ + | plutil -extract Entitlements xml1 -o "$ENT" - \ + || { echo "ERROR: could not extract entitlements from profile" >&2; exit 1; } + + # pins wildcard application-identifier to the concrete TEAM.bundle.id form. + BUNDLE_ID="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP/Info.plist")" + TEAM_ID="$(/usr/libexec/PlistBuddy -c "Print :com.apple.developer.team-identifier" "$ENT" 2>/dev/null || true)" + if [ -z "$TEAM_ID" ]; then + TEAM_ID="$(/usr/libexec/PlistBuddy -c "Print :application-identifier" "$ENT" | cut -d. -f1)" + fi + /usr/libexec/PlistBuddy -c "Set :application-identifier ${TEAM_ID}.${BUNDLE_ID}" "$ENT" + /usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$ENT" 2>/dev/null || true + echo "Pinned application-identifier=${TEAM_ID}.${BUNDLE_ID}" + + TMPDIR_PROF="$(mktemp -d)" + PROFILE_PLIST="$TMPDIR_PROF/profile.plist" + security cms -D -i "$PROFILE" > "$PROFILE_PLIST" 2>/dev/null + + PROFILE_SHAS="" + for i in 0 1 2 3 4 5 6 7 8 9; do + if ! plutil -extract "DeveloperCertificates.$i" raw -o "$TMPDIR_PROF/c$i.b64" "$PROFILE_PLIST" >/dev/null 2>&1; then + break + fi + base64 -D -i "$TMPDIR_PROF/c$i.b64" -o "$TMPDIR_PROF/c$i.cer" + sha=$(openssl x509 -inform der -in "$TMPDIR_PROF/c$i.cer" -fingerprint -noout 2>/dev/null \ + | sed 's/.*=//;s/://g') + PROFILE_SHAS="$PROFILE_SHAS $sha" + done + + KEYCHAIN_SHAS=$(security find-identity -v 2>/dev/null \ + | awk '/[0-9A-F]{40}/ {gsub(/[^0-9A-F]/, "", $2); print $2}') + + IDENTITY="" + for s in $PROFILE_SHAS; do + if echo "$KEYCHAIN_SHAS" | grep -qi "^$s$"; then + IDENTITY="$s" + break + fi + done + rm -rf "$TMPDIR_PROF" + + if [ -z "$IDENTITY" ]; then + echo "ERROR: no codesigning identity in your keychain matches any cert in the profile" >&2 + echo " profile certs:$PROFILE_SHAS" >&2 + exit 1 + fi + + echo "Signing with $IDENTITY..." + codesign --force \ + --sign "$IDENTITY" \ + --entitlements "$ENT" \ + --options runtime \ + --timestamp=none \ + "$APP" +fi + +echo "Built: $APP" diff --git a/scripts/ios/debug.sh b/scripts/ios/debug.sh new file mode 100755 index 0000000..330fdb3 --- /dev/null +++ b/scripts/ios/debug.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +[ -f "$ROOT/.yrxtls-ios-target" ] && . "$ROOT/.yrxtls-ios-target" + +TARGET="${1:-${YRXTALS_IOS_KIND:-}}" +if [ -z "$TARGET" ]; then + if xcrun devicectl list devices 2>/dev/null | grep -q "available (paired)"; then + TARGET="device" + else + TARGET="sim" + fi +fi + +case "$TARGET" in + device) export YRXTALS_IOS_DEVICE="${YRXTALS_IOS_DEVICE:-${YRXTALS_IOS_UDID:-}}" ;; + sim) export YRXTALS_IOS_SIM="${YRXTALS_IOS_SIM:-${YRXTALS_IOS_UDID:-}}" ;; +esac + +case "$TARGET" in + sim) + YRXTALS_IOS_CARGO_PROFILE=release-debug bash "$ROOT/scripts/ios/install.sh" sim + xcrun simctl spawn "${YRXTALS_IOS_SIM:-booted}" log stream \ + --predicate 'processImagePath CONTAINS "YrXtals"' \ + --level debug + ;; + + device) + YRXTALS_IOS_CARGO_PROFILE=release-debug bash "$ROOT/scripts/ios/build.sh" device + + APP="$ROOT/build/ios/YrXtals.app" + BUNDLE_ID="org.else-if.yrxtals" + + AVAILABLE_LINES="$(xcrun devicectl list devices 2>/dev/null \ + | grep 'available (paired)' \ + | grep -E 'iPad|iPhone' || true)" + + DEVICE_ID="${YRXTALS_IOS_DEVICE:-}" + if [ -n "$DEVICE_ID" ] && ! echo "$AVAILABLE_LINES" | grep -q "$DEVICE_ID"; then + echo "saved device $DEVICE_ID not currently available, falling back to first paired device" >&2 + DEVICE_ID="" + fi + if [ -z "$DEVICE_ID" ]; then + DEVICE_ID="$(echo "$AVAILABLE_LINES" \ + | awk '{for(i=1;i<=NF;i++) if($i ~ /^[A-F0-9-]{36}$/) {print $i; exit}}')" + fi + + if [ -z "$DEVICE_ID" ]; then + echo "no paired iPad/iPhone found; connect via cable and trust this Mac on the device" >&2 + exit 1 + fi + + echo "Installing to device $DEVICE_ID..." + xcrun devicectl device install app --device "$DEVICE_ID" "$APP" + + echo "Launching with live console (Ctrl+C to detach)..." + echo "----------------------------------------------------------" + exec xcrun devicectl device process launch \ + --device "$DEVICE_ID" \ + --console \ + --terminate-existing \ + "$BUNDLE_ID" + ;; + + *) + echo "usage: $0 [sim|device]" >&2 + exit 2 + ;; +esac diff --git a/scripts/ios/generate-icons.sh b/scripts/ios/generate-icons.sh new file mode 100755 index 0000000..6835037 --- /dev/null +++ b/scripts/ios/generate-icons.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SVG="$ROOT/assets/Icon.svg" +ASSETS="$ROOT/ios/Assets.xcassets" +APPICON="$ASSETS/AppIcon.appiconset" + +if [ ! -f "$SVG" ]; then + echo "ERROR: $SVG not found" >&2 + exit 1 +fi + +if ! command -v rsvg-convert >/dev/null 2>&1; then + echo "ERROR: rsvg-convert not on PATH (brew install librsvg)" >&2 + exit 1 +fi + +mkdir -p "$APPICON" + +SIZES=( + "Icon-20.png 20" + "Icon-20@2x.png 40" + "Icon-20@3x.png 60" + "Icon-29.png 29" + "Icon-29@2x.png 58" + "Icon-29@3x.png 87" + "Icon-40.png 40" + "Icon-40@2x.png 80" + "Icon-40@3x.png 120" + "Icon-60@2x.png 120" + "Icon-60@3x.png 180" + "Icon-76.png 76" + "Icon-76@2x.png 152" + "Icon-83.5@2x.png 167" + "Icon-1024.png 1024" +) + +for entry in "${SIZES[@]}"; do + name="${entry%% *}" + size="${entry##* }" + rsvg-convert --width="$size" --height="$size" "$SVG" -o "$APPICON/$name" +done + +cat > "$ASSETS/Contents.json" <<'EOF' +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} +EOF + +cat > "$APPICON/Contents.json" <<'EOF' +{ + "images" : [ + { "idiom" : "iphone", "size" : "20x20", "scale" : "2x", "filename" : "Icon-20@2x.png" }, + { "idiom" : "iphone", "size" : "20x20", "scale" : "3x", "filename" : "Icon-20@3x.png" }, + { "idiom" : "iphone", "size" : "29x29", "scale" : "2x", "filename" : "Icon-29@2x.png" }, + { "idiom" : "iphone", "size" : "29x29", "scale" : "3x", "filename" : "Icon-29@3x.png" }, + { "idiom" : "iphone", "size" : "40x40", "scale" : "2x", "filename" : "Icon-40@2x.png" }, + { "idiom" : "iphone", "size" : "40x40", "scale" : "3x", "filename" : "Icon-40@3x.png" }, + { "idiom" : "iphone", "size" : "60x60", "scale" : "2x", "filename" : "Icon-60@2x.png" }, + { "idiom" : "iphone", "size" : "60x60", "scale" : "3x", "filename" : "Icon-60@3x.png" }, + { "idiom" : "ipad", "size" : "20x20", "scale" : "1x", "filename" : "Icon-20.png" }, + { "idiom" : "ipad", "size" : "20x20", "scale" : "2x", "filename" : "Icon-20@2x.png" }, + { "idiom" : "ipad", "size" : "29x29", "scale" : "1x", "filename" : "Icon-29.png" }, + { "idiom" : "ipad", "size" : "29x29", "scale" : "2x", "filename" : "Icon-29@2x.png" }, + { "idiom" : "ipad", "size" : "40x40", "scale" : "1x", "filename" : "Icon-40.png" }, + { "idiom" : "ipad", "size" : "40x40", "scale" : "2x", "filename" : "Icon-40@2x.png" }, + { "idiom" : "ipad", "size" : "76x76", "scale" : "1x", "filename" : "Icon-76.png" }, + { "idiom" : "ipad", "size" : "76x76", "scale" : "2x", "filename" : "Icon-76@2x.png" }, + { "idiom" : "ipad", "size" : "83.5x83.5","scale" : "2x","filename" : "Icon-83.5@2x.png" }, + { "idiom" : "ios-marketing","size" : "1024x1024","scale" : "1x","filename" : "Icon-1024.png" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} +EOF + +echo "Wrote $APPICON ($(ls "$APPICON" | wc -l | tr -d ' ') files)" diff --git a/scripts/ios/install.sh b/scripts/ios/install.sh new file mode 100755 index 0000000..4af9d34 --- /dev/null +++ b/scripts/ios/install.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +[ -f "$ROOT/.yrxtls-ios-target" ] && . "$ROOT/.yrxtls-ios-target" + +TARGET="${1:-${YRXTALS_IOS_KIND:-}}" +if [ -z "$TARGET" ]; then + if xcrun devicectl list devices 2>/dev/null | grep -q "available (paired)"; then + TARGET="device" + else + TARGET="sim" + fi +fi + +case "$TARGET" in + device) export YRXTALS_IOS_DEVICE="${YRXTALS_IOS_DEVICE:-${YRXTALS_IOS_UDID:-}}" ;; + sim) export YRXTALS_IOS_SIM="${YRXTALS_IOS_SIM:-${YRXTALS_IOS_UDID:-}}" ;; +esac + +case "$TARGET" in + sim) + bash "$ROOT/scripts/ios/build.sh" sim + APP="$ROOT/build/ios/YrXtals.app" + BUNDLE_ID="org.else-if.yrxtals" + + DEV="${YRXTALS_IOS_SIM:-}" + if [ -z "$DEV" ]; then + DEV="$(xcrun simctl list devices booted | awk '/Booted/ {print $NF}' | tr -d '()' | head -1 || true)" + fi + if [ -z "$DEV" ]; then + DEV="$(xcrun simctl list devices available | awk '/iPad/ && /\([A-F0-9\-]+\)/ {gsub(/[\(\)]/,"",$NF); print $NF; exit}')" + if [ -z "$DEV" ]; then + echo "no iPad simulator available, open Xcode > Window > Devices and Simulators to add one" >&2 + exit 1 + fi + fi + xcrun simctl boot "$DEV" 2>/dev/null || true + open -a Simulator + + echo "Installing to simulator $DEV..." + xcrun simctl install "$DEV" "$APP" + echo "Launching..." + xcrun simctl launch "$DEV" "$BUNDLE_ID" + ;; + + device) + bash "$ROOT/scripts/ios/build.sh" device + APP="$ROOT/build/ios/YrXtals.app" + + AVAILABLE_LINES="$(xcrun devicectl list devices 2>/dev/null \ + | grep 'available (paired)' \ + | grep -E 'iPad|iPhone' || true)" + + DEVICE_ID="${YRXTALS_IOS_DEVICE:-}" + if [ -n "$DEVICE_ID" ] && ! echo "$AVAILABLE_LINES" | grep -q "$DEVICE_ID"; then + echo "saved device $DEVICE_ID not currently available, falling back to first paired device" >&2 + DEVICE_ID="" + fi + if [ -z "$DEVICE_ID" ]; then + DEVICE_ID="$(echo "$AVAILABLE_LINES" \ + | awk '{for(i=1;i<=NF;i++) if($i ~ /^[A-F0-9-]{36}$/) {print $i; exit}}')" + fi + + if [ -z "$DEVICE_ID" ]; then + echo "no paired iPad/iPhone found, connect via cable and trust the host on the device" >&2 + exit 1 + fi + + echo "Installing to device $DEVICE_ID..." + xcrun devicectl device install app --device "$DEVICE_ID" "$APP" + + echo "Launching..." + xcrun devicectl device process launch --device "$DEVICE_ID" org.else-if.yrxtals || true + ;; + + *) + echo "usage: $0 [sim|device]" >&2 + exit 2 + ;; +esac diff --git a/scripts/ios/release.sh b/scripts/ios/release.sh new file mode 100755 index 0000000..4e53b83 --- /dev/null +++ b/scripts/ios/release.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s), iOS release requires macOS" >&2; exit 1;; +esac + +RUST_TARGET="aarch64-apple-ios" +SDK_NAME="iphoneos" +SWIFT_TARGET="arm64-apple-ios17.0" +CARGO_PROFILE="release" + +PROFILE="${YRXTALS_IOS_APPSTORE_PROFILE:-/Volumes/External/prvProfiles/Yr_Xtals.mobileprovision}" +if [ ! -f "$PROFILE" ]; then + echo "ERROR: App Store provisioning profile not found at $PROFILE" >&2 + echo " set YRXTALS_IOS_APPSTORE_PROFILE to override" >&2 + exit 1 +fi + +if [ -n "${VER:-}" ]; then + if ! [[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: VER must be x.y.z (got '$VER')" >&2 + exit 1 + fi + /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VER" "$ROOT/ios/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VER" "$ROOT/ios/Info.plist" + echo "Pinned ios/Info.plist version to $VER" +fi + +BUILD="$ROOT/build" +APP="$BUILD/ios-release/YrXtals.app" +PAYLOAD="$BUILD/ios-release/Payload" +IPA="$BUILD/ios-release/YrXtals.ipa" +RUST_LIB="/tmp/yr_crystals-target/$RUST_TARGET/$CARGO_PROFILE" + +SDK="$(xcrun --sdk "$SDK_NAME" --show-sdk-path)" + +export CC=/usr/bin/clang +export CXX=/usr/bin/clang++ +export IPHONEOS_DEPLOYMENT_TARGET=17.0 + +echo "Building Rust staticlib for $RUST_TARGET (profile=$CARGO_PROFILE)..." +cargo build --profile "$CARGO_PROFILE" --target "$RUST_TARGET" --lib + +rm -f "$RUST_LIB/libyr_crystals.dylib" "$RUST_LIB/deps/libyr_crystals.dylib" + +if [ ! -f "$RUST_LIB/libyr_crystals.a" ]; then + echo "ERROR: libyr_crystals.a not found at $RUST_LIB" >&2 + exit 1 +fi + +rm -rf "$BUILD/ios-release" +mkdir -p "$APP" +cp "$ROOT/ios/Info.plist" "$APP/Info.plist" + +bash "$ROOT/scripts/ios/generate-icons.sh" + +ACTOOL_PARTIAL="$BUILD/ios-release/actool-partial-info.plist" +echo "Compiling asset catalog..." +xcrun actool "$ROOT/ios/Assets.xcassets" \ + --compile "$APP" \ + --platform "$SDK_NAME" \ + --minimum-deployment-target 17.0 \ + --app-icon AppIcon \ + --output-partial-info-plist "$ACTOOL_PARTIAL" \ + --target-device ipad \ + --target-device iphone \ + >/dev/null + +if [ -f "$ACTOOL_PARTIAL" ]; then + /usr/libexec/PlistBuddy -c "Merge $ACTOOL_PARTIAL" "$APP/Info.plist" 2>/dev/null || true +fi + +RUST_FLAGS=(-import-objc-header "$ROOT/include/yr_xtals.h" -L "$RUST_LIB" -lyr_crystals) + +echo "Compiling Swift (release, no DEBUG)..." +xcrun -sdk "$SDK_NAME" swiftc \ + -target "$SWIFT_TARGET" \ + -sdk "$SDK" \ + "${RUST_FLAGS[@]}" \ + -framework UIKit \ + -framework SwiftUI \ + -framework QuartzCore \ + -framework Metal \ + -framework MetalKit \ + -framework AVFoundation \ + -framework AudioToolbox \ + -framework MediaPlayer \ + -framework CoreGraphics \ + -framework CoreFoundation \ + -O \ + -whole-module-optimization \ + -o "$APP/YrXtals" \ + "$ROOT"/ios/src/*.swift + +cp "$PROFILE" "$APP/embedded.mobileprovision" + +ENT="$BUILD/ios-release/entitlements.plist" +security cms -D -i "$PROFILE" 2>/dev/null \ + | plutil -extract Entitlements xml1 -o "$ENT" - \ + || { echo "ERROR: could not extract entitlements from profile" >&2; exit 1; } + +# forces get-task-allow=false in entitlements. +/usr/libexec/PlistBuddy -c "Set :get-task-allow false" "$ENT" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :get-task-allow bool false" "$ENT" + +# pins wildcard application-identifier to the concrete TEAM.bundle.id form. +BUNDLE_ID="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP/Info.plist")" +TEAM_ID="$(/usr/libexec/PlistBuddy -c "Print :com.apple.developer.team-identifier" "$ENT" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Print :application-identifier" "$ENT" | cut -d. -f1)" +case "$(/usr/libexec/PlistBuddy -c "Print :application-identifier" "$ENT")" in + *\*) /usr/libexec/PlistBuddy -c "Set :application-identifier ${TEAM_ID}.${BUNDLE_ID}" "$ENT" ;; +esac + +# locates the Apple Distribution identity in the keychain by SHA-matching the profile's certs. +TMPDIR_PROF="$(mktemp -d)" +PROFILE_PLIST="$TMPDIR_PROF/profile.plist" +security cms -D -i "$PROFILE" > "$PROFILE_PLIST" 2>/dev/null + +PROFILE_SHAS="" +for i in 0 1 2 3 4 5 6 7 8 9; do + if ! plutil -extract "DeveloperCertificates.$i" raw -o "$TMPDIR_PROF/c$i.b64" "$PROFILE_PLIST" >/dev/null 2>&1; then + break + fi + base64 -D -i "$TMPDIR_PROF/c$i.b64" -o "$TMPDIR_PROF/c$i.cer" + sha=$(openssl x509 -inform der -in "$TMPDIR_PROF/c$i.cer" -fingerprint -noout 2>/dev/null \ + | sed 's/.*=//;s/://g') + PROFILE_SHAS="$PROFILE_SHAS $sha" +done + +KEYCHAIN_SHAS=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk '/[0-9A-F]{40}/ {gsub(/[^0-9A-F]/, "", $2); print $2}') + +IDENTITY="" +for s in $PROFILE_SHAS; do + if echo "$KEYCHAIN_SHAS" | grep -qi "^$s$"; then + IDENTITY="$s" + break + fi +done +rm -rf "$TMPDIR_PROF" + +if [ -z "$IDENTITY" ]; then + echo "ERROR: no Apple Distribution identity in keychain matches the profile's certs" >&2 + echo " profile certs:$PROFILE_SHAS" >&2 + exit 1 +fi + +echo "Signing with Apple Distribution $IDENTITY..." +codesign --force \ + --sign "$IDENTITY" \ + --entitlements "$ENT" \ + --options runtime \ + --timestamp \ + "$APP" + +codesign --verify --deep --strict --verbose=2 "$APP" + +mkdir -p "$PAYLOAD" +cp -R "$APP" "$PAYLOAD/YrXtals.app" +rm -f "$IPA" +( cd "$BUILD/ios-release" && zip -qry "$(basename "$IPA")" Payload ) +rm -rf "$PAYLOAD" + +SHIPPED_VER="$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP/Info.plist")" + +# resolves BUMP from --bump flag, BUMP env var, or fallback default. +EXPLICIT_BUMP="" +for arg in "$@"; do + case "$arg" in + --bump) EXPLICIT_BUMP=patch ;; + --bump=*) EXPLICIT_BUMP="${arg#--bump=}" ;; + esac +done +if [ -n "$EXPLICIT_BUMP" ]; then + BUMP="$EXPLICIT_BUMP" +elif [ -z "${BUMP:-}" ]; then + if [ -n "${VER:-}" ]; then + BUMP=none + else + BUMP=patch + fi +fi +case "$BUMP" in + none) + echo "Skipping version bump (BUMP=none)" + ;; + major|minor|patch) + IFS='.' read -r CUR_MAJ CUR_MIN CUR_PATCH <<< "$SHIPPED_VER" + CUR_MAJ="${CUR_MAJ:-0}"; CUR_MIN="${CUR_MIN:-0}"; CUR_PATCH="${CUR_PATCH:-0}" + case "$BUMP" in + major) NEW_VER="$((CUR_MAJ + 1)).0.0" ;; + minor) NEW_VER="$CUR_MAJ.$((CUR_MIN + 1)).0" ;; + patch) NEW_VER="$CUR_MAJ.$CUR_MIN.$((CUR_PATCH + 1))" ;; + esac + /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $NEW_VER" "$ROOT/ios/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $NEW_VER" "$ROOT/ios/Info.plist" + echo "Bumped ios/Info.plist: $SHIPPED_VER → $NEW_VER ($BUMP) for next release" + ;; + *) + echo "ERROR: BUMP must be patch|minor|major|none, got '$BUMP'" >&2 + exit 1 + ;; +esac + +echo "Built: $IPA ($BUNDLE_ID @ $SHIPPED_VER)" diff --git a/scripts/ios/select.sh b/scripts/ios/select.sh new file mode 100755 index 0000000..a7801a1 --- /dev/null +++ b/scripts/ios/select.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +STATE="$ROOT/.yrxtls-ios-target" + +case "$(uname -s)" in + Darwin) ;; + *) echo "select-ios only works on macOS" >&2; exit 1;; +esac + +ENTRIES=() + +while IFS= read -r line; do + line="${line%"${line##*[![:space:]]}"}" + udid=$(echo "$line" | grep -oE '[A-F0-9-]{36}' | head -1) + [ -z "$udid" ] && continue + name=$(echo "$line" | awk -F' +' '{print $1}') + model=$(echo "$line" | awk -F' +' '{print $5}') + ENTRIES+=("device|$udid|$name ($model)") +done < <(xcrun devicectl list devices 2>/dev/null | grep "available (paired)") + +declare -A SEEN_SIMS +runtime_re='^-- (.*) --$' +sim_re='^[[:space:]]+(.*)[[:space:]]\(([A-F0-9-]{36})\)[[:space:]]\(([^)]+)\)' +while IFS= read -r line; do + if [[ "$line" =~ $runtime_re ]]; then + runtime="${BASH_REMATCH[1]}" + continue + fi + if [[ "$line" =~ $sim_re ]]; then + name="${BASH_REMATCH[1]}" + udid="${BASH_REMATCH[2]}" + [ -n "${SEEN_SIMS[$name]:-}" ] && continue + SEEN_SIMS[$name]=1 + ENTRIES+=("sim|$udid|$name ($runtime)") + fi +done < <(xcrun simctl list devices available 2>/dev/null) + +if [ ${#ENTRIES[@]} -eq 0 ]; then + echo "no devices or simulators found" >&2 + exit 1 +fi + +echo "Pick an iOS target (saved to .yrxtls-ios-target):" +echo +i=1 +for e in "${ENTRIES[@]}"; do + kind="${e%%|*}" + rest="${e#*|}" + udid="${rest%%|*}" + label="${rest#*|}" + case "$kind" in + device) tag="📱 device" ;; + sim) tag="🖥️ sim " ;; + esac + printf " %2d) %s %s\n" "$i" "$tag" "$label" + i=$((i+1)) +done +echo " 0) clear selection (auto-pick)" +echo + +read -r -p "> " choice + +if [ "$choice" = "0" ]; then + rm -f "$STATE" + echo "selection cleared, scripts will auto-pick again." + exit 0 +fi + +if ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt ${#ENTRIES[@]} ]; then + echo "not a valid choice" >&2 + exit 1 +fi + +picked="${ENTRIES[$((choice-1))]}" +KIND="${picked%%|*}" +rest="${picked#*|}" +UDID="${rest%%|*}" +LABEL="${rest#*|}" + +cat > "$STATE" <&2; exit 1 ;; + esac + ;; + iphonesimulator) + case "${ARCHS:-}" in + *arm64*) RUST_TARGET="aarch64-apple-ios-sim" ;; + *x86_64*) RUST_TARGET="x86_64-apple-ios" ;; + *) echo "unsupported ARCHS=$ARCHS for $PLATFORM_NAME" >&2; exit 1 ;; + esac + ;; + *) + echo "unsupported PLATFORM_NAME=$PLATFORM_NAME" >&2 + exit 1 + ;; +esac + +ROOT="$(cd "${PROJECT_DIR:-.}/.." && pwd)" + +export PATH="$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" +export CC=/usr/bin/clang +export CXX=/usr/bin/clang++ +export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-17.0}" + +cd "$ROOT" +cargo build --release --target "$RUST_TARGET" --lib + +rm -f "/tmp/yr_crystals-target/$RUST_TARGET/release/libyr_crystals.dylib" \ + "/tmp/yr_crystals-target/$RUST_TARGET/release/deps/libyr_crystals.dylib" + +mkdir -p "$ROOT/target" +ln -sfn "/tmp/yr_crystals-target/$RUST_TARGET" "$ROOT/target/$PLATFORM_NAME" diff --git a/scripts/ios/xcodeproj.sh b/scripts/ios/xcodeproj.sh new file mode 100755 index 0000000..a516940 --- /dev/null +++ b/scripts/ios/xcodeproj.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +if ! command -v xcodegen >/dev/null 2>&1; then + echo "xcodegen not found. install with:" >&2 + echo " brew install xcodegen" >&2 + exit 1 +fi + +export CC=/usr/bin/clang +export CXX=/usr/bin/clang++ +export IPHONEOS_DEPLOYMENT_TARGET=17.0 + +echo "Building Rust staticlibs for both iOS targets (release)..." +cargo build --release --target aarch64-apple-ios --lib +cargo build --release --target aarch64-apple-ios-sim --lib + +mkdir -p "$ROOT/target" +[ -L "$ROOT/target/iphoneos" ] || ln -sf "/tmp/yr_crystals-target/aarch64-apple-ios" "$ROOT/target/iphoneos" +[ -L "$ROOT/target/iphonesimulator" ] || ln -sf "/tmp/yr_crystals-target/aarch64-apple-ios-sim" "$ROOT/target/iphonesimulator" + +bash "$ROOT/scripts/ios/generate-icons.sh" + +cd "$ROOT/ios" +echo "Generating YrXtals.xcodeproj..." +xcodegen generate + +echo +echo "Generated: $ROOT/ios/YrXtals.xcodeproj" +echo "Open with: open $ROOT/ios/YrXtals.xcodeproj" +echo +echo "Build/run from xcode: pick a destination (an iPad or a sim) and hit Cmd+R." +echo "After changing Rust code, re-run this command to rebuild the staticlibs." diff --git a/scripts/linux/build.sh b/scripts/linux/build.sh new file mode 100755 index 0000000..9d912ac --- /dev/null +++ b/scripts/linux/build.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Linux) ;; + *) echo "wrong platform: $(uname -s), use cargo xtask build" >&2; exit 1;; +esac + +BUILD="$ROOT/build" + +echo "Building Rust binary (release)..." +cargo build --release --bin yr_crystals + +BIN="/tmp/yr_crystals-target/release/yr_crystals" +if [ ! -f "$BIN" ]; then + echo "ERROR: yr_crystals binary not found at $BIN" >&2 + exit 1 +fi + +mkdir -p "$BUILD/bin" "$BUILD/icons" +cp "$BIN" "$BUILD/bin/yr_crystals" + +SVG="$ROOT/assets/Icon.svg" +if [ -f "$SVG" ] && command -v rsvg-convert >/dev/null 2>&1; then + echo "Generating icon PNGs..." + for size in 32 64 128 256 512; do + rsvg-convert --width="$size" --height="$size" "$SVG" -o "$BUILD/icons/yr_crystals_${size}.png" + done +fi + +echo "Built: $BUILD/bin/yr_crystals" diff --git a/scripts/linux/debug.sh b/scripts/linux/debug.sh new file mode 100755 index 0000000..486e4f2 --- /dev/null +++ b/scripts/linux/debug.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Linux) ;; + *) echo "wrong platform: $(uname -s) — use cargo xtask debug" >&2; exit 1;; +esac + +export RUST_BACKTRACE=1 +cargo run --bin yr_crystals diff --git a/scripts/macos/build.sh b/scripts/macos/build.sh new file mode 100755 index 0000000..0c7e2ea --- /dev/null +++ b/scripts/macos/build.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +# compiles the rust binary, renders AppIcon.icns from the svg, and assembles "Yr Xtals.app" under build/bin. + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s) — use cargo xtask build" >&2; exit 1;; +esac + +BUILD="$ROOT/build" +APP="$BUILD/bin/Yr Xtals.app" +CONTENTS="$APP/Contents" +MACOS="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" + +export MACOSX_DEPLOYMENT_TARGET=14.0 + +echo "Building Rust binary (release)..." +cargo build --release --bin yr_crystals + +# cargo target dir under /tmp per .cargo/config.toml +BIN="/tmp/yr_crystals-target/release/yr_crystals" +if [ ! -f "$BIN" ]; then + echo "ERROR: yr_crystals binary not found at $BIN" >&2 + exit 1 +fi + +# AppIcon.icns from assets/Icon.svg via rsvg-convert + iconutil. +SVG="$ROOT/assets/Icon.svg" +if [ -f "$SVG" ]; then + if ! command -v rsvg-convert >/dev/null 2>&1; then + echo "ERROR: rsvg-convert missing — install with 'brew install librsvg' or 'port install librsvg'" >&2 + exit 1 + fi + echo "Generating app icon..." + ICONSET="$BUILD/AppIcon.iconset" + rm -rf "$ICONSET" + mkdir -p "$ICONSET" + for size in 16 32 64 128 256 512 1024; do + rsvg-convert --width="$size" --height="$size" "$SVG" -o "$ICONSET/icon_${size}.png" + done + cp "$ICONSET/icon_16.png" "$ICONSET/icon_16x16.png" + cp "$ICONSET/icon_32.png" "$ICONSET/icon_16x16@2x.png" + cp "$ICONSET/icon_32.png" "$ICONSET/icon_32x32.png" + cp "$ICONSET/icon_64.png" "$ICONSET/icon_32x32@2x.png" + cp "$ICONSET/icon_128.png" "$ICONSET/icon_128x128.png" + cp "$ICONSET/icon_256.png" "$ICONSET/icon_128x128@2x.png" + cp "$ICONSET/icon_256.png" "$ICONSET/icon_256x256.png" + cp "$ICONSET/icon_512.png" "$ICONSET/icon_256x256@2x.png" + cp "$ICONSET/icon_512.png" "$ICONSET/icon_512x512.png" + cp "$ICONSET/icon_1024.png" "$ICONSET/icon_512x512@2x.png" + rm -f "$ICONSET"/icon_16.png "$ICONSET"/icon_32.png "$ICONSET"/icon_64.png \ + "$ICONSET"/icon_128.png "$ICONSET"/icon_256.png "$ICONSET"/icon_512.png \ + "$ICONSET"/icon_1024.png + iconutil -c icns "$ICONSET" -o "$BUILD/AppIcon.icns" + rm -rf "$ICONSET" +fi + +mkdir -p "$MACOS" "$RESOURCES" +cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist" +cp "$BIN" "$MACOS/yr_crystals" +[ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" + +codesign --force --sign - "$APP" + +echo "Built: $APP" +open "$APP" diff --git a/scripts/macos/debug.sh b/scripts/macos/debug.sh new file mode 100755 index 0000000..97ed993 --- /dev/null +++ b/scripts/macos/debug.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s), use cargo xtask debug" >&2; exit 1;; +esac + +BUILD="$ROOT/build" +APP="$BUILD/bin/Yr Xtals.app" +CONTENTS="$APP/Contents" +MACOS="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" + +export MACOSX_DEPLOYMENT_TARGET=14.0 +export RUST_BACKTRACE=1 + +echo "Building Rust binary (debug)..." +cargo build --bin yr_crystals + +BIN="/tmp/yr_crystals-target/debug/yr_crystals" +if [ ! -f "$BIN" ]; then + echo "ERROR: yr_crystals binary not found at $BIN" >&2 + exit 1 +fi + +mkdir -p "$MACOS" "$RESOURCES" +cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist" +cp "$BIN" "$MACOS/yr_crystals" +[ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" + +codesign --force --sign - "$APP" + +pkill -f "Yr Xtals.app/Contents/MacOS/yr_crystals" 2>/dev/null || true +sleep 0.3 + +echo +echo "Launching $MACOS/yr_crystals. Rust panics will print below." +echo "(Ctrl+C to exit, or quit Yr Xtals normally.)" +echo "----------------------------------------------------------" +exec "$MACOS/yr_crystals" diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh new file mode 100755 index 0000000..fae1a78 --- /dev/null +++ b/scripts/macos/install.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s), use cargo xtask install" >&2; exit 1;; +esac + +DEST="/Applications/Yr Xtals.app" + +bash "$ROOT/scripts/macos/build.sh" + +pkill -f "Yr Xtals.app/Contents/MacOS/yr_crystals" 2>/dev/null || true +sleep 0.5 + +echo "Installing to $DEST..." +rm -rf "$DEST" +cp -R "$ROOT/build/bin/Yr Xtals.app" "$DEST" + +echo "Installed: $DEST" diff --git a/scripts/windows/build.ps1 b/scripts/windows/build.ps1 new file mode 100644 index 0000000..f89a4eb --- /dev/null +++ b/scripts/windows/build.ps1 @@ -0,0 +1,42 @@ +$ErrorActionPreference = "Stop" + +$Root = Resolve-Path "$PSScriptRoot\..\.." +Set-Location $Root + +if ($env:OS -ne "Windows_NT") { + Write-Error "wrong platform, use cargo xtask build" + exit 1 +} + +Write-Host "Building Rust binary (release)..." +cargo build --release --bin yr_crystals + +$Bin = Join-Path $Root "target\release\yr_crystals.exe" +if (-not (Test-Path $Bin)) { + Write-Error "yr_crystals.exe not found at $Bin" + exit 1 +} + +$Build = Join-Path $Root "build" +$BinDir = Join-Path $Build "bin" +New-Item -ItemType Directory -Force -Path $BinDir | Out-Null +Copy-Item $Bin (Join-Path $BinDir "yr_crystals.exe") -Force + +$Svg = Join-Path $Root "assets\Icon.svg" +$Magick = Get-Command magick -ErrorAction SilentlyContinue +if ((Test-Path $Svg) -and $Magick) { + Write-Host "Generating yr_crystals.ico..." + $IconDir = Join-Path $Build "icons" + New-Item -ItemType Directory -Force -Path $IconDir | Out-Null + $Sizes = 16, 32, 48, 64, 128, 256 + $Pngs = @() + foreach ($s in $Sizes) { + $Out = Join-Path $IconDir "icon_$s.png" + & $Magick.Source -background none -density 384 $Svg -resize ${s}x${s} $Out + $Pngs += $Out + } + & $Magick.Source $Pngs (Join-Path $BinDir "yr_crystals.ico") + Remove-Item $IconDir -Recurse -Force +} + +Write-Host "Built: $BinDir\yr_crystals.exe" diff --git a/shaders/fft.wgsl b/shaders/fft.wgsl new file mode 100644 index 0000000..d0ffa59 --- /dev/null +++ b/shaders/fft.wgsl @@ -0,0 +1,54 @@ +// radix-2 Cooley-Tukey 1D complex FFT, one bit_reverse pass plus log2N butterfly passes over scratch. + +struct Args { + n: u32, + log2_n: u32, + stride: u32, + inverse: u32, +}; + +@group(0) @binding(0) var args: Args; +@group(0) @binding(1) var data: array>; +@group(0) @binding(2) var scratch: array>; + +@compute @workgroup_size(64) +fn bit_reverse(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if (i >= args.n) { return; } + var rev: u32 = 0u; + var x = i; + for (var b: u32 = 0u; b < args.log2_n; b = b + 1u) { + rev = (rev << 1u) | (x & 1u); + x = x >> 1u; + } + scratch[rev] = data[i]; +} + +const PI: f32 = 3.14159265358979; + +@compute @workgroup_size(64) +fn butterfly(@builtin(global_invocation_id) gid: vec3) { + let k = gid.x; + let n = args.n; + if (k >= n / 2u) { return; } + + let half = args.stride; + let m = half * 2u; + let group = k / half; + let j = k % half; + let base = group * m; + let ia = base + j; + let ib = ia + half; + + var sign: f32 = -1.0; + if (args.inverse != 0u) { sign = 1.0; } + let angle = sign * PI * f32(j) / f32(half); + let w = vec2(cos(angle), sin(angle)); + + let a = scratch[ia]; + let b = scratch[ib]; + let bw = vec2(b.x * w.x - b.y * w.y, b.x * w.y + b.y * w.x); + + scratch[ia] = a + bw; + scratch[ib] = a - bw; +} diff --git a/shaders/visualizer.wgsl b/shaders/visualizer.wgsl new file mode 100644 index 0000000..539faf8 --- /dev/null +++ b/shaders/visualizer.wgsl @@ -0,0 +1,252 @@ +// synthesizes triangle and line vertices per-bin from a storage buffer, with mirrors as an instance axis. + +struct Globals { + bounds: vec2, // full canvas in pixels + base: vec2, // building rect in pixels, 0.55w by 0.5h when mirrored + num_bins: u32, + num_channels: u32, + flags: u32, // 1=glass, 2=mirrored, 4=inverted, 8=stereo + fade_bins: u32, // count of tail bins fading toward zero alpha when mirrored + hue_param: f32, + contrast: f32, + brightness: f32, + _pad0: f32, + unified_hue: f32, + unified_sat: f32, + unified_val: f32, + _pad1: f32, +}; + +struct Bin { + log_x: f32, // 0..1 along the log-frequency axis + visual_norm: f32, // smoothed dB on a 0..1 scale + primary_norm: f32, // primary dB on a 0..1 scale + bright_mod: f32, + alpha_mod: f32, + hue: f32, + sat: f32, + val: f32, +}; + +@group(0) @binding(0) var globals: Globals; +@group(0) @binding(1) var bins: array; + +struct VertexOut { + @builtin(position) clip_position: vec4, + @location(0) color: vec4, +}; + +fn flag(bit: u32) -> bool { + return (globals.flags & bit) != 0u; +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> vec3 { + let hh = fract(fract(h) + 1.0) * 6.0; + let i = floor(hh); + let f = hh - i; + let p = v * (1.0 - s); + let q = v * (1.0 - s * f); + let t = v * (1.0 - s * (1.0 - f)); + let ii = i32(i) % 6; + if (ii == 0) { return vec3(v, t, p); } + if (ii == 1) { return vec3(q, v, p); } + if (ii == 2) { return vec3(p, v, t); } + if (ii == 3) { return vec3(p, q, v); } + if (ii == 4) { return vec3(t, p, v); } + return vec3(v, p, q); +} + +fn final_brightness(b: Bin) -> f32 { + let base_b = sqrt(b.primary_norm); + let bm = b.bright_mod; + var b_mult: f32; + if (bm >= 0.0) { + b_mult = 1.0 + bm; + } else { + b_mult = 1.0 / (1.0 - bm * 2.0); + } + return clamp(base_b * b_mult * globals.brightness, 0.0, 1.0); +} + +fn alpha_for(b: Bin, fade: f32) -> f32 { + let am = b.alpha_mod; + var a_mult: f32; + if (am >= 0.0) { + a_mult = 1.0 + am * 0.5; + } else { + a_mult = 1.0 + am; + } + a_mult = max(a_mult, 0.1); + var a = 0.4 + (b.primary_norm - 0.5) * globals.contrast; + a = clamp(a * a_mult, 0.0, 1.0); + return a * fade; +} + +fn dyn_rgb(b: Bin) -> vec3 { + let fb = final_brightness(b); + let s = clamp(b.sat * globals.hue_param, 0.0, 1.0); + let v = clamp(b.val * fb, 0.0, 1.0); + return hsv_to_rgb(b.hue, s, v); +} + +fn fill_rgb(b: Bin) -> vec3 { + if (flag(1u)) { + let fb = final_brightness(b); + let v = clamp(globals.unified_val * fb, 0.0, 1.0); + return hsv_to_rgb(globals.unified_hue, globals.unified_sat, v); + } + return dyn_rgb(b); +} + +fn channel_offset(rgb: vec3, ch: u32) -> vec3 { + if (ch == 1u && flag(8u)) { + let off = 40.0 / 255.0; + return vec3( + max(rgb.x - off, 0.0), + max(rgb.y - off, 0.0), + min(rgb.z + off, 1.0), + ); + } + return rgb; +} + +fn fade_factor(seg: u32) -> f32 { + if (!flag(2u)) { return 1.0; } + let from_end = i32(globals.num_bins) - 2 - i32(seg); + if (from_end < i32(globals.fade_bins)) { + var f = f32(from_end + 1) / f32(globals.fade_bins + 1u); + f = f * f; + return max(f, 0.0); + } + return 1.0; +} + +fn pixel_to_clip(p: vec2) -> vec4 { + let nx = (p.x / max(globals.bounds.x, 1.0)) * 2.0 - 1.0; + let ny = 1.0 - (p.y / max(globals.bounds.y, 1.0)) * 2.0; + return vec4(nx, ny, 0.0, 1.0); +} + +fn mirror_xform(iid: u32, p: vec2) -> vec2 { + var sx: f32 = 1.0; + var sy: f32 = 1.0; + var tx: f32 = 0.0; + var ty: f32 = 0.0; + if (iid == 1u) { + sx = -1.0; tx = globals.bounds.x; + } else if (iid == 2u) { + sy = -1.0; ty = globals.bounds.y; + } else if (iid == 3u) { + sx = -1.0; sy = -1.0; + tx = globals.bounds.x; ty = globals.bounds.y; + } + return vec2(p.x * sx + tx, p.y * sy + ty); +} + +@vertex +fn vs_fill(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> VertexOut { + let nb = globals.num_bins; + let segs = max(nb, 1u) - 1u; + let per_ch = segs * 6u; + let ch = vid / per_ch; + let in_ch = vid % per_ch; + let seg = in_ch / 6u; + let corner = in_ch % 6u; + + var i = seg; + var j = seg + 1u; + if (flag(4u)) { + i = nb - 1u - seg; + j = nb - 2u - seg; + } + + let base = ch * nb; + let bi = bins[base + i]; + let bj = bins[base + j]; + + let w = globals.base.x; + let h = globals.base.y; + let x1 = bi.log_x * w; + let x2 = bj.log_x * w; + let y1 = h - bi.visual_norm * h; + let y2 = h - bj.visual_norm * h; + let anchor_y = h; + + var p: vec2; + switch corner { + case 0u: { p = vec2(x1, anchor_y); } + case 1u: { p = vec2(x1, y1); } + case 2u: { p = vec2(x2, y2); } + case 3u: { p = vec2(x1, anchor_y); } + case 4u: { p = vec2(x2, y2); } + default: { p = vec2(x2, anchor_y); } + } + let pw = mirror_xform(iid, p); + + var rgb = fill_rgb(bi); + rgb = channel_offset(rgb, ch); + let a = alpha_for(bi, fade_factor(seg)); + + var out: VertexOut; + out.clip_position = pixel_to_clip(pw); + out.color = vec4(rgb, a); + return out; +} + +@vertex +fn vs_line(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> VertexOut { + let nb = globals.num_bins; + let per_ch = nb * 2u; + let ch = vid / per_ch; + let in_ch = vid % per_ch; + let seg = in_ch / 2u; + let endpoint = in_ch % 2u; + + var i = seg; + if (flag(4u)) { + i = nb - 1u - seg; + } + let bi = bins[ch * nb + i]; + + let w = globals.base.x; + let h = globals.base.y; + let x = bi.log_x * w; + let y_top = h - bi.visual_norm * h; + let anchor_y = h; + var p: vec2; + if (endpoint == 0u) { + p = vec2(x, anchor_y); + } else { + p = vec2(x, y_top); + } + let pw = mirror_xform(iid, p); + + var rgb = dyn_rgb(bi); + rgb = channel_offset(rgb, ch); + var a = alpha_for(bi, fade_factor(seg)); + a = min(a, 0.9); + + var out: VertexOut; + out.clip_position = pixel_to_clip(pw); + out.color = vec4(rgb, a); + return out; +} + +// cepstrum line strip in pixel space. +struct CepIn { + @location(0) position: vec2, + @location(1) color: vec4, +}; + +@vertex +fn vs_cep(in: CepIn) -> VertexOut { + var out: VertexOut; + out.clip_position = pixel_to_clip(in.position); + out.color = in.color; + return out; +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 { + return in.color; +} diff --git a/src/analyzer.rs b/src/analyzer.rs new file mode 100644 index 0000000..bbc4594 --- /dev/null +++ b/src/analyzer.rs @@ -0,0 +1,318 @@ + + +use std::sync::Arc; + +use num_complex::Complex64; + +use crate::hilbert_stream::RealtimeHilbert; +use crate::processor::Processor; +use crate::track::TrackData; + +/// per-channel analyzer output combining the compressed multi-band db, the unmodified main-band db, and an optional cepstrum. +#[derive(Debug, Clone, Default)] +pub struct FrameData { + pub freqs: Vec, + pub db: Vec, + pub primary_db: Vec, + + pub cepstrum: Vec, +} + +/// stereo three-band processor pool driven by a streaming hilbert source and surfacing one frame per call to step. +pub struct Analyzer { + main: [Processor; 2], + transient: [Processor; 2], + deep: [Processor; 2], + + frame_size: usize, + hop_size: usize, + + hilbert: RealtimeHilbert, + hilbert_fft_size: usize, + hilbert_hop_size: usize, + hilbert_needs_reset: bool, + last_hilbert_sample: usize, + + track: Option>, + last_frames: Vec, +} + +impl Analyzer { + /// builds the main, transient, and deep processor pairs with band-appropriate expanders, hpf, and smoothing. + pub fn new(device: wgpu::Device, queue: wgpu::Queue) -> Self { + let frame_size = 4096_usize; + let hop_size = 1024_usize; + + let mut main = [ + Processor::new(frame_size, 48_000, device.clone(), queue.clone()), + Processor::new(frame_size, 48_000, device.clone(), queue.clone()), + ]; + for p in &mut main { + p.set_expander(1.5, -50.0); + p.set_hpf(80.0); + p.set_smoothing(3); + } + + let trans_size = (frame_size / 4).max(64); + let mut transient = [ + Processor::new(trans_size, 48_000, device.clone(), queue.clone()), + Processor::new(trans_size, 48_000, device.clone(), queue.clone()), + ]; + for p in &mut transient { + p.set_expander(2.5, -40.0); + p.set_hpf(100.0); + p.set_smoothing(2); + } + + let mut deep = [ + Processor::new(frame_size * 2, 48_000, device.clone(), queue.clone()), + Processor::new(frame_size * 2, 48_000, device.clone(), queue.clone()), + ]; + for p in &mut deep { + p.set_expander(1.2, -60.0); + p.set_hpf(0.0); + p.set_smoothing(5); + } + + Self { + main, + transient, + deep, + frame_size, + hop_size, + hilbert: RealtimeHilbert::new(), + hilbert_fft_size: 8192, + hilbert_hop_size: hop_size, + hilbert_needs_reset: true, + last_hilbert_sample: 0, + track: None, + last_frames: Vec::new(), + } + } + + /// returns the loaded track's sample rate, falling back to 48 khz before any track loads. + pub fn sample_rate(&self) -> u32 { + self.track.as_ref().map(|t| t.sample_rate).unwrap_or(48_000) + } + + /// returns the loaded track's stereo-frame count. + pub fn total_samples(&self) -> usize { + self.track.as_ref().map(|t| t.total_samples()).unwrap_or(0) + } + + /// swaps in incoming pcm data and marks the streaming hilbert dirty. + pub fn set_track_data(&mut self, data: Arc) { + let rate = data.sample_rate; + for p in self.main.iter_mut().chain(self.transient.iter_mut()).chain(self.deep.iter_mut()) { + p.set_sample_rate(rate); + } + self.track = Some(data); + self.hilbert_needs_reset = true; + } + + /// retunes the three bands' transform lengths around a base frame size and routes the hilbert hop to match. + pub fn set_dsp_params(&mut self, frame_size: usize, hop_size: usize) { + let trans_size = (frame_size / 4).max(64); + let deep_size = if frame_size < 2048 { frame_size * 4 } else { frame_size * 2 }; + let hilbert_changed = + frame_size != self.hilbert_fft_size || hop_size != self.hilbert_hop_size; + if hilbert_changed { + self.hilbert_fft_size = frame_size; + self.hilbert_hop_size = hop_size; + self.hilbert_needs_reset = true; + } + self.frame_size = frame_size; + self.hop_size = hop_size; + for p in &mut self.main { + p.set_frame_size(frame_size); + } + for p in &mut self.transient { + p.set_frame_size(trans_size); + } + for p in &mut self.deep { + p.set_frame_size(deep_size); + } + } + + /// propagates the bin count to every band and channel. + pub fn set_num_bins(&mut self, n: usize) { + for p in self.main.iter_mut().chain(self.transient.iter_mut()).chain(self.deep.iter_mut()) { + p.set_num_bins(n); + } + } + + /// distributes the cepstral idealisation parameters across the three bands with band-specific strength weights. + pub fn set_smoothing_params(&mut self, granularity: i32, detail: i32, strength: f32) { + for p in &mut self.main { + p.set_cepstral_params(granularity, detail, strength); + } + for p in &mut self.transient { + p.set_cepstral_params(granularity, detail, strength * 0.3); + } + for p in &mut self.deep { + p.set_cepstral_params(granularity, detail, strength * 1.2); + } + } + + /// propagates the cpu/gpu fft crossfade to every band and channel. + pub fn set_gpu_blend(&mut self, blend: f32) { + for p in self + .main + .iter_mut() + .chain(self.transient.iter_mut()) + .chain(self.deep.iter_mut()) + { + p.set_gpu_blend(blend); + } + } + + /// advances one hop of analytic-signal data at the requested normalised playhead and publishes a stereo frame pair. + pub fn step(&mut self, position: f64) -> Option<&[FrameData]> { + let total = self.total_samples(); + if total == 0 { + return None; + } + let target = (position.clamp(0.0, 1.0) * total as f64) as usize; + let track = self.track.clone()?; + if !track.is_valid() { + return None; + } + let total_samples = track.total_samples(); + let hop = self.hilbert_hop_size; + let fft_size = self.hilbert_fft_size; + if hop == 0 || fft_size == 0 || target + hop >= total_samples { + return None; + } + + if !self.hilbert_needs_reset { + let delta = target.abs_diff(self.last_hilbert_sample); + if delta > 2 * hop { + self.hilbert_needs_reset = true; + } + } + + if self.hilbert_needs_reset { + self.hilbert.reinit(fft_size); + self.hilbert_needs_reset = false; + // pre-fills the sliding history with the prior fft_size of audio. + let warmup_blocks = fft_size / hop; + let warmup_start = target.saturating_sub(warmup_blocks * hop); + for w in 0..warmup_blocks { + let block_start = warmup_start + w * hop; + if block_start + hop > total_samples { + break; + } + let (left, right) = stereo_block(&track.pcm, block_start, hop); + let _ = self.hilbert.process(&left, &right); + } + } + + self.last_hilbert_sample = target; + + let (left, right) = stereo_block(&track.pcm, target, hop); + let (cl, cr) = self.hilbert.process(&left, &right); + self.push_to_processors(&cl, &cr); + + let produced_new = true; + if produced_new { + self.compute_and_publish(); + Some(&self.last_frames) + } else { + None + } + } + + /// 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); + self.main[1].push_data(complex_r); + self.transient[0].push_data(complex_l); + self.transient[1].push_data(complex_r); + self.deep[0].push_data(complex_l); + self.deep[1].push_data(complex_r); + } + + /// borrows the most recently computed stereo frame pair without recomputing. + pub fn latest(&self) -> &[FrameData] { + &self.last_frames + } + + /// runs the six band/channel spectra in parallel and per-bin max-merges them through a soft-knee compressor. + fn compute_and_publish(&mut self) { + let comp_threshold = -15.0_f32; + let comp_ratio = 4.0_f32; + + let (m0, m1) = self.main.split_at_mut(1); + let (t0, t1) = self.transient.split_at_mut(1); + let (d0, d1) = self.deep.split_at_mut(1); + let main_l = &mut m0[0]; + let main_r = &mut m1[0]; + let trans_l = &mut t0[0]; + let trans_r = &mut t1[0]; + let deep_l = &mut d0[0]; + let deep_r = &mut d1[0]; + + let mut sml = None; + let mut smr = None; + let mut stl = None; + let mut str_ = None; + let mut sdl = None; + let mut sdr = None; + rayon::scope(|s| { + s.spawn(|_| sml = Some(main_l.get_spectrum())); + s.spawn(|_| smr = Some(main_r.get_spectrum())); + s.spawn(|_| stl = Some(trans_l.get_spectrum())); + s.spawn(|_| str_ = Some(trans_r.get_spectrum())); + s.spawn(|_| sdl = Some(deep_l.get_spectrum())); + s.spawn(|_| sdr = Some(deep_r.get_spectrum())); + }); + let pairs = [ + (sml.unwrap(), stl.unwrap(), sdl.unwrap()), + (smr.unwrap(), str_.unwrap(), sdr.unwrap()), + ]; + + let mut results = Vec::with_capacity(2); + for (i, (mut spec_main, spec_trans, spec_deep)) in pairs.into_iter().enumerate() { + let primary_db = spec_main.db.clone(); + + let same_size = spec_main.db.len() == spec_trans.db.len() + && spec_main.db.len() == spec_deep.db.len(); + if same_size { + for b in 0..spec_main.db.len() { + let mut val = spec_main.db[b].max(spec_trans.db[b]).max(spec_deep.db[b]); + if val > comp_threshold { + val = comp_threshold + (val - comp_threshold) / comp_ratio; + } + spec_main.db[b] = val; + } + } + + let cepstrum = if i == 0 { + std::mem::take(&mut spec_main.cepstrum) + } else { + Vec::new() + }; + + results.push(FrameData { + freqs: spec_main.freqs, + db: spec_main.db, + primary_db, + cepstrum, + }); + } + + self.last_frames = results; + } +} + +/// deinterleaves a stereo pcm slice starting at the given frame into separate left and right f64 buffers. +fn stereo_block(pcm: &[f32], start: usize, hop: usize) -> (Vec, Vec) { + let mut left = Vec::with_capacity(hop); + let mut right = Vec::with_capacity(hop); + for i in 0..hop { + let base = (start + i) * 2; + left.push(pcm[base] as f64); + right.push(pcm[base + 1] as f64); + } + (left, right) +} diff --git a/src/analyzer_worker.rs b/src/analyzer_worker.rs new file mode 100644 index 0000000..f9a007f --- /dev/null +++ b/src/analyzer_worker.rs @@ -0,0 +1,182 @@ + + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use arc_swap::ArcSwap; +use crossbeam_channel::{bounded, Receiver, RecvTimeoutError, Sender}; + +use crate::analyzer::{Analyzer, FrameData}; +use crate::track::TrackData; + +/// command messages routed from the ui thread into the analyzer thread. +enum Cmd { + SetTrack(Arc), + SetDspParams { fft: usize, hop: usize }, + SetNumBins(usize), + SetSmoothing { granularity: i32, detail: i32, strength: f32 }, + SetGpuBlend(f32), + Shutdown, +} + +/// owning handle to the background analyzer thread with lock-free access to the latest frame snapshot. +pub struct AnalyzerWorker { + cmd_tx: Sender, + frames: Arc>>, + playhead_frame: Arc, + total_frames: Arc, + join: Option>, +} + +impl AnalyzerWorker { + /// launches the background analyzer thread and returns an owning handle. + pub fn spawn(device: wgpu::Device, queue: wgpu::Queue) -> Self { + let (cmd_tx, cmd_rx) = bounded::(64); + let frames: Arc>> = Arc::new(ArcSwap::from_pointee(Vec::new())); + let playhead_frame = Arc::new(AtomicU64::new(0)); + let total_frames = Arc::new(AtomicU64::new(0)); + + let f_thread = frames.clone(); + let p_thread = playhead_frame.clone(); + let t_thread = total_frames.clone(); + + let join = thread::Builder::new() + .name("yr_crystals.analyzer".into()) + .spawn(move || run(cmd_rx, f_thread, p_thread, t_thread, device, queue)) + .expect("spawn analyzer worker"); + + Self { + cmd_tx, + frames, + playhead_frame, + total_frames, + join: Some(join), + } + } + + /// publishes the total-frame count immediately and queues the track swap. + pub fn set_track(&self, td: Arc) { + self.total_frames + .store(td.total_samples() as u64, Ordering::Release); + let _ = self.cmd_tx.send(Cmd::SetTrack(td)); + } + + /// queues a transform-length and hop change. + pub fn set_dsp_params(&self, fft: usize, hop: usize) { + let _ = self.cmd_tx.send(Cmd::SetDspParams { fft, hop }); + } + + /// queues a bin-count change. + pub fn set_num_bins(&self, n: usize) { + let _ = self.cmd_tx.send(Cmd::SetNumBins(n)); + } + + /// queues a cepstral-smoothing parameter change. + pub fn set_smoothing(&self, granularity: i32, detail: i32, strength: f32) { + let _ = self.cmd_tx.send(Cmd::SetSmoothing { + granularity, + detail, + strength, + }); + } + + /// queues a cpu/gpu fft crossfade change. + pub fn set_gpu_blend(&self, blend: f32) { + let _ = self.cmd_tx.send(Cmd::SetGpuBlend(blend)); + } + + /// stores the audio thread's normalised playhead atomically. + pub fn publish_playhead(&self, normalised: f32) { + let total = self.total_frames.load(Ordering::Acquire); + if total == 0 { + return; + } + let frame = ((normalised.clamp(0.0, 1.0) as f64) * total as f64) as u64; + self.playhead_frame.store(frame, Ordering::Release); + } + + /// snapshots the most recently published frame vector without locking. + pub fn latest_frames(&self) -> Arc> { + self.frames.load_full() + } +} + +impl Drop for AnalyzerWorker { + fn drop(&mut self) { + let _ = self.cmd_tx.send(Cmd::Shutdown); + if let Some(j) = self.join.take() { + let _ = j.join(); + } + } +} + +/// worker-thread main loop running a 60 hz analyzer step interleaved with command draining and graceful shutdown. +fn run( + cmd_rx: Receiver, + frames: Arc>>, + playhead_frame: Arc, + total_frames: Arc, + device: wgpu::Device, + queue: wgpu::Queue, +) { + let mut analyzer = Analyzer::new(device, queue); + + let interval = Duration::from_micros(16_667); + let mut next_tick = Instant::now() + interval; + + loop { + let now = Instant::now(); + let timeout = next_tick.saturating_duration_since(now); + + match cmd_rx.recv_timeout(timeout) { + Ok(Cmd::Shutdown) => break, + Ok(cmd) => { + apply(&mut analyzer, cmd); + continue; + } + Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => {} + } + + while let Ok(cmd) = cmd_rx.try_recv() { + if matches!(cmd, Cmd::Shutdown) { + return; + } + 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())); + } + } + + next_tick += interval; + let now = Instant::now(); + // re-anchors the schedule after a stall. + if next_tick < now { + next_tick = now + interval; + } + } +} + +/// dispatches one queued command into the analyzer's setter surface. +fn apply(analyzer: &mut Analyzer, cmd: Cmd) { + match cmd { + Cmd::SetTrack(td) => analyzer.set_track_data(td), + Cmd::SetDspParams { fft, hop } => analyzer.set_dsp_params(fft, hop), + Cmd::SetNumBins(n) => analyzer.set_num_bins(n), + Cmd::SetSmoothing { + granularity, + detail, + strength, + } => analyzer.set_smoothing_params(granularity, detail, strength), + Cmd::SetGpuBlend(b) => analyzer.set_gpu_blend(b), + Cmd::Shutdown => {} + } +} diff --git a/src/android.rs b/src/android.rs new file mode 100644 index 0000000..11a522f --- /dev/null +++ b/src/android.rs @@ -0,0 +1,344 @@ +use jni::objects::{JByteArray, JClass, JIntArray, JObject, JObjectArray, JString}; +use jni::sys::{jboolean, jbyte, jfloat, jint, jlong, jobject}; +use jni::JNIEnv; +use ndk::native_window::NativeWindow; +use raw_window_handle::{ + AndroidDisplayHandle, AndroidNdkWindowHandle, RawDisplayHandle, RawWindowHandle, +}; +use std::ffi::c_void; +use std::ptr::NonNull; +use std::sync::Once; + +use crate::viewport::ViewportHandle; + +macro_rules! dbg_log { + ($($arg:tt)*) => { + #[cfg(debug_assertions)] + log::info!($($arg)*); + }; +} + +/// owns the inner viewport plus the ANativeWindow whose lifetime backs the wgpu surface. +struct AndroidViewport { + inner: ViewportHandle, + _window: NativeWindow, +} + +/// initializes android logger and the ndk_context singleton consumed by cpal's AAudio backend. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_initContext<'a>( + env: JNIEnv<'a>, + _class: JClass<'a>, + activity: JObject<'a>, +) { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + #[cfg(debug_assertions)] + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Debug) + .with_tag("yr_crystals"), + ); + let vm = match env.get_java_vm() { + Ok(vm) => vm, + Err(_) => return, + }; + let activity_ref = env.new_global_ref(&activity).ok(); + if let Some(activity_ref) = activity_ref { + unsafe { + ndk_context::initialize_android_context( + vm.get_java_vm_pointer() as *mut c_void, + activity_ref.as_raw() as *mut c_void, + ); + } + std::mem::forget(activity_ref); + } + dbg_log!("ndk_context initialized"); + }); +} + +/// builds the viewport from a Surface jobject, holding the ANativeWindow for the surface's life. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportCreate<'a>( + env: JNIEnv<'a>, + _class: JClass<'a>, + surface: JObject<'a>, + width: jfloat, + height: jfloat, + scale: jfloat, +) -> jlong { + let scale_f = (scale as f32).max(1.0); + let window = match unsafe { NativeWindow::from_surface(env.get_raw(), surface.as_raw()) } { + Some(w) => w, + None => { + dbg_log!("ANativeWindow_fromSurface returned null"); + return 0; + } + }; + let win_ptr = match NonNull::new(window.ptr().as_ptr() as *mut c_void) { + Some(p) => p, + None => return 0, + }; + let raw_window = RawWindowHandle::AndroidNdk(AndroidNdkWindowHandle::new(win_ptr)); + let raw_display = RawDisplayHandle::Android(AndroidDisplayHandle::new()); + let inner = match ViewportHandle::new_from_raw( + raw_window, + raw_display, + width as f32, + height as f32, + scale_f, + ) { + Some(v) => v, + None => { + dbg_log!("ViewportHandle::new_from_raw returned None"); + return 0; + } + }; + dbg_log!("viewportCreate ok: {}x{}@{}", width, height, scale); + let boxed = Box::new(AndroidViewport { + inner, + _window: window, + }); + Box::into_raw(boxed) as jlong +} + +/// drops the viewport and releases the ANativeWindow reference. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportDestroy<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) { + if handle == 0 { + return; + } + unsafe { drop(Box::from_raw(handle as *mut AndroidViewport)) }; +} + +/// renders one frame from the choreographer callback. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportRender<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.inner.render_frame(); +} + +/// reconfigures the wgpu surface to the given bounds and scale. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportResize<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + width: jfloat, + height: jfloat, + scale: jfloat, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.inner.resize_px(width as f32, height as f32, (scale as f32).max(1.0)); +} + +/// forwards a single touch lifecycle event into the viewport gesture pipeline. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTouchEvent<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + x: jfloat, + y: jfloat, + pressed: jboolean, + moved: jboolean, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.inner.push_touch(x as f32, y as f32, pressed != 0, moved != 0); +} + +/// forwards an Android KeyEvent into the viewport with optional unicode text. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportKeyEvent<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + key: jint, + modifiers: jint, + pressed: jboolean, + text: JString<'a>, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + let utf8 = if text.is_null() { + None + } else { + env.get_string(&text).ok().map(|s| String::from(s)) + }; + av.inner.push_key_event(key as u32, utf8, modifiers as u32, pressed != 0); +} + +/// resolves a SAF picked folder path into a library scan request. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportApplyPickedFolder<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + path: JString<'a>, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + let Ok(path_str) = env.get_string(&path) else { return }; + av.inner.apply_picked_folder(String::from(path_str).into()); +} + +/// loads a single picked audio file path as a one-track library. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportApplyPickedFile<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + path: JString<'a>, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + let Ok(path_str) = env.get_string(&path) else { return }; + av.inner.apply_picked_file(String::from(path_str).into()); +} + +/// passes a batch of picked file paths to the library in a single call. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportApplyPickedFiles<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + paths: JObjectArray<'a>, +) { + let Ok(count) = env.get_array_length(&paths) else { return }; + dbg_log!("viewportApplyPickedFiles: enter count={count} handle={handle:#x}"); + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { + dbg_log!("viewportApplyPickedFiles: null handle"); + return; + }; + let mut out = Vec::with_capacity(count as usize); + for i in 0..count { + let Ok(elem) = env.get_object_array_element(&paths, i) else { continue }; + let jstr: JString = elem.into(); + let Ok(s) = env.get_string(&jstr) else { continue }; + let s = String::from(s); + dbg_log!("viewportApplyPickedFiles: path[{i}] = {s}"); + out.push(std::path::PathBuf::from(s)); + } + dbg_log!("viewportApplyPickedFiles: forwarding {} paths to App", out.len()); + av.inner.apply_picked_files(out); + dbg_log!("viewportApplyPickedFiles: returned from App"); +} + +/// drives the import progress strip, clearing on a total of zero. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetLibraryProgress<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + current: jint, + total: jint, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.inner.set_library_progress(current.max(0) as u32, total.max(0) as u32); +} + +/// seeds the sidebar with placeholder rows of titles plus track-number tags ahead of export completion. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetPendingTitles<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + titles: JObjectArray<'a>, + track_numbers: JIntArray<'a>, +) { + let Ok(count) = env.get_array_length(&titles) else { return }; + dbg_log!("viewportSetPendingTitles: count={count}"); + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { + dbg_log!("viewportSetPendingTitles: null handle"); + return; + }; + let mut tns = vec![0i32; count as usize]; + let _ = env.get_int_array_region(&track_numbers, 0, &mut tns); + let mut out = Vec::with_capacity(count as usize); + for i in 0..count { + let title = env + .get_object_array_element(&titles, i) + .ok() + .map(|e| { + let js: JString = e.into(); + env.get_string(&js).ok().map(String::from).unwrap_or_default() + }) + .unwrap_or_default(); + let title = if title.is_empty() { + format!("Track {}", i + 1) + } else { + title + }; + let tn_raw = tns[i as usize]; + let tn = if tn_raw <= 0 { None } else { Some(tn_raw as u32) }; + out.push((title, tn)); + } + av.inner.set_pending_titles(out); +} + +/// fills in the file path for a placeholder track at completion of the export. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetTrackPath<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + idx: jint, + path: JString<'a>, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + let Ok(s) = env.get_string(&path) else { return }; + let s = String::from(s); + dbg_log!("viewportSetTrackPath: idx={idx} path={s}"); + av.inner.set_track_path(idx.max(0) as usize, std::path::PathBuf::from(s)); +} + +/// hands raw JPEG/PNG artwork bytes to the art decode worker for a given track index. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetTrackArt<'a>( + env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + idx: jint, + bytes: JByteArray<'a>, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + let Ok(len) = env.get_array_length(&bytes) else { return }; + dbg_log!("viewportSetTrackArt: idx={idx} len={len}"); + if len <= 0 { return; } + let mut buf = vec![0i8; len as usize]; + if env.get_byte_array_region(&bytes, 0, &mut buf).is_err() { return; } + let owned: Vec = buf.into_iter().map(|b: jbyte| b as u8).collect(); + av.inner.set_track_art_bytes(idx.max(0) as usize, owned); +} + +/// drains the pending picker request flag: 0 idle, 1 folder, 2 file. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingPick<'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_pick() as jint +} + +/// nullability check on a JObject through its raw pointer. +trait JObjectExt { + fn is_null(&self) -> bool; +} +impl<'a> JObjectExt for JObject<'a> { + fn is_null(&self) -> bool { + self.as_raw() as *const jobject == std::ptr::null() + } +} +impl<'a> JObjectExt for JString<'a> { + fn is_null(&self) -> bool { + let o: &JObject = self.as_ref(); + o.is_null() + } +} diff --git a/src/decoder.rs b/src/decoder.rs new file mode 100644 index 0000000..83e8c91 --- /dev/null +++ b/src/decoder.rs @@ -0,0 +1,263 @@ +#![allow(clippy::needless_range_loop)] + +use std::fs::File; +use std::io::ErrorKind; +use std::path::Path; +use std::sync::Arc; + +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::errors::Error as SymError; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +use crate::track::TrackData; + +/// failure modes for loading a track off disk into stereo PCM. +#[derive(Debug)] +pub enum DecodeError { + Io(std::io::Error), + NoTrack, + Symphonia(SymError), +} + +impl std::fmt::Display for DecodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DecodeError::Io(e) => write!(f, "io: {e}"), + DecodeError::NoTrack => write!(f, "no decodable audio track"), + DecodeError::Symphonia(e) => write!(f, "decode: {e}"), + } + } +} +impl std::error::Error for DecodeError {} +impl From for DecodeError { + fn from(e: std::io::Error) -> Self { + DecodeError::Io(e) + } +} +impl From for DecodeError { + fn from(e: SymError) -> Self { + DecodeError::Symphonia(e) + } +} + +/// decodes any symphonia-supported audio file at path into interleaved stereo f32 PCM. +pub fn decode_file(path: &Path) -> Result { + #[cfg(debug_assertions)] + let _t0 = std::time::Instant::now(); + + let file = File::open(path)?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let probed = symphonia::default::get_probe().format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + )?; + let mut format = probed.format; + + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or(DecodeError::NoTrack)?; + let track_id = track.id; + let probed_rate = track.codec_params.sample_rate; + let probed_channels = track.codec_params.channels.map(|c| c.count()); + + let mut decoder = + symphonia::default::get_codecs().make(&track.codec_params, &DecoderOptions::default())?; + + let mut pcm: Vec = Vec::new(); + let mut sample_buf: Option> = None; + + let mut actual_sample_rate = probed_rate.unwrap_or(48_000); + let mut actual_channels = probed_channels.unwrap_or(2).max(1); + + loop { + let packet = match format.next_packet() { + Ok(p) => p, + Err(SymError::IoError(e)) if e.kind() == ErrorKind::UnexpectedEof => break, + Err(SymError::ResetRequired) => break, + Err(e) => return Err(e.into()), + }; + if packet.track_id() != track_id { + continue; + } + + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + + Err(SymError::DecodeError(_)) => continue, + Err(e) => return Err(e.into()), + }; + + let spec = *decoded.spec(); + if sample_buf.is_none() { + sample_buf = Some(SampleBuffer::::new(decoded.capacity() as u64, spec)); + actual_sample_rate = spec.rate; + actual_channels = spec.channels.count().max(1); + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] decode spec for {:?}: codec_params rate={:?} ch={:?}, decoded rate={} ch={}", + path.file_name().unwrap_or_default(), + probed_rate, + probed_channels, + spec.rate, + spec.channels.count(), + ); + } + let buf = sample_buf.as_mut().unwrap(); + buf.copy_interleaved_ref(decoded); + append_stereo(&mut pcm, buf.samples(), actual_channels); + } + + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] decode_file({:?}) took {:?} — {} stereo frames @ {} Hz", + path.file_name().unwrap_or_default(), + _t0.elapsed(), + pcm.len() / 2, + actual_sample_rate, + ); + + Ok(TrackData { + pcm: Arc::from(pcm), + sample_rate: actual_sample_rate, + frame_size: 4096, + }) +} + +/// decodes a file and rate-converts to target_sr, skipping the resample if already matching. +pub fn decode_file_resampled(path: &Path, target_sr: u32) -> Result { + let td = decode_file(path)?; + if td.sample_rate == target_sr || td.total_samples() == 0 { + return Ok(td); + } + #[cfg(debug_assertions)] + let _t0 = std::time::Instant::now(); + let out = resample_track(&td, target_sr); + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] resample {} → {} Hz took {:?} ({} → {} stereo frames)", + td.sample_rate, + target_sr, + _t0.elapsed(), + td.total_samples(), + out.total_samples(), + ); + Ok(out) +} + +/// chunked FFT resample of stereo PCM via rubato, falling back to a clone on planner failure. +fn resample_track(td: &TrackData, target_sr: u32) -> TrackData { + use rubato::{FftFixedInOut, Resampler}; + + let in_sr = td.sample_rate as usize; + let out_sr = target_sr as usize; + let frames_in_total = td.total_samples(); + + let mut left = vec![0.0_f32; frames_in_total]; + let mut right = vec![0.0_f32; frames_in_total]; + for f in 0..frames_in_total { + left[f] = td.pcm[f * 2]; + right[f] = td.pcm[f * 2 + 1]; + } + + let chunk = 8192; + let mut resampler = match FftFixedInOut::::new(in_sr, out_sr, chunk, 2) { + Ok(r) => r, + Err(_) => return td.clone(), + }; + + let chunk_in = resampler.input_frames_max(); + let chunk_out = resampler.output_frames_max(); + + let frames_out_est = + ((frames_in_total as u64 * out_sr as u64) / in_sr.max(1) as u64) as usize + chunk_out; + let mut out_left: Vec = Vec::with_capacity(frames_out_est); + let mut out_right: Vec = Vec::with_capacity(frames_out_est); + + let mut in_l = vec![0.0_f32; chunk_in]; + let mut in_r = vec![0.0_f32; chunk_in]; + let mut out_l = vec![0.0_f32; chunk_out]; + let mut out_r = vec![0.0_f32; chunk_out]; + + let mut pos = 0; + while pos < frames_in_total { + let needed = resampler.input_frames_next(); + let end = (pos + needed).min(frames_in_total); + let take = end - pos; + in_l[..take].copy_from_slice(&left[pos..end]); + in_r[..take].copy_from_slice(&right[pos..end]); + if take < needed { + for v in in_l[take..needed].iter_mut() { + *v = 0.0; + } + for v in in_r[take..needed].iter_mut() { + *v = 0.0; + } + } + let inputs: [&[f32]; 2] = [&in_l[..needed], &in_r[..needed]]; + let mut outputs: [&mut [f32]; 2] = [&mut out_l[..], &mut out_r[..]]; + let (in_used, out_written) = + match resampler.process_into_buffer(&inputs, &mut outputs, None) { + Ok(o) => o, + Err(_) => break, + }; + out_left.extend_from_slice(&out_l[..out_written]); + out_right.extend_from_slice(&out_r[..out_written]); + pos += in_used; + } + + let frames_out = out_left.len(); + let mut pcm = Vec::with_capacity(frames_out * 2); + for f in 0..frames_out { + pcm.push(out_left[f]); + pcm.push(out_right[f]); + } + + TrackData { + pcm: Arc::from(pcm), + sample_rate: target_sr, + frame_size: td.frame_size, + } +} + +/// appends an interleaved source as stereo, duplicating mono and dropping channels beyond L+R. +fn append_stereo(out: &mut Vec, src: &[f32], channels: usize) { + if channels == 0 || src.is_empty() { + return; + } + let frames = src.len() / channels; + out.reserve(frames * 2); + match channels { + 1 => { + for i in 0..frames { + let s = src[i]; + out.push(s); + out.push(s); + } + } + 2 => { + out.extend_from_slice(&src[..frames * 2]); + } + _ => { + for i in 0..frames { + let base = i * channels; + out.push(src[base]); + out.push(src[base + 1]); + } + } + } +} + diff --git a/src/engine.rs b/src/engine.rs new file mode 100644 index 0000000..90acf73 --- /dev/null +++ b/src/engine.rs @@ -0,0 +1,525 @@ + + +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::time::Instant; + +#[cfg(not(target_os = "android"))] +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +#[cfg(not(target_os = "android"))] +use cpal::{BufferSize, SampleFormat, SampleRate, Stream, StreamConfig}; +#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] +use cpal::SupportedBufferSize; +use crossbeam_channel::{unbounded, Sender}; + +use crate::track::TrackData; + +/// lazy-initialises the engine clock's reference instant. +fn engine_clock() -> Instant { + static START: OnceLock = OnceLock::new(); + *START.get_or_init(Instant::now) +} +/// reads nanoseconds elapsed since the engine clock started. +fn now_nanos() -> u64 { + engine_clock().elapsed().as_nanos() as u64 +} + +/// shared atomic snapshot the audio callback writes and the ui samples. +pub struct EngineState { + pub frame_pos: AtomicU64, + pub total_frames: AtomicU64, + pub playing: AtomicBool, + pub sample_rate: AtomicU32, + pub last_callback_pos: AtomicU64, + pub last_callback_nanos: AtomicU64, +} + +/// transport messages crossing from the ui thread into the cpal callback. +enum Cmd { + Load(Arc), + Play, + Pause, + Stop, + Seek(u64), +} + +/// owns the platform output stream plus the channel feeding transport commands into the audio callback. +pub struct AudioEngine { + pub state: Arc, + cmd_tx: Sender, + #[cfg(not(target_os = "android"))] + _stream: Stream, + #[cfg(target_os = "android")] + _stream: ndk::audio::AudioStream, +} + +impl AudioEngine { + /// opens the default output device, builds the f32 stream, and starts the audio thread paused. + #[cfg(not(target_os = "android"))] + pub fn new() -> Result { + #[cfg(debug_assertions)] + let _t0 = std::time::Instant::now(); + + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| "no default output device".to_string())?; + let config = device + .default_output_config() + .map_err(|e| format!("default output config: {e}"))?; + + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] cpal device + config in {:?} — rate={} Hz, ch={}, fmt={:?}", + _t0.elapsed(), + config.sample_rate().0, + config.channels(), + config.sample_format(), + ); + + let device_sr = config.sample_rate().0; + let device_ch = config.channels() as usize; + let sample_fmt = config.sample_format(); + + #[cfg(target_os = "ios")] + let buffer_size = BufferSize::Default; + + #[cfg(not(target_os = "ios"))] + let buffer_size = { + const TARGET_BUFFER: u32 = 64; + let device_buffer_range = match config.buffer_size() { + SupportedBufferSize::Range { min, max } => Some((*min, *max)), + SupportedBufferSize::Unknown => None, + } + .or_else(|| { + device.supported_output_configs().ok().and_then(|iter| { + let mut best: Option<(u32, u32)> = None; + for cfg in iter { + if let SupportedBufferSize::Range { min, max } = cfg.buffer_size() { + best = Some(match best { + Some((bmin, bmax)) => (bmin.min(*min), bmax.max(*max)), + None => (*min, *max), + }); + } + } + best + }) + }); + let chosen = device_buffer_range + .map(|(min, max)| TARGET_BUFFER.clamp(min, max)) + .unwrap_or(TARGET_BUFFER); + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] cpal buffer: target {} frames, device range {:?}, applied {} frames", + TARGET_BUFFER, device_buffer_range, chosen, + ); + BufferSize::Fixed(chosen) + }; + + let stream_cfg = StreamConfig { + channels: device_ch as u16, + sample_rate: SampleRate(device_sr), + buffer_size, + }; + + let _ = engine_clock(); + let state = Arc::new(EngineState { + frame_pos: AtomicU64::new(0), + total_frames: AtomicU64::new(0), + playing: AtomicBool::new(false), + sample_rate: AtomicU32::new(device_sr), + last_callback_pos: AtomicU64::new(0), + last_callback_nanos: AtomicU64::new(0), + }); + let (cmd_tx, cmd_rx) = unbounded::(); + + let cb_state = state.clone(); + let mut current: Option> = None; + let mut local_pos: u64 = 0; + let mut local_playing = false; + + let err_fn = |e| eprintln!("yr_crystals: cpal stream error: {e}"); + + let stream = match sample_fmt { + SampleFormat::F32 => device.build_output_stream( + &stream_cfg, + move |out: &mut [f32], _info: &cpal::OutputCallbackInfo| { + while let Ok(cmd) = cmd_rx.try_recv() { + match cmd { + Cmd::Load(td) => { + cb_state + .total_frames + .store(td.total_samples() as u64, Ordering::Release); + current = Some(td); + local_pos = 0; + cb_state.frame_pos.store(0, Ordering::Release); + cb_state.last_callback_pos.store(0, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + Cmd::Play => { + local_playing = true; + cb_state.playing.store(true, Ordering::Release); + + cb_state + .last_callback_pos + .store(local_pos, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + Cmd::Pause => { + local_playing = false; + cb_state.playing.store(false, Ordering::Release); + } + Cmd::Stop => { + local_playing = false; + local_pos = 0; + cb_state.playing.store(false, Ordering::Release); + cb_state.frame_pos.store(0, Ordering::Release); + cb_state.last_callback_pos.store(0, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + Cmd::Seek(p) => { + local_pos = p; + cb_state.frame_pos.store(p, Ordering::Release); + cb_state.last_callback_pos.store(p, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + } + } + + let frames_out = out.len() / device_ch.max(1); + if !local_playing || current.is_none() || device_ch == 0 { + out.fill(0.0); + return; + } + let track = current.as_ref().unwrap(); + let total = track.total_samples() as u64; + let pcm = &track.pcm; + + cb_state + .last_callback_pos + .store(local_pos, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + + for f in 0..frames_out { + let dst = f * device_ch; + if local_pos >= total { + for c in 0..device_ch { + out[dst + c] = 0.0; + } + local_playing = false; + cb_state.playing.store(false, Ordering::Release); + continue; + } + let src = (local_pos as usize) * 2; + let l = pcm[src]; + let r = pcm[src + 1]; + match device_ch { + 1 => out[dst] = 0.5 * (l + r), + 2 => { + out[dst] = l; + out[dst + 1] = r; + } + _ => { + out[dst] = l; + out[dst + 1] = r; + for c in 2..device_ch { + out[dst + c] = 0.0; + } + } + } + local_pos += 1; + } + cb_state.frame_pos.store(local_pos, Ordering::Release); + }, + err_fn, + None, + ), + _ => return Err(format!("unsupported sample format: {sample_fmt:?}")), + } + .map_err(|e| format!("build output stream: {e}"))?; + + stream.play().map_err(|e| format!("stream play: {e}"))?; + + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] AudioEngine::new total {:?}", + _t0.elapsed(), + ); + + Ok(AudioEngine { + state, + cmd_tx, + _stream: stream, + }) + } + + /// builds an AAudio stream with low-latency mode and anchors the visual playhead at the speaker via frames_written - frames_read. + #[cfg(target_os = "android")] + pub fn new() -> Result { + use ndk::audio::{ + AudioCallbackResult, AudioFormat, AudioPerformanceMode, AudioSharingMode, AudioStream, + AudioStreamBuilder, + }; + use std::ffi::c_void; + + #[cfg(debug_assertions)] + let _t0 = std::time::Instant::now(); + + let _ = engine_clock(); + + let state = Arc::new(EngineState { + frame_pos: AtomicU64::new(0), + total_frames: AtomicU64::new(0), + playing: AtomicBool::new(false), + sample_rate: AtomicU32::new(48000), + last_callback_pos: AtomicU64::new(0), + last_callback_nanos: AtomicU64::new(0), + }); + let (cmd_tx, cmd_rx) = unbounded::(); + + let cb_state = state.clone(); + let mut current: Option> = None; + let mut local_pos: u64 = 0; + let mut local_playing = false; + + #[cfg(debug_assertions)] + let mut cb_count: u64 = 0; + + let callback: ndk::audio::AudioStreamDataCallback = Box::new( + move |stream: &AudioStream, audio_data: *mut c_void, num_frames: i32| { + while let Ok(cmd) = cmd_rx.try_recv() { + match cmd { + Cmd::Load(td) => { + cb_state + .total_frames + .store(td.total_samples() as u64, Ordering::Release); + current = Some(td); + local_pos = 0; + cb_state.frame_pos.store(0, Ordering::Release); + cb_state.last_callback_pos.store(0, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + Cmd::Play => { + local_playing = true; + cb_state.playing.store(true, Ordering::Release); + } + Cmd::Pause => { + local_playing = false; + cb_state.playing.store(false, Ordering::Release); + } + Cmd::Stop => { + local_playing = false; + local_pos = 0; + cb_state.playing.store(false, Ordering::Release); + cb_state.frame_pos.store(0, Ordering::Release); + cb_state.last_callback_pos.store(0, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + Cmd::Seek(p) => { + local_pos = p; + cb_state.frame_pos.store(p, Ordering::Release); + cb_state.last_callback_pos.store(p, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + } + } + + let device_ch = stream.channel_count().max(1) as usize; + let frames = num_frames.max(0) as usize; + let total_samples = frames * device_ch; + let out = unsafe { + std::slice::from_raw_parts_mut(audio_data as *mut f32, total_samples) + }; + + if !local_playing || current.is_none() { + out.fill(0.0); + return AudioCallbackResult::Continue; + } + + // queue depth = frames committed by the app minus frames consumed by the hardware. + let written = stream.frames_written().max(0) as u64; + let read = stream.frames_read().max(0) as u64; + let queue_depth = written.saturating_sub(read); + let speaker_frame = local_pos.saturating_sub(queue_depth); + + #[cfg(debug_assertions)] + { + if cb_count < 20 || cb_count % 100 == 0 { + eprintln!( + "yr_crystals[dbg] aaudio cb#{cb_count}: frames={frames} ch={device_ch} written={written} read={read} qd={queue_depth} local_pos={local_pos} speaker={speaker_frame}", + ); + } + cb_count = cb_count.wrapping_add(1); + } + + cb_state + .last_callback_pos + .store(speaker_frame, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + + let track = current.as_ref().unwrap(); + let total = track.total_samples() as u64; + let pcm = &track.pcm; + + for f in 0..frames { + let dst = f * device_ch; + if local_pos >= total { + for c in 0..device_ch { + out[dst + c] = 0.0; + } + local_playing = false; + cb_state.playing.store(false, Ordering::Release); + continue; + } + let src = (local_pos as usize) * 2; + let l = pcm[src]; + let r = pcm[src + 1]; + match device_ch { + 1 => out[dst] = 0.5 * (l + r), + 2 => { + out[dst] = l; + out[dst + 1] = r; + } + _ => { + out[dst] = l; + out[dst + 1] = r; + for c in 2..device_ch { + out[dst + c] = 0.0; + } + } + } + local_pos += 1; + } + cb_state.frame_pos.store(local_pos, Ordering::Release); + + AudioCallbackResult::Continue + }, + ); + + let stream = AudioStreamBuilder::new() + .map_err(|e| format!("AAudio builder: {e:?}"))? + .format(AudioFormat::PCM_Float) + .channel_count(2) + .performance_mode(AudioPerformanceMode::LowLatency) + .sharing_mode(AudioSharingMode::Shared) + .data_callback(callback) + .open_stream() + .map_err(|e| format!("AAudio open: {e:?}"))?; + + let device_sr = stream.sample_rate() as u32; + state.sample_rate.store(device_sr, Ordering::Release); + + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] aaudio open: sr={} ch={} format={:?} perf={:?} share={:?} buffer_capacity_in_frames={:?}", + device_sr, + stream.channel_count(), + stream.format(), + stream.performance_mode(), + stream.sharing_mode(), + stream.frames_written(), + ); + + stream + .request_start() + .map_err(|e| format!("AAudio request_start: {e:?}"))?; + + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] AudioEngine::new total {:?}", + _t0.elapsed(), + ); + + Ok(AudioEngine { + state, + cmd_tx, + _stream: stream, + }) + } + + /// reports the device's negotiated sample rate. + pub fn output_sample_rate(&self) -> u32 { + self.state.sample_rate.load(Ordering::Acquire) + } + + /// hands incoming pcm to the audio callback and rewinds the playhead to zero. + pub fn load(&self, track: Arc) { + let _ = self.cmd_tx.send(Cmd::Load(track)); + } + + /// resumes output from the current frame position. + pub fn play(&self) { + let _ = self.cmd_tx.send(Cmd::Play); + } + + /// holds the playhead at the current position and silences output. + pub fn pause(&self) { + let _ = self.cmd_tx.send(Cmd::Pause); + } + + /// resets the playhead to zero and silences output. + #[allow(dead_code)] + pub fn stop(&self) { + let _ = self.cmd_tx.send(Cmd::Stop); + } + + /// seeks to a fraction of total length in 0..=1, clamped. + pub fn seek_normalised(&self, t: f32) { + let total = self.state.total_frames.load(Ordering::Acquire); + if total == 0 { + return; + } + let t = t.clamp(0.0, 1.0); + let frame = ((t as f64) * total as f64) as u64; + let _ = self.cmd_tx.send(Cmd::Seek(frame)); + } + + /// returns the playhead as a 0..=1 fraction, extrapolated forward from the last callback anchor. + pub fn position(&self) -> f32 { + let total = self.state.total_frames.load(Ordering::Acquire); + if total == 0 { + return 0.0; + } + let anchor = self.state.last_callback_pos.load(Ordering::Acquire); + let anchor_ns = self.state.last_callback_nanos.load(Ordering::Acquire); + + if anchor_ns == 0 { + let pos = self.state.frame_pos.load(Ordering::Acquire); + return (pos as f64 / total as f64) as f32; + } + let rate = self.state.sample_rate.load(Ordering::Acquire) as u64; + + let extra = if self.state.playing.load(Ordering::Acquire) { + let now = now_nanos(); + let dt_ns = now.saturating_sub(anchor_ns); + (dt_ns as u128 * rate as u128 / 1_000_000_000) as u64 + } else { + 0 + }; + let pos = (anchor + extra).min(total); + (pos as f64 / total as f64) as f32 + } + + /// reports the latched play/pause flag without checking the position extrapolation. + pub fn is_playing(&self) -> bool { + self.state.playing.load(Ordering::Acquire) + } +} diff --git a/src/gpu_dsp.rs b/src/gpu_dsp.rs new file mode 100644 index 0000000..bcc42c7 --- /dev/null +++ b/src/gpu_dsp.rs @@ -0,0 +1,322 @@ + + +use bytemuck::{Pod, Zeroable}; +use num_complex::Complex64; + +const FFT_WGSL: &str = include_str!("../shaders/fft.wgsl"); + +/// per-pass uniform block matching the WGSL shader binding layout. +#[repr(C)] +#[derive(Debug, Clone, Copy, Pod, Zeroable)] +struct Args { + n: u32, + log2_n: u32, + stride: u32, + inverse: u32, +} + +const ARGS_BYTES: u64 = std::mem::size_of::() as u64; + +/// fixed-size 1D radix-2 FFT running on a wgpu compute queue with a single staged readback path. +pub struct GpuFft1D { + device: wgpu::Device, + queue: wgpu::Queue, + n: u32, + log2_n: u32, + + pending: Option, + + staging_mapped: bool, + + bit_reverse: wgpu::ComputePipeline, + butterfly: wgpu::ComputePipeline, + + args_buf: wgpu::Buffer, + + all_args_buf: wgpu::Buffer, + + cached_inverse: std::cell::Cell>, + + data_buf: wgpu::Buffer, + scratch_buf: wgpu::Buffer, + staging: wgpu::Buffer, + + bind_group: wgpu::BindGroup, +} + +impl GpuFft1D { + /// allocates pipelines, buffers, and bind groups for an N-point FFT with N a power of two. + pub fn new(device: wgpu::Device, queue: wgpu::Queue, n: u32) -> Self { + assert!(n.is_power_of_two() && n >= 2, "fft size must be a power of two ≥ 2"); + let log2_n = n.trailing_zeros(); + let num_passes = (log2_n as u64) + 1; + + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("yr_crystals.fft.shader"), + source: wgpu::ShaderSource::Wgsl(FFT_WGSL.into()), + }); + + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("yr_crystals.fft.bind_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("yr_crystals.fft.pipeline_layout"), + bind_group_layouts: &[&layout], + push_constant_ranges: &[], + }); + + let make = |entry: &str, label: &str| { + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(label), + layout: Some(&pipeline_layout), + module: &module, + entry_point: Some(entry), + compilation_options: Default::default(), + cache: None, + }) + }; + let bit_reverse = make("bit_reverse", "yr_crystals.fft.bit_reverse"); + let butterfly = make("butterfly", "yr_crystals.fft.butterfly"); + + let args_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.fft.args"), + size: ARGS_BYTES, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let all_args_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.fft.all_args"), + size: num_passes * ARGS_BYTES, + usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bytes = (n as u64) * 8; + let make_storage = |label: &str| { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size: bytes, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST + | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }) + }; + let data_buf = make_storage("yr_crystals.fft.data"); + let scratch_buf = make_storage("yr_crystals.fft.scratch"); + + let staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.fft.staging"), + size: bytes, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("yr_crystals.fft.bind_group"), + layout: &layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: args_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: data_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: scratch_buf.as_entire_binding(), + }, + ], + }); + + Self { + device, + queue, + n, + log2_n, + pending: None, + staging_mapped: false, + bit_reverse, + butterfly, + args_buf, + all_args_buf, + cached_inverse: std::cell::Cell::new(None), + data_buf, + scratch_buf, + staging, + bind_group, + } + } + + /// transform length in complex samples. + pub fn size(&self) -> usize { + self.n as usize + } + + /// queues a forward transform of the input buffer. + pub fn submit_forward(&mut self, input: &[Complex64]) { + self.submit(input, false); + } + + /// queues an inverse transform of the input buffer. + #[allow(dead_code)] + pub fn submit_inverse(&mut self, input: &[Complex64]) { + self.submit(input, true); + } + + /// drains the most recently submitted transform into the output slice, returning false if nothing pending. + pub fn try_collect_into(&mut self, out: &mut [Complex64]) -> bool { + debug_assert_eq!(out.len(), self.n as usize); + let Some(_sub) = self.pending.take() else { + return false; + }; + + let _ = self.device.poll(wgpu::PollType::wait_indefinitely()); + + let slice = self.staging.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = self.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().expect("map_async result").expect("map ok"); + self.staging_mapped = true; + + { + let view = slice.get_mapped_range(); + let f32s: &[[f32; 2]] = bytemuck::cast_slice(&view); + for (i, c) in f32s.iter().enumerate() { + out[i] = Complex64::new(c[0] as f64, c[1] as f64); + } + } + self.staging.unmap(); + self.staging_mapped = false; + true + } + + /// rebuilds the per-pass args table for the requested direction, skipping if already cached. + fn ensure_all_args(&self, inverse: bool) { + if self.cached_inverse.get() == Some(inverse) { + return; + } + let mut all = Vec::with_capacity((self.log2_n + 1) as usize); + + all.push(Args { + n: self.n, + log2_n: self.log2_n, + stride: 1, + inverse: inverse as u32, + }); + + let mut s = 1u32; + while s < self.n { + all.push(Args { + n: self.n, + log2_n: self.log2_n, + stride: s, + inverse: inverse as u32, + }); + s *= 2; + } + self.queue + .write_buffer(&self.all_args_buf, 0, bytemuck::cast_slice(&all)); + self.cached_inverse.set(Some(inverse)); + } + + /// uploads input, dispatches bit-reversal and log2(N) butterfly passes, and queues the readback copy. + fn submit(&mut self, input: &[Complex64], inverse: bool) { + debug_assert_eq!(input.len(), self.n as usize); + self.ensure_all_args(inverse); + + if self.staging_mapped { + self.staging.unmap(); + self.staging_mapped = false; + } + self.pending = None; + + let mut input_f32: Vec<[f32; 2]> = Vec::with_capacity(self.n as usize); + for c in input.iter() { + input_f32.push([c.re as f32, c.im as f32]); + } + self.queue + .write_buffer(&self.data_buf, 0, bytemuck::cast_slice(&input_f32)); + + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("yr_crystals.fft.encoder"), + }); + + let groups_n = (self.n + 63) / 64; + let groups_half = (self.n / 2 + 63) / 64; + + encoder.copy_buffer_to_buffer(&self.all_args_buf, 0, &self.args_buf, 0, ARGS_BYTES); + { + let mut p = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("yr_crystals.fft.bit_reverse"), + timestamp_writes: None, + }); + p.set_pipeline(&self.bit_reverse); + p.set_bind_group(0, &self.bind_group, &[]); + p.dispatch_workgroups(groups_n, 1, 1); + } + + for pass_idx in 1..=self.log2_n { + let off = (pass_idx as u64) * ARGS_BYTES; + encoder.copy_buffer_to_buffer(&self.all_args_buf, off, &self.args_buf, 0, ARGS_BYTES); + let mut p = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("yr_crystals.fft.butterfly"), + timestamp_writes: None, + }); + p.set_pipeline(&self.butterfly); + p.set_bind_group(0, &self.bind_group, &[]); + p.dispatch_workgroups(groups_half, 1, 1); + } + + encoder.copy_buffer_to_buffer( + &self.scratch_buf, + 0, + &self.staging, + 0, + (self.n as u64) * 8, + ); + let sub = self.queue.submit(std::iter::once(encoder.finish())); + self.pending = Some(sub); + } +} diff --git a/src/hilbert_block.rs b/src/hilbert_block.rs new file mode 100644 index 0000000..b959cc1 --- /dev/null +++ b/src/hilbert_block.rs @@ -0,0 +1,73 @@ + + +use num_complex::Complex64; +use rustfft::{Fft, FftPlanner}; +use std::sync::Arc; + +/// one-shot analytic-signal builder for fixed-length blocks of audio. +pub struct BlockHilbert { + planner: FftPlanner, +} + +impl Default for BlockHilbert { + fn default() -> Self { + Self::new() + } +} + +impl BlockHilbert { + pub fn new() -> Self { + Self { + planner: FftPlanner::new(), + } + } + + /// builds the analytic signal of a real input by zeroing the negative-frequency half and doubling the positive half. + pub fn hilbert_single(&mut self, signal: &[f64]) -> Vec { + let n = signal.len(); + if n == 0 { + return Vec::new(); + } + + let fwd: Arc> = self.planner.plan_fft_forward(n); + let bwd: Arc> = self.planner.plan_fft_inverse(n); + + let mut buf: Vec = signal.iter().map(|&s| Complex64::new(s, 0.0)).collect(); + + fwd.process(&mut buf); + + let half = (n as f64) / 2.0; + for (i, x) in buf.iter_mut().enumerate() { + let m = if i > 0 && (i as f64) < half { + 2.0 + } else if (i as f64) > half { + 0.0 + } else { + 1.0 + }; + *x *= m; + } + + bwd.process(&mut buf); + + let inv_n = 1.0 / n as f64; + for x in &mut buf { + *x *= inv_n; + } + buf + } + + /// runs the single-channel transform on a matched left/right pair. + pub fn hilbert_stereo( + &mut self, + left: &[f64], + right: &[f64], + ) -> (Vec, Vec) { + if left.is_empty() || left.len() != right.len() { + return (Vec::new(), Vec::new()); + } + let l = self.hilbert_single(left); + let r = self.hilbert_single(right); + (l, r) + } +} diff --git a/src/hilbert_stream.rs b/src/hilbert_stream.rs new file mode 100644 index 0000000..938bd6f --- /dev/null +++ b/src/hilbert_stream.rs @@ -0,0 +1,125 @@ + + +use num_complex::Complex64; +use rustfft::{Fft, FftPlanner}; +use std::sync::Arc; + +/// streaming analytic-signal builder driven by fixed-hop blocks against a sliding fft-sized history. +pub struct RealtimeHilbert { + fft_size: usize, + hop_size: usize, + history_l: Vec, + history_r: Vec, + fft_buf_l: Vec, + fft_buf_r: Vec, + fwd: Option>>, + inv: Option>>, + planner: FftPlanner, +} + +impl Default for RealtimeHilbert { + fn default() -> Self { + Self::new() + } +} + +impl RealtimeHilbert { + pub fn new() -> Self { + Self { + fft_size: 0, + hop_size: 0, + history_l: Vec::new(), + history_r: Vec::new(), + fft_buf_l: Vec::new(), + fft_buf_r: Vec::new(), + fwd: None, + inv: None, + planner: FftPlanner::new(), + } + } + + /// resizes the sliding history and replans both fft directions for a power-of-two transform length. + pub fn reinit(&mut self, fft_size: usize) { + assert!(fft_size > 0 && fft_size.is_power_of_two(), "fft size must be a power of two"); + self.fft_size = fft_size; + self.hop_size = 0; + self.history_l = vec![0.0; fft_size]; + self.history_r = vec![0.0; fft_size]; + self.fft_buf_l = vec![Complex64::new(0.0, 0.0); fft_size]; + self.fft_buf_r = vec![Complex64::new(0.0, 0.0); fft_size]; + self.fwd = Some(self.planner.plan_fft_forward(fft_size)); + self.inv = Some(self.planner.plan_fft_inverse(fft_size)); + } + + /// shifts a stereo hop into the sliding window and returns the analytic signal across the trailing hop. + pub fn process( + &mut self, + left: &[f64], + right: &[f64], + ) -> (Vec, Vec) { + assert!(self.fft_size > 0, "RealtimeHilbert::process called before reinit"); + assert_eq!(left.len(), right.len(), "L/R block sizes must match"); + + if self.hop_size == 0 { + self.hop_size = left.len(); + assert!( + self.hop_size <= self.fft_size, + "hop must be <= fft size" + ); + } else { + assert_eq!(left.len(), self.hop_size, "hop size cannot change between process() calls"); + } + + let n = self.fft_size; + let hop = self.hop_size; + let keep = n - hop; + + self.history_l.copy_within(hop.., 0); + self.history_l[keep..].copy_from_slice(left); + self.history_r.copy_within(hop.., 0); + self.history_r[keep..].copy_from_slice(right); + + for (dst, &s) in self.fft_buf_l.iter_mut().zip(self.history_l.iter()) { + *dst = Complex64::new(s, 0.0); + } + for (dst, &s) in self.fft_buf_r.iter_mut().zip(self.history_r.iter()) { + *dst = Complex64::new(s, 0.0); + } + + let fwd = self.fwd.as_ref().expect("forward plan"); + fwd.process(&mut self.fft_buf_l); + fwd.process(&mut self.fft_buf_r); + + let half = n / 2; + for i in 0..n { + let m = if i == 0 || i == half { + 1.0 + } else if i < half { + 2.0 + } else { + 0.0 + }; + self.fft_buf_l[i] *= m; + self.fft_buf_r[i] *= m; + } + + let inv = self.inv.as_ref().expect("inverse plan"); + inv.process(&mut self.fft_buf_l); + inv.process(&mut self.fft_buf_r); + + let inv_n = 1.0 / n as f64; + let mut out_l = Vec::with_capacity(hop); + let mut out_r = Vec::with_capacity(hop); + for i in 0..hop { + let idx = keep + i; + out_l.push(self.fft_buf_l[idx] * inv_n); + out_r.push(self.fft_buf_r[idx] * inv_n); + } + (out_l, out_r) + } + + /// reports the configured transform length, or zero before reinit. + pub fn fft_size(&self) -> usize { + self.fft_size + } +} diff --git a/src/ios.rs b/src/ios.rs new file mode 100644 index 0000000..d7f73f7 --- /dev/null +++ b/src/ios.rs @@ -0,0 +1,294 @@ + + +use raw_window_handle::{ + RawDisplayHandle, RawWindowHandle, UiKitDisplayHandle, UiKitWindowHandle, +}; +use std::ffi::{c_char, c_void, CStr, CString}; +use std::ptr::NonNull; + +use crate::viewport::ViewportHandle; + +/// builds a viewport bound to a UIView pointer at the given logical size and scale. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_create( + uiview: *mut c_void, + width: f32, + height: f32, + scale: f32, +) -> *mut ViewportHandle { + let Some(view_nn) = NonNull::new(uiview) else { + return std::ptr::null_mut(); + }; + + let raw_window = + RawWindowHandle::UiKit(UiKitWindowHandle::new(view_nn)); + let raw_display = RawDisplayHandle::UiKit(UiKitDisplayHandle::new()); + + match ViewportHandle::new_from_raw(raw_window, raw_display, width, height, scale) { + Some(h) => Box::into_raw(Box::new(h)), + None => std::ptr::null_mut(), + } +} + +/// drops the viewport and releases the underlying wgpu surface. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_destroy(handle: *mut ViewportHandle) { + if handle.is_null() { + return; + } + unsafe { drop(Box::from_raw(handle)) }; +} + +/// renders one frame from the iOS display link. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_render(handle: *mut ViewportHandle) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + h.render_frame(); +} + +/// reconfigures the wgpu surface to the given bounds and scale. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_resize( + handle: *mut ViewportHandle, + width: f32, + height: f32, + scale: f32, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + h.resize_px(width, height, scale); +} + +/// forwards a single-finger UITouch lifecycle event into the viewport's gesture pipeline. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_touch_event( + handle: *mut ViewportHandle, + x: f32, + y: f32, + pressed: bool, + moved: bool, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + h.push_touch(x, y, pressed, moved); +} + +/// forwards a UIKey press/release into the viewport, paired with optional UTF-8 text. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_key_event( + handle: *mut ViewportHandle, + key: u32, + modifiers: u32, + pressed: bool, + text: *const c_char, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + let utf8 = if text.is_null() { + None + } else { + unsafe { CStr::from_ptr(text) }.to_str().ok().map(|s| s.to_string()) + }; + h.push_key_event(key, utf8, modifiers, pressed); +} + +/// resolves a UIDocumentPicker folder URL into a library scan request. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_apply_picked_folder( + handle: *mut ViewportHandle, + path: *const c_char, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + let Some(path_str) = unsafe { CStr::from_ptr(path) }.to_str().ok() else { + return; + }; + h.apply_picked_folder(path_str.into()); +} + +/// loads a single picked audio file as a one-track library. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_apply_picked_file( + handle: *mut ViewportHandle, + path: *const c_char, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + let Some(path_str) = unsafe { CStr::from_ptr(path) }.to_str().ok() else { + return; + }; + h.apply_picked_file(path_str.into()); +} + +/// loads a batch of picked file paths into the library in a single call. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_apply_picked_files( + handle: *mut ViewportHandle, + paths: *const *const c_char, + count: usize, +) { + #[cfg(debug_assertions)] + log_dbg(format!("viewport_apply_picked_files: enter count={count} paths_null={}", paths.is_null())); + let Some(h) = (unsafe { handle.as_mut() }) else { + #[cfg(debug_assertions)] + log_dbg("viewport_apply_picked_files: null handle"); + return; + }; + if paths.is_null() || count == 0 { + #[cfg(debug_assertions)] + log_dbg("viewport_apply_picked_files: empty input"); + return; + } + let mut out = Vec::with_capacity(count); + for i in 0..count { + let p = unsafe { *paths.add(i) }; + if p.is_null() { + #[cfg(debug_assertions)] + log_dbg(format!("viewport_apply_picked_files: path[{i}] is null")); + continue; + } + match unsafe { CStr::from_ptr(p) }.to_str() { + Ok(s) => { + #[cfg(debug_assertions)] + log_dbg(format!("viewport_apply_picked_files: path[{i}] = {s}")); + out.push(std::path::PathBuf::from(s)); + } + Err(_e) => { + #[cfg(debug_assertions)] + log_dbg(format!("viewport_apply_picked_files: path[{i}] utf8 err: {_e}")); + } + } + } + #[cfg(debug_assertions)] + log_dbg(format!("viewport_apply_picked_files: forwarding {} paths to App", out.len())); + h.apply_picked_files(out); + #[cfg(debug_assertions)] + log_dbg("viewport_apply_picked_files: returned from App"); +} + +/// drives the import progress strip, clearing on a total of zero. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_set_library_progress( + handle: *mut ViewportHandle, + current: u32, + total: u32, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + h.set_library_progress(current, total); +} + +/// seeds the sidebar with placeholder rows of titles plus track-number tags ahead of export completion. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_set_pending_titles( + handle: *mut ViewportHandle, + titles: *const *const c_char, + track_numbers: *const u32, + count: usize, +) { + #[cfg(debug_assertions)] + log_dbg(format!("viewport_set_pending_titles: count={count}")); + let Some(h) = (unsafe { handle.as_mut() }) else { + #[cfg(debug_assertions)] + log_dbg("viewport_set_pending_titles: null handle"); + return; + }; + if titles.is_null() || count == 0 { + return; + } + let mut out = Vec::with_capacity(count); + for i in 0..count { + let p = unsafe { *titles.add(i) }; + let title = if p.is_null() { + format!("Track {}", i + 1) + } else { + unsafe { CStr::from_ptr(p) } + .to_str() + .unwrap_or("") + .to_string() + }; + let title = if title.is_empty() { + format!("Track {}", i + 1) + } else { + title + }; + let tn = if track_numbers.is_null() { + None + } else { + let raw = unsafe { *track_numbers.add(i) }; + if raw == 0 { None } else { Some(raw) } + }; + out.push((title, tn)); + } + h.set_pending_titles(out); +} + +/// fills in the file path for a placeholder track at completion of the export. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_set_track_path( + handle: *mut ViewportHandle, + idx: usize, + path: *const c_char, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { return }; + if path.is_null() { + return; + } + let Ok(s) = (unsafe { CStr::from_ptr(path) }).to_str() else { return }; + #[cfg(debug_assertions)] + log_dbg(format!("viewport_set_track_path: idx={idx} path={s}")); + h.set_track_path(idx, std::path::PathBuf::from(s)); +} + +/// hands raw JPEG/PNG artwork bytes to the art decode worker for a given track index. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_set_track_art( + handle: *mut ViewportHandle, + idx: usize, + bytes: *const u8, + len: usize, +) { + #[cfg(debug_assertions)] + log_dbg(format!("viewport_set_track_art: idx={idx} len={len}")); + let Some(h) = (unsafe { handle.as_mut() }) else { return }; + if bytes.is_null() || len == 0 { + return; + } + let slice = unsafe { std::slice::from_raw_parts(bytes, len) }; + h.set_track_art_bytes(idx, slice.to_vec()); +} + +/// writes a prefixed line through stdout, flushing per call. +#[cfg(debug_assertions)] +fn log_dbg(msg: impl AsRef) { + use std::io::Write; + let mut stdout = std::io::stdout().lock(); + let _ = writeln!(stdout, "[Rust] {}", msg.as_ref()); + let _ = stdout.flush(); +} + +/// drains the pending picker request flag: 0 idle, 1 folder, 2 file. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_take_pending_pick(handle: *mut ViewportHandle) -> u8 { + let Some(h) = (unsafe { handle.as_mut() }) else { + return 0; + }; + h.take_pending_pick() +} + +/// 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) { + if s.is_null() { + return; + } + unsafe { drop(CString::from_raw(s)) }; +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ef73191 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,28 @@ +pub mod hilbert_block; +pub mod hilbert_stream; +pub mod weave; +pub mod processor; +pub mod trig_interpolation; +pub mod analyzer; +pub mod analyzer_worker; +pub mod gpu_dsp; +pub mod track; +pub mod decoder; +pub mod engine; + +pub mod library; +pub mod library_worker; +pub mod palette; +pub mod visualizer; +pub mod ui; + +pub mod viewport; + +#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] +pub mod shell; + +#[cfg(target_os = "ios")] +pub mod ios; + +#[cfg(target_os = "android")] +pub mod android; diff --git a/src/library.rs b/src/library.rs new file mode 100644 index 0000000..c63909f --- /dev/null +++ b/src/library.rs @@ -0,0 +1,258 @@ + + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use iced_widget::image::Handle as ImageHandle; + +use crate::palette; + +/// sidebar entry holding tag metadata, decoded thumbnail handle, and the album-art palette strip. +#[derive(Debug, Clone)] +pub struct Track { + pub path: PathBuf, + pub title: String, + pub artist: Option, + pub album: Option, + pub track_number: Option, + pub art: Option, + pub palette: Option>>, + + /// marks an iOS placeholder row pending audio export to disk. + pub exporting: bool, +} + +/// audio file extensions accepted by the folder scanner. +const AUDIO_EXTS: &[&str] = &[ + "mp3", "m4a", "m4b", "m4r", "mp4", "flac", "wav", "ogg", "oga", "opus", "aac", "aiff", + "aif", +]; + +/// reads a directory non-recursively and returns sorted track stubs with metadata only. +pub fn scan_folder(folder: &Path) -> Vec { + #[cfg(debug_assertions)] + let _t0 = std::time::Instant::now(); + + let entries = match std::fs::read_dir(folder) { + Ok(e) => e, + Err(_e) => { + #[cfg(all(target_os = "ios", debug_assertions))] + eprintln!("[Rust.dbg] scan_folder read_dir failed: {} err={_e}", folder.display()); + return Vec::new(); + } + }; + + #[cfg(all(target_os = "ios", debug_assertions))] + let raw_entries: Vec = entries.filter_map(|r| r.ok()).map(|e| e.path()).collect(); + #[cfg(all(target_os = "ios", debug_assertions))] + { + eprintln!("[Rust.dbg] scan_folder raw entries={} for {}", raw_entries.len(), folder.display()); + for (i, p) in raw_entries.iter().take(10).enumerate() { + let is_file = p.is_file(); + let is_dir = p.is_dir(); + let ext = p.extension().and_then(|e| e.to_str()).unwrap_or(""); + let kept = is_file && has_audio_ext(p); + eprintln!("[Rust.dbg] entry[{i}] file={is_file} dir={is_dir} ext={ext:?} kept={kept} path={}", p.display()); + } + } + + #[cfg(all(target_os = "ios", debug_assertions))] + let mut paths: Vec = raw_entries + .into_iter() + .filter(|p| p.is_file() && has_audio_ext(p)) + .collect(); + #[cfg(not(all(target_os = "ios", debug_assertions)))] + let mut paths: Vec = entries + .filter_map(|r| r.ok()) + .map(|e| e.path()) + .filter(|p| p.is_file() && has_audio_ext(p)) + .collect(); + paths.sort(); + + #[cfg(debug_assertions)] + let _t_meta = std::time::Instant::now(); + + let mut tracks: Vec = paths.iter().filter_map(|p| read_track_meta(p).ok()).collect(); + sort_tracks(&mut tracks); + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] scan_folder({:?}): {} files, dir+filter {:?}, metadata pass {:?}, total {:?}", + folder.file_name().unwrap_or_default(), + tracks.len(), + _t_meta.duration_since(_t0), + _t_meta.elapsed(), + _t0.elapsed(), + ); + tracks +} + +/// reads tags and decodes embedded album art into a thumbnail and palette in one pass. +pub fn read_track(path: &Path) -> Result { + use lofty::file::TaggedFileExt; + use lofty::tag::Accessor; + + #[cfg(debug_assertions)] + let _t0 = std::time::Instant::now(); + + let tagged = lofty::read_from_path(path).map_err(|e| e.to_string())?; + #[cfg(debug_assertions)] + let _t_lofty = _t0.elapsed(); + + let tag = tagged + .primary_tag() + .or_else(|| tagged.first_tag()); + + let (title, artist, album, track_number, art_bytes) = if let Some(tag) = tag { + let title = tag.title().map(|s| s.to_string()); + let artist = tag.artist().map(|s| s.to_string()); + let album = tag.album().map(|s| s.to_string()); + let track_number = tag.track(); + let art = tag.pictures().first().map(|p| p.data().to_vec()); + (title, artist, album, track_number, art) + } else { + (None, None, None, None, None) + }; + + let title = title.unwrap_or_else(|| { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Untitled") + .to_string() + }); + + #[cfg(debug_assertions)] + let _art_bytes_len = art_bytes.as_ref().map(|v| v.len()).unwrap_or(0); + #[cfg(debug_assertions)] + let _t_art = std::time::Instant::now(); + let (art, palette) = art_bytes + .as_ref() + .map(|bytes| build_art(bytes)) + .unwrap_or((None, None)); + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] read_track({:?}): lofty {:?}, art {:?} ({} art bytes), total {:?}", + path.file_name().unwrap_or_default(), + _t_lofty, + _t_art.elapsed(), + _art_bytes_len, + _t0.elapsed(), + ); + + Ok(Track { + path: path.to_path_buf(), + title, + artist, + album, + track_number, + art, + palette, + exporting: false, + }) +} + +/// reads tags only and skips art decoding. +pub fn read_track_meta(path: &Path) -> Result { + use lofty::file::TaggedFileExt; + use lofty::tag::Accessor; + + let tagged = lofty::read_from_path(path).map_err(|e| e.to_string())?; + let tag = tagged.primary_tag().or_else(|| tagged.first_tag()); + let (title, artist, album, track_number) = if let Some(tag) = tag { + ( + tag.title().map(|s| s.to_string()), + tag.artist().map(|s| s.to_string()), + tag.album().map(|s| s.to_string()), + tag.track(), + ) + } else { + (None, None, None, None) + }; + let title = title.unwrap_or_else(|| { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Untitled") + .to_string() + }); + Ok(Track { + path: path.to_path_buf(), + title, + artist, + album, + track_number, + art: None, + palette: None, + exporting: false, + }) +} + +/// decodes raw image bytes into a 96x96 sidebar thumbnail and a 32-color vibrancy palette strip. +#[allow(clippy::type_complexity)] +pub fn build_art(bytes: &[u8]) -> (Option, Option>>) { + let Ok(img) = image::load_from_memory(bytes) else { + return (None, None); + }; + let rgba = img.to_rgba8(); + let palette = Some(palette::extract(&rgba, 32)); + + let thumb = image::imageops::resize(&rgba, 96, 96, image::imageops::FilterType::Triangle); + let (w, h) = (thumb.width(), thumb.height()); + let handle = ImageHandle::from_rgba(w, h, thumb.into_raw()); + (Some(handle), palette) +} + +/// pulls title, artist, album, and track number from a file's tags as a flat tuple. +pub fn read_tags( + path: &Path, +) -> (Option, Option, Option, Option) { + use lofty::file::TaggedFileExt; + use lofty::tag::Accessor; + + let Ok(tagged) = lofty::read_from_path(path) else { + return (None, None, None, None); + }; + let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) else { + return (None, None, None, None); + }; + ( + tag.title().map(|s| s.to_string()), + tag.artist().map(|s| s.to_string()), + tag.album().map(|s| s.to_string()), + tag.track(), + ) +} + +/// extracts a file's first embedded picture and decodes a thumbnail and vibrancy palette. +#[allow(clippy::type_complexity)] +pub fn read_art(path: &Path) -> (Option, Option>>) { + use lofty::file::TaggedFileExt; + + let Ok(tagged) = lofty::read_from_path(path) else { + return (None, None); + }; + let tag = tagged.primary_tag().or_else(|| tagged.first_tag()); + let bytes = tag.and_then(|t| t.pictures().first().map(|p| p.data().to_vec())); + match bytes { + Some(b) => build_art(&b), + None => (None, None), + } +} + +/// orders tracks by tag track-number ascending with a case-insensitive title fallback. +pub fn sort_tracks(tracks: &mut [Track]) { + tracks.sort_by(|a, b| { + let key_a = (a.track_number.unwrap_or(u32::MAX), a.title.to_lowercase()); + let key_b = (b.track_number.unwrap_or(u32::MAX), b.title.to_lowercase()); + key_a.cmp(&key_b) + }); +} + +/// case-insensitive match against the supported-extension list. +fn has_audio_ext(p: &Path) -> bool { + p.extension() + .and_then(|s| s.to_str()) + .map(|s| { + let lower = s.to_ascii_lowercase(); + AUDIO_EXTS.iter().any(|e| *e == lower) + }) + .unwrap_or(false) +} diff --git a/src/library_worker.rs b/src/library_worker.rs new file mode 100644 index 0000000..b174cd9 --- /dev/null +++ b/src/library_worker.rs @@ -0,0 +1,180 @@ + + +use std::path::PathBuf; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; + +use crossbeam_channel::{unbounded, Receiver, Sender}; +use iced_widget::image::Handle as ImageHandle; + +use crate::decoder; +use crate::library; +use crate::track::TrackData; + +/// messages emitted by the background workers as metadata, art, or decoded pcm becomes available. +pub enum LibraryUpdate { + Meta { + path: PathBuf, + title: Option, + artist: Option, + album: Option, + track_number: Option, + }, + Art { + path: PathBuf, + art: Option, + palette: Option>>, + }, + + /// art keyed by sidebar index, paired with a vibrancy palette. + ArtForIdx { + idx: usize, + art: Option, + palette: Option>>, + }, + Decoded { + request_id: u64, + path: PathBuf, + result: Result, String>, + }, +} + +/// decode job carrying the request id and target sample rate. +struct DecodeReq { + request_id: u64, + path: PathBuf, + target_sr: u32, +} + +/// art decode request as either an on-disk file path or already-fetched image bytes. +enum ArtReq { + FromPath(PathBuf), + FromBytes { idx: usize, bytes: Vec }, +} + +/// owns the three background threads handling metadata reads, art decoding, and audio decoding. +pub struct LibraryWorker { + meta_tx: Sender, + art_tx: Sender, + decode_tx: Sender, + update_rx: Receiver, + _meta_join: Option>, + _art_join: Option>, + _decode_join: Option>, +} + +impl LibraryWorker { + /// starts the metadata, art, and decode threads on a shared update channel. + pub fn spawn() -> Self { + let (meta_tx, meta_rx) = unbounded::(); + let (art_tx, art_rx) = unbounded::(); + let (decode_tx, decode_rx) = unbounded::(); + let (update_tx, update_rx) = unbounded::(); + + let meta_update_tx = update_tx.clone(); + let meta_join = thread::Builder::new() + .name("yr_crystals.meta".into()) + .spawn(move || run_meta(meta_rx, meta_update_tx)) + .expect("spawn meta thread"); + + let art_update_tx = update_tx.clone(); + let art_join = thread::Builder::new() + .name("yr_crystals.art".into()) + .spawn(move || run_art(art_rx, art_update_tx)) + .expect("spawn art thread"); + + let decode_join = thread::Builder::new() + .name("yr_crystals.decode".into()) + .spawn(move || run_decode(decode_rx, update_tx)) + .expect("spawn decode thread"); + + Self { + meta_tx, + art_tx, + decode_tx, + update_rx, + _meta_join: Some(meta_join), + _art_join: Some(art_join), + _decode_join: Some(decode_join), + } + } + + /// queues a tag-only read of a file path. + pub fn request_meta(&self, path: PathBuf) { + let _ = self.meta_tx.send(path); + } + + /// queues an art read against a file path, matched back to the sidebar by path. + pub fn request_art(&self, path: PathBuf) { + let _ = self.art_tx.send(ArtReq::FromPath(path)); + } + + /// queues art decoding from in-memory bytes, keyed by sidebar index. + pub fn request_art_bytes(&self, idx: usize, bytes: Vec) { + let _ = self.art_tx.send(ArtReq::FromBytes { idx, bytes }); + } + + /// queues a full decode-and-resample to the engine's output rate, tagged with a request id. + pub fn request_decode(&self, request_id: u64, path: PathBuf, target_sr: u32) { + let _ = self.decode_tx.send(DecodeReq { + request_id, + path, + target_sr, + }); + } + + /// drains everything available without blocking, returning the batch in arrival order. + pub fn drain_updates(&self) -> Vec { + self.update_rx.try_iter().collect() + } +} + +/// metadata thread loop reading tags one path at a time and emitting Meta updates. +fn run_meta(rx: Receiver, tx: Sender) { + while let Ok(path) = rx.recv() { + let (title, artist, album, track_number) = library::read_tags(&path); + let _ = tx.send(LibraryUpdate::Meta { + path, + title, + artist, + album, + track_number, + }); + } +} + +/// art thread loop dispatching between path-keyed and index-keyed art decode requests. +fn run_art(rx: Receiver, tx: Sender) { + while let Ok(req) = rx.recv() { + match req { + ArtReq::FromPath(path) => { + let (art, palette) = library::read_art(&path); + let _ = tx.send(LibraryUpdate::Art { path, art, palette }); + } + ArtReq::FromBytes { idx, bytes } => { + let (art, palette) = library::build_art(&bytes); + let _ = tx.send(LibraryUpdate::ArtForIdx { idx, art, palette }); + } + } + } +} + +/// decode thread loop, collapsing a queue burst down to the freshest request. +fn run_decode(rx: Receiver, tx: Sender) { + while let Ok(req) = rx.recv() { + // drains pending requests down to the newest path. + let mut latest = req; + while let Ok(next) = rx.try_recv() { + latest = next; + } + + let result = decoder::decode_file_resampled(&latest.path, latest.target_sr) + .map(Arc::new) + .map_err(|e| e.to_string()); + let _ = tx.send(LibraryUpdate::Decoded { + request_id: latest.request_id, + path: latest.path, + result, + }); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..34c1e74 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,8 @@ +/// hands off to the winit-driven shell. +#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] +fn main() { + yr_crystals::shell::run(); +} + +#[cfg(any(target_os = "ios", target_os = "android"))] +fn main() {} diff --git a/src/palette.rs b/src/palette.rs new file mode 100644 index 0000000..0493826 --- /dev/null +++ b/src/palette.rs @@ -0,0 +1,54 @@ + + +use std::sync::Arc; + +/// reduces an image to N horizontal bands and picks the most vibrant pixel per band. +pub fn extract(img: &image::RgbaImage, n: usize) -> Arc> { + if n == 0 || img.width() == 0 || img.height() == 0 { + return Arc::new(Vec::new()); + } + let strip = image::imageops::resize( + img, + n as u32, + 20, + image::imageops::FilterType::Triangle, + ); + let mut out: Vec<[f32; 3]> = Vec::with_capacity(n); + for x in 0..n as u32 { + let mut best = [0.5_f32, 0.5, 0.5]; + let mut best_vibrancy = -1.0_f32; + for y in 0..strip.height() { + let p = strip.get_pixel(x, y); + let r = p[0] as f32 / 255.0; + let g = p[1] as f32 / 255.0; + let b = p[2] as f32 / 255.0; + let (_, s, v) = rgb_to_hsv(r, g, b); + let vib = s * v; + if vib > best_vibrancy { + best_vibrancy = vib; + best = [r, g, b]; + } + } + out.push(best); + } + Arc::new(out) +} + +/// converts an sRGB triple in 0..=1 to hue/saturation/value, all in 0..=1. +pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) { + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let v = max; + let d = max - min; + let s = if max <= 0.0 { 0.0 } else { d / max }; + let h = if d <= 1e-6 { + 0.0 + } else if (max - r).abs() < f32::EPSILON { + ((g - b) / d).rem_euclid(6.0) / 6.0 + } else if (max - g).abs() < f32::EPSILON { + ((b - r) / d + 2.0) / 6.0 + } else { + ((r - g) / d + 4.0) / 6.0 + }; + ((h + 1.0).rem_euclid(1.0), s, v) +} diff --git a/src/processor.rs b/src/processor.rs new file mode 100644 index 0000000..c446d3f --- /dev/null +++ b/src/processor.rs @@ -0,0 +1,371 @@ + + +use num_complex::Complex64; +use rustfft::{Fft, FftPlanner}; +use std::collections::VecDeque; +use std::f64::consts::PI; +use std::sync::Arc; + +use crate::gpu_dsp::GpuFft1D; +use crate::weave; + +/// log-spaced spectrum frame plus the real cepstrum used for transient detection. +#[derive(Debug, Clone, Default)] +pub struct Spectrum { + pub freqs: Vec, + pub db: Vec, + + pub cepstrum: Vec, +} + +/// single-channel windowed-fft pipeline with optional gpu offload, log-spaced binning, and cepstral envelope blending. +pub struct Processor { + frame_size: usize, + sample_rate: u32, + + device: wgpu::Device, + queue: wgpu::Queue, + + cpu_fft: Option>>, + cpu_cep_inv: Option>>, + cpu_planner: FftPlanner, + + gpu_fft: Option, + + gpu_blend: f32, + + window: Vec, + buffer: Vec, + + custom_bins: Vec, + freqs_const: Vec, + + history: VecDeque>, + smoothing_length: usize, + + expand_ratio: f32, + expand_threshold: f32, + + hpf_cutoff: f32, + + granularity: i32, + detail: i32, + cepstral_strength: f32, + + weave_caps: Option, +} + +impl Processor { + /// allocates fft plans, the analysis window, and a default 26-bin log-spaced layout. + pub fn new(frame_size: usize, sample_rate: u32, device: wgpu::Device, queue: wgpu::Queue) -> Self { + let mut p = Self { + frame_size: 0, + sample_rate, + device, + queue, + cpu_fft: None, + cpu_cep_inv: None, + cpu_planner: FftPlanner::new(), + gpu_fft: None, + gpu_blend: 0.0, + window: Vec::new(), + buffer: Vec::new(), + custom_bins: Vec::new(), + freqs_const: Vec::new(), + history: VecDeque::new(), + smoothing_length: 3, + expand_ratio: 1.0, + expand_threshold: -60.0, + hpf_cutoff: 0.0, + granularity: 33, + detail: 50, + cepstral_strength: 0.0, + weave_caps: default_caps(), + }; + p.set_frame_size(frame_size); + p.set_num_bins(26); + p + } + + /// updates the bin-to-hertz sample rate. + pub fn set_sample_rate(&mut self, rate: u32) { + self.sample_rate = rate; + } + + /// caps the rolling history depth of the per-bin db time-average. + pub fn set_smoothing(&mut self, history_length: usize) { + self.smoothing_length = history_length.max(1); + while self.history.len() > self.smoothing_length { + self.history.pop_front(); + } + } + + /// configures the upward expander applied below the threshold in db. + pub fn set_expander(&mut self, ratio: f32, threshold_db: f32) { + self.expand_ratio = ratio; + self.expand_threshold = threshold_db; + } + + /// sets the corner frequency of the per-bin first-order high-pass roll-off applied during binning. + pub fn set_hpf(&mut self, cutoff_freq: f32) { + self.hpf_cutoff = cutoff_freq; + } + + /// dials the bin density, iteration count, and blend weight of the cepstral envelope smoother. + pub fn set_cepstral_params(&mut self, granularity: i32, detail: i32, strength: f32) { + self.granularity = granularity; + self.detail = detail; + self.cepstral_strength = strength; + } + + /// crossfades between cpu-fft and gpu-fft magnitudes within zero to one inclusive. + pub fn set_gpu_blend(&mut self, blend: f32) { + self.gpu_blend = blend.clamp(0.0, 1.0); + } + + /// rebuilds the geometric 40 hz to 11 khz bin edges and the matching center frequencies for the n+1 output columns. + pub fn set_num_bins(&mut self, n: usize) { + self.custom_bins.clear(); + self.freqs_const.clear(); + self.history.clear(); + + let min_freq = 40.0_f64; + let max_freq = 11_000.0_f64; + for i in 0..=n { + let f = min_freq * (max_freq / min_freq).powf(i as f64 / n as f64); + self.custom_bins.push(f); + } + + self.freqs_const.push(10.0); + for i in 0..self.custom_bins.len() - 1 { + self.freqs_const + .push((self.custom_bins[i] + self.custom_bins[i + 1]) / 2.0); + } + } + + /// rebuilds fft plans, the blackman-harris window, and the working buffer at the requested transform length. + pub fn set_frame_size(&mut self, size: usize) { + if self.frame_size == size { + return; + } + self.frame_size = size; + + self.cpu_fft = Some(self.cpu_planner.plan_fft_forward(size)); + self.cpu_cep_inv = Some(self.cpu_planner.plan_fft_inverse(size)); + self.gpu_fft = Some(GpuFft1D::new(self.device.clone(), self.queue.clone(), size as u32)); + + self.window = (0..size) + .map(|i| { + let a0 = 0.35875; + let a1 = 0.48829; + let a2 = 0.14128; + let a3 = 0.01168; + let denom = (size - 1) as f64; + a0 - a1 * (2.0 * PI * i as f64 / denom).cos() + + a2 * (4.0 * PI * i as f64 / denom).cos() + - a3 * (6.0 * PI * i as f64 / denom).cos() + }) + .collect(); + + self.buffer = vec![Complex64::new(0.0, 0.0); size]; + self.history.clear(); + } + + /// shifts an incoming chunk into the tail of the analytic-signal buffer, evicting the head. + pub fn push_data(&mut self, data: &[Complex64]) { + let n = self.frame_size; + if data.len() == n { + self.buffer.copy_from_slice(data); + } else if data.len() < n { + let drop = data.len(); + self.buffer.copy_within(drop.., 0); + let tail = n - drop; + self.buffer[tail..].copy_from_slice(data); + } + } + + /// produces a spectrum frame through the windowed cpu/gpu fft blend, hpf shaping, cepstral idealisation, expander, and history smoothing. + pub fn get_spectrum(&mut self) -> Spectrum { + let n = self.frame_size; + let blend = self.gpu_blend as f64; + + let work_template: Vec = self + .buffer + .iter() + .zip(self.window.iter()) + .map(|(c, w)| Complex64::new(c.re * w, c.im * w)) + .collect(); + + let cpu_work = if blend < 1.0 { + let mut w = work_template.clone(); + self.cpu_fft + .as_ref() + .expect("cpu fft plan") + .clone() + .process(&mut w); + Some(w) + } else { + None + }; + + let gpu_work = if blend > 0.0 { + let gpu = self.gpu_fft.as_mut().expect("gpu fft plan"); + let mut prev = vec![Complex64::new(0.0, 0.0); n]; + // collect lags one frame behind the cpu path. + let got_prev = gpu.try_collect_into(&mut prev); + gpu.submit_forward(&work_template); + if got_prev { + Some(prev) + } else { + None + } + } else { + + let gpu = self.gpu_fft.as_mut().expect("gpu fft plan"); + let mut discard = vec![Complex64::new(0.0, 0.0); n]; + let _ = gpu.try_collect_into(&mut discard); + None + }; + + let bins = n / 2 + 1; + let mut freqs_full = vec![0.0_f64; bins]; + let mut mag_full = vec![0.0_f64; bins]; + for i in 0..bins { + let cpu_m = cpu_work.as_ref().map(|w| { + let re = w[i].re; + let im = w[i].im; + 2.0 * (re * re + im * im).sqrt() / n as f64 + }); + let gpu_m = gpu_work.as_ref().map(|w| { + let re = w[i].re; + let im = w[i].im; + 2.0 * (re * re + im * im).sqrt() / n as f64 + }); + let mut mag = match (cpu_m, gpu_m) { + (Some(c), Some(g)) => c * (1.0 - blend) + g * blend, + (Some(c), None) => c, + (None, Some(g)) => g, + (None, None) => 0.0, + }; + let freq = i as f64 * self.sample_rate as f64 / n as f64; + if self.hpf_cutoff > 0.0 && freq > 0.0 { + let ratio = self.hpf_cutoff as f64 / freq; + let r2 = ratio * ratio; + let gain = 1.0 / (1.0 + r2 * r2).sqrt(); + mag *= gain; + } else if freq == 0.0 { + mag = 0.0; + } + mag_full[i] = mag; + freqs_full[i] = freq; + } + + let mut cep_buf: Vec = Vec::with_capacity(n); + for i in 0..n { + let idx = if i <= n / 2 { i } else { n - i }; + let v = mag_full[idx].max(1e-9); + cep_buf.push(Complex64::new(v.ln(), 0.0)); + } + self.cpu_cep_inv + .as_ref() + .expect("cpu cep inverse plan") + .clone() + .process(&mut cep_buf); + let cep_scale = 1.0 / n as f64; + let half_n = n / 2; + let cepstrum: Vec = (0..half_n) + .map(|i| (cep_buf[i].re * cep_scale) as f32) + .collect(); + + if self.cepstral_strength > 0.0 { + let envelope = weave::idealize_curve( + &mag_full, + self.granularity, + self.detail, + self.weave_caps, + ); + let s = self.cepstral_strength as f64; + for (m, e) in mag_full.iter_mut().zip(envelope.iter()) { + *m = *m * (1.0 - s) + e * s; + } + } + + let mut current_db = vec![0.0_f64; self.freqs_const.len()]; + for (i, &target) in self.freqs_const.iter().enumerate() { + let mag = lerp_at(&freqs_full, &mag_full, target); + let mut val = 20.0 * mag.max(1e-12).log10(); + if (self.expand_ratio - 1.0).abs() > f32::EPSILON { + let t = self.expand_threshold as f64; + let r = self.expand_ratio as f64; + val = (val - t) * r + t; + } + if val < -100.0 { + val = -100.0; + } + current_db[i] = val; + } + + self.history.push_back(current_db); + while self.history.len() > self.smoothing_length { + self.history.pop_front(); + } + + let cols = self.freqs_const.len(); + let mut averaged = vec![0.0_f32; cols]; + if !self.history.is_empty() { + for v in &self.history { + for (i, &x) in v.iter().enumerate() { + averaged[i] += x as f32; + } + } + let factor = 1.0 / self.history.len() as f32; + for v in &mut averaged { + *v *= factor; + } + } + + let freqs_ret: Vec = self.freqs_const.iter().map(|&f| f as f32).collect(); + Spectrum { + freqs: freqs_ret, + db: averaged, + cepstrum, + } + } + + /// returns the active fft transform length. + pub fn frame_size(&self) -> usize { + self.frame_size + } +} + +/// linearly interpolates a value from the freqs/values table at the given target frequency with endpoint clamping. +fn lerp_at(freqs: &[f64], values: &[f64], target: f64) -> f64 { + if freqs.is_empty() { + return 0.0; + } + if target <= freqs[0] { + return values[0]; + } + if target >= freqs[freqs.len() - 1] { + return values[values.len() - 1]; + } + let upper = freqs.partition_point(|&f| f < target); + let lower = upper - 1; + let f0 = freqs[lower]; + let f1 = freqs[upper]; + let v0 = values[lower]; + let v1 = values[upper]; + let t = (target - f0) / (f1 - f0); + v0 + t * (v1 - v0) +} + +/// chooses lighter idealisation caps on mobile targets and unlimited caps on desktop. +#[cfg(any(target_os = "ios", target_os = "android"))] +fn default_caps() -> Option { + Some(weave::Caps::mobile()) +} + +#[cfg(not(any(target_os = "ios", target_os = "android")))] +fn default_caps() -> Option { + None +} diff --git a/src/shell.rs b/src/shell.rs new file mode 100644 index 0000000..e0013e7 --- /dev/null +++ b/src/shell.rs @@ -0,0 +1,239 @@ + + +use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; +use winit::application::ApplicationHandler; +use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize}; +use winit::event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent}; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::keyboard::{Key, ModifiersState, NamedKey}; +use winit::window::{Window, WindowAttributes, WindowId}; + +use crate::viewport::ViewportHandle; + +const DEFAULT_LOGICAL: (u32, u32) = (1100, 700); +const MIN_LOGICAL: (u32, u32) = (520, 380); + +/// winit application driver owning the desktop window and forwarding events into the viewport. +pub struct ShellApp { + window: Option>, + handle: Option, + modifiers: ModifiersState, + last_cursor: PhysicalPosition, +} + +impl Default for ShellApp { + fn default() -> Self { + Self { + window: None, + handle: None, + modifiers: ModifiersState::empty(), + last_cursor: PhysicalPosition::new(0.0, 0.0), + } + } +} + +/// boots the winit event loop and runs the desktop shell until the window closes. +pub fn run() { + let event_loop = EventLoop::new().expect("winit: create event loop"); + event_loop.set_control_flow(ControlFlow::Wait); + let mut app = ShellApp::default(); + if let Err(e) = event_loop.run_app(&mut app) { + eprintln!("yr_crystals shell exited with error: {e}"); + std::process::exit(1); + } +} + +impl ApplicationHandler for ShellApp { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; + } + let attrs = WindowAttributes::default() + .with_title("Yr Xtals") + .with_inner_size(LogicalSize::new(DEFAULT_LOGICAL.0, DEFAULT_LOGICAL.1)) + .with_min_inner_size(LogicalSize::new(MIN_LOGICAL.0, MIN_LOGICAL.1)); + + let window = event_loop + .create_window(attrs) + .expect("winit: create window"); + let inner = window.inner_size(); + let scale = window.scale_factor() as f32; + let window = Box::new(window); + + let raw_window = window + .window_handle() + .expect("winit: window handle") + .as_raw(); + let raw_display = window + .display_handle() + .expect("winit: display handle") + .as_raw(); + + let handle = ViewportHandle::new_from_raw( + raw_window, + raw_display, + (inner.width as f32 / scale).max(1.0), + (inner.height as f32 / scale).max(1.0), + scale, + ) + .expect("yr_crystals: failed to build wgpu/iced viewport"); + + self.window = Some(window); + self.handle = Some(handle); + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) { + let Some(window) = self.window.as_ref() else { + return; + }; + let Some(handle) = self.handle.as_mut() else { + return; + }; + let scale = window.scale_factor() as f32; + + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(PhysicalSize { width, height }) => { + let w = (width as f32 / scale).max(1.0); + let h = (height as f32 / scale).max(1.0); + handle.resize_px(w, h, scale); + window.request_redraw(); + } + WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + let size = window.inner_size(); + let s = scale_factor as f32; + let w = (size.width as f32 / s).max(1.0); + let h = (size.height as f32 / s).max(1.0); + handle.resize_px(w, h, s); + window.request_redraw(); + } + WindowEvent::CursorMoved { position, .. } => { + self.last_cursor = position; + handle.push_mouse_move(position.x as f32 / scale, position.y as f32 / scale); + window.request_redraw(); + } + WindowEvent::CursorLeft { .. } => { + handle.push_mouse_left(); + window.request_redraw(); + } + WindowEvent::MouseInput { state, button, .. } => { + let code = match button { + MouseButton::Left => 0, + MouseButton::Right => 1, + MouseButton::Middle => 2, + _ => return, + }; + let pressed = matches!(state, ElementState::Pressed); + handle.push_mouse_button( + self.last_cursor.x as f32 / scale, + self.last_cursor.y as f32 / scale, + code, + pressed, + ); + window.request_redraw(); + } + WindowEvent::MouseWheel { delta, .. } => { + let (dx, dy) = match delta { + MouseScrollDelta::LineDelta(x, y) => (x * 20.0, y * 20.0), + MouseScrollDelta::PixelDelta(p) => (p.x as f32, p.y as f32), + }; + handle.push_mouse_scroll( + self.last_cursor.x as f32 / scale, + self.last_cursor.y as f32 / scale, + dx, + dy, + ); + window.request_redraw(); + } + WindowEvent::ModifiersChanged(mods) => { + self.modifiers = mods.state(); + } + WindowEvent::KeyboardInput { event, .. } => { + let KeyEvent { + logical_key, + state, + text, + .. + } = event; + let pressed = matches!(state, ElementState::Pressed); + let named = map_named_key(&logical_key); + let utf8 = match (named, &logical_key, &text) { + (0, Key::Character(s), _) => Some(s.to_string()), + (0, _, Some(s)) => Some(s.to_string()), + _ => None, + }; + let mods = encode_modifiers(self.modifiers); + handle.push_key_event(named, utf8, mods, pressed); + window.request_redraw(); + } + WindowEvent::RedrawRequested => handle.render_frame(), + _ => {} + } + } + + /// switches between Poll and Wait control flow depending on active playback or loading. + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + let Some(window) = self.window.as_ref() else { + return; + }; + let Some(handle) = self.handle.as_mut() else { + return; + }; + let playing = handle + .state + .engine + .as_ref() + .map(|e| e.is_playing()) + .unwrap_or(false); + let animating = playing + || handle.state.track_loading + || handle.state.library_progress.is_some(); + if animating { + window.request_redraw(); + event_loop.set_control_flow(ControlFlow::Poll); + } else { + event_loop.set_control_flow(ControlFlow::Wait); + } + } +} + +/// maps a winit named key into the viewport's small-integer key code. +fn map_named_key(k: &Key) -> u32 { + match k { + Key::Named(NamedKey::Enter) => 1, + Key::Named(NamedKey::Escape) => 2, + Key::Named(NamedKey::Backspace) => 3, + Key::Named(NamedKey::Tab) => 4, + Key::Named(NamedKey::ArrowLeft) => 5, + Key::Named(NamedKey::ArrowRight) => 6, + Key::Named(NamedKey::ArrowUp) => 7, + Key::Named(NamedKey::ArrowDown) => 8, + Key::Named(NamedKey::Delete) => 9, + Key::Named(NamedKey::Home) => 10, + Key::Named(NamedKey::End) => 11, + _ => 0, + } +} + +/// packs winit modifier state into the viewport's modifier bitfield. +fn encode_modifiers(mods: ModifiersState) -> u32 { + let mut out = 0; + if mods.shift_key() { + out |= 1; + } + if mods.control_key() { + out |= 2; + } + if mods.alt_key() { + out |= 4; + } + if mods.super_key() { + out |= 8; + } + out +} diff --git a/src/track.rs b/src/track.rs new file mode 100644 index 0000000..21ccfaa --- /dev/null +++ b/src/track.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +/// shared interleaved-stereo pcm bundle paired with the track's sample rate and dsp frame size. +#[derive(Debug, Clone)] +pub struct TrackData { + pub pcm: Arc<[f32]>, + pub sample_rate: u32, + pub frame_size: usize, +} + +impl TrackData { + /// builds a zero-length placeholder at 48 kHz with a 4096-sample frame. + pub fn empty() -> Self { + Self { + pcm: Arc::from(Vec::::new()), + sample_rate: 48_000, + frame_size: 4096, + } + } + + /// counts stereo frames (pcm length divided by two channels). + pub fn total_samples(&self) -> usize { + self.pcm.len() / 2 + } + + /// reports whether the pcm slice holds any samples. + pub fn is_valid(&self) -> bool { + !self.pcm.is_empty() + } +} + +impl Default for TrackData { + fn default() -> Self { + Self::empty() + } +} diff --git a/src/trig_interpolation.rs b/src/trig_interpolation.rs new file mode 100644 index 0000000..82fd762 --- /dev/null +++ b/src/trig_interpolation.rs @@ -0,0 +1,260 @@ +#![allow(clippy::needless_range_loop, clippy::too_many_arguments)] + +use num_complex::Complex64; +use rustfft::{Fft, FftPlanner}; +use std::f64::consts::PI; +use std::sync::Arc; + +use crate::hilbert_block::BlockHilbert; +use crate::weave; + +/// phase model applied when converting a matching curve into a finite impulse response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PhaseMode { + /// minimum-phase reconstruction via the cepstrum and a one-sided lifter. + Hilbert, + /// zero-phase, treats the curve as a real spectrum and inverse-transforms directly. + StandardCepstral, +} + +/// FIR taps paired with source, target, and matched magnitude curves in dB. +#[derive(Debug, Clone, Default)] +pub struct MatchResult { + pub fir_l: Vec, + pub fir_r: Vec, + pub frequency_axis: Vec, + pub source_l_db: Vec, + pub target_l_db: Vec, + pub matched_l_db: Vec, + pub source_r_db: Vec, + pub target_r_db: Vec, + pub matched_r_db: Vec, +} + +/// holds the FFT planner and block-hilbert helper reused across match passes. +pub struct TrigInterpolation { + hilbert: BlockHilbert, + planner: FftPlanner, +} + +impl Default for TrigInterpolation { + fn default() -> Self { + Self::new() + } +} + +impl TrigInterpolation { + pub fn new() -> Self { + Self { + hilbert: BlockHilbert::new(), + planner: FftPlanner::new(), + } + } + + /// derives stereo FIR taps warping source magnitudes toward target magnitudes. + pub fn process_and_generate_fir( + &mut self, + source_l: &[f64], + source_r: &[f64], + target_l: &[f64], + target_r: &[f64], + granularity: i32, + detail: i32, + strength: f64, + fir_size: usize, + lr_link: f64, + phase_mode: PhaseMode, + ) -> MatchResult { + let cep_src_l = self.cepstrum_from_magnitude(source_l); + let cep_src_r = self.cepstrum_from_magnitude(source_r); + let cep_tgt_l = self.cepstrum_from_magnitude(target_l); + let cep_tgt_r = self.cepstrum_from_magnitude(target_r); + + let smooth_src_l = idealize_complex(&cep_src_l, granularity, detail); + let smooth_src_r = idealize_complex(&cep_src_r, granularity, detail); + let smooth_tgt_l = idealize_complex(&cep_tgt_l, granularity, detail); + let smooth_tgt_r = idealize_complex(&cep_tgt_r, granularity, detail); + + let pre_link_l = create_match_curve(&smooth_src_l, &smooth_tgt_l, strength); + let pre_link_r = create_match_curve(&smooth_src_r, &smooth_tgt_r, strength); + + let n_match = pre_link_l.len(); + let mut match_l = vec![0.0_f64; n_match]; + let mut match_r = vec![0.0_f64; n_match]; + for i in 0..n_match { + let lv = pre_link_l[i]; + let rv = pre_link_r[i]; + match_l[i] = lv * (1.0 - lr_link) + (lv + rv) * 0.5 * lr_link; + match_r[i] = rv * (1.0 - lr_link) + (lv + rv) * 0.5 * lr_link; + } + + let fir_l = self.create_fir_from_curve(&match_l, fir_size, phase_mode); + let fir_r = self.create_fir_from_curve(&match_r, fir_size, phase_mode); + + let mut result = MatchResult { + fir_l, + fir_r, + ..Default::default() + }; + let num_plot_points = if match_l.len() > 1 { match_l.len() / 2 } else { 0 }; + if num_plot_points == 0 { + return result; + } + + let freq_axis_size = source_l.len(); + result.frequency_axis = (0..freq_axis_size).map(|i| i as f64).collect(); + let to_db = |v: f64| 20.0 * v.max(1e-9).log10(); + + result.source_l_db = (0..freq_axis_size).map(|i| to_db(smooth_src_l[i])).collect(); + result.target_l_db = (0..freq_axis_size).map(|i| to_db(smooth_tgt_l[i])).collect(); + result.matched_l_db = (0..freq_axis_size).map(|i| to_db(match_l[i])).collect(); + result.source_r_db = (0..freq_axis_size).map(|i| to_db(smooth_src_r[i])).collect(); + result.target_r_db = (0..freq_axis_size).map(|i| to_db(smooth_tgt_r[i])).collect(); + result.matched_r_db = (0..freq_axis_size).map(|i| to_db(match_r[i])).collect(); + + result + } + + /// mirrors a half-spectrum into a full log-magnitude spectrum and returns the analytic cepstrum. + fn cepstrum_from_magnitude(&mut self, mag_spectrum: &[f64]) -> Vec { + if mag_spectrum.is_empty() { + return Vec::new(); + } + let half_n = mag_spectrum.len(); + let n = if half_n > 1 { (half_n - 1) * 2 } else { 0 }; + if n == 0 { + return Vec::new(); + } + + let mut log_full = vec![Complex64::new(0.0, 0.0); n]; + for i in 0..half_n { + log_full[i] = Complex64::new(mag_spectrum[i].max(1e-20).ln(), 0.0); + } + for i in 1..(half_n - 1) { + log_full[n - i] = log_full[i]; + } + self.ifft_inplace(&mut log_full); + + let real_part: Vec = log_full.iter().map(|c| c.re).collect(); + let (analytic, _) = self.hilbert.hilbert_stereo(&real_part, &real_part); + analytic + } + + /// builds a windowed, normalised FIR from a desired magnitude curve under the chosen phase model. + fn create_fir_from_curve( + &mut self, + match_curve: &[f64], + fir_size: usize, + phase_mode: PhaseMode, + ) -> Vec { + if match_curve.is_empty() || fir_size == 0 { + return Vec::new(); + } + let n = match_curve.len(); + let mut spectrum = vec![Complex64::new(0.0, 0.0); n]; + + match phase_mode { + PhaseMode::Hilbert => { + + let mut log_mag: Vec = match_curve + .iter() + .map(|&v| Complex64::new(v.max(1e-20).ln(), 0.0)) + .collect(); + self.ifft_inplace(&mut log_mag); + + let mut cep = log_mag; + for i in (n / 2)..n { + cep[i] = Complex64::new(0.0, 0.0); + } + for i in 1..(n / 2) { + cep[i] *= 2.0; + } + + let mut min_phase_spectrum = cep; + self.fft_inplace(&mut min_phase_spectrum); + for i in 0..n { + spectrum[i] = min_phase_spectrum[i].exp(); + } + } + PhaseMode::StandardCepstral => { + for i in 0..n { + spectrum[i] = Complex64::new(match_curve[i], 0.0); + } + } + } + + let mut impulse = spectrum; + self.ifft_inplace(&mut impulse); + + let mut taps = vec![0.0_f32; fir_size]; + let center = fir_size / 2; + for i in 0..fir_size { + let hann = 0.5 * (1.0 - (2.0 * PI * i as f64 / (fir_size - 1) as f64).cos()); + let signed = i as isize - center as isize; + let mut idx = signed.rem_euclid(n as isize) as usize; + if idx >= n { + idx %= n; + } + taps[i] = (impulse[idx].re * hann) as f32; + } + + let tap_sum: f64 = taps.iter().map(|&t| t as f64).sum(); + if tap_sum.abs() > 1e-9 { + let inv = 1.0 / tap_sum as f32; + for t in &mut taps { + *t *= inv; + } + } + taps + } + + /// forward FFT with no normalisation. + fn fft_inplace(&mut self, buf: &mut [Complex64]) { + let plan: Arc> = self.planner.plan_fft_forward(buf.len()); + plan.process(buf); + } + + /// inverse FFT with the conventional 1/N scaling applied. + fn ifft_inplace(&mut self, buf: &mut [Complex64]) { + let n = buf.len(); + let plan: Arc> = self.planner.plan_fft_inverse(n); + plan.process(buf); + let inv_n = 1.0 / n as f64; + for x in buf.iter_mut() { + *x *= inv_n; + } + } +} + +/// reduces an analytic signal to a magnitude envelope and runs the cosine-bell idealiser on top. +fn idealize_complex(analytic: &[Complex64], granularity: i32, detail: i32) -> Vec { + if analytic.is_empty() { + return Vec::new(); + } + let mag: Vec = analytic.iter().map(|c| c.norm()).collect(); + weave::idealize_curve(&mag, granularity, detail, None) +} + +/// blends source extrema toward target extrema by strength and weaves them back into a curve. +fn create_match_curve( + smooth_source: &[f64], + smooth_target: &[f64], + strength: f64, +) -> Vec { + if smooth_source.is_empty() { + return Vec::new(); + } + let extrema = weave::local_extrema(smooth_source); + if extrema.is_empty() { + return smooth_source.to_vec(); + } + let anchor_vals: Vec = extrema + .iter() + .map(|&idx| { + let s = smooth_source[idx]; + let t = smooth_target[idx]; + s + (t - s) * strength + }) + .collect(); + weave::weave_anchors(smooth_source, &anchor_vals) +} diff --git a/src/ui/app.rs b/src/ui/app.rs new file mode 100644 index 0000000..d2eb33d --- /dev/null +++ b/src/ui/app.rs @@ -0,0 +1,635 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use iced_wgpu::core::{Element, Theme}; + +use crate::analyzer::FrameData; +use crate::analyzer_worker::AnalyzerWorker; +use crate::engine::AudioEngine; +use crate::library::{self, Track}; +use crate::library_worker::{LibraryUpdate, LibraryWorker}; + +use super::player; + +/// 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 engine: Option, + pub worker: AnalyzerWorker, + pub library_worker: LibraryWorker, + pub frame_data: Arc>, + pub current_palette: Option>>, + pub settings: Settings, + pub show_settings: bool, + + /// shell-side picker request flag drained by the iOS host once per tick. + pub pending_pick: u8, + + /// monotonic id stamped onto every decode request, matched against returning results. + pub current_decode_id: u64, + pub next_decode_id: u64, + + pub track_loading: bool, + + /// running count and total of the iOS library import progress. + pub library_progress: Option<(u32, u32)>, +} + +/// every visualizer toggle, slider value, and DSP parameter the settings panel exposes. +#[derive(Debug, Clone, Copy)] +pub struct Settings { + pub glass: bool, + pub entropy_on: bool, + pub album_colors: bool, + pub mirrored: bool, + pub inverted: bool, + pub entropy_strength: f32, + pub hue: f32, + pub contrast: f32, + pub brightness: f32, + pub num_bins: u32, + pub fft: u32, + pub hop: u32, + pub granularity: i32, + pub detail: i32, + pub strength: f32, + + /// fraction of FFT work routed to the GPU pipeline, blended against the CPU result. + pub gpu_blend: f32, +} + +impl Default for Settings { + fn default() -> Self { + Self { + glass: true, + entropy_on: false, + album_colors: false, + mirrored: false, + inverted: false, + entropy_strength: 0.0, + hue: 0.9, + contrast: 1.0, + brightness: 1.0, + num_bins: 26, + fft: 16384, + hop: 4096, + granularity: 33, + detail: 50, + strength: 0.0, + gpu_blend: 0.7, + } + } +} + +/// loaded track list paired with the folder path of origin. +#[derive(Default)] +pub struct Library { + pub folder: Option, + pub tracks: Vec, +} + +/// every UI event the player widget tree can emit. +#[derive(Debug, Clone)] +pub enum Message { + OpenFolder, + OpenFile, + SelectTrack(usize), + TogglePlayPause, + Next, + Prev, + + Seek(f32), + ToggleImmersive, + ToggleSettings, + SetGlass(bool), + SetEntropy(bool), + SetAlbumColors(bool), + SetMirrored(bool), + SetInverted(bool), + SetEntropyStrength(f32), + SetHue(f32), + SetContrast(f32), + SetBrightness(f32), + SetNumBins(u32), + SetFft(u32), + SetHop(u32), + SetGranularity(i32), + SetDetail(i32), + SetStrength(f32), + SetGpuBlend(f32), + PickedFolder(PathBuf), + PickedFile(PathBuf), + PickedFiles(Vec), +} + +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 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); + worker.set_smoothing(settings.granularity, settings.detail, settings.strength); + worker.set_gpu_blend(settings.gpu_blend); + let library_worker = LibraryWorker::spawn(); + Self { + library: Library::default(), + selected_track: None, + playing: false, + immersive: false, + engine: AudioEngine::new() + .map_err(|e| eprintln!("yr_crystals: audio engine unavailable: {e}")) + .ok(), + worker, + library_worker, + frame_data: Arc::new(Vec::new()), + current_palette: None, + settings, + show_settings: false, + pending_pick: 0, + current_decode_id: 0, + next_decode_id: 0, + track_loading: false, + library_progress: None, + } + } + + /// returns the picker flag and clears the slot in one step. + pub fn take_pending_pick(&mut self) -> u8 { + let p = self.pending_pick; + self.pending_pick = 0; + #[cfg(all(target_os = "ios", debug_assertions))] + if p != 0 { + eprintln!("[Rust.dbg] take_pending_pick -> {p}"); + } + p + } + + /// checks whether a logical-coords point falls inside the scrollable sidebar region. + pub fn point_in_sidebar(&self, x: f32, y: f32, viewport_height: f32) -> bool { + if self.immersive || self.show_settings { + return false; + } + x >= 0.0 + && x < player::SIDEBAR_W + && y >= player::TOP_BAR_H + && y < viewport_height - player::TRANSPORT_H + } + + /// scans a folder, replaces the library, queues art, and starts decoding the first track. + fn apply_picked_folder(&mut self, folder: PathBuf) { + #[cfg(all(target_os = "ios", debug_assertions))] + eprintln!("[Rust.dbg] apply_picked_folder enter path={}", folder.display()); + let tracks = library::scan_folder(&folder); + #[cfg(all(target_os = "ios", debug_assertions))] + { + eprintln!("[Rust.dbg] apply_picked_folder scan -> {} tracks", tracks.len()); + for (i, t) in tracks.iter().take(5).enumerate() { + eprintln!("[Rust.dbg] track[{i}] title={:?} path={}", t.title, t.path.display()); + } + } + self.library.folder = Some(folder); + self.library.tracks = tracks; + self.queue_art_for_all(); + self.selected_track = self.library.tracks.first().map(|_| 0); + self.playing = self.selected_track.is_some(); + if let Some(idx) = self.selected_track { + self.load_index(idx); + } + } + + /// loads a single audio file as a one-track library and starts playback. + fn apply_picked_file(&mut self, path: PathBuf) { + match library::read_track_meta(&path) { + Ok(track) => { + self.library.folder = path.parent().map(|p| p.to_path_buf()); + self.library.tracks = vec![track]; + self.queue_art_for_all(); + self.selected_track = Some(0); + self.playing = true; + self.load_index(0); + } + Err(e) => { + eprintln!("yr_crystals: read_track_meta failed: {}", e); + } + } + } + + /// builds an ad-hoc library from a multi-file pick and queues meta and art lookups. + fn apply_picked_files(&mut self, paths: Vec) { + + let mut tracks = Vec::with_capacity(paths.len()); + let mut folder = None; + for path in &paths { + if folder.is_none() { + folder = path.parent().map(|p| p.to_path_buf()); + } + let title = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Untitled") + .to_string(); + tracks.push(library::Track { + path: path.clone(), + title, + artist: None, + album: None, + track_number: None, + art: None, + palette: None, + exporting: false, + }); + } + self.library.folder = folder; + self.library.tracks = tracks; + self.queue_meta_for_all(); + self.queue_art_for_all(); + self.selected_track = self.library.tracks.first().map(|_| 0); + self.playing = self.selected_track.is_some(); + if let Some(idx) = self.selected_track { + self.load_index(idx); + } + } + + /// seeds the sidebar with placeholder rows for tracks pending export from the iOS host. + pub fn set_pending_titles(&mut self, entries: Vec<(String, Option)>) { + let mut tracks: Vec = entries + .into_iter() + .map(|(title, track_number)| library::Track { + path: PathBuf::new(), + title, + artist: None, + album: None, + track_number, + art: None, + palette: None, + exporting: true, + }) + .collect(); + library::sort_tracks(&mut tracks); + self.library.folder = None; + self.library.tracks = tracks; + self.selected_track = self.library.tracks.first().map(|_| 0); + self.playing = self.selected_track.is_some(); + + self.track_loading = self.selected_track.is_some(); + } + + /// resolves a placeholder track to a real file path once iOS export finishes. + pub fn set_track_path(&mut self, idx: usize, path: PathBuf) { + let Some(track) = self.library.tracks.get_mut(idx) else { return }; + track.path = path.clone(); + track.exporting = false; + self.library_worker.request_meta(path.clone()); + self.library_worker.request_art(path); + if self.selected_track == Some(idx) { + self.load_index(idx); + } + } + + /// hands raw artwork bytes to the art worker. + pub fn set_track_art_bytes(&mut self, idx: usize, bytes: Vec) { + if idx >= self.library.tracks.len() || bytes.is_empty() { + return; + } + self.library_worker.request_art_bytes(idx, bytes); + } + + /// queues artwork extraction across the whole library on the art worker. + fn queue_art_for_all(&self) { + for t in &self.library.tracks { + self.library_worker.request_art(t.path.clone()); + } + } + + /// queues tag reads across the whole library on the meta worker. + fn queue_meta_for_all(&self) { + for t in &self.library.tracks { + self.library_worker.request_meta(t.path.clone()); + } + } + + /// reports the engine playhead as a normalised 0..=1 fraction of the loaded track. + pub fn position(&self) -> f32 { + self.engine.as_ref().map(|e| e.position()).unwrap_or(0.0) + } + + /// recomputes the active album palette from the currently selected track. + fn refresh_palette(&mut self) { + self.current_palette = self + .selected_track + .and_then(|i| self.library.tracks.get(i)) + .and_then(|t| t.palette.clone()); + } + + /// drains worker updates, advances tracks, and refreshes analyzer frames each frame. + pub fn tick(&mut self) { + let mut needs_resort = false; + for upd in self.library_worker.drain_updates() { + match upd { + LibraryUpdate::Meta { + path, + title, + artist, + album, + track_number, + } => { + if let Some(t) = self.library.tracks.iter_mut().find(|t| t.path == path) { + + if let Some(title) = title { + t.title = title; + } + if artist.is_some() { + t.artist = artist; + } + if album.is_some() { + t.album = album; + } + if let Some(tn) = track_number { + if t.track_number != Some(tn) { + t.track_number = Some(tn); + needs_resort = true; + } + } + } + } + LibraryUpdate::Art { + path, + art, + palette, + } => { + let match_idx = self + .library + .tracks + .iter() + .position(|t| t.path == path); + if let Some(idx) = match_idx { + + if art.is_some() { + self.library.tracks[idx].art = art; + } + if palette.is_some() { + self.library.tracks[idx].palette = palette; + } + if self.selected_track == Some(idx) { + self.refresh_palette(); + } + } + } + LibraryUpdate::ArtForIdx { + idx, + art, + palette, + } => { + if let Some(t) = self.library.tracks.get_mut(idx) { + t.art = art; + t.palette = palette; + if self.selected_track == Some(idx) { + self.refresh_palette(); + } + } + } + LibraryUpdate::Decoded { + request_id, + path: _, + result, + } => { + if request_id != self.current_decode_id { + continue; + } + self.track_loading = false; + match result { + Ok(td) => { + if let Some(eng) = &self.engine { + eng.load(td.clone()); + if self.playing { + eng.play(); + } else { + eng.pause(); + } + } + self.worker.set_track(td); + } + Err(e) => eprintln!("yr_crystals: decode failed: {e}"), + } + } + } + } + + if needs_resort { + self.resort_library(); + } + + self.advance_if_finished(); + + self.worker.publish_playhead(self.position()); + self.frame_data = self.worker.latest_frames(); + } + + /// re-sorts the library on arrival of track-number tags and preserves the current selection. + fn resort_library(&mut self) { + let selected_path = self + .selected_track + .and_then(|i| self.library.tracks.get(i)) + .map(|t| t.path.clone()); + library::sort_tracks(&mut self.library.tracks); + if let Some(p) = selected_path { + self.selected_track = self.library.tracks.iter().position(|t| t.path == p); + } + } + + /// rolls onto the next track once the playhead reaches the end, pausing past the final entry. + fn advance_if_finished(&mut self) { + if !self.playing { + return; + } + let Some(idx) = self.selected_track else { return }; + if self.position() < 0.999 { + return; + } + let next = idx + 1; + if next >= self.library.tracks.len() { + self.playing = false; + if let Some(eng) = &self.engine { + eng.pause(); + } + return; + } + self.selected_track = Some(next); + self.load_index(next); + } + + /// dispatches a UI message into the matching state mutation and worker call. + pub fn update(&mut self, msg: Message) { + match msg { + Message::OpenFolder => { + #[cfg(all(not(target_os = "ios"), not(target_os = "android")))] + { + if let Some(folder) = rfd::FileDialog::new().pick_folder() { + self.apply_picked_folder(folder); + } + } + #[cfg(any(target_os = "ios", target_os = "android"))] + { + #[cfg(all(target_os = "ios", debug_assertions))] + eprintln!("[Rust.dbg] Message::OpenFolder -> pending_pick=1"); + self.pending_pick = 1; + } + } + Message::OpenFile => { + #[cfg(all(not(target_os = "ios"), not(target_os = "android")))] + { + let file = rfd::FileDialog::new() + .add_filter( + "Audio", + &[ + "mp3", "m4a", "m4b", "mp4", "flac", "wav", "ogg", "oga", + "opus", "aac", "aiff", + ], + ) + .pick_file(); + if let Some(path) = file { + self.apply_picked_file(path); + } + } + #[cfg(any(target_os = "ios", target_os = "android"))] + { + #[cfg(all(target_os = "ios", debug_assertions))] + eprintln!("[Rust.dbg] Message::OpenFile -> pending_pick=2"); + self.pending_pick = 2; + } + } + Message::PickedFolder(folder) => self.apply_picked_folder(folder), + Message::PickedFile(path) => self.apply_picked_file(path), + Message::PickedFiles(paths) => self.apply_picked_files(paths), + Message::SelectTrack(idx) => { + if idx < self.library.tracks.len() { + self.selected_track = Some(idx); + self.load_index(idx); + } + } + Message::TogglePlayPause => { + if self.selected_track.is_some() { + self.playing = !self.playing; + if let Some(eng) = &self.engine { + if self.playing { eng.play(); } else { eng.pause(); } + } + } + } + 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)); + } + } + Message::ToggleImmersive => self.immersive = !self.immersive, + Message::ToggleSettings => self.show_settings = !self.show_settings, + Message::SetGlass(on) => self.settings.glass = on, + Message::SetEntropy(on) => self.settings.entropy_on = on, + Message::SetAlbumColors(on) => self.settings.album_colors = on, + Message::SetMirrored(on) => self.settings.mirrored = on, + Message::SetInverted(on) => self.settings.inverted = on, + Message::SetEntropyStrength(v) => self.settings.entropy_strength = v.clamp(-1.5, 1.5), + Message::SetHue(v) => self.settings.hue = v.clamp(0.0, 1.0), + Message::SetContrast(v) => self.settings.contrast = v.clamp(0.0, 2.0), + Message::SetBrightness(v) => self.settings.brightness = v.clamp(0.1, 2.0), + Message::SetNumBins(n) => { + let n = n.clamp(8, 256); + self.settings.num_bins = n; + self.worker.set_num_bins(n as usize); + } + Message::SetFft(n) => { + let fft = n.clamp(512, 65536).next_power_of_two(); + let hop = self.settings.hop.min(fft / 2).max(64); + self.settings.fft = fft; + self.settings.hop = hop; + self.worker.set_dsp_params(fft as usize, hop as usize); + } + Message::SetHop(n) => { + let hop = n.clamp(64, self.settings.fft / 2); + self.settings.hop = hop; + self.worker + .set_dsp_params(self.settings.fft as usize, hop as usize); + } + Message::SetGranularity(v) => { + self.settings.granularity = v.clamp(1, 100); + self.worker.set_smoothing( + self.settings.granularity, + self.settings.detail, + self.settings.strength, + ); + } + Message::SetDetail(v) => { + self.settings.detail = v.clamp(1, 100); + self.worker.set_smoothing( + self.settings.granularity, + self.settings.detail, + self.settings.strength, + ); + } + Message::SetStrength(v) => { + self.settings.strength = v.clamp(0.0, 1.0); + self.worker.set_smoothing( + self.settings.granularity, + self.settings.detail, + self.settings.strength, + ); + } + Message::SetGpuBlend(v) => { + self.settings.gpu_blend = v.clamp(0.0, 1.0); + self.worker.set_gpu_blend(self.settings.gpu_blend); + } + } + } + + /// queues a fresh decode for the given track, stamping a new request id. + fn load_index(&mut self, idx: usize) { + let track = match self.library.tracks.get(idx) { + Some(t) => t, + None => return, + }; + if track.exporting { + self.track_loading = true; + return; + } + let path = track.path.clone(); + self.refresh_palette(); + + let Some(eng) = self.engine.as_ref() else { + return; + }; + let target_sr = eng.output_sample_rate(); + + self.track_loading = true; + self.next_decode_id += 1; + self.current_decode_id = self.next_decode_id; + self.library_worker + .request_decode(self.current_decode_id, path, target_sr); + } + + /// builds the iced widget tree for the current frame. + pub fn view(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + player::view(self) + } +} + diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..bd84e55 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,8 @@ +/// top-level App state, message enum, and update loop. +pub mod app; +/// iced widget tree for sidebar, transport, settings overlay, and visualizer surface. +pub mod player; +/// dark palette tokens and the compositor clear color. +pub mod theme; + +pub use app::{App, Message}; diff --git a/src/ui/player.rs b/src/ui/player.rs new file mode 100644 index 0000000..d5a9822 --- /dev/null +++ b/src/ui/player.rs @@ -0,0 +1,790 @@ +use std::ops::RangeInclusive; +use std::path::PathBuf; + +use iced_wgpu::core::{Background, Border, Color, Element, Length, Padding, Theme}; +use iced_widget::{ + button::{self, Status as ButtonStatus}, + checkbox, column, container, image, lazy, mouse_area, progress_bar, row, scrollable, shader, + slider, stack, svg, text, Space, +}; + +use crate::visualizer::{VisualizerProgram, VizParams}; + +const PLAY_SVG: &[u8] = include_bytes!("../../assets/Play.svg"); +const PAUSE_SVG: &[u8] = include_bytes!("../../assets/Pause.svg"); +const BSKIP_SVG: &[u8] = include_bytes!("../../assets/BSkip.svg"); +const FSKIP_SVG: &[u8] = include_bytes!("../../assets/FSkip.svg"); +const SETTINGS_SVG: &[u8] = include_bytes!("../../assets/Settings.svg"); +const LOADING_SVG: &str = include_str!("../../assets/Loading.svg"); + +const LOADING_DEG_PER_SEC: f32 = 120.0; + +use crate::library::Track; + +use super::app::{App, Message}; +use super::theme::palette; + +pub const SIDEBAR_W: f32 = 280.0; +pub const TOP_BAR_H: f32 = 44.0; +pub const TRANSPORT_H: f32 = 72.0; +const ROW_H: f32 = 56.0; +const THUMB: f32 = 40.0; + +/// assembles the top bar, sidebar, transport, and visualizer into the active layout. +pub fn view(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let body: Element<'_, Message, Theme, iced_wgpu::Renderer> = if app.immersive { + visualiser(app) + } else { + column![ + top_bar(app), + library_progress_strip(app), + row![sidebar(app), visualiser(app)].height(Length::Fill), + transport(app), + ] + .into() + }; + + if app.show_settings { + stack![body, settings_overlay(app)].into() + } else { + body + } +} + +/// renders a thin progress bar under the top bar during an active library import. +fn library_progress_strip(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + match app.library_progress { + Some((current, total)) if total > 0 => { + let fraction = (current as f32 / total as f32).clamp(0.0, 1.0); + progress_bar(0.0..=1.0, fraction) + .length(Length::Fill) + .girth(Length::Fixed(3.0)) + .into() + } + _ => Space::new().height(Length::Fixed(0.0)).into(), + } +} + +/// 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 folder_btn = chip_button("Folder", Message::OpenFolder); + #[cfg(target_os = "ios")] + let file_btn = chip_button("Library", Message::OpenFile); + #[cfg(not(target_os = "ios"))] + let file_btn = chip_button("File", Message::OpenFile); + let settings_btn = icon_chip_button(SETTINGS_SVG, Message::ToggleSettings); + + let bar = row![ + title, + Space::new().width(Length::Fixed(20.0)), + folder_btn, + Space::new().width(Length::Fixed(8.0)), + file_btn, + Space::new().width(Length::Fill), + settings_btn, + ] + .padding(Padding::from([0, 16])) + .spacing(0) + .align_y(iced_wgpu::core::Alignment::Center) + .height(Length::Fill); + + container(bar) + .width(Length::Fill) + .height(Length::Fixed(TOP_BAR_H)) + .style(panel_style) + .into() +} + +/// hash key feeding iced's lazy cache, redrawing only after a meaningful sidebar change. +#[derive(Hash)] +struct SidebarKey { + selected: Option, + rows: Vec, +} + +/// minimal per-row identity used inside the sidebar lazy hash. +#[derive(Hash)] +struct TrackRowKey { + path: PathBuf, + title: String, + artist: Option, + has_art: bool, +} + +/// builds the lazy-cached scrollable list of tracks down the left edge. +fn sidebar(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + if app.library.tracks.is_empty() { + return container(empty_library_hint()) + .width(Length::Fixed(SIDEBAR_W)) + .height(Length::Fill) + .style(sidebar_style) + .into(); + } + + let key = SidebarKey { + selected: app.selected_track, + rows: app + .library + .tracks + .iter() + .map(|t| TrackRowKey { + path: t.path.clone(), + title: t.title.clone(), + artist: t.artist.clone(), + has_art: t.art.is_some(), + }) + .collect(), + }; + + let tracks: Vec = app.library.tracks.clone(); + let selected = app.selected_track; + + let inner = lazy(key, move |_| { + let mut col = column![].spacing(2).padding(Padding::from([8, 8])); + for (i, t) in tracks.iter().enumerate() { + col = col.push(track_row_owned(i, t.clone(), selected == Some(i))); + } + scrollable(col).height(Length::Fill) + }); + + container(inner) + .width(Length::Fixed(SIDEBAR_W)) + .height(Length::Fill) + .style(sidebar_style) + .into() +} + +/// placeholder shown inside the sidebar before any tracks load. +fn empty_library_hint<'a>() -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let copy = text("No tracks loaded.\n\nUse Folder or File above\nto load some music.") + .size(13) + .color(palette::text_dim()); + container(copy) + .width(Length::Fill) + .height(Length::Fill) + .padding(Padding::from(24)) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() +} + +/// renders a single sidebar row over a cloned, owned Track. +fn track_row_owned( + idx: usize, + track: Track, + active: bool, +) -> Element<'static, Message, Theme, iced_wgpu::Renderer> { + let thumb: Element<'static, Message, Theme, iced_wgpu::Renderer> = match track.art { + Some(handle) => image(handle) + .width(Length::Fixed(THUMB)) + .height(Length::Fixed(THUMB)) + .into(), + None => container(Space::new()) + .width(Length::Fixed(THUMB)) + .height(Length::Fixed(THUMB)) + .style(art_placeholder_style) + .into(), + }; + + let title_color = if active { palette::text() } else { palette::text_dim() }; + let title_widget = text(track.title) + .size(13) + .color(title_color) + .width(Length::Fill); + let artist_line = match track.artist { + Some(a) => text(a).size(11).color(palette::text_dim()), + None => text(String::new()).size(11).color(palette::text_dim()), + }; + let info = column![title_widget, artist_line] + .spacing(2) + .width(Length::Fill); + + let inner = row![thumb, info] + .spacing(10) + .align_y(iced_wgpu::core::Alignment::Center) + .padding(Padding::from([6, 8])) + .width(Length::Fill) + .height(Length::Fixed(ROW_H)); + + let btn = iced_widget::button(inner) + .padding(0) + .on_press(Message::SelectTrack(idx)) + .width(Length::Fill) + .style(move |_t: &Theme, status: ButtonStatus| { + let bg = match (active, status) { + (true, _) => Some(Background::Color(Color { + a: 0.20, + ..palette::accent() + })), + (false, ButtonStatus::Hovered) => Some(Background::Color(Color { + a: 0.06, + ..palette::accent() + })), + _ => None, + }; + button::Style { + background: bg, + text_color: palette::text(), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }); + btn.into() +} + +/// 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 viz: Element<'_, Message, Theme, iced_wgpu::Renderer> = shader( + VisualizerProgram::new( + app.frame_data.clone(), + params_from(&app.settings), + app.current_palette.clone(), + ), + ) + .width(Length::Fill) + .height(Length::Fill) + .into(); + + let overlay: Element<'_, Message, Theme, iced_wgpu::Renderer> = if app.track_loading { + loading_overlay() + } else if no_track { + centered_overlay_text("Pick a track from the sidebar", palette::text()) + } else { + Space::new().width(Length::Fill).height(Length::Fill).into() + }; + + let layered = stack![viz, overlay]; + + let bordered = container(layered) + .width(Length::Fill) + .height(Length::Fill) + .style(|_t: &Theme| container::Style { + background: Some(Background::Color(palette::bg())), + border: Border { + color: palette::border(), + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }); + + mouse_area(bordered).on_press(Message::ToggleImmersive).into() +} + +/// animated cog and label overlay shown during track decode. +fn loading_overlay<'a>() -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + let elapsed = START.get_or_init(std::time::Instant::now).elapsed().as_secs_f32(); + let angle = elapsed * LOADING_DEG_PER_SEC; + let bytes = loading_svg_at_angle(angle); + let handle = svg::Handle::from_memory(bytes); + let spinner = svg::Svg::new(handle) + .width(Length::Fixed(160.0)) + .height(Length::Fixed(160.0)); + let label = text("Loading.").size(36).color(palette::text()); + let stack = column![spinner, label] + .spacing(12) + .align_x(iced_wgpu::core::Alignment::Center); + container(stack) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() +} + +/// rewrites the cog and nautilus rotate transforms inside the loading SVG. +fn loading_svg_at_angle(angle_deg: f32) -> Vec { + let mut s = LOADING_SVG.to_string(); + rewrite_rotate_angle(&mut s, "Cog", angle_deg); + rewrite_rotate_angle(&mut s, "Nautilus", -angle_deg); + s.into_bytes() +} + +/// patches the angle inside an SVG element's rotate transform without touching surrounding markup. +fn rewrite_rotate_angle(s: &mut String, id: &str, angle_deg: f32) { + let id_marker = format!(r#"id="{}""#, id); + let Some(id_pos) = s.find(&id_marker) else { return }; + let Some(elem_start) = s[..id_pos].rfind('<') else { return }; + let Some(elem_end_rel) = s[id_pos..].find('>') else { return }; + let elem_end = id_pos + elem_end_rel; + let key = r#"transform="rotate("#; + let Some(off) = s[elem_start..elem_end].find(key) else { return }; + let inner_start = elem_start + off + key.len(); + let Some(close_rel) = s[inner_start..elem_end].find(')') else { return }; + let inner_end = inner_start + close_rel; + let parts: Vec<&str> = s[inner_start..inner_end].split_whitespace().collect(); + let new_inner = match parts.len() { + 1 => format!("{:.2}", angle_deg), + 2 => format!("{:.2} {}", angle_deg, parts[1]), + _ => format!("{:.2} {} {}", angle_deg, parts[1], parts[2]), + }; + s.replace_range(inner_start..inner_end, &new_inner); +} + +/// fills the visualizer area with a single centered string. +fn centered_overlay_text<'a>( + label: &'a str, + color: Color, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + container(text(label).size(15).color(color)) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() +} + +/// projects the App settings struct down to the visualizer parameter subset. +fn params_from(s: &super::app::Settings) -> VizParams { + VizParams { + glass: s.glass, + entropy_on: s.entropy_on, + entropy_strength: s.entropy_strength, + album_colors: s.album_colors, + mirrored: s.mirrored, + inverted: s.inverted, + hue: s.hue, + contrast: s.contrast, + brightness: s.brightness, + } +} + +const SETTINGS_W: f32 = 340.0; + +/// right-aligned settings panel built from grouped slider and toggle rows. +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("Close", Message::ToggleSettings), + ] + .align_y(iced_wgpu::core::Alignment::Center); + + let body = column![ + header, + Space::new().height(Length::Fixed(10.0)), + section_label("style"), + toggle_row("glass", s.glass, Message::SetGlass), + toggle_row("album colors", s.album_colors, Message::SetAlbumColors), + toggle_row("mirrored", s.mirrored, Message::SetMirrored), + toggle_row("inverted", s.inverted, Message::SetInverted), + Space::new().height(Length::Fixed(8.0)), + section_label("color"), + slider_row( + "hue", + s.hue, + 0.0..=1.0, + 0.01, + format!("{:.2}", s.hue), + Message::SetHue, + ), + slider_row( + "contrast", + s.contrast, + 0.0..=2.0, + 0.01, + format!("{:.2}", s.contrast), + Message::SetContrast, + ), + slider_row( + "brightness", + s.brightness, + 0.1..=2.0, + 0.01, + format!("{:.2}", s.brightness), + Message::SetBrightness, + ), + Space::new().height(Length::Fixed(8.0)), + section_label("entropy filter"), + toggle_row("enabled", s.entropy_on, Message::SetEntropy), + slider_row( + "strength", + s.entropy_strength, + -1.5..=1.5, + 0.05, + format!("{:+.2}", s.entropy_strength), + Message::SetEntropyStrength, + ), + Space::new().height(Length::Fixed(8.0)), + section_label("dsp"), + slider_row( + "bins", + s.num_bins as f32, + 8.0..=128.0, + 1.0, + format!("{}", s.num_bins), + |v| Message::SetNumBins(v as u32), + ), + + pow2_slider_row("fft", s.fft, 9, 16, Message::SetFft), + pow2_slider_row( + "hop", + s.hop, + 6, + (s.fft / 2).max(64).trailing_zeros(), + Message::SetHop, + ), + Space::new().height(Length::Fixed(8.0)), + section_label("cepstral smoothing"), + slider_row( + "granularity", + s.granularity as f32, + 1.0..=100.0, + 1.0, + format!("{}", s.granularity), + |v| Message::SetGranularity(v as i32), + ), + slider_row( + "detail", + s.detail as f32, + 1.0..=100.0, + 1.0, + format!("{}", s.detail), + |v| Message::SetDetail(v as i32), + ), + slider_row( + "strength", + s.strength, + 0.0..=1.0, + 0.01, + format!("{:.2}", s.strength), + Message::SetStrength, + ), + Space::new().height(Length::Fixed(8.0)), + section_label("fft engine blend"), + slider_row( + "cpu ↔ gpu", + s.gpu_blend, + 0.0..=1.0, + 0.01, + format!("{:.2}", s.gpu_blend), + Message::SetGpuBlend, + ), + ] + .spacing(8) + .padding(Padding::from(16)) + .width(Length::Fixed(SETTINGS_W)); + + let scroll = scrollable(body).height(Length::Fill); + + let panel = container(scroll) + .width(Length::Fixed(SETTINGS_W)) + .height(Length::Fill) + .style(settings_panel_style); + + container(panel) + .width(Length::Fill) + .height(Length::Fill) + .align_right(Length::Fill) + .into() +} + +/// label + slider + value-text trio used by every numeric setting. +fn slider_row<'a, F>( + label: &'a str, + value: f32, + range: RangeInclusive, + step: f32, + value_text: String, + on_change: F, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> +where + F: 'a + Fn(f32) -> Message, +{ + let label_w = 96.0; + let value_w = 56.0; + row![ + container(text(label).size(12).color(palette::text_dim())) + .width(Length::Fixed(label_w)), + slider(range, value, on_change).step(step).width(Length::Fill), + container( + text(value_text) + .size(12) + .color(palette::text()) + .align_x(iced_wgpu::core::alignment::Horizontal::Right) + ) + .width(Length::Fixed(value_w)) + .align_right(Length::Fixed(value_w)), + ] + .spacing(10) + .align_y(iced_wgpu::core::Alignment::Center) + .into() +} + +/// slider that snaps to powers of two, exposed as a log2 axis underneath. +fn pow2_slider_row<'a, F>( + label: &'a str, + current: u32, + min_log2: u32, + max_log2: u32, + on_change: F, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> +where + F: 'a + Fn(u32) -> Message, +{ + let max_log2 = max_log2.max(min_log2); + let resolved = current.max(1).next_power_of_two(); + let cur_log2 = resolved.trailing_zeros().clamp(min_log2, max_log2); + let value_text = format!("{}", 1u32 << cur_log2); + slider_row( + label, + cur_log2 as f32, + min_log2 as f32..=max_log2 as f32, + 1.0, + value_text, + move |lv| on_change(1u32 << (lv as u32)), + ) +} + +/// label + checkbox pair used by every boolean setting. +fn toggle_row<'a, F>( + label: &'a str, + value: bool, + on_change: F, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> +where + F: 'a + Fn(bool) -> Message, +{ + row![ + container(text(label).size(12).color(palette::text_dim())) + .width(Length::Fixed(96.0)), + checkbox(value).on_toggle(on_change).size(16), + ] + .spacing(10) + .align_y(iced_wgpu::core::Alignment::Center) + .into() +} + +/// dim small-caps heading separating groups of settings rows. +fn section_label<'a>(label: &'a str) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + container(text(label).size(10).color(palette::text_dim())) + .padding(Padding::from([0, 0])) + .into() +} + +/// translucent backdrop styling for the settings overlay. +fn settings_panel_style(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(Color { + a: 0.96, + ..palette::sidebar() + })), + border: Border { + color: palette::border(), + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + } +} + +/// bottom transport bar with skip, play/pause, scrub slider, and position readout. +fn transport(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 + .as_ref() + .map(|e| e.is_playing()) + .unwrap_or(app.playing); + let play_glyph = if engine_playing { PAUSE_SVG } else { PLAY_SVG }; + let play = transport_button(play_glyph, Message::TogglePlayPause, app.selected_track.is_some()); + let next = transport_button(FSKIP_SVG, Message::Next, app.selected_track.is_some()); + + let pos = app.position(); + let scrub = slider(0.0..=1.0, pos, Message::Seek) + .step(0.001_f32) + .width(Length::Fill); + let pos_label = text(format!("{:>5.1}%", pos * 100.0)) + .size(11) + .color(palette::text_dim()); + + let bar = row![ + prev, + Space::new().width(Length::Fixed(6.0)), + play, + Space::new().width(Length::Fixed(6.0)), + next, + Space::new().width(Length::Fixed(20.0)), + scrub, + Space::new().width(Length::Fixed(12.0)), + pos_label, + ] + .padding(Padding::from([0, 16])) + .align_y(iced_wgpu::core::Alignment::Center) + .height(Length::Fill); + + container(bar) + .width(Length::Fill) + .height(Length::Fixed(TRANSPORT_H)) + .style(panel_style) + .into() +} + +/// small soft accent-tinted text-label button. +fn chip_button<'a>( + label: &'a str, + msg: Message, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + iced_widget::button(text(label).size(13).color(palette::text())) + .padding(Padding::from([6, 12])) + .on_press(msg) + .style(|_t: &Theme, status: ButtonStatus| { + let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed); + button::Style { + background: Some(Background::Color(if hovered { + Color { + a: 0.12, + ..palette::accent() + } + } else { + Color { + a: 0.04, + ..palette::accent() + } + })), + text_color: palette::text(), + border: Border { + color: palette::border(), + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +/// chip button variant carrying an inline SVG glyph. +fn icon_chip_button<'a>( + glyph: &'static [u8], + msg: Message, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let handle = svg::Handle::from_memory(glyph); + let icon = svg::Svg::new(handle) + .width(Length::Fixed(16.0)) + .height(Length::Fixed(16.0)) + .style(|_t: &Theme, _status| svg::Style { + color: Some(palette::text()), + }); + + iced_widget::button(icon) + .padding(Padding::from([4, 10])) + .on_press(msg) + .style(|_t: &Theme, status: ButtonStatus| { + let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed); + button::Style { + background: Some(Background::Color(if hovered { + Color { + a: 0.12, + ..palette::accent() + } + } else { + Color { + a: 0.04, + ..palette::accent() + } + })), + text_color: palette::text(), + border: Border { + color: palette::border(), + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +/// large icon button used by the transport bar, dimmed and disabled without a track. +fn transport_button<'a>( + glyph: &'static [u8], + msg: Message, + enabled: bool, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let handle = svg::Handle::from_memory(glyph); + let tint = if enabled { palette::text() } else { palette::text_dim() }; + let icon = svg::Svg::new(handle) + .width(Length::Fixed(22.0)) + .height(Length::Fixed(22.0)) + .style(move |_t: &Theme, _status| svg::Style { color: Some(tint) }); + + let mut btn = iced_widget::button(icon).padding(Padding::from([6, 12])); + if enabled { + btn = btn.on_press(msg); + } + btn.style(|_t: &Theme, status: ButtonStatus| { + let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed); + button::Style { + background: if hovered { + Some(Background::Color(Color { + a: 0.10, + ..palette::accent() + })) + } else { + None + }, + text_color: palette::text(), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +/// flat sidebar-tinted background with a single hairline border. +fn panel_style(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(palette::sidebar())), + border: Border { + color: palette::border(), + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + } +} + +/// track-list sidebar background and border styling. +fn sidebar_style(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(palette::sidebar())), + border: Border { + color: palette::border(), + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + } +} + +/// faded square shown in place of cover art before the art worker resolves a track. +fn art_placeholder_style(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(Color { + a: 0.20, + ..palette::text_dim() + })), + border: Border { + color: palette::border(), + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + } +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..6777594 --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,25 @@ +use iced_wgpu::core::Color; + +/// dark-theme color tokens shared by every panel and widget. +pub mod palette { + use super::Color; + + const BG_C: Color = Color::from_rgb(0.06, 0.06, 0.08); + const SIDEBAR_C: Color = Color::from_rgb(0.09, 0.09, 0.12); + const BORDER_C: Color = Color::from_rgb(0.18, 0.18, 0.22); + const TEXT_C: Color = Color::from_rgb(0.92, 0.92, 0.95); + const TEXT_DIM_C: Color = Color::from_rgb(0.55, 0.55, 0.62); + const ACCENT_C: Color = Color::from_rgb(0.78, 0.62, 0.95); + + pub fn bg() -> Color { BG_C } + pub fn sidebar() -> Color { SIDEBAR_C } + pub fn border() -> Color { BORDER_C } + pub fn text() -> Color { TEXT_C } + pub fn text_dim() -> Color { TEXT_DIM_C } + pub fn accent() -> Color { ACCENT_C } +} + +/// supplies the clear color handed to the wgpu compositor each frame. +pub fn compositor_clear() -> Color { + palette::bg() +} diff --git a/src/viewport.rs b/src/viewport.rs new file mode 100644 index 0000000..cc22169 --- /dev/null +++ b/src/viewport.rs @@ -0,0 +1,513 @@ + + +use iced_graphics::{Shell as GShell, Viewport}; +use iced_runtime::user_interface::{self, UserInterface}; +use iced_wgpu::core::renderer::Style; +use iced_wgpu::core::time::Instant; +use iced_wgpu::core::{ + clipboard, keyboard, mouse, window, Color, Event, Font, Pixels, Point, Size, SmolStr, + Theme, +}; +use iced_wgpu::Engine; +use raw_window_handle::{RawDisplayHandle, RawWindowHandle}; + +use crate::ui::{theme, App, Message}; + +/// per-window bundle of the wgpu surface, iced renderer, App state, and pending input event queue. +pub struct ViewportHandle { + surface: wgpu::Surface<'static>, + device: wgpu::Device, + #[allow(dead_code)] + queue: wgpu::Queue, + format: wgpu::TextureFormat, + width: u32, + height: u32, + #[allow(dead_code)] + scale: f32, + renderer: iced_wgpu::Renderer, + viewport: Viewport, + cache: user_interface::Cache, + events: Vec, + cursor: mouse::Cursor, + needs_redraw: bool, + + /// most recent touch coordinate, source of scroll deltas across move events. + last_touch: Option<(f32, f32)>, + + /// touch-down anchor compared against the release point in the tap-vs-drag check. + touch_start: Option<(f32, f32)>, + + /// accumulated euclidean travel from touch-down, gating the tap-vs-scroll decision. + touch_drift: f32, + + /// marks an active touch originating over the sidebar, routing vertical drags to wheel scrolls. + touch_in_sidebar: bool, + pub state: App, +} + +/// stub clipboard handed to iced, returning empty on reads and dropping writes. +struct NullClipboard; +impl clipboard::Clipboard for NullClipboard { + fn read(&self, _kind: clipboard::Kind) -> Option { + None + } + fn write(&mut self, _kind: clipboard::Kind, _contents: String) {} +} + +impl ViewportHandle { + + /// builds a viewport surface from raw display+window handles supplied by the host shell. + pub fn new_from_raw( + raw_window: RawWindowHandle, + raw_display: RawDisplayHandle, + width: f32, + height: f32, + scale: f32, + ) -> Option { + let (instance, surface) = create_instance_and_surface(|instance| { + let target = wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: raw_display, + raw_window_handle: raw_window, + }; + unsafe { instance.create_surface_unsafe(target).ok() } + })?; + finalise(instance, surface, width, height, scale) + } + + /// drives one frame of state ticking, iced layout, and wgpu submission. + pub fn render_frame(&mut self) { + render(self); + } + + /// reconfigures the wgpu surface and iced viewport against the supplied size and scale factor. + pub fn resize_px(&mut self, width: f32, height: f32, scale: f32) { + let phys_w = (width * scale) as u32; + let phys_h = (height * scale) as u32; + if phys_w == 0 || phys_h == 0 { + return; + } + self.width = phys_w; + self.height = phys_h; + self.scale = scale; + self.viewport = Viewport::with_physical_size(Size::new(phys_w, phys_h), scale); + self.surface.configure( + &self.device, + &wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: self.format, + width: phys_w, + height: phys_h, + present_mode: wgpu::PresentMode::AutoVsync, + alpha_mode: wgpu::CompositeAlphaMode::Auto, + view_formats: vec![], + desired_maximum_frame_latency: 2, + }, + ); + self.needs_redraw = true; + } + + /// queues a cursor-moved event in logical coords and flags the viewport for redraw. + pub fn push_mouse_move(&mut self, x: f32, y: f32) { + let p = Point::new(x, y); + self.cursor = mouse::Cursor::Available(p); + self.events + .push(Event::Mouse(mouse::Event::CursorMoved { position: p })); + self.needs_redraw = true; + } + + /// queues a cursor-left event after the pointer exits the window. + pub fn push_mouse_left(&mut self) { + self.cursor = mouse::Cursor::Unavailable; + self.events.push(Event::Mouse(mouse::Event::CursorLeft)); + self.needs_redraw = true; + } + + /// queues a cursor move plus mouse button press or release at the supplied logical point. + pub fn push_mouse_button(&mut self, x: f32, y: f32, button: u32, pressed: bool) { + let p = Point::new(x, y); + self.cursor = mouse::Cursor::Available(p); + self.events + .push(Event::Mouse(mouse::Event::CursorMoved { position: p })); + let b = match button { + 0 => mouse::Button::Left, + 1 => mouse::Button::Right, + 2 => mouse::Button::Middle, + _ => return, + }; + let ev = if pressed { + mouse::Event::ButtonPressed(b) + } else { + mouse::Event::ButtonReleased(b) + }; + self.events.push(Event::Mouse(ev)); + self.needs_redraw = true; + } + + /// queues a wheel-scrolled event with pixel-delta semantics. + pub fn push_mouse_scroll(&mut self, x: f32, y: f32, dx: f32, dy: f32) { + let p = Point::new(x, y); + self.cursor = mouse::Cursor::Available(p); + self.events.push(Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: dx, y: dy }, + })); + self.needs_redraw = true; + } + + /// folds an iOS touch into mouse-style events, treating sidebar drags as wheel scrolls. + pub fn push_touch(&mut self, x: f32, y: f32, pressed: bool, moved: bool) { + const TOUCH_TAP_SLOP: f32 = 10.0; + if !pressed && !moved { + + if self.touch_in_sidebar { + if let Some((sx, sy)) = self.touch_start.take() { + if self.touch_drift <= TOUCH_TAP_SLOP { + self.push_mouse_button(sx, sy, 0, true); + self.push_mouse_button(sx, sy, 0, false); + } + } + } else { + self.push_mouse_button(x, y, 0, false); + } + self.last_touch = None; + self.touch_drift = 0.0; + self.touch_in_sidebar = false; + return; + } + if pressed && !moved { + + let h = self.viewport.logical_size().height; + self.touch_in_sidebar = self.state.point_in_sidebar(x, y, h); + self.touch_start = Some((x, y)); + self.touch_drift = 0.0; + self.last_touch = Some((x, y)); + if self.touch_in_sidebar { + self.push_mouse_move(x, y); + } else { + self.push_mouse_button(x, y, 0, true); + } + return; + } + + self.push_mouse_move(x, y); + if let Some((px, py)) = self.last_touch { + let dx = x - px; + let dy = y - py; + self.touch_drift += (dx * dx + dy * dy).sqrt(); + if self.touch_in_sidebar && dy.abs() > 0.0 { + let p = Point::new(x, y); + self.events.push(Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0.0, y: dy }, + })); + self.cursor = mouse::Cursor::Available(p); + } + } + self.last_touch = Some((x, y)); + } + + /// translates a host-encoded key triple into iced keyboard events with modifiers. + pub fn push_key_event( + &mut self, + named: u32, + utf8: Option, + mods: u32, + pressed: bool, + ) { + let text = utf8.map(SmolStr::from); + let key = match named { + 1 => keyboard::Key::Named(keyboard::key::Named::Enter), + 2 => keyboard::Key::Named(keyboard::key::Named::Escape), + 3 => keyboard::Key::Named(keyboard::key::Named::Backspace), + 4 => keyboard::Key::Named(keyboard::key::Named::Tab), + 5 => keyboard::Key::Named(keyboard::key::Named::ArrowLeft), + 6 => keyboard::Key::Named(keyboard::key::Named::ArrowRight), + 7 => keyboard::Key::Named(keyboard::key::Named::ArrowUp), + 8 => keyboard::Key::Named(keyboard::key::Named::ArrowDown), + 9 => keyboard::Key::Named(keyboard::key::Named::Delete), + 10 => keyboard::Key::Named(keyboard::key::Named::Home), + 11 => keyboard::Key::Named(keyboard::key::Named::End), + _ => match &text { + Some(s) => keyboard::Key::Character(s.clone()), + None => keyboard::Key::Unidentified, + }, + }; + let mut m = keyboard::Modifiers::empty(); + if mods & 1 != 0 { + m |= keyboard::Modifiers::SHIFT; + } + if mods & 2 != 0 { + m |= keyboard::Modifiers::CTRL; + } + if mods & 4 != 0 { + m |= keyboard::Modifiers::ALT; + } + if mods & 8 != 0 { + m |= keyboard::Modifiers::LOGO; + } + let physical = + keyboard::key::Physical::Unidentified(keyboard::key::NativeCode::Unidentified); + let event = if pressed { + keyboard::Event::KeyPressed { + key: key.clone(), + modified_key: key, + physical_key: physical, + location: keyboard::Location::Standard, + modifiers: m, + text, + repeat: false, + } + } else { + keyboard::Event::KeyReleased { + key: key.clone(), + modified_key: key, + physical_key: physical, + location: keyboard::Location::Standard, + modifiers: m, + } + }; + self.events.push(Event::Keyboard(event)); + self.needs_redraw = true; + } + + /// hands a folder picked by the host into the App's library loading flow. + pub fn apply_picked_folder(&mut self, path: std::path::PathBuf) { + self.state.update(Message::PickedFolder(path)); + self.needs_redraw = true; + } + + /// hands a single file picked by the host into the App's track loading flow. + pub fn apply_picked_file(&mut self, path: std::path::PathBuf) { + self.state.update(Message::PickedFile(path)); + self.needs_redraw = true; + } + + /// hands a multi-file pick from the host into the ad-hoc library loading flow. + pub fn apply_picked_files(&mut self, paths: Vec) { + self.state.update(Message::PickedFiles(paths)); + self.needs_redraw = true; + } + + /// updates the library import progress strip, hiding on a total of zero. + pub fn set_library_progress(&mut self, current: u32, total: u32) { + self.state.library_progress = if total == 0 { None } else { Some((current, total)) }; + self.needs_redraw = true; + } + + /// pushes placeholder track rows from the iOS host ahead of export completion. + pub fn set_pending_titles(&mut self, entries: Vec<(String, Option)>) { + self.state.set_pending_titles(entries); + self.needs_redraw = true; + } + + /// resolves a placeholder row to a real path once the host finishes the export. + pub fn set_track_path(&mut self, idx: usize, path: std::path::PathBuf) { + self.state.set_track_path(idx, path); + self.needs_redraw = true; + } + + /// forwards raw artwork bytes from the host to the art decode worker. + pub fn set_track_art_bytes(&mut self, idx: usize, bytes: Vec) { + self.state.set_track_art_bytes(idx, bytes); + self.needs_redraw = true; + } + + /// drains the App's picker request flag. + pub fn take_pending_pick(&mut self) -> u8 { + self.state.take_pending_pick() + } +} + +/// builds a wgpu instance restricted to the platform's preferred backend and obtains a surface from the caller. +fn create_instance_and_surface( + build_surface: F, +) -> Option<(wgpu::Instance, wgpu::Surface<'static>)> +where + F: FnOnce(&wgpu::Instance) -> Option>, +{ + #[cfg(any(target_os = "macos", target_os = "ios"))] + let backends = wgpu::Backends::METAL; + #[cfg(target_os = "windows")] + let backends = wgpu::Backends::DX12; + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))] + let backends = wgpu::Backends::all(); + + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends, + ..Default::default() + }); + let surface = build_surface(&instance)?; + Some((instance, surface)) +} + +/// completes wgpu adapter selection, configures the surface, loads the bundled font, and assembles the App. +fn finalise( + instance: wgpu::Instance, + surface: wgpu::Surface<'static>, + width: f32, + height: f32, + scale: f32, +) -> Option { + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + })) + .ok()?; + + let (device, queue) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?; + + let phys_w = (width * scale) as u32; + let phys_h = (height * scale) as u32; + + let caps = surface.get_capabilities(&adapter); + let format = caps.formats.first().copied()?; + let alpha_mode = caps + .alpha_modes + .first() + .copied() + .unwrap_or(wgpu::CompositeAlphaMode::Auto); + + surface.configure( + &device, + &wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: phys_w.max(1), + height: phys_h.max(1), + present_mode: wgpu::PresentMode::AutoVsync, + alpha_mode, + view_formats: vec![], + desired_maximum_frame_latency: 2, + }, + ); + + let engine = Engine::new( + &adapter, + device.clone(), + queue.clone(), + format, + None, + GShell::headless(), + ); + + static FONT_LOADED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + FONT_LOADED.get_or_init(|| { + let bytes: &'static [u8] = include_bytes!("../fonts/Inter-Regular.ttf"); + if let Ok(mut fs) = iced_graphics::text::font_system().write() { + fs.load_font(std::borrow::Cow::Borrowed(bytes)); + } + }); + let renderer = iced_wgpu::Renderer::new(engine, Font::with_name("Inter 18pt"), Pixels(14.0)); + let viewport = Viewport::with_physical_size(Size::new(phys_w.max(1), phys_h.max(1)), scale); + let state = App::new(device.clone(), queue.clone()); + + Some(ViewportHandle { + surface, + device, + queue, + format, + width: phys_w, + height: phys_h, + scale, + renderer, + viewport, + cache: user_interface::Cache::new(), + events: Vec::new(), + cursor: mouse::Cursor::Available(Point::new(width / 2.0, height / 2.0)), + needs_redraw: true, + last_touch: None, + touch_start: None, + touch_drift: 0.0, + touch_in_sidebar: false, + state, + }) +} + +/// runs one tick, rebuilds the UI, dispatches messages, and presents the next surface frame. +fn render(handle: &mut ViewportHandle) { + handle.state.tick(); + let pending = !handle.events.is_empty(); + let playing = handle + .state + .engine + .as_ref() + .map(|e| e.is_playing()) + .unwrap_or(false); + + let animating = playing + || handle.state.track_loading + || handle.state.library_progress.is_some(); + if !animating && !handle.needs_redraw && !pending { + return; + } + + let frame = match handle.surface.get_current_texture() { + Ok(f) => f, + Err(_e) => { + #[cfg(debug_assertions)] + { + use std::sync::atomic::{AtomicBool, Ordering}; + static LOGGED: AtomicBool = AtomicBool::new(false); + if !LOGGED.swap(true, Ordering::Relaxed) { + eprintln!("yr_crystals: surface acquire failed: {_e:?}"); + } + } + return; + } + }; + let view = frame.texture.create_view(&Default::default()); + let logical_size = handle.viewport.logical_size(); + + handle + .events + .push(Event::Window(window::Event::RedrawRequested(Instant::now()))); + + let cache = std::mem::take(&mut handle.cache); + let mut ui = UserInterface::build( + handle.state.view(), + Size::new(logical_size.width, logical_size.height), + cache, + &mut handle.renderer, + ); + + let mut clipboard = NullClipboard; + let mut messages: Vec = Vec::new(); + let drained: Vec = handle.events.drain(..).collect(); + let _ = ui.update( + &drained, + handle.cursor, + &mut handle.renderer, + &mut clipboard, + &mut messages, + ); + + let theme = Theme::Dark; + let style = Style { + text_color: Color::WHITE, + }; + + if messages.is_empty() { + ui.draw(&mut handle.renderer, &theme, &style, handle.cursor); + handle.cache = ui.into_cache(); + } else { + let cache = ui.into_cache(); + for msg in messages.drain(..) { + handle.state.update(msg); + } + let mut ui = UserInterface::build( + handle.state.view(), + Size::new(logical_size.width, logical_size.height), + cache, + &mut handle.renderer, + ); + ui.draw(&mut handle.renderer, &theme, &style, handle.cursor); + handle.cache = ui.into_cache(); + } + + let background = theme::compositor_clear(); + handle + .renderer + .present(Some(background), handle.format, &view, &handle.viewport); + frame.present(); + handle.needs_redraw = false; +} diff --git a/src/visualizer/build.rs b/src/visualizer/build.rs new file mode 100644 index 0000000..11049e9 --- /dev/null +++ b/src/visualizer/build.rs @@ -0,0 +1,84 @@ + + +use crate::visualizer::pipeline::CepVertex; +use crate::visualizer::state::VisState; + +/// emits a vertical cepstrum line plot centered in the viewport and fades the top and bottom edges. +pub fn build_cepstrum(out: &mut Vec, state: &VisState, w: f32, h: f32) { + out.clear(); + if state.smoothed_cepstrum.is_empty() { + return; + } + let q_start = 12_usize; + let q_end = 600_usize.min(state.smoothed_cepstrum.len()); + if q_end <= q_start { + return; + } + + let mut peak = 0.0_f32; + for i in q_start..q_end { + peak = peak.max(state.smoothed_cepstrum[i].abs()); + } + if peak < 1e-7 { + return; + } + let inv_peak = 1.0 / peak; + let max_disp = w * 0.06; + let cx = w * 0.5; + + let uc = state.unified_color; + let (cr, cg, cb_) = hsv_to_rgb(uc[0], (uc[1] * 0.7).clamp(0.0, 1.0), uc[2]); + let ca = 0.45_f32; + let fade_margin = 0.08_f32; + + let edge_fade = |t: f32| -> f32 { + if t < fade_margin { + t / fade_margin + } else if t > 1.0 - fade_margin { + (1.0 - t) / fade_margin + } else { + 1.0 + } + }; + + let mut prev_x = cx + state.smoothed_cepstrum[q_start] * inv_peak * max_disp; + let mut prev_y = 0.0_f32; + let mut prev_t = 0.0_f32; + + for i in q_start + 1..q_end { + let t = (i - q_start) as f32 / (q_end - q_start) as f32; + let y = t * h; + let x = cx + state.smoothed_cepstrum[i] * inv_peak * max_disp; + let a0 = ca * edge_fade(prev_t); + let a1 = ca * edge_fade(t); + out.push(CepVertex { + position: [prev_x, prev_y], + color: [cr, cg, cb_, a0], + }); + out.push(CepVertex { + position: [x, y], + color: [cr, cg, cb_, a1], + }); + prev_x = x; + prev_y = y; + prev_t = t; + } +} + +/// converts an hsv triple in 0..1 ranges into a linear rgb triple. +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { + let h = (h.fract() + 1.0).fract() * 6.0; + let i = h.floor(); + let f = h - i; + let p = v * (1.0 - s); + let q = v * (1.0 - s * f); + let t = v * (1.0 - s * (1.0 - f)); + match i as i32 % 6 { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + } +} diff --git a/src/visualizer/mod.rs b/src/visualizer/mod.rs new file mode 100644 index 0000000..44f89ab --- /dev/null +++ b/src/visualizer/mod.rs @@ -0,0 +1,81 @@ + + +pub mod build; +pub mod pipeline; +pub mod primitive; +pub mod state; + +use std::sync::Arc; + +use iced_wgpu::core::{mouse, Rectangle}; +use iced_widget::shader::Program; + +use crate::analyzer::FrameData; +use crate::visualizer::primitive::VisPrimitive; + +/// snapshot of every visualizer toggle and slider value. +#[derive(Debug, Clone, Copy)] +pub struct VizParams { + pub glass: bool, + pub entropy_on: bool, + pub entropy_strength: f32, + pub album_colors: bool, + pub mirrored: bool, + pub inverted: bool, + pub hue: f32, + pub contrast: f32, + pub brightness: f32, +} + +impl Default for VizParams { + fn default() -> Self { + Self { + glass: true, + entropy_on: false, + entropy_strength: 0.0, + album_colors: false, + mirrored: false, + inverted: false, + hue: 0.9, + contrast: 1.0, + brightness: 1.0, + } + } +} + +/// iced shader-widget bundle holding the latest analyzer frames, render params, and album palette. +#[derive(Debug, Clone)] +pub struct VisualizerProgram { + pub frames: Arc>, + pub params: VizParams, + pub palette: Option>>, +} + +impl VisualizerProgram { + pub fn new( + frames: Arc>, + params: VizParams, + palette: Option>>, + ) -> Self { + Self { frames, params, palette } + } +} + +impl Program for VisualizerProgram { + type State = (); + type Primitive = VisPrimitive; + + /// hands the frames, params, and palette across to the wgpu primitive layer. + fn draw( + &self, + _state: &Self::State, + _cursor: mouse::Cursor, + _bounds: Rectangle, + ) -> Self::Primitive { + VisPrimitive { + frames: self.frames.clone(), + params: self.params, + palette: self.palette.clone(), + } + } +} diff --git a/src/visualizer/pipeline.rs b/src/visualizer/pipeline.rs new file mode 100644 index 0000000..ec2e4c0 --- /dev/null +++ b/src/visualizer/pipeline.rs @@ -0,0 +1,445 @@ + + +use bytemuck::{Pod, Zeroable}; +use iced_wgpu::primitive::Pipeline; + +use crate::visualizer::state::VisState; + +/// gpu-side per-bin record consumed by the visualizer storage buffer. +#[repr(C)] +#[derive(Debug, Clone, Copy, Pod, Zeroable, Default)] +pub struct BinGpu { + pub log_x: f32, + pub visual_norm: f32, + pub primary_norm: f32, + pub bright_mod: f32, + pub alpha_mod: f32, + pub hue: f32, + pub sat: f32, + pub val: f32, +} + +/// uniform block holding viewport size, layout counts, render flags, and the unified glass color. +#[repr(C)] +#[derive(Debug, Clone, Copy, Pod, Zeroable)] +pub struct GlobalsGpu { + pub bounds: [f32; 2], + pub base: [f32; 2], + pub num_bins: u32, + pub num_channels: u32, + pub flags: u32, + pub fade_bins: u32, + pub hue_param: f32, + pub contrast: f32, + pub brightness: f32, + pub _pad0: f32, + pub unified_hue: f32, + pub unified_sat: f32, + pub unified_val: f32, + pub _pad1: f32, +} + +/// vertex for the cepstrum line plot, carrying pixel position and rgba color. +#[repr(C)] +#[derive(Debug, Clone, Copy, Pod, Zeroable)] +pub struct CepVertex { + pub position: [f32; 2], + pub color: [f32; 4], +} + +/// bitfield values packed into GlobalsGpu::flags. +pub const FLAG_GLASS: u32 = 1; +pub const FLAG_MIRRORED: u32 = 2; +pub const FLAG_INVERTED: u32 = 4; +pub const FLAG_STEREO: u32 = 8; + +/// owns the wgpu render pipelines, gpu buffers, and cpu-side smoothing state. +pub struct VisPipeline { + fill_pipeline: wgpu::RenderPipeline, + line_pipeline: wgpu::RenderPipeline, + cep_pipeline: wgpu::RenderPipeline, + + bind_group: wgpu::BindGroup, + globals_buf: wgpu::Buffer, + bins_buf: wgpu::Buffer, + bins_capacity: u64, + + cep_buf: wgpu::Buffer, + cep_capacity: u64, + pub cep_count: u32, + + pub state: VisState, + + pub fill_verts: u32, + pub line_verts: u32, + pub instances: u32, + + pub scratch_bins: Vec, + pub scratch_cep: Vec, +} + +const INITIAL_BINS_CAPACITY: u64 = 256 * 2; +const INITIAL_CEP_CAPACITY: u64 = 1024; + +impl Pipeline for VisPipeline { + /// builds the three render pipelines, allocates the uniform/storage/vertex buffers, and seeds the bind group. + fn new(device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("yr_crystals.visualizer.shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("../../shaders/visualizer.wgsl").into(), + ), + }); + + let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("yr_crystals.visualizer.bind_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("yr_crystals.visualizer.pipeline_layout"), + bind_group_layouts: &[&bind_layout], + push_constant_ranges: &[], + }); + + let blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::SrcAlpha, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + }; + + let target = wgpu::ColorTargetState { + format, + blend: Some(blend), + write_mask: wgpu::ColorWrites::ALL, + }; + + let fill_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("yr_crystals.visualizer.fill"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_fill"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(target.clone())], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + let line_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("yr_crystals.visualizer.line"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_line"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(target.clone())], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::LineList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + let cep_attrs = [ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: 8, + shader_location: 1, + format: wgpu::VertexFormat::Float32x4, + }, + ]; + + let cep_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("yr_crystals.visualizer.cep"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_cep"), + buffers: &[wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &cep_attrs, + }], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(target)], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::LineList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + let globals_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.visualizer.globals"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bins_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.visualizer.bins"), + size: INITIAL_BINS_CAPACITY * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("yr_crystals.visualizer.bind_group"), + layout: &bind_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: globals_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: bins_buf.as_entire_binding(), + }, + ], + }); + + let cep_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.visualizer.cep"), + size: INITIAL_CEP_CAPACITY * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + Self { + fill_pipeline, + line_pipeline, + cep_pipeline, + bind_group, + globals_buf, + bins_buf, + bins_capacity: INITIAL_BINS_CAPACITY, + cep_buf, + cep_capacity: INITIAL_CEP_CAPACITY, + cep_count: 0, + state: VisState::default(), + fill_verts: 0, + line_verts: 0, + instances: 0, + scratch_bins: Vec::with_capacity((INITIAL_BINS_CAPACITY) as usize), + scratch_cep: Vec::with_capacity(INITIAL_CEP_CAPACITY as usize), + } + } +} + +impl VisPipeline { + + /// pushes globals, scratch bins, and cepstrum vertices to the gpu, growing buffers if outgrown. + pub fn upload( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + globals: &GlobalsGpu, + num_channels: u32, + num_bins: u32, + instances: u32, + ) { + queue.write_buffer(&self.globals_buf, 0, bytemuck::bytes_of(globals)); + + if !self.scratch_bins.is_empty() { + let needed = self.scratch_bins.len() as u64; + if needed > self.bins_capacity { + let mut new_cap = self.bins_capacity.max(1); + while new_cap < needed { + new_cap *= 2; + } + self.bins_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.visualizer.bins"), + size: new_cap * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.bins_capacity = new_cap; + + self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("yr_crystals.visualizer.bind_group"), + layout: &device.create_bind_group_layout(&bind_layout_descriptor()), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.globals_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: self.bins_buf.as_entire_binding(), + }, + ], + }); + } + queue.write_buffer(&self.bins_buf, 0, bytemuck::cast_slice(&self.scratch_bins)); + } + + self.cep_count = self.scratch_cep.len() as u32; + if !self.scratch_cep.is_empty() { + let needed = self.scratch_cep.len() as u64; + if needed > self.cep_capacity { + let mut new_cap = self.cep_capacity.max(1); + while new_cap < needed { + new_cap *= 2; + } + self.cep_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.visualizer.cep"), + size: new_cap * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.cep_capacity = new_cap; + } + queue.write_buffer(&self.cep_buf, 0, bytemuck::cast_slice(&self.scratch_cep)); + } + + let segs = num_bins.saturating_sub(1); + self.fill_verts = num_channels * segs * 6; + self.line_verts = num_channels * num_bins * 2; + self.instances = instances.max(1); + } + + /// records a single render pass over the fills, the bin outline, and any cepstrum overlay into the clip rect. + pub fn render_into( + &self, + encoder: &mut wgpu::CommandEncoder, + target: &wgpu::TextureView, + clip: &iced_wgpu::core::Rectangle, + ) { + if self.fill_verts == 0 && self.line_verts == 0 && self.cep_count == 0 { + return; + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("yr_crystals.visualizer.pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_viewport( + clip.x as f32, + clip.y as f32, + clip.width as f32, + clip.height as f32, + 0.0, + 1.0, + ); + pass.set_scissor_rect(clip.x, clip.y, clip.width, clip.height); + pass.set_bind_group(0, &self.bind_group, &[]); + + if self.fill_verts > 0 { + pass.set_pipeline(&self.fill_pipeline); + pass.draw(0..self.fill_verts, 0..self.instances); + } + if self.line_verts > 0 { + pass.set_pipeline(&self.line_pipeline); + pass.draw(0..self.line_verts, 0..self.instances); + } + if self.cep_count > 0 { + pass.set_pipeline(&self.cep_pipeline); + pass.set_vertex_buffer(0, self.cep_buf.slice(..)); + pass.draw(0..self.cep_count, 0..1); + } + } +} + +/// returns the bind-group layout that pairs the globals uniform with the bins storage buffer. +fn bind_layout_descriptor<'a>() -> wgpu::BindGroupLayoutDescriptor<'a> { + wgpu::BindGroupLayoutDescriptor { + label: Some("yr_crystals.visualizer.bind_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + } +} diff --git a/src/visualizer/primitive.rs b/src/visualizer/primitive.rs new file mode 100644 index 0000000..ffe801a --- /dev/null +++ b/src/visualizer/primitive.rs @@ -0,0 +1,120 @@ + + +use std::sync::Arc; + +use iced_wgpu::core::Rectangle; +use iced_wgpu::graphics::Viewport; +use iced_wgpu::primitive::Primitive; + +use crate::analyzer::FrameData; +use crate::visualizer::build; +use crate::visualizer::pipeline::{ + GlobalsGpu, VisPipeline, FLAG_GLASS, FLAG_INVERTED, FLAG_MIRRORED, FLAG_STEREO, +}; +use crate::visualizer::VizParams; + +/// frame snapshot carried from the iced widget into the wgpu primitive layer each draw. +#[derive(Debug, Clone)] +pub struct VisPrimitive { + pub frames: Arc>, + pub params: VizParams, + pub palette: Option>>, +} + +impl Primitive for VisPrimitive { + type Pipeline = VisPipeline; + + /// folds the frame into cpu state and uploads packed bins, cepstrum vertices, and globals to the gpu. + fn prepare( + &self, + pipeline: &mut Self::Pipeline, + device: &wgpu::Device, + queue: &wgpu::Queue, + bounds: &Rectangle, + viewport: &Viewport, + ) { + let palette = self.palette.as_deref().map(|v| v.as_slice()); + if !self.frames.is_empty() { + let frames_id = Arc::as_ptr(&self.frames) as usize; + pipeline + .state + .ingest(&self.frames, frames_id, &self.params, palette); + } + + let scale = viewport.scale_factor(); + let w_px = (bounds.width * scale).max(1.0); + let h_px = (bounds.height * scale).max(1.0); + + let stereo = pipeline.state.channels.len() > 1; + let num_channels = pipeline.state.channels.len() as u32; + let num_bins = pipeline + .state + .channels + .first() + .map(|c| c.bins.len() as u32) + .unwrap_or(0); + + let (base_w, base_h, instances) = if self.params.mirrored { + (w_px * 0.55, h_px * 0.5, 4u32) + } else { + (w_px, h_px, 1u32) + }; + + let mut flags = 0u32; + if self.params.glass { + flags |= FLAG_GLASS; + } + if self.params.mirrored { + flags |= FLAG_MIRRORED; + } + if self.params.inverted { + flags |= FLAG_INVERTED; + } + if stereo { + flags |= FLAG_STEREO; + } + + let uc = pipeline.state.unified_color; + let globals = GlobalsGpu { + bounds: [w_px, h_px], + base: [base_w, base_h], + num_bins, + num_channels, + flags, + fade_bins: if self.params.mirrored { 4 } else { 0 }, + hue_param: self.params.hue, + contrast: self.params.contrast, + brightness: self.params.brightness, + _pad0: 0.0, + unified_hue: uc[0], + unified_sat: uc[1], + unified_val: uc[2], + _pad1: 0.0, + }; + + let mut scratch_bins = std::mem::take(&mut pipeline.scratch_bins); + let mut scratch_cep = std::mem::take(&mut pipeline.scratch_cep); + pipeline + .state + .pack_bins(&self.frames, stereo, &mut scratch_bins); + scratch_cep.clear(); + if self.params.mirrored { + build::build_cepstrum(&mut scratch_cep, &pipeline.state, w_px, h_px); + } + pipeline.scratch_bins = scratch_bins; + pipeline.scratch_cep = scratch_cep; + + pipeline.upload(device, queue, &globals, num_channels, num_bins, instances); + } + + /// dispatches the prepared pipeline into the encoder against the iced-supplied clip rectangle. + fn render( + &self, + pipeline: &Self::Pipeline, + encoder: &mut wgpu::CommandEncoder, + target: &wgpu::TextureView, + clip_bounds: &iced_wgpu::core::Rectangle, + ) { + pipeline.render_into(encoder, target, clip_bounds); + } +} diff --git a/src/visualizer/state.rs b/src/visualizer/state.rs new file mode 100644 index 0000000..a863309 --- /dev/null +++ b/src/visualizer/state.rs @@ -0,0 +1,437 @@ + + +use std::collections::VecDeque; + +use crate::analyzer::FrameData; +use crate::palette; +use crate::visualizer::pipeline::BinGpu; +use crate::visualizer::VizParams; + +const HUE_HISTORY_LEN: usize = 40; +const HISTORY_LEN: usize = 30; + +/// per-bin smoothed magnitude, modulation offsets, palette color, and recent visual history. +#[derive(Debug, Clone, Default)] +pub struct BinState { + pub visual_db: f32, + pub primary_visual_db: f32, + pub last_raw_db: f32, + pub bright_mod: f32, + pub alpha_mod: f32, + pub cached_color: [f32; 3], + pub history: VecDeque, +} + +/// row of bins for one audio channel. +#[derive(Debug, Default, Clone)] +pub struct ChannelState { + pub bins: Vec, +} + +/// cpu-side smoothing state for the visualizer. +#[derive(Debug, Default)] +pub struct VisState { + pub channels: Vec, + pub hue_history: VecDeque<(f32, f32)>, + pub hue_sum_cos: f32, + pub hue_sum_sin: f32, + pub unified_color: [f32; 3], + pub smoothed_cepstrum: Vec, + + pub last_frames_id: usize, +} + +impl VisState { + + /// folds a fresh analyzer frame into the smoothed bin, hue, and cepstrum tracks. + pub fn ingest( + &mut self, + frames: &[FrameData], + frames_id: usize, + params: &VizParams, + palette: Option<&[[f32; 3]]>, + ) { + self.last_frames_id = frames_id; + + if self.channels.len() != frames.len() { + self.channels.resize(frames.len(), ChannelState::default()); + } + + if params.glass { + if let Some(f0) = frames.first() { + self.unified_color = self.update_glass_color(f0, params); + } + } else { + self.unified_color = [0.0, 0.0, 1.0]; + } + + for (ch_idx, frame) in frames.iter().enumerate() { + ingest_channel(&mut self.channels[ch_idx], frame, params, palette); + } + + if params.mirrored { + if let Some(f0) = frames.first() { + let raw = &f0.cepstrum; + if self.smoothed_cepstrum.len() != raw.len() { + self.smoothed_cepstrum = vec![0.0; raw.len()]; + } + for (i, r) in raw.iter().enumerate() { + self.smoothed_cepstrum[i] = 0.15 * r + 0.85 * self.smoothed_cepstrum[i]; + } + } + } + } + + /// flattens every channel's bins into the gpu-bound vector and applies a small x-shift to the right channel. + pub fn pack_bins(&self, frames: &[FrameData], stereo: bool, out: &mut Vec) { + out.clear(); + let n_bins = self.channels.first().map(|c| c.bins.len()).unwrap_or(0); + if n_bins == 0 { + return; + } + for (ch_idx, channel) in self.channels.iter().enumerate() { + let freqs = frames + .get(ch_idx) + .map(|f| f.freqs.as_slice()) + .unwrap_or(&[]); + let x_offset = if ch_idx == 1 && stereo { 1.005 } else { 1.0 }; + for (i, b) in channel.bins.iter().enumerate() { + let freq = freqs.get(i).copied().unwrap_or(0.0); + let visual_norm = ((b.visual_db + 80.0) / 80.0).clamp(0.0, 1.0); + let primary_norm = ((b.primary_visual_db + 80.0) / 80.0).clamp(0.0, 1.0); + out.push(BinGpu { + log_x: log_x(freq * x_offset), + visual_norm, + primary_norm, + bright_mod: b.bright_mod, + alpha_mod: b.alpha_mod, + hue: b.cached_color[0], + sat: b.cached_color[1], + val: b.cached_color[2], + }); + } + } + } + + /// derives a hue from spectral midpoint and mean amplitude, smoothed by a circular running mean. + fn update_glass_color(&mut self, f0: &FrameData, params: &VizParams) -> [f32; 3] { + let mid_freq = f0 + .freqs + .get(f0.freqs.len() / 2) + .copied() + .unwrap_or(1000.0); + let mean_db = if f0.db.is_empty() { + -80.0 + } else { + f0.db.iter().sum::() / f0.db.len() as f32 + }; + + let log_min = 20.0_f32.log10(); + let log_max = 20_000.0_f32.log10(); + let freq_norm = + (mid_freq.max(1e-9).log10() - log_min) / (log_max - log_min); + + let amp_norm = ((mean_db + 80.0) / 80.0).clamp(0.0, 1.0); + let amp_weight = (1.0 / (freq_norm + 1e-4).powf(5.0) * 2.0).clamp(0.5, 6.0); + + let mut hue = (freq_norm + amp_norm * amp_weight * params.hue).rem_euclid(1.0); + if params.mirrored { + hue = 1.0 - hue; + } + if hue < 0.0 { + hue += 1.0; + } + + let angle = hue * std::f32::consts::TAU; + let cos_v = angle.cos(); + let sin_v = angle.sin(); + + self.hue_history.push_back((cos_v, sin_v)); + self.hue_sum_cos += cos_v; + self.hue_sum_sin += sin_v; + if self.hue_history.len() > HUE_HISTORY_LEN { + if let Some((c, s)) = self.hue_history.pop_front() { + self.hue_sum_cos -= c; + self.hue_sum_sin -= s; + } + } + + let smoothed_angle = self.hue_sum_sin.atan2(self.hue_sum_cos); + let mut smoothed_hue = smoothed_angle / std::f32::consts::TAU; + if smoothed_hue < 0.0 { + smoothed_hue += 1.0; + } + [smoothed_hue, 1.0, 1.0] + } +} + +/// maps a frequency in hertz to a 0..1 horizontal position on the 20 hz to 20 khz log axis. +fn log_x(freq: f32) -> f32 { + let log_min = 20.0_f32.log10(); + let log_max = 20_000.0_f32.log10(); + if freq <= 0.0 { + return 0.0; + } + (freq.max(1e-9).log10() - log_min) / (log_max - log_min) +} + +/// updates one channel's bin smoothing, peak modulations, treble compensation, and palette colors. +fn ingest_channel( + channel: &mut ChannelState, + frame: &FrameData, + params: &VizParams, + palette: Option<&[[f32; 3]]>, +) { + let n = frame.db.len(); + if channel.bins.len() != n { + channel.bins.resize(n, BinState::default()); + } + if n == 0 { + return; + } + + let use_entropy = params.entropy_on; + + let mut bin_entropy = vec![0.0_f32; n]; + if use_entropy { + for (i, b) in channel.bins.iter().enumerate() { + bin_entropy[i] = calculate_entropy(&b.history); + } + } + let median_entropy = if use_entropy { + median_of(&bin_entropy) + } else { + 0.0 + }; + + for (i, b) in channel.bins.iter_mut().enumerate() { + let raw = frame.db[i]; + let primary = frame.primary_db.get(i).copied().unwrap_or(raw); + + let change = raw - b.visual_db; + if use_entropy { + let relative = median_entropy - bin_entropy[i]; + let base = 1.5_f32; + let reward_gain = base + params.entropy_strength; + let penalty_gain = base - params.entropy_strength; + let gain = if relative >= 0.0 { reward_gain } else { penalty_gain }; + let multiplier = (1.0 + relative * gain * 2.0).clamp(0.05, 4.0); + b.visual_db += change * multiplier; + b.history.push_back(b.visual_db); + while b.history.len() > HISTORY_LEN { + b.history.pop_front(); + } + } else { + let resp = 0.2_f32; + b.visual_db = b.visual_db * (1.0 - resp) + raw * resp; + + b.history.push_back(b.visual_db); + while b.history.len() > HISTORY_LEN { + b.history.pop_front(); + } + } + + let pattern_resp = 0.1_f32; + b.primary_visual_db = b.primary_visual_db * (1.0 - pattern_resp) + primary * pattern_resp; + b.last_raw_db = raw; + } + + let mut vertex_energy: Vec = channel + .bins + .iter() + .map(|b| ((b.primary_visual_db + 80.0) / 80.0).clamp(0.0, 1.0)) + .collect(); + + let split = n / 2; + let mut max_low = 0.01_f32; + let mut max_high = 0.01_f32; + for v in vertex_energy.iter().take(split) { + max_low = max_low.max(*v); + } + for v in vertex_energy.iter().skip(split) { + max_high = max_high.max(*v); + } + let treble_boost = (max_low / max_high).clamp(1.0, 40.0); + + let mut global_max = 0.001_f32; + for (j, v) in vertex_energy.iter_mut().enumerate() { + if j >= split { + let t = (j - split) as f32 / (n - split) as f32; + *v *= 1.0 + (treble_boost - 1.0) * t; + } + let compressed = v.tanh(); + *v = compressed; + if compressed > global_max { + global_max = compressed; + } + } + for v in vertex_energy.iter_mut() { + *v = (*v / global_max).clamp(0.0, 1.0); + } + + for b in channel.bins.iter_mut() { + b.bright_mod = 0.0; + b.alpha_mod = 0.0; + } + + let entropy_factor = if use_entropy { + params.entropy_strength.abs().max(0.1) + } else { + 1.0 + }; + + if n >= 3 { + for i in 1..n - 1 { + let curr = vertex_energy[i]; + let prev = vertex_energy[i - 1]; + let next = vertex_energy[i + 1]; + if curr > prev && curr > next { + let left_dominant = prev > next; + let sharpness = (curr - prev).min(curr - next); + let peak_intensity = + (sharpness * 10.0 * entropy_factor).powf(0.3).clamp(0.0, 1.0); + let decay_base = 0.65 - (sharpness * 3.0).clamp(0.0, 0.35); + + for d in 1..=12_i32 { + apply_pattern(&mut channel.bins, i, d, left_dominant, -1, peak_intensity, decay_base); + apply_pattern(&mut channel.bins, i, d, !left_dominant, 1, peak_intensity, decay_base); + } + } + } + } + + let use_palette = params.album_colors + && palette + .map(|p| !p.is_empty()) + .unwrap_or(false); + let denom = (n as f32 - 1.0).max(1.0); + for (i, b) in channel.bins.iter_mut().enumerate() { + if use_palette { + let pal = palette.unwrap(); + let plen = pal.len(); + let raw_idx = if plen >= n { + i * (plen - 1) / (n - 1).max(1) + } else { + i * plen / n.max(1) + }; + let pal_idx = if params.mirrored { + plen.saturating_sub(1).saturating_sub(raw_idx) + } else { + raw_idx + } + .min(plen - 1); + let rgb = pal[pal_idx]; + let (h, s, v) = palette::rgb_to_hsv(rgb[0], rgb[1], rgb[2]); + b.cached_color = [h, s, v]; + } else { + let mut hue = i as f32 / denom; + if params.mirrored { + hue = 1.0 - hue; + } + b.cached_color = [hue, 1.0, 1.0]; + } + } +} + +/// stamps a three-step bright/alpha modulation pattern outward from a peak bin, decaying with distance. +fn apply_pattern( + bins: &mut [BinState], + centre: usize, + dist: i32, + is_bright_side: bool, + direction: i32, + peak_intensity: f32, + decay_base: f32, +) { + let target = if direction == -1 { + centre as isize - dist as isize + } else { + centre as isize + dist as isize - 1 + }; + if target < 0 || target as usize >= bins.len() { + return; + } + let cycle = (dist - 1) / 3; + let step = (dist - 1) % 3; + let decay = decay_base.powi(cycle); + let intensity = peak_intensity * decay; + if intensity < 0.01 { + return; + } + let mut ty = step; + if is_bright_side { + ty = (ty + 2) % 3; + } + let bin = &mut bins[target as usize]; + match ty { + 0 => { + bin.bright_mod += 0.8 * intensity; + bin.alpha_mod -= 0.8 * intensity; + } + 1 => { + bin.bright_mod -= 0.8 * intensity; + bin.alpha_mod += 0.2 * intensity; + } + _ => { + bin.bright_mod += 0.8 * intensity; + bin.alpha_mod += 0.2 * intensity; + } + } +} + +/// scores deviation of a bin's recent history from a low-frequency reconstruction as the entropy proxy. +fn calculate_entropy(history: &VecDeque) -> f32 { + let buf: Vec = history.iter().copied().collect(); + let n = buf.len(); + if n < 4 { + return 0.0; + } + let nf = n as f64; + + let mut x_re = vec![0.0_f64; n]; + let mut x_im = vec![0.0_f64; n]; + for k in 0..n { + let mut re = 0.0; + let mut im = 0.0; + for (idx, &h) in buf.iter().enumerate() { + let angle = -2.0 * std::f64::consts::PI * k as f64 * idx as f64 / nf; + re += h as f64 * angle.cos(); + im += h as f64 * angle.sin(); + } + x_re[k] = re; + x_im[k] = im; + } + + for k in (n / 2 + 1)..n { + x_re[k] = 0.0; + x_im[k] = 0.0; + } + for k in 1..n.div_ceil(2) { + x_re[k] *= 2.0; + x_im[k] *= 2.0; + } + + let mut sq_sum = 0.0_f64; + for idx in 0..n { + let mut im = 0.0; + for k in 0..n { + let angle = 2.0 * std::f64::consts::PI * k as f64 * idx as f64 / nf; + im += x_re[k] * angle.sin() + x_im[k] * angle.cos(); + } + im /= nf; + sq_sum += im * im; + } + + ((sq_sum / nf).sqrt() as f32 / 10.0).clamp(0.0, 1.0) +} + +/// returns the median element via a partial selection sort over a copy. +fn median_of(values: &[f32]) -> f32 { + if values.is_empty() { + return 0.0; + } + let mut v = values.to_vec(); + let mid = v.len() / 2; + v.select_nth_unstable_by(mid, |a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + v[mid] +} diff --git a/src/weave.rs b/src/weave.rs new file mode 100644 index 0000000..1b370e4 --- /dev/null +++ b/src/weave.rs @@ -0,0 +1,212 @@ +#![allow(clippy::needless_range_loop)] + +use std::f64::consts::PI; + +/// smooths a curve by re-weaving log-spaced bins from local extrema with a cosine-bell window. +pub fn idealize_curve( + curve: &[f64], + granularity: i32, + detail: i32, + caps: Option, +) -> Vec { + let n = curve.len(); + if n == 0 { + return Vec::new(); + } + let mut current = curve.to_vec(); + + let mut num_bins = 4 + ((granularity as f64) / 100.0 * 60.0) as i32; + let mut iterations = 1 + ((detail as f64) / 100.0 * 4.0) as i32; + if let Some(c) = caps { + if let Some(b) = c.max_bins { + num_bins = num_bins.min(b); + } + if let Some(i) = c.max_iters { + iterations = iterations.min(i); + } + } + if num_bins < 1 { + num_bins = 1; + } + if iterations < 1 { + iterations = 1; + } + + for _ in 0..iterations { + let mut next = vec![0.0_f64; n]; + let mut is_set = vec![false; n]; + + let log_n = (n as f64).ln(); + let mut boundaries: Vec = Vec::with_capacity(num_bins as usize + 1); + for i in 0..=num_bins { + let ratio = (i as f64) / (num_bins as f64); + let b = (ratio * log_n).exp() as usize; + boundaries.push(b.clamp(1, n - 1)); + } + boundaries[0] = 0; + + for i in 0..num_bins as usize { + let bin_start = boundaries[i]; + let bin_end = boundaries[i + 1]; + if bin_start + 1 >= bin_end { + continue; + } + + let mut extrema: Vec<(usize, f64)> = Vec::new(); + for j in (bin_start + 1)..bin_end { + let prev = current[j - 1]; + let cur = current[j]; + let next_v = current[j + 1]; + if (cur > prev && cur > next_v) || (cur < prev && cur < next_v) { + extrema.push((j, cur)); + } + } + if extrema.is_empty() { + continue; + } + + let lobe_width = (bin_end - bin_start) as f64 / 2.0; + for j in bin_start..=bin_end { + let mut total = 0.0_f64; + let mut sum = 0.0_f64; + for &(idx, val) in &extrema { + let dist = j as f64 - idx as f64; + if dist.abs() < lobe_width { + let w = (((dist / lobe_width) * PI).cos() + 1.0) * 0.5; + sum += val * w; + total += w; + } + } + if total > 0.0 { + next[j] = sum / total; + is_set[j] = true; + } + } + } + + let mut last_set = 0usize; + if !is_set[0] { + for i in 0..n { + if is_set[i] { + last_set = i; + break; + } + } + } + for i in 1..n { + if is_set[i] { + if i > last_set + 1 { + let start_val = next[last_set]; + let end_val = next[i]; + let span = (i - last_set) as f64; + for j in (last_set + 1)..i { + let progress = (j - last_set) as f64 / span; + next[j] = start_val + (end_val - start_val) * progress; + } + } + last_set = i; + } + } + current = next; + } + + current +} + +/// reconstructs a curve by interpolating supplied values across the source's own extrema. +pub fn weave_anchors(source: &[f64], anchor_vals: &[f64]) -> Vec { + let n = source.len(); + if n == 0 { + return Vec::new(); + } + + let mut anchors: Vec = Vec::new(); + if n > 2 { + for i in 1..n - 1 { + let prev = source[i - 1]; + let cur = source[i]; + let next_v = source[i + 1]; + if (cur > prev && cur > next_v) || (cur < prev && cur < next_v) { + anchors.push(i); + } + } + } + if anchors.is_empty() { + return source.to_vec(); + } + debug_assert_eq!(anchors.len(), anchor_vals.len()); + + let mut out = vec![0.0_f64; n]; + for i in 0..n { + let mut total = 0.0_f64; + let mut sum = 0.0_f64; + for (k, &idx) in anchors.iter().enumerate() { + let prev_idx = if k > 0 { anchors[k - 1] } else { 0 }; + let next_idx = if k + 1 < anchors.len() { anchors[k + 1] } else { n - 1 }; + let mut lobe_width = (next_idx - prev_idx) as f64 / 2.0; + if lobe_width == 0.0 { + lobe_width = n as f64 / (2.0 * anchors.len() as f64); + } + let dist = i as f64 - idx as f64; + if dist.abs() < lobe_width { + let w = (((dist / lobe_width) * PI).cos() + 1.0) * 0.5; + sum += anchor_vals[k] * w; + total += w; + } + } + if total > 0.0 { + out[i] = sum / total; + } else { + + let pos = anchors.partition_point(|&a| a < i); + if pos == 0 { + out[i] = anchor_vals[0]; + } else if pos == anchors.len() { + out[i] = anchor_vals[anchors.len() - 1]; + } else { + let prev_idx = anchors[pos - 1]; + let next_idx = anchors[pos]; + let span = (next_idx - prev_idx) as f64; + let progress = (i - prev_idx) as f64 / span; + out[i] = anchor_vals[pos - 1] + + (anchor_vals[pos] - anchor_vals[pos - 1]) * progress; + } + } + } + out +} + +/// returns the indices of strict local maxima and minima in a curve. +pub fn local_extrema(curve: &[f64]) -> Vec { + let n = curve.len(); + if n < 3 { + return Vec::new(); + } + let mut out = Vec::new(); + for i in 1..n - 1 { + let prev = curve[i - 1]; + let cur = curve[i]; + let next_v = curve[i + 1]; + if (cur > prev && cur > next_v) || (cur < prev && cur < next_v) { + out.push(i); + } + } + out +} + +/// upper bounds on bin count and iteration depth. +#[derive(Debug, Clone, Copy, Default)] +pub struct Caps { + pub max_bins: Option, + pub max_iters: Option, +} + +impl Caps { + /// mobile preset capping bins to 30 and iterations to 2. + pub fn mobile() -> Self { + Self { + max_bins: Some(30), + max_iters: Some(2), + } + } +} diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..f706b5b --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xtask" +version = "0.0.0" +edition = "2021" +publish = false + +[[bin]] +name = "xtask" +path = "src/main.rs" diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..ef75f67 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,133 @@ + + +use std::env; +use std::path::PathBuf; +use std::process::{Command, ExitCode}; + +const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux", "ios", "android"]; + +/// dispatches a cargo xtask sub-command to the matching platform script under scripts/. +fn main() -> ExitCode { + let args: Vec = env::args().skip(1).collect(); + let cmd = args.first().map(String::as_str).unwrap_or(""); + + if cmd.is_empty() || cmd == "help" || cmd == "--help" || cmd == "-h" { + print_help(); + return ExitCode::from(2); + } + + let extra_args: Vec<&String> = args.iter().skip(1).collect(); + let (action, platform) = parse(cmd); + + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask manifest must have a parent") + .to_path_buf(); + + let (script, runner) = match platform.as_str() { + "windows" => ( + repo_root.join(format!("scripts/windows/{action}.ps1")), + vec![ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + ], + ), + "linux" | "macos" | "ios" | "android" => ( + repo_root.join(format!("scripts/{platform}/{action}.sh")), + vec!["bash"], + ), + other => { + eprintln!("unknown platform: {other}"); + return ExitCode::from(2); + } + }; + + if !script.exists() { + eprintln!("script not found: {}", script.display()); + return ExitCode::from(1); + } + + let extra_display = if extra_args.is_empty() { + String::new() + } else { + format!( + " {}", + extra_args.iter().map(|s| s.as_str()).collect::>().join(" "), + ) + }; + eprintln!("→ {} {}{}", runner.join(" "), script.display(), extra_display); + + let mut command = Command::new(runner[0]); + for arg in &runner[1..] { + command.arg(arg); + } + command.arg(&script); + for a in &extra_args { + command.arg(a.as_str()); + } + command.current_dir(&repo_root); + + match command.status() { + Ok(status) if status.success() => ExitCode::SUCCESS, + Ok(status) => ExitCode::from(status.code().unwrap_or(1) as u8), + Err(e) => { + eprintln!("failed to run {}: {e}", script.display()); + ExitCode::from(1) + } + } +} + +/// splits an action-platform command into (action, platform), defaulting to the host OS. +fn parse(cmd: &str) -> (String, String) { + if let Some(idx) = cmd.rfind('-') { + let suffix = &cmd[idx + 1..]; + if KNOWN_PLATFORMS.contains(&suffix) { + return (cmd[..idx].to_string(), suffix.to_string()); + } + } + (cmd.to_string(), current_platform().to_string()) +} + +/// resolves the host OS to a script directory name, exiting if the OS lacks scripts. +fn current_platform() -> &'static str { + match env::consts::OS { + "linux" => "linux", + "macos" => "macos", + "windows" => "windows", + other => { + eprintln!("unsupported OS: {other}"); + std::process::exit(2); + } + } +} + +/// prints command list, platform suffix syntax, and iOS provisioning hints. +fn print_help() { + eprintln!("usage: cargo xtask "); + eprintln!(); + eprintln!("commands:"); + eprintln!(" build release build for the current platform"); + eprintln!(" install release build + install (macOS: /Applications)"); + eprintln!(" debug debug build + foreground launch (live console on iOS)"); + eprintln!(" select-ios pick a physical device or simulator interactively"); + eprintln!(" select-android pick an attached Android device by adb serial"); + eprintln!(" bootstrap-android install android sdk packages from .android-sdk-packages"); + eprintln!(" generate-icons-android rasterize assets/Icon.svg into android mipmap buckets"); + eprintln!(" xcodeproj-ios generate ios/YrXtals.xcodeproj via xcodegen"); + eprintln!(" release-ios build an App Store-signed .ipa for Transporter"); + eprintln!(); + eprintln!("append -macos / -windows / -linux / -ios / -android to force a platform."); + eprintln!("trailing args pass through to the script — iOS scripts take [sim|device]."); + eprintln!(); + eprintln!("examples:"); + eprintln!(" cargo xtask select-ios # menu → saved to .yrxtls-ios-target"); + eprintln!(" cargo xtask debug-ios # honours saved target, no arg needed"); + eprintln!(" cargo xtask install-ios device # one-off override"); + eprintln!(" cargo xtask build-macos"); + eprintln!(); + eprintln!("iOS device builds default to ~/Downloads/All.mobileprovision. Override with"); + eprintln!("YRXTALS_IOS_PROFILE; YRXTALS_IOS_DEVICE / YRXTALS_IOS_SIM override the saved UDID."); +}