Init
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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/
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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*
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
captures/
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
-keep class org.elseif.yrxtals.NativeBridge { *; }
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="false" />
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:theme="@style/Theme.YrXtals"
|
||||
android:hardwareAccelerated="true"
|
||||
android:allowBackup="false"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|density|smallestScreenSize|uiMode"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Uri?> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
pendingKind = 0
|
||||
if (uri != null) handlePickedTree(uri)
|
||||
}
|
||||
|
||||
private val openDocsLauncher: ActivityResultLauncher<Array<String>> =
|
||||
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<DocumentFile>()
|
||||
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<DocumentFile>) {
|
||||
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<Uri>) {
|
||||
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<String>(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>)
|
||||
|
||||
external fun viewportSetLibraryProgress(handle: Long, current: Int, total: Int)
|
||||
external fun viewportSetPendingTitles(handle: Long, titles: Array<String>, 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
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">YrXtals</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.YrXtals" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:statusBarColor">@android:color/black</item>
|
||||
<item name="android:navigationBarColor">@android:color/black</item>
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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" "$@"
|
||||
|
|
@ -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%
|
||||
|
|
@ -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")
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128px" height="128px" xmlns:bx="https://boxy-svg.com">
|
||||
<g transform="matrix(-1, 0, 0, -1, -30.330099, 34.871891)" style="transform-origin: 78.714px 29.1281px;">
|
||||
<path d="M -37.092 -7.469 Q -27.725 -15.838 -19.419 -7.469 L 23.299 35.57 Q 31.605 43.939 13.932 43.939 L -76.956 43.939 Q -94.629 43.939 -85.262 35.57 Z" bx:shape="triangle -94.629 -15.838 126.234 59.777 0.53 0.14 1@21337862" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); vector-effect: non-scaling-stroke; stroke-width: 1;" transform="matrix(0, 1, -1, 0, 108.18455637, 60.40422131)"/>
|
||||
<path d="M 57.153 -236 C 57.864 -236 58.22 -238.9 58.22 -244.68 L 58.22 -335.76 C 58.22 -341.54 57.864 -344.43 57.153 -344.43 L 45.959 -344.43 C 45.248 -344.43 44.892 -341.54 44.892 -335.76 L 44.892 -244.68 C 44.892 -238.9 45.248 -236 45.959 -236 L 57.153 -236 Z" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); vector-effect: non-scaling-stroke; stroke-width: 1; transform-origin: 47.905px -130.317px;" transform="matrix(-1, 0, 0, -1, 0.000003, 0.000005)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128px" height="128px" xmlns:bx="https://boxy-svg.com">
|
||||
<path d="M -37.092 -7.469 Q -27.725 -15.838 -19.419 -7.469 L 23.299 35.57 Q 31.605 43.939 13.932 43.939 L -76.956 43.939 Q -94.629 43.939 -85.262 35.57 Z" bx:shape="triangle -94.629 -15.838 126.234 59.777 0.53 0.14 1@21337862" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); vector-effect: non-scaling-stroke;" transform="matrix(0, 1, -1, 0, 106.79862343, 95.27597925)"/>
|
||||
<path d="M 55.767 -201.13 C 56.478 -201.13 56.834 -204.03 56.834 -209.81 L 56.834 -300.89 C 56.834 -306.67 56.478 -309.56 55.767 -309.56 L 44.573 -309.56 C 43.862 -309.56 43.506 -306.67 43.506 -300.89 L 43.506 -209.81 C 43.506 -204.03 43.862 -201.13 44.573 -201.13 L 55.767 -201.13 Z" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); vector-effect: non-scaling-stroke; stroke-width: 1; transform-origin: 46.519px -95.445px;" transform="matrix(-1, 0, 0, -1, 0.000006, -0.000003)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 203.60941 199.56867" version="1.1" id="svg2" width="203.60941" height="199.56866" xmlns="http://www.w3.org/2000/svg" xmlns:bx="https://boxy-svg.com">
|
||||
<path d="m 102.80806,103.72747 q 0.0229,-1.41491 1.32445,-2.21052 1.38987,-0.84959 3.17985,-0.29419 1.99019,0.61808 3.22559,2.6296 1.3884,2.26045 1.11266,5.17295 -0.30823,3.25651 -2.58135,5.96396 -2.51716,2.99825 -6.51101,4.19858 -4.378886,1.31659 -9.021381,0.0143 -5.043675,-1.41394 -8.747503,-5.45638 -3.992479,-4.35659 -5.17199,-10.393 -1.262573,-6.462589 1.083531,-12.869419 2.497613,-6.82007 8.331892,-11.53167 6.179257,-4.99009 14.274881,-6.14499 8.53824,-1.21788 16.7178,2.18186 8.59451,3.57161 14.31467,11.20643 5.99128,7.99594 7.11838,18.156899 1.17724,10.61152 -3.27974,20.5664 -4.64255,10.36961 -14.08192,17.09888 -9.81002,6.99363 -22.03823,8.09142 -12.683189,1.13922 -24.414746,-4.37756 -12.145115,-5.71149 -19.882319,-16.9575 -7.996894,-11.62269 -9.064773,-25.9203 -1.101651,-14.755009 5.47593,-28.262879 6.778894,-13.92131 19.83246,-22.66658 13.43477,-9.00132 29.802098,-10.03783 16.82618,-1.06568 32.11116,6.57426 15.69684,7.84529 25.45,22.70704 10.00559,15.24697 11.01065,33.684209 1.03065,18.89755 -7.67213,35.95985 -8.91168,17.47308 -25.58249,28.23379 -17.05802,11.01108 -37.56546,11.98425 -20.968711,0.9952 -39.808093,-8.76995 -19.249089,-9.97809 -31.017152,-28.4576 -12.016353,-18.86974 -12.957547,-41.44812 -0.960158,-23.040109 9.868329,-43.656319 11.04346,-21.02588 31.33252,-33.8015 20.680777,-13.02186 45.329833,-13.93067 25.11126,-0.92573 47.50452,10.96615 22.80187,12.10937 36.58481,34.20816 14.02764,22.49151 14.90344,49.211509 0.63672,19.40598 -6.00722,37.77605" bx:shape="spiral 386.246 447.692 0 201.472 0 1191.71 1@8f593177" style="stroke: rgb(126, 131, 224); stroke-width: 0.508112; fill: rgb(15, 15, 20);" id="Nautilus" transform="rotate(0 101.80 99.78)" role="status">
|
||||
<desc id="desc1">Loading indicator</desc>
|
||||
<title id="title1">Nautilus</title>
|
||||
</path>
|
||||
<path style="stroke: rgb(241, 96, 96); stroke-width: 0.522248; fill: rgb(23, 23, 31);" d="M 63.568448,5.5633906 78.640154,0.90578059 97.448723,64.059001 a 35.076328,35.076138 58.913597 0 1 5.503327,-0.0778 L 119.96927,0.32230059 135.16648,4.5521606 116.84616,67.848511 a 35.076328,35.076138 58.913597 0 1 4.67203,2.91001 l 48.73224,-44.35298 10.49772,11.77475 -49.63292,43.34348 a 35.076328,35.076138 58.913597 0 1 2.35703,4.97392 l 64.97551,-10.9651 2.46514,15.58097 -65.1866,9.629349 a 35.076328,35.076138 58.913597 0 1 -0.70618,5.45865 l 60.58894,25.90368 -6.3495,14.44037 -60.04462,-27.14216 a 35.076328,35.076138 58.913597 0 1 -3.5452,4.2102 l 36.96624,54.54868 -13.14876,8.71508 -35.83887,-55.2961 a 35.076328,35.076138 58.913597 0 1 -5.25909,1.62521 l 1.60694,65.8748 -15.772685,0.22253 -0.254956,-65.89358 a 35.076328,35.076138 58.913597 0 1 -5.302429,-1.47601 l -34.262572,56.28595 -13.389517,-8.34042 35.410733,-55.57048 a 35.076328,35.076138 58.913597 0 1 -3.662751,-4.10852 L 13.508009,149.02736 6.7527809,134.77216 66.586129,107.16754 a 35.076328,35.076138 58.913597 0 1 -0.860137,-5.43651 L 0.29281922,93.946471 2.3171022,78.302041 l 65.2590768,9.1262 a 35.076328,35.076138 58.913597 0 1 2.215671,-5.03852 L 18.955268,40.464961 29.115905,28.398751 79.08162,71.357901 a 35.076328,35.076138 58.913597 0 1 4.587713,-3.04065 z M 92.11318,71.217201 A 29.063155,29.062998 58.913597 0 0 109.2753,126.75212 29.063155,29.062998 58.913597 0 0 92.11318,71.217201" bx:shape="cog 522.262 470.9 55.65 67.164 191.897 0.45 11 1@487a76bf" id="Cog" transform="rotate(0 101.80 99.78)" role="status">
|
||||
<desc id="desc2">Loading indicator</desc>
|
||||
<title id="title2">Cog</title>
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128px" height="128px">
|
||||
<g transform="matrix(0.256755, 0, 0, 0.256755, 15.824668, -0.188759)">
|
||||
<path d="M 0.525 40.62 C 0.525 14.032 3.85 0.737 10.504 0.737 L 115.283 0.737 C 121.937 0.737 125.262 14.032 125.262 40.62 L 125.262 459.381 C 125.262 485.971 121.937 499.263 115.283 499.263 L 10.504 499.263 C 3.85 499.263 0.525 485.971 0.525 459.381 L 0.525 40.62 Z" style="stroke: rgb(0, 0, 0); stroke-width: 0.992; fill: rgb(223, 230, 246);"/>
|
||||
<path d="M 250 40.622 C 250 14.035 253.326 0.735 259.979 0.735 L 364.759 0.735 C 371.412 0.735 374.738 14.035 374.738 40.622 L 374.738 459.383 C 374.738 485.973 371.412 499.265 364.759 499.265 L 259.979 499.265 C 253.326 499.265 250 485.973 250 459.383 L 250 40.622 Z" style="stroke: rgb(0, 0, 0); stroke-width: 0.992; fill: rgb(223, 230, 246);"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 920 B |
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128px" height="128px" xmlns:bx="https://boxy-svg.com">
|
||||
<path d="M 152.166 44.27 Q 161.533 28.965 169.839 44.27 L 212.557 122.981 Q 220.863 138.286 203.19 138.286 L 112.302 138.286 Q 94.629 138.286 103.996 122.981 Z" bx:shape="triangle 94.629 28.965 126.234 109.321 0.53 0.14 1@c35ca8d5" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); vector-effect: non-scaling-stroke;" transform="matrix(0, 1, -1, 0, 154.88379094, -93.9818737)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 555 B |
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128px" height="128px">
|
||||
<path style="vector-effect: non-scaling-stroke; fill: rgb(193, 198, 255); stroke: rgb(0, 0, 0); stroke-width: 3px;" d="M 41.519 125.571 C 37.956 124.233 34.524 122.577 31.26 120.624 L 33.596 95.707 L 28.485 89.44 L 3.484 85.68 C 2.317 82.06 1.478 78.339 0.977 74.566 L 20.847 60.176 L 22.607 52.559 L 11.037 30.991 C 13.143 27.813 15.531 24.83 18.169 22.077 L 41.766 28.613 L 48.816 25.222 L 58.489 2.686 C 62.282 2.344 66.098 2.344 69.891 2.686 L 79.445 25.222 L 86.477 28.613 L 110.107 22.077 C 112.732 24.83 115.103 27.813 117.194 30.991 L 105.51 52.559 L 107.229 60.176 L 127.023 74.566 C 126.503 78.339 125.643 82.06 124.456 85.68 L 100.333 90.039 L 95.445 96.147 L 96.5 120.624 C 93.225 122.577 89.782 124.233 86.212 125.571 L 67.816 109.436 L 60.001 109.436 Z M 63.999 27.641 C 43.92 28.153 27.642 44.848 27.642 64.927 C 27.642 85.008 43.92 100.87 63.999 100.359 C 84.079 99.844 100.357 83.151 100.357 63.07 C 100.357 42.99 84.079 27.129 63.999 27.641 Z"/>
|
||||
<path style="fill: rgb(193, 198, 255); vector-effect: non-scaling-stroke; stroke-width: 3px; stroke: rgb(0, 0, 0);" d="M 57.92 80.368 C 56.995 80.013 56 79.533 55.149 79.032 L 53.772 78.219 L 54.353 72.014 L 48.112 71.076 L 47.637 69.579 C 47.338 68.634 47.094 67.552 46.959 66.573 L 46.752 65.075 L 51.737 61.466 L 48.833 56.054 L 49.678 54.794 C 50.231 53.973 50.922 53.109 51.606 52.389 L 52.648 51.291 L 58.566 52.93 L 60.994 47.276 L 62.502 47.149 C 63.488 47.066 64.594 47.066 65.58 47.149 L 67.095 47.276 L 69.492 52.929 L 75.414 51.291 L 76.458 52.395 C 77.138 53.115 77.825 53.98 78.374 54.803 L 79.217 56.068 L 76.287 61.479 L 81.247 65.086 L 81.034 66.589 C 80.894 67.569 80.644 68.651 80.341 69.592 L 79.878 71.032 L 73.826 72.127 L 74.09 78.268 L 72.785 79.037 C 71.931 79.54 70.935 80.017 70.008 80.373 L 68.591 80.913 L 63.975 76.866 L 59.34 80.913 L 57.92 80.368 Z M 68.143 59.55 C 66.959 58.396 65.849 57.924 64.06 57.969 C 62.261 58.015 61.098 58.571 59.892 59.809 C 58.685 61.045 58.16 62.22 58.16 64.019 C 58.16 65.805 58.662 66.903 59.844 68.057 C 61.029 69.212 62.139 69.686 63.928 69.64 C 65.726 69.592 66.889 69.039 68.094 67.801 C 69.3 66.565 69.824 65.39 69.824 63.592 C 69.824 61.803 69.322 60.706 68.14 59.553 L 68.143 59.55 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
#ifndef YR_XTALS_H
|
||||
#define YR_XTALS_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
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
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Yr Xtals</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Audio</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.audio</string>
|
||||
<string>public.mp3</string>
|
||||
<string>public.mpeg-4-audio</string>
|
||||
<string>public.folder</string>
|
||||
<string>public.directory</string>
|
||||
<string>public.content</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>YrXtals</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.else-if.yrxtals</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>YrXtals</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.1</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>iPhoneOS</string>
|
||||
<string>iPhoneSimulator</string>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0.1</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>17.0</string>
|
||||
<key>NSAppleMusicUsageDescription</key>
|
||||
<string>Yr Xtals plays audio files from your library or Files app for offline visualization.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
<integer>2</integer>
|
||||
<integer>1</integer>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string></string>
|
||||
</dict>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
<string>metal</string>
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -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: []
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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<UITouch>, 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<UITouch>, 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<UITouch>, 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<UITouch>, 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<UIPress>, 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<UIPress>, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CChar>?] = titles.map { strdup($0) }
|
||||
defer { cstrs.forEach { if let p = $0 { free(p) } } }
|
||||
var pointers: [UnsafePointer<CChar>?] = 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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: "<?xml"), let end = s.range(of: "</plist>") {
|
||||
let plistRange = start.lowerBound..<end.upperBound
|
||||
let plistStr = String(s[plistRange])
|
||||
if let plistData = plistStr.data(using: .utf8),
|
||||
let plist = try? PropertyListSerialization.propertyList(from: plistData, options: [], format: nil) as? [String: Any] {
|
||||
let entitlements = plist["Entitlements"] as? [String: Any] ?? [:]
|
||||
print("[YrXtals.dbg] entitlements keys=\(Array(entitlements.keys).sorted())")
|
||||
for (k, v) in entitlements.sorted(by: { $0.key < $1.key }) {
|
||||
print("[YrXtals.dbg] \(k) = \(v)")
|
||||
}
|
||||
print("[YrXtals.dbg] profile name=\(plist["Name"] ?? "?") teamId=\((plist["TeamIdentifier"] as? [String])?.first ?? "?") expires=\(plist["ExpirationDate"] ?? "?")")
|
||||
} else {
|
||||
print("[YrXtals.dbg] embedded plist parse failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print("[YrXtals.dbg] embedded.mobileprovision read failed at \(provURL.path)")
|
||||
}
|
||||
}
|
||||
print("[YrXtals.dbg] processName=\(ProcessInfo.processInfo.processName) pid=\(ProcessInfo.processInfo.processIdentifier) " +
|
||||
"thermal=\(ProcessInfo.processInfo.thermalState.rawValue) lowPower=\(ProcessInfo.processInfo.isLowPowerModeEnabled)")
|
||||
print("[YrXtals.dbg] os=\(ProcessInfo.processInfo.operatingSystemVersionString) " +
|
||||
"device=\(UIDevice.current.model) idiom=\(UIDevice.current.userInterfaceIdiom.rawValue) " +
|
||||
"systemName=\(UIDevice.current.systemName) systemVersion=\(UIDevice.current.systemVersion)")
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
print("[YrXtals.dbg] audioSession category=\(session.category.rawValue) mode=\(session.mode.rawValue) " +
|
||||
"options=\(session.categoryOptions.rawValue) sampleRate=\(session.sampleRate) ioBufDur=\(session.ioBufferDuration)")
|
||||
}
|
||||
|
||||
/// observes app, scene, window, FileProvider, and BSService notifications.
|
||||
private static func startNotificationSpy() {
|
||||
let center = NotificationCenter.default
|
||||
let names: [Notification.Name] = [
|
||||
UIApplication.willResignActiveNotification,
|
||||
UIApplication.didBecomeActiveNotification,
|
||||
UIApplication.willEnterForegroundNotification,
|
||||
UIApplication.didEnterBackgroundNotification,
|
||||
UIWindow.didBecomeKeyNotification,
|
||||
UIWindow.didResignKeyNotification,
|
||||
UIWindow.didBecomeVisibleNotification,
|
||||
UIWindow.didBecomeHiddenNotification,
|
||||
UIScene.willConnectNotification,
|
||||
UIScene.didActivateNotification,
|
||||
UIScene.willDeactivateNotification,
|
||||
UIResponder.keyboardWillShowNotification,
|
||||
UIResponder.keyboardDidShowNotification,
|
||||
UIResponder.keyboardWillHideNotification,
|
||||
]
|
||||
for n in names {
|
||||
center.addObserver(forName: n, object: nil, queue: .main) { note in
|
||||
print("[YrXtals.dbg] notif: \(note.name.rawValue) object=\(String(describing: note.object.map { type(of: $0) }))")
|
||||
}
|
||||
}
|
||||
let raw = [
|
||||
"NSFileProviderManagerStateChangedNotification",
|
||||
"_UIDocumentPickerDidPickDocumentsNotification",
|
||||
"UIRemoteViewControllerInterfaceDidLoad",
|
||||
"BSServiceConnectionInvalidated",
|
||||
"BSServiceConnectionInterrupted",
|
||||
]
|
||||
for r in raw {
|
||||
center.addObserver(forName: Notification.Name(r), object: nil, queue: .main) { note in
|
||||
print("[YrXtals.dbg] notif(raw): \(note.name.rawValue) object=\(String(describing: note.object))")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// pairs every connecting scene with SceneDelegate.
|
||||
func application(_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions) -> 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<CChar>?
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>yr_crystals</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.else-if.yrcrystals</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Yr Xtals</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Yr Xtals</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.music</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<false/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Yr Xtals does not record audio. This entry is here only because the audio framework asks for it on some macOS versions.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -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;<version>' 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
|
||||
|
|
@ -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."
|
||||
|
|
@ -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)"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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)"
|
||||
|
|
@ -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
|
||||
|
|
@ -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)"
|
||||
|
|
@ -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" <<EOF
|
||||
YRXTALS_IOS_KIND=$KIND
|
||||
YRXTALS_IOS_UDID=$UDID
|
||||
YRXTALS_IOS_LABEL="$LABEL"
|
||||
EOF
|
||||
|
||||
echo
|
||||
echo "saved: $KIND $UDID"
|
||||
echo "label: $LABEL"
|
||||
echo
|
||||
echo "next: cargo xtask debug-ios (kind no longer needs to be passed)"
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
case "${PLATFORM_NAME:-}" in
|
||||
iphoneos)
|
||||
case "${ARCHS:-}" in
|
||||
*arm64*) RUST_TARGET="aarch64-apple-ios" ;;
|
||||
*) echo "unsupported ARCHS=$ARCHS for $PLATFORM_NAME" >&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"
|
||||
|
|
@ -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."
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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<uniform> args: Args;
|
||||
@group(0) @binding(1) var<storage, read_write> data: array<vec2<f32>>;
|
||||
@group(0) @binding(2) var<storage, read_write> scratch: array<vec2<f32>>;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn bit_reverse(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
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<u32>) {
|
||||
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<f32>(cos(angle), sin(angle));
|
||||
|
||||
let a = scratch[ia];
|
||||
let b = scratch[ib];
|
||||
let bw = vec2<f32>(b.x * w.x - b.y * w.y, b.x * w.y + b.y * w.x);
|
||||
|
||||
scratch[ia] = a + bw;
|
||||
scratch[ib] = a - bw;
|
||||
}
|
||||
|
|
@ -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<f32>, // full canvas in pixels
|
||||
base: vec2<f32>, // 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<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var<storage, read> bins: array<Bin>;
|
||||
|
||||
struct VertexOut {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) color: vec4<f32>,
|
||||
};
|
||||
|
||||
fn flag(bit: u32) -> bool {
|
||||
return (globals.flags & bit) != 0u;
|
||||
}
|
||||
|
||||
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> vec3<f32> {
|
||||
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<f32>(v, t, p); }
|
||||
if (ii == 1) { return vec3<f32>(q, v, p); }
|
||||
if (ii == 2) { return vec3<f32>(p, v, t); }
|
||||
if (ii == 3) { return vec3<f32>(p, q, v); }
|
||||
if (ii == 4) { return vec3<f32>(t, p, v); }
|
||||
return vec3<f32>(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<f32> {
|
||||
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<f32> {
|
||||
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<f32>, ch: u32) -> vec3<f32> {
|
||||
if (ch == 1u && flag(8u)) {
|
||||
let off = 40.0 / 255.0;
|
||||
return vec3<f32>(
|
||||
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<f32>) -> vec4<f32> {
|
||||
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<f32>(nx, ny, 0.0, 1.0);
|
||||
}
|
||||
|
||||
fn mirror_xform(iid: u32, p: vec2<f32>) -> vec2<f32> {
|
||||
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<f32>(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<f32>;
|
||||
switch corner {
|
||||
case 0u: { p = vec2<f32>(x1, anchor_y); }
|
||||
case 1u: { p = vec2<f32>(x1, y1); }
|
||||
case 2u: { p = vec2<f32>(x2, y2); }
|
||||
case 3u: { p = vec2<f32>(x1, anchor_y); }
|
||||
case 4u: { p = vec2<f32>(x2, y2); }
|
||||
default: { p = vec2<f32>(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<f32>(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<f32>;
|
||||
if (endpoint == 0u) {
|
||||
p = vec2<f32>(x, anchor_y);
|
||||
} else {
|
||||
p = vec2<f32>(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<f32>(rgb, a);
|
||||
return out;
|
||||
}
|
||||
|
||||
// cepstrum line strip in pixel space.
|
||||
struct CepIn {
|
||||
@location(0) position: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
};
|
||||
|
||||
@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<f32> {
|
||||
return in.color;
|
||||
}
|
||||
|
|
@ -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<f32>,
|
||||
pub db: Vec<f32>,
|
||||
pub primary_db: Vec<f32>,
|
||||
|
||||
pub cepstrum: Vec<f32>,
|
||||
}
|
||||
|
||||
/// 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<Arc<TrackData>>,
|
||||
last_frames: Vec<FrameData>,
|
||||
}
|
||||
|
||||
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<TrackData>) {
|
||||
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<f64>, Vec<f64>) {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<TrackData>),
|
||||
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<Cmd>,
|
||||
frames: Arc<ArcSwap<Vec<FrameData>>>,
|
||||
playhead_frame: Arc<AtomicU64>,
|
||||
total_frames: Arc<AtomicU64>,
|
||||
join: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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::<Cmd>(64);
|
||||
let frames: Arc<ArcSwap<Vec<FrameData>>> = 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<TrackData>) {
|
||||
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<Vec<FrameData>> {
|
||||
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<Cmd>,
|
||||
frames: Arc<ArcSwap<Vec<FrameData>>>,
|
||||
playhead_frame: Arc<AtomicU64>,
|
||||
total_frames: Arc<AtomicU64>,
|
||||
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 => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u8> = 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<std::io::Error> for DecodeError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
DecodeError::Io(e)
|
||||
}
|
||||
}
|
||||
impl From<SymError> 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<TrackData, DecodeError> {
|
||||
#[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<f32> = Vec::new();
|
||||
let mut sample_buf: Option<SampleBuffer<f32>> = 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::<f32>::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<TrackData, DecodeError> {
|
||||
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::<f32>::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<f32> = Vec::with_capacity(frames_out_est);
|
||||
let mut out_right: Vec<f32> = 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<f32>, 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<Instant> = 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<TrackData>),
|
||||
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<EngineState>,
|
||||
cmd_tx: Sender<Cmd>,
|
||||
#[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<Self, String> {
|
||||
#[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::<Cmd>();
|
||||
|
||||
let cb_state = state.clone();
|
||||
let mut current: Option<Arc<TrackData>> = 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<Self, String> {
|
||||
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::<Cmd>();
|
||||
|
||||
let cb_state = state.clone();
|
||||
let mut current: Option<Arc<TrackData>> = 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<TrackData>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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::<Args>() 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<wgpu::SubmissionIndex>,
|
||||
|
||||
staging_mapped: bool,
|
||||
|
||||
bit_reverse: wgpu::ComputePipeline,
|
||||
butterfly: wgpu::ComputePipeline,
|
||||
|
||||
args_buf: wgpu::Buffer,
|
||||
|
||||
all_args_buf: wgpu::Buffer,
|
||||
|
||||
cached_inverse: std::cell::Cell<Option<bool>>,
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<f64>,
|
||||
}
|
||||
|
||||
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<Complex64> {
|
||||
let n = signal.len();
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let fwd: Arc<dyn Fft<f64>> = self.planner.plan_fft_forward(n);
|
||||
let bwd: Arc<dyn Fft<f64>> = self.planner.plan_fft_inverse(n);
|
||||
|
||||
let mut buf: Vec<Complex64> = 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<Complex64>, Vec<Complex64>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<f64>,
|
||||
history_r: Vec<f64>,
|
||||
fft_buf_l: Vec<Complex64>,
|
||||
fft_buf_r: Vec<Complex64>,
|
||||
fwd: Option<Arc<dyn Fft<f64>>>,
|
||||
inv: Option<Arc<dyn Fft<f64>>>,
|
||||
planner: FftPlanner<f64>,
|
||||
}
|
||||
|
||||
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<Complex64>, Vec<Complex64>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<str>) {
|
||||
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)) };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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<String>,
|
||||
pub album: Option<String>,
|
||||
pub track_number: Option<u32>,
|
||||
pub art: Option<ImageHandle>,
|
||||
pub palette: Option<Arc<Vec<[f32; 3]>>>,
|
||||
|
||||
/// 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<Track> {
|
||||
#[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<PathBuf> = 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<PathBuf> = 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<PathBuf> = 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<Track> = 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<Track, String> {
|
||||
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<Track, String> {
|
||||
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<ImageHandle>, Option<Arc<Vec<[f32; 3]>>>) {
|
||||
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<String>, Option<String>, Option<String>, Option<u32>) {
|
||||
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<ImageHandle>, Option<Arc<Vec<[f32; 3]>>>) {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
artist: Option<String>,
|
||||
album: Option<String>,
|
||||
track_number: Option<u32>,
|
||||
},
|
||||
Art {
|
||||
path: PathBuf,
|
||||
art: Option<ImageHandle>,
|
||||
palette: Option<Arc<Vec<[f32; 3]>>>,
|
||||
},
|
||||
|
||||
/// art keyed by sidebar index, paired with a vibrancy palette.
|
||||
ArtForIdx {
|
||||
idx: usize,
|
||||
art: Option<ImageHandle>,
|
||||
palette: Option<Arc<Vec<[f32; 3]>>>,
|
||||
},
|
||||
Decoded {
|
||||
request_id: u64,
|
||||
path: PathBuf,
|
||||
result: Result<Arc<TrackData>, 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<u8> },
|
||||
}
|
||||
|
||||
/// owns the three background threads handling metadata reads, art decoding, and audio decoding.
|
||||
pub struct LibraryWorker {
|
||||
meta_tx: Sender<PathBuf>,
|
||||
art_tx: Sender<ArtReq>,
|
||||
decode_tx: Sender<DecodeReq>,
|
||||
update_rx: Receiver<LibraryUpdate>,
|
||||
_meta_join: Option<JoinHandle<()>>,
|
||||
_art_join: Option<JoinHandle<()>>,
|
||||
_decode_join: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl LibraryWorker {
|
||||
/// starts the metadata, art, and decode threads on a shared update channel.
|
||||
pub fn spawn() -> Self {
|
||||
let (meta_tx, meta_rx) = unbounded::<PathBuf>();
|
||||
let (art_tx, art_rx) = unbounded::<ArtReq>();
|
||||
let (decode_tx, decode_rx) = unbounded::<DecodeReq>();
|
||||
let (update_tx, update_rx) = unbounded::<LibraryUpdate>();
|
||||
|
||||
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<u8>) {
|
||||
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<LibraryUpdate> {
|
||||
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<PathBuf>, tx: Sender<LibraryUpdate>) {
|
||||
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<ArtReq>, tx: Sender<LibraryUpdate>) {
|
||||
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<DecodeReq>, tx: Sender<LibraryUpdate>) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
|
|
@ -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<Vec<[f32; 3]>> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<f32>,
|
||||
pub db: Vec<f32>,
|
||||
|
||||
pub cepstrum: Vec<f32>,
|
||||
}
|
||||
|
||||
/// 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<Arc<dyn Fft<f64>>>,
|
||||
cpu_cep_inv: Option<Arc<dyn Fft<f64>>>,
|
||||
cpu_planner: FftPlanner<f64>,
|
||||
|
||||
gpu_fft: Option<GpuFft1D>,
|
||||
|
||||
gpu_blend: f32,
|
||||
|
||||
window: Vec<f64>,
|
||||
buffer: Vec<Complex64>,
|
||||
|
||||
custom_bins: Vec<f64>,
|
||||
freqs_const: Vec<f64>,
|
||||
|
||||
history: VecDeque<Vec<f64>>,
|
||||
smoothing_length: usize,
|
||||
|
||||
expand_ratio: f32,
|
||||
expand_threshold: f32,
|
||||
|
||||
hpf_cutoff: f32,
|
||||
|
||||
granularity: i32,
|
||||
detail: i32,
|
||||
cepstral_strength: f32,
|
||||
|
||||
weave_caps: Option<weave::Caps>,
|
||||
}
|
||||
|
||||
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<Complex64> = 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<Complex64> = 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<f32> = (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<f32> = 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<weave::Caps> {
|
||||
Some(weave::Caps::mobile())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "android")))]
|
||||
fn default_caps() -> Option<weave::Caps> {
|
||||
None
|
||||
}
|
||||
|
|
@ -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<Box<Window>>,
|
||||
handle: Option<ViewportHandle>,
|
||||
modifiers: ModifiersState,
|
||||
last_cursor: PhysicalPosition<f64>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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::<f32>::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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<f32>,
|
||||
pub fir_r: Vec<f32>,
|
||||
pub frequency_axis: Vec<f64>,
|
||||
pub source_l_db: Vec<f64>,
|
||||
pub target_l_db: Vec<f64>,
|
||||
pub matched_l_db: Vec<f64>,
|
||||
pub source_r_db: Vec<f64>,
|
||||
pub target_r_db: Vec<f64>,
|
||||
pub matched_r_db: Vec<f64>,
|
||||
}
|
||||
|
||||
/// holds the FFT planner and block-hilbert helper reused across match passes.
|
||||
pub struct TrigInterpolation {
|
||||
hilbert: BlockHilbert,
|
||||
planner: FftPlanner<f64>,
|
||||
}
|
||||
|
||||
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<Complex64> {
|
||||
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<f64> = 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<f32> {
|
||||
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<Complex64> = 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<dyn Fft<f64>> = 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<dyn Fft<f64>> = 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<f64> {
|
||||
if analytic.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mag: Vec<f64> = 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<f64> {
|
||||
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<f64> = 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)
|
||||
}
|
||||
|
|
@ -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<usize>,
|
||||
pub playing: bool,
|
||||
pub immersive: bool,
|
||||
pub engine: Option<AudioEngine>,
|
||||
pub worker: AnalyzerWorker,
|
||||
pub library_worker: LibraryWorker,
|
||||
pub frame_data: Arc<Vec<FrameData>>,
|
||||
pub current_palette: Option<Arc<Vec<[f32; 3]>>>,
|
||||
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<PathBuf>,
|
||||
pub tracks: Vec<Track>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf>),
|
||||
}
|
||||
|
||||
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<PathBuf>) {
|
||||
|
||||
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<u32>)>) {
|
||||
let mut tracks: Vec<library::Track> = 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<u8>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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};
|
||||
|
|
@ -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<usize>,
|
||||
rows: Vec<TrackRowKey>,
|
||||
}
|
||||
|
||||
/// minimal per-row identity used inside the sidebar lazy hash.
|
||||
#[derive(Hash)]
|
||||
struct TrackRowKey {
|
||||
path: PathBuf,
|
||||
title: String,
|
||||
artist: Option<String>,
|
||||
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<Track> = 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::time::Instant> = 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<u8> {
|
||||
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<f32>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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<Event>,
|
||||
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<String> {
|
||||
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<Self> {
|
||||
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<String>,
|
||||
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<std::path::PathBuf>) {
|
||||
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<u32>)>) {
|
||||
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<u8>) {
|
||||
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<F>(
|
||||
build_surface: F,
|
||||
) -> Option<(wgpu::Instance, wgpu::Surface<'static>)>
|
||||
where
|
||||
F: FnOnce(&wgpu::Instance) -> Option<wgpu::Surface<'static>>,
|
||||
{
|
||||
#[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<ViewportHandle> {
|
||||
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<Message> = Vec::new();
|
||||
let drained: Vec<Event> = 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;
|
||||
}
|
||||
|
|
@ -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<CepVertex>, 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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Vec<FrameData>>,
|
||||
pub params: VizParams,
|
||||
pub palette: Option<Arc<Vec<[f32; 3]>>>,
|
||||
}
|
||||
|
||||
impl VisualizerProgram {
|
||||
pub fn new(
|
||||
frames: Arc<Vec<FrameData>>,
|
||||
params: VizParams,
|
||||
palette: Option<Arc<Vec<[f32; 3]>>>,
|
||||
) -> Self {
|
||||
Self { frames, params, palette }
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message> Program<Message> 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<BinGpu>,
|
||||
pub scratch_cep: Vec<CepVertex>,
|
||||
}
|
||||
|
||||
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::<CepVertex>() 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::<GlobalsGpu>() 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::<BinGpu>() 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::<CepVertex>() 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::<BinGpu>() 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::<CepVertex>() 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<u32>,
|
||||
) {
|
||||
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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Vec<FrameData>>,
|
||||
pub params: VizParams,
|
||||
pub palette: Option<Arc<Vec<[f32; 3]>>>,
|
||||
}
|
||||
|
||||
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<u32>,
|
||||
) {
|
||||
pipeline.render_into(encoder, target, clip_bounds);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<f32>,
|
||||
}
|
||||
|
||||
/// row of bins for one audio channel.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ChannelState {
|
||||
pub bins: Vec<BinState>,
|
||||
}
|
||||
|
||||
/// cpu-side smoothing state for the visualizer.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct VisState {
|
||||
pub channels: Vec<ChannelState>,
|
||||
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<f32>,
|
||||
|
||||
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<BinGpu>) {
|
||||
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::<f32>() / 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<f32> = 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>) -> f32 {
|
||||
let buf: Vec<f32> = 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]
|
||||
}
|
||||
|
|
@ -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<Caps>,
|
||||
) -> Vec<f64> {
|
||||
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<usize> = 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<f64> {
|
||||
let n = source.len();
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut anchors: Vec<usize> = 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<usize> {
|
||||
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<i32>,
|
||||
pub max_iters: Option<i32>,
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[package]
|
||||
name = "xtask"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "xtask"
|
||||
path = "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<String> = 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::<Vec<_>>().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 <command>");
|
||||
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.");
|
||||
}
|
||||