This commit is contained in:
jess 2026-05-08 23:47:42 -07:00
commit dc9ddedd62
96 changed files with 11680 additions and 0 deletions

6
.android-sdk-packages Normal file
View File

@ -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

6
.cargo/config.toml Normal file
View File

@ -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"

35
.gitignore vendored Normal file
View File

@ -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/

4
.sdkmanrc Normal file
View File

@ -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

76
Cargo.toml Normal file
View File

@ -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

12
LICENCE Normal file
View File

@ -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.

21
PRIVACY_POLICY.md Normal file
View File

@ -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*

6
android/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.gradle/
build/
local.properties
.idea/
*.iml
captures/

View File

@ -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")
}

1
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1 @@
-keep class org.elseif.yrxtals.NativeBridge { *; }

View File

@ -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>

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">YrXtals</string>
</resources>

View File

@ -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>

4
android/build.gradle.kts Normal file
View File

@ -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
}

View File

@ -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

Binary file not shown.

View File

@ -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

248
android/gradlew vendored Executable file
View File

@ -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" "$@"

82
android/gradlew.bat vendored Normal file
View File

@ -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%

View File

@ -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")

7
assets/BSkip.svg Normal file
View File

@ -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

5
assets/FSkip.svg Normal file
View File

@ -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

262
assets/Icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

11
assets/Loading.svg Normal file
View File

@ -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

7
assets/Pause.svg Normal file
View File

@ -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

4
assets/Play.svg Normal file
View File

@ -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

5
assets/Settings.svg Normal file
View File

@ -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

260
assets/androidIcon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

93
fonts/Inter-OFL.txt Normal file
View File

@ -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.

BIN
fonts/Inter-Regular.ttf Normal file

Binary file not shown.

68
include/yr_xtals.h Normal file
View File

@ -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

89
ios/Info.plist Normal file
View File

@ -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>

53
ios/project.yml Normal file
View File

@ -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: []

View File

@ -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 }
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

175
ios/src/YrXtalsApp.swift Normal file
View File

@ -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
}
}

34
macos/Info.plist Normal file
View File

@ -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>

67
scripts/android/_env.sh Executable file
View File

@ -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

50
scripts/android/bootstrap.sh Executable file
View File

@ -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."

48
scripts/android/build.sh Executable file
View File

@ -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)"

30
scripts/android/debug.sh Executable file
View File

@ -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

View File

@ -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"

30
scripts/android/install.sh Executable file
View File

@ -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"

32
scripts/android/select.sh Executable file
View File

@ -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"

170
scripts/ios/build.sh Executable file
View 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"

72
scripts/ios/debug.sh Executable file
View File

@ -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

83
scripts/ios/generate-icons.sh Executable file
View File

@ -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)"

83
scripts/ios/install.sh Executable file
View File

@ -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

210
scripts/ios/release.sh Executable file
View File

@ -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)"

92
scripts/ios/select.sh Executable file
View File

@ -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)"

38
scripts/ios/xcode-cargo.sh Executable file
View File

@ -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"

36
scripts/ios/xcodeproj.sh Executable file
View File

@ -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."

34
scripts/linux/build.sh Executable file
View File

@ -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"

13
scripts/linux/debug.sh Executable file
View File

@ -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

71
scripts/macos/build.sh Executable file
View File

@ -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"

44
scripts/macos/debug.sh Executable file
View File

@ -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"

23
scripts/macos/install.sh Executable file
View File

@ -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"

42
scripts/windows/build.ps1 Normal file
View File

@ -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"

54
shaders/fft.wgsl Normal file
View File

@ -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;
}

252
shaders/visualizer.wgsl Normal file
View File

@ -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;
}

318
src/analyzer.rs Normal file
View File

@ -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)
}

182
src/analyzer_worker.rs Normal file
View File

@ -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 => {}
}
}

344
src/android.rs Normal file
View File

@ -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()
}
}

263
src/decoder.rs Normal file
View File

@ -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]);
}
}
}
}

525
src/engine.rs Normal file
View File

@ -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)
}
}

322
src/gpu_dsp.rs Normal file
View File

@ -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);
}
}

73
src/hilbert_block.rs Normal file
View File

@ -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)
}
}

125
src/hilbert_stream.rs Normal file
View File

@ -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
}
}

294
src/ios.rs Normal file
View File

@ -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)) };
}

28
src/lib.rs Normal file
View File

@ -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;

258
src/library.rs Normal file
View File

@ -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)
}

180
src/library_worker.rs Normal file
View File

@ -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,
});
}
}

8
src/main.rs Normal file
View File

@ -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() {}

54
src/palette.rs Normal file
View File

@ -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)
}

371
src/processor.rs Normal file
View File

@ -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
}

239
src/shell.rs Normal file
View File

@ -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
}

36
src/track.rs Normal file
View File

@ -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()
}
}

260
src/trig_interpolation.rs Normal file
View File

@ -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)
}

635
src/ui/app.rs Normal file
View File

@ -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)
}
}

8
src/ui/mod.rs Normal file
View File

@ -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};

790
src/ui/player.rs Normal file
View File

@ -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()
}
}

25
src/ui/theme.rs Normal file
View File

@ -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()
}

513
src/viewport.rs Normal file
View File

@ -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;
}

84
src/visualizer/build.rs Normal file
View File

@ -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),
}
}

81
src/visualizer/mod.rs Normal file
View File

@ -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(),
}
}
}

445
src/visualizer/pipeline.rs Normal file
View File

@ -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,
},
],
}
}

120
src/visualizer/primitive.rs Normal file
View File

@ -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);
}
}

437
src/visualizer/state.rs Normal file
View File

@ -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]
}

212
src/weave.rs Normal file
View File

@ -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),
}
}
}

9
xtask/Cargo.toml Normal file
View File

@ -0,0 +1,9 @@
[package]
name = "xtask"
version = "0.0.0"
edition = "2021"
publish = false
[[bin]]
name = "xtask"
path = "src/main.rs"

133
xtask/src/main.rs Normal file
View File

@ -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.");
}