From dc9ddedd62b0bca42876995d81469f07dc40893a Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 8 May 2026 23:47:42 -0700 Subject: [PATCH] Init --- .android-sdk-packages | 6 + .cargo/config.toml | 6 + .gitignore | 35 + .sdkmanrc | 4 + Cargo.toml | 76 ++ LICENCE | 12 + PRIVACY_POLICY.md | 21 + android/.gitignore | 6 + android/app/build.gradle.kts | 76 ++ android/app/proguard-rules.pro | 1 + android/app/src/main/AndroidManifest.xml | 28 + .../org/elseif/yrxtals/IcedSurfaceView.kt | 137 +++ .../org/elseif/yrxtals/LibraryController.kt | 173 ++++ .../java/org/elseif/yrxtals/MainActivity.kt | 43 + .../java/org/elseif/yrxtals/NativeBridge.kt | 32 + android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/themes.xml | 9 + android/build.gradle.kts | 4 + android/gradle.properties | 8 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + android/gradlew | 248 ++++++ android/gradlew.bat | 82 ++ android/settings.gradle.kts | 18 + assets/BSkip.svg | 7 + assets/FSkip.svg | 5 + assets/Icon.svg | 262 ++++++ assets/Loading.svg | 11 + assets/Pause.svg | 7 + assets/Play.svg | 4 + assets/Settings.svg | 5 + assets/androidIcon.svg | 260 ++++++ fonts/Inter-OFL.txt | 93 +++ fonts/Inter-Regular.ttf | Bin 0 -> 342680 bytes include/yr_xtals.h | 68 ++ ios/Info.plist | 89 ++ ios/project.yml | 53 ++ ios/src/IcedViewportRepresentable.swift | 15 + ios/src/IcedViewportView.swift | 207 +++++ ios/src/LibraryController.swift | 360 ++++++++ ios/src/YrXtalsApp.swift | 175 ++++ macos/Info.plist | 34 + scripts/android/_env.sh | 67 ++ scripts/android/bootstrap.sh | 50 ++ scripts/android/build.sh | 48 ++ scripts/android/debug.sh | 30 + scripts/android/generate-icons.sh | 41 + scripts/android/install.sh | 30 + scripts/android/select.sh | 32 + scripts/ios/build.sh | 170 ++++ scripts/ios/debug.sh | 72 ++ scripts/ios/generate-icons.sh | 83 ++ scripts/ios/install.sh | 83 ++ scripts/ios/release.sh | 210 +++++ scripts/ios/select.sh | 92 ++ scripts/ios/xcode-cargo.sh | 38 + scripts/ios/xcodeproj.sh | 36 + scripts/linux/build.sh | 34 + scripts/linux/debug.sh | 13 + scripts/macos/build.sh | 71 ++ scripts/macos/debug.sh | 44 + scripts/macos/install.sh | 23 + scripts/windows/build.ps1 | 42 + shaders/fft.wgsl | 54 ++ shaders/visualizer.wgsl | 252 ++++++ src/analyzer.rs | 318 +++++++ src/analyzer_worker.rs | 182 ++++ src/android.rs | 344 ++++++++ src/decoder.rs | 263 ++++++ src/engine.rs | 525 ++++++++++++ src/gpu_dsp.rs | 322 +++++++ src/hilbert_block.rs | 73 ++ src/hilbert_stream.rs | 125 +++ src/ios.rs | 294 +++++++ src/lib.rs | 28 + src/library.rs | 258 ++++++ src/library_worker.rs | 180 ++++ src/main.rs | 8 + src/palette.rs | 54 ++ src/processor.rs | 371 ++++++++ src/shell.rs | 239 ++++++ src/track.rs | 36 + src/trig_interpolation.rs | 260 ++++++ src/ui/app.rs | 635 ++++++++++++++ src/ui/mod.rs | 8 + src/ui/player.rs | 790 ++++++++++++++++++ src/ui/theme.rs | 25 + src/viewport.rs | 513 ++++++++++++ src/visualizer/build.rs | 84 ++ src/visualizer/mod.rs | 81 ++ src/visualizer/pipeline.rs | 445 ++++++++++ src/visualizer/primitive.rs | 120 +++ src/visualizer/state.rs | 437 ++++++++++ src/weave.rs | 212 +++++ xtask/Cargo.toml | 9 + xtask/src/main.rs | 133 +++ 96 files changed, 11680 insertions(+) create mode 100644 .android-sdk-packages create mode 100644 .cargo/config.toml create mode 100644 .gitignore create mode 100644 .sdkmanrc create mode 100644 Cargo.toml create mode 100644 LICENCE create mode 100644 PRIVACY_POLICY.md create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt create mode 100644 android/app/src/main/java/org/elseif/yrxtals/LibraryController.kt create mode 100644 android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt create mode 100644 android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 assets/BSkip.svg create mode 100644 assets/FSkip.svg create mode 100644 assets/Icon.svg create mode 100644 assets/Loading.svg create mode 100644 assets/Pause.svg create mode 100644 assets/Play.svg create mode 100644 assets/Settings.svg create mode 100644 assets/androidIcon.svg create mode 100644 fonts/Inter-OFL.txt create mode 100644 fonts/Inter-Regular.ttf create mode 100644 include/yr_xtals.h create mode 100644 ios/Info.plist create mode 100644 ios/project.yml create mode 100644 ios/src/IcedViewportRepresentable.swift create mode 100644 ios/src/IcedViewportView.swift create mode 100644 ios/src/LibraryController.swift create mode 100644 ios/src/YrXtalsApp.swift create mode 100644 macos/Info.plist create mode 100755 scripts/android/_env.sh create mode 100755 scripts/android/bootstrap.sh create mode 100755 scripts/android/build.sh create mode 100755 scripts/android/debug.sh create mode 100755 scripts/android/generate-icons.sh create mode 100755 scripts/android/install.sh create mode 100755 scripts/android/select.sh create mode 100755 scripts/ios/build.sh create mode 100755 scripts/ios/debug.sh create mode 100755 scripts/ios/generate-icons.sh create mode 100755 scripts/ios/install.sh create mode 100755 scripts/ios/release.sh create mode 100755 scripts/ios/select.sh create mode 100755 scripts/ios/xcode-cargo.sh create mode 100755 scripts/ios/xcodeproj.sh create mode 100755 scripts/linux/build.sh create mode 100755 scripts/linux/debug.sh create mode 100755 scripts/macos/build.sh create mode 100755 scripts/macos/debug.sh create mode 100755 scripts/macos/install.sh create mode 100644 scripts/windows/build.ps1 create mode 100644 shaders/fft.wgsl create mode 100644 shaders/visualizer.wgsl create mode 100644 src/analyzer.rs create mode 100644 src/analyzer_worker.rs create mode 100644 src/android.rs create mode 100644 src/decoder.rs create mode 100644 src/engine.rs create mode 100644 src/gpu_dsp.rs create mode 100644 src/hilbert_block.rs create mode 100644 src/hilbert_stream.rs create mode 100644 src/ios.rs create mode 100644 src/lib.rs create mode 100644 src/library.rs create mode 100644 src/library_worker.rs create mode 100644 src/main.rs create mode 100644 src/palette.rs create mode 100644 src/processor.rs create mode 100644 src/shell.rs create mode 100644 src/track.rs create mode 100644 src/trig_interpolation.rs create mode 100644 src/ui/app.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/player.rs create mode 100644 src/ui/theme.rs create mode 100644 src/viewport.rs create mode 100644 src/visualizer/build.rs create mode 100644 src/visualizer/mod.rs create mode 100644 src/visualizer/pipeline.rs create mode 100644 src/visualizer/primitive.rs create mode 100644 src/visualizer/state.rs create mode 100644 src/weave.rs create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/main.rs diff --git a/.android-sdk-packages b/.android-sdk-packages new file mode 100644 index 0000000..361ca98 --- /dev/null +++ b/.android-sdk-packages @@ -0,0 +1,6 @@ +# android sdk coordinates installed via `sdkmanager --install` +# scripts/android/build.sh consumes this file +platform-tools +platforms;android-35 +build-tools;35.0.1 +ndk;27.3.13750724 diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..d1a6c40 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +[alias] +xtask = "run --release --package xtask --" + +# build target lives on /tmp (internal SSD) instead of the project root (external spin disk). +[build] +target-dir = "/tmp/yr_crystals-target" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85d5d92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +build/ +build_android/ +build_ios/ +build_macos/ +build_windows/ +icons +*.png +vamp-plugin/ +libraries/loop-tempo-estimator/ +tests/ +ADC-2024/ +*.json +readme.md +vamp-plugin-sdk.cmake +*.keystore +*.jks +.yrxtls-ios-target +.yrxtls-android-target +target/ +*.so +*.a +*.o +*.dynlib + +ios/YrXtals.xcodeproj/ +ios/Assets.xcassets/AppIcon.appiconset/ +android/app/src/main/res/mipmap-hdpi/ +android/app/src/main/res/mipmap-mdpi/ +android/app/src/main/res/mipmap-xhdpi/ +android/app/src/main/res/mipmap-xxhdpi/ +android/app/src/main/res/mipmap-xxxhdpi/ +android/app/src/main/res/mipmap-anydpi-v26/ +android/app/src/main/jniLibs/ +*.xcuserstate +xcuserdata/ diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 0000000..22b8e68 --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1,4 @@ +# versions sdkman can install for this project +# usage: cd into project root, run `sdk env install` then `sdk env` +java=17.0.19-tem +gradle=8.9 diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..759460e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,76 @@ +[workspace] +members = [".", "xtask"] +default-members = ["."] +resolver = "2" + +[package] +name = "yr_crystals" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" +crate-type = ["rlib", "staticlib", "cdylib"] + +[[bin]] +name = "yr_crystals" +path = "src/main.rs" + +[dependencies] +rustfft = "6" +num-complex = "0.4" + +# core audio +cpal = "0.16" +ringbuf = "0.4" +crossbeam-channel = "0.5" +rubato = "0.14" + +# file decoding +symphonia = { version = "0.5", default-features = false, features = [ + "pcm", "wav", "flac", "ogg", "vorbis", + "mp3", "aac", "alac", "isomp4", "adpcm", +] } + +# iced/wgpu +wgpu = "27" +raw-window-handle = "0.6" +pollster = "0.4" +smol_str = "0.2" +iced_wgpu = "0.14" +iced_graphics = "0.14" +iced_runtime = "0.14" +iced_widget = { version = "0.14", features = ["wgpu", "image", "canvas", "svg", "lazy"] } + +# tag and cover-art reading +lofty = "0.22" +image = "0.25" + +# visualizer wgpu +bytemuck = { version = "1", features = ["derive"] } + +# concurrency +rayon = "1.10" +arc-swap = "1.7" + +# desktop shell deps +[target.'cfg(all(not(target_os = "ios"), not(target_os = "android")))'.dependencies] +winit = "0.30" +rfd = "0.15" + +# android shell deps +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" +ndk = "0.9" +ndk-context = "0.1" +android_logger = "0.14" +log = "0.4" + +[profile.release] +lto = "thin" +codegen-units = 1 + +[profile.release-debug] +inherits = "release" +debug-assertions = true diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..a38c8a4 --- /dev/null +++ b/LICENCE @@ -0,0 +1,12 @@ +This is free to use, without conditions. + +There is no licence here on purpose. Individuals, students, hobbyists — take what +you need, make it yours, don't think twice. You'd flatter me. + +The absence of a licence is deliberate. A licence is a legal surface. Words can be +reinterpreted, and corporations employ lawyers whose job is exactly that. Silence is +harder to exploit than language. If a company wants to use this, the lack of explicit +permission makes it just inconvenient enough to matter. + +This won't change the balance of power. But it shifts the weight, even slightly, away +from the system that co-opts open work for closed profit. That's enough for me. diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md new file mode 100644 index 0000000..1a829b7 --- /dev/null +++ b/PRIVACY_POLICY.md @@ -0,0 +1,21 @@ +# Privacy Policy + +**Yr Xtals** does not collect, store, or transmit any personal data. + +## Device Permissions + +The app may request access to the following device features solely for local, on-device functionality: + +- **Music Library** — To play audio tracks from your library. + +No data from these sources is recorded, uploaded, or shared. All processing happens entirely on your device. + +## Third-Party Services + +Yr Xtals does not use analytics, advertising, tracking, or any third-party services. + +## Contact + +If you have questions about this policy, contact: **[your email]** + +*Last updated: March 1, 2026* diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..82426b1 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +local.properties +.idea/ +*.iml +captures/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..eb5687e --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,76 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "org.elseif.yrxtals" + compileSdk = 35 + ndkVersion = "27.3.13750724" + + defaultConfig { + applicationId = "org.elseif.yrxtals" + minSdk = 28 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + ndk { + abiFilters += "arm64-v8a" + } + } + + signingConfigs { + getByName("debug") { + storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore") + } + } + + buildTypes { + getByName("debug") { + isMinifyEnabled = false + isJniDebuggable = true + } + getByName("release") { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("debug") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + create("releaseDebug") { + initWith(getByName("release")) + isDebuggable = true + isJniDebuggable = true + matchingFallbacks += listOf("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + sourceSets { + getByName("main") { + jniLibs.srcDirs("src/main/jniLibs") + } + } + + packaging { + jniLibs { + useLegacyPackaging = false + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-ktx:1.9.2") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.documentfile:documentfile:1.0.1") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..dccc446 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +-keep class org.elseif.yrxtals.NativeBridge { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..06e9480 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt b/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt new file mode 100644 index 0000000..81140cf --- /dev/null +++ b/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt @@ -0,0 +1,137 @@ +package org.elseif.yrxtals + +import android.content.Context +import android.util.AttributeSet +import android.view.Choreographer +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.SurfaceHolder +import android.view.SurfaceView + +/// hosts the rust-driven wgpu surface inside an android SurfaceView and forwards input through jni. +class IcedSurfaceView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : SurfaceView(context, attrs, defStyleAttr), SurfaceHolder.Callback, Choreographer.FrameCallback { + + var controller: LibraryController? = null + + private var handle: Long = 0 + private var frameCallbackPosted: Boolean = false + private var paused: Boolean = false + + val viewportHandle: Long get() = handle + + private val density: Float get() = resources.displayMetrics.density.coerceAtLeast(1.0f) + + init { + holder.addCallback(this) + isFocusable = true + isFocusableInTouchMode = true + } + + /// allocates the rust viewport bound to the new surface, sized in logical dp at the screen density. + override fun surfaceCreated(holder: SurfaceHolder) { + val s = density + val wDp = holder.surfaceFrame.width() / s + val hDp = holder.surfaceFrame.height() / s + handle = NativeBridge.viewportCreate(holder.surface, wDp, hDp, s) + if (handle == 0L) { + return + } + startFrames() + requestFocus() + } + + /// reconfigures the wgpu surface for a new bounds, expressed in logical dp. + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + if (handle == 0L) return + val s = density + NativeBridge.viewportResize(handle, width / s, height / s, s) + } + + /// drops the viewport on surface detach. + override fun surfaceDestroyed(holder: SurfaceHolder) { + stopFrames() + if (handle != 0L) { + NativeBridge.viewportDestroy(handle) + handle = 0 + } + } + + /// pauses the choreographer loop while the activity is backgrounded. + fun onActivityPause() { + paused = true + stopFrames() + } + + /// resumes the choreographer loop on activity foreground if a surface is alive. + fun onActivityResume() { + paused = false + if (handle != 0L) startFrames() + } + + private fun startFrames() { + if (frameCallbackPosted) return + frameCallbackPosted = true + Choreographer.getInstance().postFrameCallback(this) + } + + private fun stopFrames() { + if (!frameCallbackPosted) return + Choreographer.getInstance().removeFrameCallback(this) + frameCallbackPosted = false + } + + /// renders one frame and surfaces any pending picker request to the controller. + override fun doFrame(frameTimeNanos: Long) { + frameCallbackPosted = false + if (handle == 0L || paused) return + NativeBridge.viewportRender(handle) + val pending = NativeBridge.viewportTakePendingPick(handle) + if (pending != 0) { + controller?.presentPicker(pending) + } + startFrames() + } + + /// translates a single-pointer motion event into the rust touch lifecycle, in logical dp. + override fun onTouchEvent(event: MotionEvent): Boolean { + if (handle == 0L) return false + val s = density + val x = event.x / s + val y = event.y / s + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + NativeBridge.viewportTouchEvent(handle, x, y, true, false) + requestFocus() + } + MotionEvent.ACTION_MOVE -> { + NativeBridge.viewportTouchEvent(handle, x, y, true, true) + } + MotionEvent.ACTION_UP -> { + NativeBridge.viewportTouchEvent(handle, x, y, false, false) + } + MotionEvent.ACTION_CANCEL -> { + NativeBridge.viewportTouchEvent(handle, x, y, false, false) + } + } + return true + } + + /// forwards a hardware-keyboard key down through the rust event queue. + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (handle == 0L) return super.onKeyDown(keyCode, event) + val text = if (event.unicodeChar != 0) event.unicodeChar.toChar().toString() else null + NativeBridge.viewportKeyEvent(handle, keyCode, event.metaState, true, text) + return true + } + + /// forwards a hardware-keyboard key up through the rust event queue. + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + if (handle == 0L) return super.onKeyUp(keyCode, event) + NativeBridge.viewportKeyEvent(handle, keyCode, event.metaState, false, null) + return true + } +} diff --git a/android/app/src/main/java/org/elseif/yrxtals/LibraryController.kt b/android/app/src/main/java/org/elseif/yrxtals/LibraryController.kt new file mode 100644 index 0000000..32ebbb9 --- /dev/null +++ b/android/app/src/main/java/org/elseif/yrxtals/LibraryController.kt @@ -0,0 +1,173 @@ +package org.elseif.yrxtals + +import android.app.Activity +import android.content.Intent +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.OpenableColumns +import android.util.Log +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.ComponentActivity +import androidx.documentfile.provider.DocumentFile +import java.io.File +import java.util.UUID +import java.util.concurrent.Executors + +private const val TAG = "YrXtals" + +/// owns the file/folder pickers, copies picked content into the app cache, and forwards results into the rust viewport. +class LibraryController(private val activity: ComponentActivity) { + + var view: IcedSurfaceView? = null + + private var pendingKind: Int = 0 + private val main: Handler = Handler(Looper.getMainLooper()) + private val io = Executors.newSingleThreadExecutor() + + private val openTreeLauncher: ActivityResultLauncher = + activity.registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + pendingKind = 0 + if (uri != null) handlePickedTree(uri) + } + + private val openDocsLauncher: ActivityResultLauncher> = + activity.registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + pendingKind = 0 + if (!uris.isNullOrEmpty()) handlePickedAudio(uris) + } + + /// presents the SAF folder picker for kind 1 or the audio multi-document picker for kind 2. + fun presentPicker(kind: Int) { + if (pendingKind != 0) { + Log.w(TAG, "presentPicker($kind) ignored; pendingKind already $pendingKind") + return + } + pendingKind = kind + when (kind) { + 1 -> openTreeLauncher.launch(null) + 2 -> openDocsLauncher.launch(arrayOf("audio/*")) + else -> { pendingKind = 0 } + } + } + + /// walks a picked SAF tree, filters audio children, and feeds them through the export pipeline. + private fun handlePickedTree(treeUri: Uri) { + val handle = view?.viewportHandle ?: return + try { + activity.contentResolver.takePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (_: SecurityException) {} + io.execute { + val root = DocumentFile.fromTreeUri(activity, treeUri) ?: return@execute + val audio = mutableListOf() + collectAudio(root, audio) + if (audio.isEmpty()) return@execute + val uris = audio.map { it.uri } + main.post { handlePickedAudio(uris) } + } + } + + private fun collectAudio(dir: DocumentFile, out: MutableList) { + for (child in dir.listFiles()) { + if (child.isDirectory) { + collectAudio(child, out) + } else { + val mime = child.type ?: continue + if (mime.startsWith("audio/")) out.add(child) + } + } + } + + /// extracts metadata, seeds the sidebar, copies bytes to cache, and pushes paths and art to rust. + private fun handlePickedAudio(uris: List) { + val handle = view?.viewportHandle ?: run { + Log.w(TAG, "handlePickedAudio: no viewportHandle") + return + } + Log.i(TAG, "handlePickedAudio picked ${uris.size} item(s)") + + val total = uris.size + val titles = arrayOfNulls(total) + val trackNumbers = IntArray(total) + + io.execute { + val metas = uris.map { extractMeta(it) } + for (i in 0 until total) { + titles[i] = metas[i].title.ifEmpty { displayName(uris[i]) ?: "Track ${i + 1}" } + trackNumbers[i] = metas[i].track + } + main.post { + NativeBridge.viewportSetPendingTitles(handle, titles.map { it ?: "" }.toTypedArray(), trackNumbers) + NativeBridge.viewportSetLibraryProgress(handle, 0, total) + } + for (i in 0 until total) { + val art = metas[i].art + if (art != null && art.isNotEmpty()) { + main.post { NativeBridge.viewportSetTrackArt(handle, i, art) } + } + } + var done = 0 + for (i in 0 until total) { + val cachePath = copyToCache(uris[i]) + done += 1 + main.post { + if (cachePath != null) { + NativeBridge.viewportSetTrackPath(handle, i, cachePath) + } + NativeBridge.viewportSetLibraryProgress(handle, done, total) + if (done == total) { + NativeBridge.viewportSetLibraryProgress(handle, 0, 0) + } + } + } + } + } + + private data class TrackMeta(val title: String, val track: Int, val art: ByteArray?) + + private fun extractMeta(uri: Uri): TrackMeta { + val r = MediaMetadataRetriever() + return try { + r.setDataSource(activity, uri) + val title = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE).orEmpty() + val trackStr = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER) + val track = trackStr?.substringBefore('/')?.toIntOrNull() ?: 0 + val art = r.embeddedPicture + TrackMeta(title, track, art) + } catch (e: Exception) { + Log.w(TAG, "extractMeta failed for $uri: ${e.message}") + TrackMeta("", 0, null) + } finally { + try { r.release() } catch (_: Exception) {} + } + } + + private fun displayName(uri: Uri): String? { + val cursor = activity.contentResolver.query(uri, null, null, null, null) ?: return null + cursor.use { + val nameIdx = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIdx >= 0 && it.moveToFirst()) return it.getString(nameIdx) + } + return null + } + + private fun copyToCache(uri: Uri): String? { + val ext = displayName(uri)?.substringAfterLast('.', "")?.takeIf { it.isNotEmpty() } ?: "audio" + val out = File(activity.cacheDir, "${UUID.randomUUID()}.$ext") + return try { + activity.contentResolver.openInputStream(uri)?.use { input -> + out.outputStream().use { output -> input.copyTo(output) } + } + Log.i(TAG, "copyToCache wrote ${out.length()} bytes to ${out.absolutePath}") + out.absolutePath + } catch (e: Exception) { + Log.e(TAG, "copyToCache failed for $uri: ${e.message}") + null + } + } +} diff --git a/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt b/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt new file mode 100644 index 0000000..7094ee2 --- /dev/null +++ b/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt @@ -0,0 +1,43 @@ +package org.elseif.yrxtals + +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +/// hosts the IcedSurfaceView full-bleed and binds the LibraryController to picker callbacks. +class MainActivity : ComponentActivity() { + + private lateinit var surfaceView: IcedSurfaceView + private lateinit var controller: LibraryController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + NativeBridge.initContext(applicationContext) + + WindowCompat.setDecorFitsSystemWindows(window, false) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + controller = LibraryController(this) + surfaceView = IcedSurfaceView(this).also { it.controller = controller } + controller.view = surfaceView + setContentView(surfaceView) + + WindowInsetsControllerCompat(window, surfaceView).let { c -> + c.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + c.hide(WindowInsetsCompat.Type.systemBars()) + } + } + + override fun onResume() { + super.onResume() + surfaceView.onActivityResume() + } + + override fun onPause() { + super.onPause() + surfaceView.onActivityPause() + } +} diff --git a/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt b/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt new file mode 100644 index 0000000..0f0572d --- /dev/null +++ b/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt @@ -0,0 +1,32 @@ +package org.elseif.yrxtals + +import android.content.Context +import android.view.Surface + +/// jni surface to the rust viewport, one extern method per fn in src/android.rs. +object NativeBridge { + init { + System.loadLibrary("yr_crystals") + } + + external fun initContext(activity: Context) + + external fun viewportCreate(surface: Surface, width: Float, height: Float, scale: Float): Long + external fun viewportDestroy(handle: Long) + external fun viewportRender(handle: Long) + external fun viewportResize(handle: Long, width: Float, height: Float, scale: Float) + + external fun viewportTouchEvent(handle: Long, x: Float, y: Float, pressed: Boolean, moved: Boolean) + external fun viewportKeyEvent(handle: Long, key: Int, modifiers: Int, pressed: Boolean, text: String?) + + external fun viewportApplyPickedFolder(handle: Long, path: String) + external fun viewportApplyPickedFile(handle: Long, path: String) + external fun viewportApplyPickedFiles(handle: Long, paths: Array) + + external fun viewportSetLibraryProgress(handle: Long, current: Int, total: Int) + external fun viewportSetPendingTitles(handle: Long, titles: Array, trackNumbers: IntArray) + external fun viewportSetTrackPath(handle: Long, idx: Int, path: String) + external fun viewportSetTrackArt(handle: Long, idx: Int, bytes: ByteArray) + + external fun viewportTakePendingPick(handle: Long): Int +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..532523d --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + YrXtals + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..101c422 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..047bf2c --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "2.0.20" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..78add5b --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx2048m -XX:+UseParallelGC -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true + +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1ef00a1 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..aa5f10b --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..58848d7 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "yrxtals" +include(":app") diff --git a/assets/BSkip.svg b/assets/BSkip.svg new file mode 100644 index 0000000..738b1c4 --- /dev/null +++ b/assets/BSkip.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/FSkip.svg b/assets/FSkip.svg new file mode 100644 index 0000000..3c62c33 --- /dev/null +++ b/assets/FSkip.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/Icon.svg b/assets/Icon.svg new file mode 100644 index 0000000..8a6cd93 --- /dev/null +++ b/assets/Icon.svg @@ -0,0 +1,262 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Yr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + t + + + + + + + + + + + + + + + + + + + + + + l + + + + + + + + + + + + + + + + + Z + + + + + + + + + + + X + + + + + + + + + + + a + + + + + + + + + + \ No newline at end of file diff --git a/assets/Loading.svg b/assets/Loading.svg new file mode 100644 index 0000000..a3e7f7d --- /dev/null +++ b/assets/Loading.svg @@ -0,0 +1,11 @@ + + + + Loading indicator + Nautilus + + + Loading indicator + Cog + + \ No newline at end of file diff --git a/assets/Pause.svg b/assets/Pause.svg new file mode 100644 index 0000000..3ce6377 --- /dev/null +++ b/assets/Pause.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/Play.svg b/assets/Play.svg new file mode 100644 index 0000000..195f607 --- /dev/null +++ b/assets/Play.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/Settings.svg b/assets/Settings.svg new file mode 100644 index 0000000..0aa7a42 --- /dev/null +++ b/assets/Settings.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/androidIcon.svg b/assets/androidIcon.svg new file mode 100644 index 0000000..f1646b9 --- /dev/null +++ b/assets/androidIcon.svg @@ -0,0 +1,260 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Yr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + t + + + + + + + + + + + + + + + + + + + + + + l + + + + + + + + + + + + + + + + + Z + + + + + + + + + + + X + + + + + + + + + + + a + + + + + + + + + + \ No newline at end of file diff --git a/fonts/Inter-OFL.txt b/fonts/Inter-OFL.txt new file mode 100644 index 0000000..d05ec4b --- /dev/null +++ b/fonts/Inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fonts/Inter-Regular.ttf b/fonts/Inter-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ce097c8256a78e54c70e401c670a7fab0a138621 GIT binary patch literal 342680 zcmd>{1(+1a+Ni(ko|f6g-B}1uaCZsr?zX{wVR3hN4Z$Ti1Pg%#3l=0egd{*9ga{B4 zBsgL3TfMW(LUPW@e}22qy)(~SU0q%B)mL9tS69z;ixQD|I6jez<;quR_hqY#B1R(- z^I64OHS0E?_AElwhlxb>c(-ER`eo0*H?gZo=k+2jXVt8mD{s<{Z99mlC*;zqZoM+~ z8muexnEm(IZ_&DIXjt{h3-5^>FD=q$Li^D0FkKqQ6R~yMcj@2m%idq@6Emcr7%9?p zXdBul?SgtMRM%u(!F(PaIs&$6e)3+ z{asx{`-KIMmq)};MWm2!pH9Q~9Lq`AbXdw~Sl-p_q|zuJ;)`O~+un@L9Y+I+6AoyQUak3Qq=c*%wLVxo?Zn zJuG~HqUL?ucj%=ky<@M?E~+#sGD#A#y|jiZLkgW*=cPDiD9SN*1;Vr@k(ZCpipvp8 z#LDM8!T$S^TjPXGGUk#(EM2xCzH4GcB#@;MI+6a=saw6S>=TKIh^)JN#h68bi}f*I zE$b|s5HzOCj-$g6xGzmonN`e2xJ}JAxb4l}xc$ujxC70BxP#4E zxO2?wxHrsaxG&5)+8_$;kHxH)}!aP#{L;TG{#!L8=2 zhFil|2e+QDD{gmRcibMnp18eyl68-hE`Hy3xFZyoMN-)7v8e1~z5`M$&b!S@sH zP2aD$cYSwp@B3bgvXq59R8a>TWl%Ti~{`d*Jr6`{0hSN8*mN$Ky`2r{FHNm*IY3 z>)bxI_u%fc_u=lh58xiPkK&%NPvD-hFW_FXFX8@V|0<>CSZAna)hyway{j&z&Qm+8r&^@?8JY}{{`+T|5@CN{)@Pm{NLmL6olOd)ePz(W>Bx7fw&WczQw&CbRYLg z5TjhM1PgAmVCoWFFu0(Y!DWMyEx1B(1u=pv2Uo^lCAbRV^@8i+)(>t#57j+aq$r>d zNo@aY-?VSpx9wlh*>{vODQI%gl%T0W(}Jc4tqa-`v?J)-p!?)Q8_{leNGDR<_)?EQ zK0p3x_Mm`YNg}&Lz|ZJlHwpMnDQu??_4+WUh{k!=ReXoEGpa ziDixo_!CJIvs=KQSW=k**%;}vnOOqiNu{(AC|4zu493Agcyjzp0{#^E1G-WvC57>B zAUqZRN&$arNnp^Dy1pun#4|Dl{Amf-X9^u2B5$jxveHR1bvh89Uec?L0smW)QwBY8-bd=WA zzA*CjVw*p(&FyXT1j6%3p}>B@z&0{oO>a-q`8n@apWhVZS$_)!4R|4y1gQ{#95#j&!63Wyr4=se0lM^?V_oBw-wl ztXqhL6E~C?VV-pFc(o7nWa~vpFL^VhomYo$l-`A$bCA9lX?jY3Vuuntl$>7WUXjqg z-hKys;T-Et+zMXpdy!hF)g|{rik{@xR_e1K=Eu_43Be}Qs zTIe6eDMy@er06Q|;%om}86lE|lDd<7xYtrzqmg>kw%d~eZSd*w7#_zl_@*2zPcCmqJ_dao8{uGElRhRZV4SH zvIpxBZA&=`?MX?IeIy)ND+J={-r1d0y|9a@*3mi&c_W8N`_VPmx`?!0-I8^YN!!n> zI=#wS_s!OxR9c3&iPgYMt!tyD43RuYrU{85+3S+)UZAB3l|G>DGL*K{y)b6q2xYGV z)>qq$*$bV7(0c!lhTcrolhV6FPTaQWxen#)klvnDWvE4@M#9?{Xo4gJl}*xi~V@A8c)QB=+$Z{!>^r#iHHbto1iWmLL1C3;=HzbZ+OwPB2;dTfgv z-9tFzXqgM518uRotz)LFMcnSviE!QfLumKjY&#I9wXgf}o2?kx$2t)2T{IHePjh*( zb=&5m{+;yB zPm&`hRsq@Y8pR~8U0n)Chn<&j>tEB!i}SH;uuqYe?O$?P5(g>Spz$60AEloz&pM4R z<3dVl=#+y-P!;N;yZ0g%=;MEqYYlR)iTKv%ZKb<)4}JU%Y*)hn4%T(#x-JU?TkU@h zu2~y;VJ{=Yozt?xJrU{WI;ElWHWZSEZYcDI_RyWMQ*f5LeiX4l$J2TItGGIjk%anY z*V~8#aL8CGiP+vD+^3J>KMptH5!@myAxsN|S7iGw`|V(ckyhri-C$A&!Vj1&rJ>mw z9nXR_FdvqK0k#*1{K)ra#Dd5)k#d-grSZQ<#LbxLd_PjoJ#>--ashJLS<%gSn1pPB za(r(`H1e&Au|Hf|_%ufYTl`i{+Tbs+sgE^}W6dIu`}(2JKN0aLI&7Qd@GayDwGr); z@t zh;*LJ#Cc8jKw8(JkH$k+QP|Wo3*!>|_Lx^`taQZrJLr6RLx{w;l1P{TFq27}0=xKU zB$r}VB6Z-kIN!-S>k8=Ye zMU{vM9ml?jp4dA1XzL!pc;TFk4D<5%{pHoDIF^#0e+S=l>Ei1ojsBOJ8?$_;tTc*= z?~H{0FEW?14VddY26`KlLCB{0D6oy%|Euj^)WeQ1p^S-tSwDXY33Vq)sNOoi2ja|T z`@ctXKAH zK&QE?W%1RH3G%o?n-LDZoeG+@dLM zvlco@LbE)?r)~6{BdrWKW=HuWF0r4|NE`9c%l|0tc<&#h+aV^_k9t31pQrOTj>U|p z{hV9o`ksOAC$FgL+w|Ig%wzxceqGj^d?Zz4$n&?$bgoIL-ID(KkUY;&FWTg_aY2X0 z=f&2U z|4rcaU9T_4?5lsnKlNY6(Pg|zh$!9za)_NKb5P4||OD1P~f%zopd8)&;x?35VLoLwPmmkBY#91D(>|e&w`NgHIip1yoLvN8Iww01= z2xReX;X0uY`OTNS&OXWO`&s&W*C6GPvn?=p@?_=uTi4xwkG!^XO;Le-CSaG0QQmd} zVJ7*!$95a_=tsKyq|d;$D{29MEBv9>AjW~HV}G$VBS`yqZ2LbG=Vu9z zJRe2u4QyAj9SrR_A036&f$ciBr{D{Y)d=`GZ$;FDldzWYERU}x^Vmz0{q-?8;)<_7 zb-5#<=JtPk-1*a3WobmK(%y{2cC$2P?V-F?fw8KBOO~Blx+do_|!MkdbrnOGZoMGrGX#WGp{hh~vml`F9Q%Wx~Fsu79oCC@#ex0>?- zwy`}B1AQzYm$LEy8|maQGD{RzQukF>PUOUs!1>Z*)2cgk2JmxRGgdons|fxhrZQz7!0x3B$Aq$yP7 zoYxd8TE%4z+AhnmnJY|^TxM#?YnFkO(!qRR8flEC(%7gat<3I( z4VHJ!Auvapdvuj>QBP}(|^P9rXKkes`;!)!k z+nsOXHj$>UF+|q6nh=>5O%2)VYI3}S^}2g@9_kiFE#wH{I8ev$qVTVmRj`$H{0;Uu zGSs<AvX?jz;_~&;53rVBbJnYZ5tK-|I1d_|NleuTvy*i)%{c7FSGQ$ zFMYp8&!ua7Yl2~1FL~>AtRIEx^&)R=hjkme_ze2|v;c$5Tb!4`sPKxYeZ7r&uj4g; z)ctWi=MLQ0{;PY$-n=}$&?Q@%(yC+FN(l?HgDFo}$-$qtC?xQr<*U-_|&suL>x9V$LeNC&^m{?!XZQ_-WzbhPy+Sgn9(26%<9>2l}j=bK9 zI>tFTGF~QG!gbQ?kcU-8WsJg{A`3YqXQZF%|Bz@_M8SL^qt=yOR%P_=F>w9v=`N6m zK5oR5oMv%pqwy_M=>sHXtBKrSsnQWQ9rKO#9{1&h34dc;85?6<8T-b#GK)FLAmoom z??<7-t=#;^We5G1-@7o^@1eJ}Q#ki@x=?n1)*~7Q=)&=V_8ivNo-)SuBwa7Y1AYBX zzqa$ymZo&&TE0E^Ll;{%*CIlG!nH>wsjb6pAzd}8Ywee|RyX{S_BPl0L^>0vA(Udx zv#xiYk&w2pNjNrP-IwOx+Js)8xDUS|pI#^2iyf4g$zETLOt16k5y(sX852m z5{Gut=K`k(ZL(kH@=uBl$kkV`{pxkkprPnYucHLgd-tdHJ#5xT8S^*j{;Ahwy>-`^ z{gr+i)knQCcn#%K-fL@LdYwzdT9^GUgx45y}so2Z^mD( zlc;raz5b;8zPF}DoBwGn(D_G=qcO$=Z*1e&v%W3VQ|n2~8@cAx$w!;(dmQ|BN3W?j z^TvF=El8NQ2YqeplcDwy+HV)O%f2~Lis-drZ#`CzCy`^{Ua#!HxMQ=vh|XqcXfu8< zgs%A=l3fNpvR>`A!vx9UUo1I-KcYz*E3SJ-Yt`a@$6{Mhk){x8;^1 zDbiQxTic{3*I@kPPV>h=wx7X8I0QQb{(KxOhU&5e!XL9=1V7;~oEWUauh{_n15fAm zM`yOrp)u@*;h>M}u&o?#%k~URgry?S;z36IwSi-QG==&2v%_RqqvcSWq*fHhbk<2W z!$r6P<+w%!eGRHXXTw!4$)c}cIcHqrT*Wf0aP7%2OC!z zO1u|IMV@Qfbv?QwQ*5?RAqOM}9i9?CB|e`Qp~TVghq6rw7U;O7m9n6(eRaJH5{^7t zznbT*B6(3h5LS`6GvQ!hi~sp^xXN)w`V-I)HUnk7C*5CKo2GjLVywZHb~0i8y->1ULe57ObU2|dXFEw**dO>z($U90UByQ^+o;=Hsh za*aS;jrdxx8F>3!7!T0D_Im`vy*N6)*EaME-A<8f4(R{IT}W$|Vh#)VslQjxGJ$P> zWJ(SdbzAGQ^jZb`sSL$$v*nMaA}^`yA1z@X=(zLrx&`uIgRAU!)%}nBv<|iYrbpr( z|C-|onWJQhnxhzvkr9x0Yk*5ee~z;T0xi955!Od4f!+e+&0bGO$vEFeGMsU-uQ@@w zYP9ZDGsb-6iW(2}K5{tBdqaHt#V}4%##|Y%mdP;AlS@^oSPfzHKvB7v;ZRtZD0O9&D&RnCyr^GDbF~V*K8+w4U4`>DC=uw(8(go z(VugX=>JKp{7?;OJ2ePq0XC^N<#za24esjecRzA{0J`@S(3opFaegpCO87ufa5l0+Qp$6 z41xu)3ogU&{7XAGpf_g*tOZuPT=e8th1M_-X7V3#%J3HlWcTkA31S=yB3%&af=Cy% zAHIQS{I`1;JmgQ3XgC&j6YC1FA3HW=gc496y241951+zKkvIzI7jfwCaVi7pNxcETCB8Tlvvc*)u30pi7@KJgC1dH6*nK01$| z3i1Q>i{AzY0ewFHM%V*i!mlC;Y+%(d!D!%GBf(LAR#hIF^LKOy(gOW95#v&#_AnIY z!iW499yt?JeiA<*cM|rK=>3i`9Ol7hU>rz-zLKJ^r06Rt`bvtvlA^Dql$-Pbf42+) z{_&oy7EFUX@P|n9#E=7!DLFDF?+ugT1K150;5U&J$eJQ4AZv<-&==;y$8ZrIi=<2n z)HP)lpuJPl-YIGCl&b+7Nck1q6-ng+@}xqZRLGO69Si~VmkRx*It<^!BazhTFLe^g z2^F9P^nod`63}((F9BVrA#R#9P!PyBO$X=))HlseI0LkAnh24!$eI>e(^iC*&=;n{ zD%cAbfqI8f?~t@m2x>xm7z%UYL--u7!efziaUnC5g2vDTXs2|vQ#!_pbhm|DA&?N> zg26BwHozgc0uM#rDgpIjIc$Z~@Uut;fn<;ys9y%^mth#pf%Wi(NJh%dNVyru!&0E$ z8IQu(K;1LZ&Y7A+Z=jts(axE6!v(k_+}efIfNrzofvKX|hy)Xb!z$ zGJF8L;R4*@85I^}gc496y25B!2*k^A0)Bu$L~@d6&Kyu4nge;}oGg+n7-)xF`GD~& zS5xQ=BjKJ%?h-)Wxyd^>ZIFlYED!xX5B)vQ9g)1V;l4<|AV?2Ip$>F{5%4~I0%t|? zR|n)OkR8xv0d!daT^4izT^1|^=(1pY7z%UYWB6R85N%p$AKZW!B83_63%?EJpc(Xn zNpJ#?rwHLi`U5sl1iLGWTt$(qC~_4=uA<0QbPF7V>+npZSOUlfWuXc5gbA<=K7q4v zOaC1mQb1m)0`I~Am;q~HKYRoCd6oz|Ein$3zz&g;MW8lx1o}nEd9WF<&r%#K#j#Qx zE5)%=94oa3ei12+-b>emE-(rfz&7{-&`D`@QU;xrL3d^P!cF=J3dGS`Jo!L0rGx_yvxOcP9o*wKqe>&4WJJ&4wa{`l&24rrw^2; z4^*HJR3Kgj;#DACg>Enk-iJ+a1g^moo)i-gvOsAdZbjl&90QABJCJ8Z@~nh>mC#3} zop1(z5~)nvR=zD##e{@_uB)KyD(Jcj_FCmGOEfNoKw+o_9bgy`uPX7X60hpd@KU52 zI;@rr%0d(92@_x$d;(|TmM~3*6p$CHKr2Ab>d09mT%;!bt!56OztyC_)kMd&D!_4( z+FfBZEQF5$o2~r=e+jpMbagTU=l42WMd~($XCn2Gw?1uFpEj#co7KlQ>Tial@EtrA zX)qH=(-0jrTrJXwwr*4auz^O{KqG9RF*eXRH4xsoI<$qsK-$LZ-~fCL#BD;{Cgjni z6d*^F?l2mVy9shPLGC6$2tS2_^)3P-T1mtRoTrH8S`v9BiSPbeyXBY{{)RFWZ zPlE;LO+N}Ukhi*sVJ9sM6JuYO1QqU6m!c;J!#t%Iry* zJ-Y$@z2|e0aO&F&J@ukpdM$u$@CDoe#+Kgnx!!LB_37OV1_845DFoF4z4qzPlZwc@ zFM0Q+K7A>(ZzmW5@52^22De1|VH^FD0ea|1p8d$PUoV&d)V&{d???Xqeik8H=}+AL zxdD6W-wKAod$0+Phzy`T25bh}W588-EHV(?3`92r(apey&>hACx*7P9$ROH#5bZjs zHgp8qY!GcWxR1yX6IQ`qxClS-{HT^9!w4U?5{Nsz9&~{ZMMk6n>M&wH+!h&0yphQv z7gU7i&>JShIyk_sdUQIfE!-3ty+mY8BM5_Wp!d_xc);z6V0a7A?KJw#G~}2@Kb|%hh&zq) zrhN_fM5bfg)6+o_s0qk49hs(+?{wl$C*SGhJDq%Iknargn?Zgv_5=1j1ACqs1lZin z!ayBnwgKuea|W!2JwP9x`K!n*8%RHEB+yo~w!(3sKh1h7GCMwGh7wR8x&m=$&x6hI z3rkj6p%kF|IcMRP$lUfY6sY4|>|yTbK>N&nEb?9x=n3QCJ&}3wAPmOv5U!Lk9+tvR zI0dxl`w=4ZlR_@22+aXm=8p#Co{uc^DPsZcyr48R0`goy84HfW6Oo0Kvyk>$cvxf+ z?XigVScLu-QU68MX%YD>qW+6rAfLs=T}*wJSdbl7iY(0xl(`gnmLbnF0bt{+O(+M*yPE#D8e3TN4m1VyxP~@b>w{F#9yq@CzQ{W4WZkimf2hgfxP_;62`zvI0L`&yu=Ey0Je#If&RZ}3!`C| z$O-gyqBx+_6V&}AHh7Z$bFv7atCOKXTb&#aUx=Kd|D2*foazYu0llB@0;7O3PE*Ed zZ0}5B$N|j(J3F&STv_PG%R)cXeUesCZy(C$ArhJ)~n$WJw(9U#lk*xS#v@lAAl z^8`E)xwTH@HhRB(0G^2a(gqfb+(`!5#vOF@Yi=kFwV)M*!7!Kx)c4m-BERJT+LpN51#S_a6D)+XrXi2K+Ak zOc@eE2FMTPpdPe=UN8z~!3x+0hv6%r?e4!2c|hNL5CVCDv=10hA9R2LFbNg`C!{M0AF+upIUR`9<)2Kye@)RQo!gB=SxTsi}fu}6SCSB|~ zqT(ch`M`5xSe02K%S|K zz+zEpcsgO4ynx=)d;`dv78%k~UPx@9jF1iRlc;pHfM-#rr{3v1iFzv-W{b*z{xg&S z%E>SfcEJsJDJmn+hs=l!87o6m=n6w&IxGk3o$+({8g2u&k|{1=Q<;#1HBOa@dS&Ve z17R{Ccc#s70FWmWvSoTIDl_u1-l;O@f-+DS+CpC#5A%V#WZnyB;Cpx|DvJZjAv;iy zEHxn%XooDLVJ^^SS

Z+CMAuXQljXCJYB`EIaaMZw%;`^-lFR?eg|1Q8~K96Hz%^ z!B|)dv{5e7<|+pCms}$N9pri}Dz_g}LJlYewV^fi2GZxA2kT%rkbmy);DM+-79@qN zK)!jZLrdrZ3t=Z5hburid8uz+;^l1))F&@;~|fUTlR;V*@3rI5W8^(sw2ElnGh{#jI+if~6%*_lAz?=*yuMU_K$<%nON zaiaW3qF7f{6&N=wv;g{S1^QuyDX<7O!X7vY-@>o(QdGq_kOp!CeX(Laz>X?T2JE8Z zMYt!b67{K+32H)Lpp7dbXXW@nyH~yp$W|paaJ&k(RF$@@+8I6r?5f&CQPpX~8pvCN z_N!?_5~u+m0%g@=46HRkRBhU<_AjF9q=U+ETU6cjP#?Y$RgX0FXuJAJp(pHxo1z-L z4WU5WHaIS-p#ijOLu{AzKh=hkG4z1(uoQN}8MrB`nS$hy2P#7-^oQxN20nwY;hw1GenwX6)GqFUvI3eW-=OIuBWm9PiC1oRM!{zGZ| z(1K6{+QAT*0~_Hmd<&06y&DHIK}l!;-CztXg6(h;euS5zS|@>=Pyt#%AD99wVGn!> zzj8a=hBQzRYCt;}0&`#^9ENYEN70$tJQC&?)3HgAs zy0(UaFca3n0k{MYM0E>>x8NOU3gIvjmILv+5w9EZx)ZNE@wyj-y3iR$!hHA?&cSU_ zVI~kSjCf(h3nN|_@xq7~wiS-U_wZa)kA#pN-hrkN4ijNHd69C){8dl)fGm=Lf8uA z+3R~zy~(q8YM|`iwV@->Hoa-v-aCOd>`fc?Azq*4K>PNo3AAsY!7v*(zyY`f4@C71 zhOAHq8UuZ=?|4`PpTXB~PgFk#D7#+)s0yJ#efm+Ke$=PmZnywXPeEWYVYJdWCG$03*hvv{5Cc_6no&&DHLs0``Lq;eDwV@*nh1swUsMA2| zG!R(_`5_%pf7WOfYqV<6aF_?1;V4l4V9Fny9B8A#w9#P79~=(UX)xsv{uIb_FnSt- zo`#U;kV1e?hqQ;mFcT<$2(k`2Cu*n#si6SW2jUF9D{2_y_ps8ySUrq!e%J_@0c&AD zaNZfl_%NJv%JAmU6;8o@Q6qvN1(XBEmJ#EC{yBp4z=#WQN7P8l9f>YSW(H&&Ss5s2 z@ApfY5fbK>ef{Sod)aYa|2qwV_*ahc-v}1gb3i3lWco+J?G@y(z zdw{rOZiyP360*ZP&=h(DvX1=(kaZlgvi_{bb%)XL75pk{JTi?(pA+H&dYgcJ6K&Aj z4S=i@ufRi5lhEBHbT=8fCbxkLqNX6zlrKe1eGAa%H1s{K7f>HA?bLMiHX}Bq2GY+& zzL~V$tV*yA4vLyx0kDtRUyGVE9_GVGqUP2G&eN>Ps`q{nHIMOoUJckH>izUE73c@^ z$$x%tKxYe(Z2@+*09h8I^M%;bqJeqRZWK9}qlwX`@;_hq#IvhPJLN1rPe z0`~j?cKSgTcp_?LF{lc|;CE50`T+T_9t7xcO$$JdHH58g4P$}uwJ$}j!`9d30m@uQ zz1O3Y_3eT7-++B>C<=9;6OjLg_kecWumg?&?X&?~-blKQ9e_9=CV;NMu}z_(Hq&04 zKZWB!JvKiVwIv~B2lTxKJ#Og?BZ2yEL5?j4;1WC#wKW*t0_wSyxLd=4@NLYuwvB@& zfIQny!_T5VLY9w`0rlMOhc0kS)W_J?4(hw(7<>cBz0-vdC=8XL74(B?uo}pB=Slbx zUWj5nRDF^KXlvF()hEdC$rxBE>QnOmv_C8pwF`URg@4y{SOYr&8`_OryC;a+gT3w9 z1k`bFCZM0}YbWY6Y~?d-bALIYt@mSR`%eJ&wI975NC@cd0D3#n7)$Nhsp0S@eUL3Fvkyn z4#;u%p{UOjLwTqVZJ{@ehC8BIvs6bm2EZPVrUBaP82KGTXUC}5F=RT%`HnS9b$k@y z|AKb=0{wn54we8mdLkDLfjNMVPi%*8MX^SyP9A};fHF?i0PN=!dN_p)r?HpQRbU{@ zhTU)xeuAf>&T#w;$Io#541Mkld7do@m7o!H0P;FZ_&FQU`8oQ*xiLW9&XLdgSdbT} z)A$X+Q`8S7;d@a(T2L7}!UVwHeyRn?@>4HBKR?|N^>b=y4dnmxJyAD{0Quj_ z2DIU=S)y)pKD=EVszEbY4d;Qr^Gik`?3Zouv#2}R&>dvH(;cw8JIMB{57GlV`Lz#> zgJp0`)NhpY+b5##qPx5A!ih6BQIDw4qrUJS>;(GlWBSPBLeLFX0&V{!DPW6F zmWp~x{hp2#^$Z!F^@l5>_zkvtj?P|i?8OkEoIlC{ZT_+apx20gFab`B!SC4(=?0&R zp<+QQ;B7vtIJAa|@Dn^0!^jJh;Ab&-+JIp$7Q^R2a>x!PpgOdK?m(Wt@5QjlmnW?l zR!*1(gj-+3ZTLeB{$*^~qhJoKh70fmJQl+tfBv;&IOOY$fxYlh4E_mixalAtl!t~e z1jyfA2HV8&2La{!Dc4^b+QD2nFGf&m$OW^2x&@Dh+3=efv8aEnaM&S6Y~sWwUF?N$ zQ;ayZpfd~x@`{5zaqfx{mvZ9vg>!)HaU;Zt7az(&J?JV%d>5)g2N(|T19HZvzVRQ3 zksuCKh8eICcyeO`%1r13+9M(PBwP&WE+KkL)CfqQ=pdYj8}PdriHV;$5oCb;&>RK; z`b~UEj3l&Ul5ucCjHJk&v-cUHVpre28bsa~^Mh;*t`vwDamVbwZ9 zGLu5T&&3pOirnPofVMrm%M(wBB|W!<=W@pit15~;Dd8@SO6mD3<4fWBxErLB zdp_=|sAQgxyE7`O=PQp-zpqRQcb&LfqJ8h+OXTGm&vVOqZgE^gN=X@Sk9#%54EEeu zo*NrCHny7>+f9z$rp9K|VXqmn)vVtCixjr4^C%!iymA_mMOH$hKDz9NgkjmyA05WM zFs1uW)JKQaCrsN@)JKQaBP?T709zeam#_?Pgw-L8`%KXv9j`WF>7zp;)7By^-5X)t z`%)oqgmGU>rF|o;I$>$v2&+a|>Nmow62?8V7#|xA>G4624nc$~$${&WoILeEFZa8& z26brT`p;@_uT`y9rc!51Z7MaVc;VuyiW^1J7YQm7RQO2Y)rH3uI##Glp(Ob)=RcW$ zWBwuei$j|HL-M_Z%di7xKqn{-iSiH0d%94Pyc_c_$#W}Ybx7WjWN9CzX_h!BQN4r( z6TC=pE5VluCKWoCAWQrS@!pHqE?$DTcjKOnbtG27;32%>s+B*lE54$>Smrn8YO{}7 z&Uj&rF?y<(>I?Or%A#yJBFs}f$z8U#!;FLbZ`#$h;anjh65AYWjx)!b6U>R`By+Mk z#hhwRbCTzOCCaaVG_ zERcn=NEXWyw<75cnXLa`=O%O$zE+DQyg{q7)z~g?_pztgt9Yv#vogX;S+(qPc5i#K zz0#TB9Enj%70wE`u|w?#Jd1@fGGq%=F~4+>``vQl6a{;%;;|dUsnRTZ-1>IA4<)WpwK` zlC0(y^Aq!wdBEIeer9epKQ@n>ADJi3qvi>7ySdl=)ZA_EF?X8B%w6UgbBB4_{KDL4 z?l%vbht1E;BjzD@h5LcK(p}}QcGtLT-F5DIq%zIv<}4B37Pwh_?g)3nYdX##agE=M zyT(1^zVX2L-FRp`G9DXGjHkvkzv8X@i$Ubv6|Qw?SA$&dyO;8VTMbJ(pGJ|g5B4iYOi)CI!E6~QORm# zm$k#~iS`O-hI86WQQWFwm$7@=6YS;AIOp)|Df|ii3H|Z>@%?fAar}w=iP3;z9kGsz zSYKGov0lYi*W`|Cxwh-_%0<)lyFqTS8_SLD#&zSl@!dFX0_^jpThWbh6T3;=L|8*I zH@TYvd#L1AcB{Bm-5q{$Q~H&g%1!O2ant$@H^fcnzU5|dGr8&A%x+e{>1K1YyKnn_ zZcaCso5RiR=5h17`P{;8ez%}o$SvR&bBnk|-QsQux1?LjE#sDU%ewEl<=paa1-F`8 zoqko*t>xBs=eTnt`=2}Ct>e~p>$&ya25uv_vD?IL=q_|`xlP?>ZVR`i+uUvChPv;% zZQRywTlc2l;%$Q+{I+g&cah)GcHwq*yZBwVtJ}>Db9=ktZg;nr+sE&B`?~$y{{A3$ zkUQ8N;0|<$_=DYH?r?XgKb9Vc7>!1_W8IPNXm^Y|%AMejbI1SLZrzFQBzLkq#hvC( zb*H;C+?nnycecCVJ?I{AFS=j4U%B77U%RK>Gwv7earcya!oA?0bkDlS>?QV2_eb7^ z_>+6tAKSg{?s0!{&$*Y}EAF@MRsGgK_qzMNd&B*~z3)D8AGy!n-`$7qQ}==U*uCTa z%KH=Vy7$}{?jN-C9p;*GIU}c}&y?l7UW4;%BWW$|qz6CqohUQ-Z>=7eK9H5Nihh4Y z&eHd9dZWoBd9M5_o=T`vs?^-D&!jS|Tq>_(=As&?#;T=iqx!26YK$7I#;F-VxeqjW7q7FJ8El@)4Dw`N#B9lx^8`M-LMzgAKTyXR?YA1 z8@yHXj{iVVf}q4fIfF(AjR_j-z0Yxd(8obLgFXqm5%g2g&p|hXZU_AmbSLQ7pu55R zxtI4xCJ#;#oSLUdy&AjbNG0C8xz1VdY;ZO@A3B?y&CV8QtFz7d$l30E?CfxMI-fY7 zI=h_R&K_s4v(Nd=+3y^14myXN!}?vF&Qa%>bKLpDIpLh-t(~WNXXjbxoO9l};C$&^ zbiU&IweyX0$+_%YajrVo-q`!rx#9faeCJ$uzW01TI;^fpB}SSuQkhvbN?qzEXFsVpeherc_47GK{6=ES@ULJ5wuh zR;*%GWsKHZU1x2!c3Zcs+t#nvUF)88-+Ev@ww_qetv{@nR)j6~SjTpPon%gOCxw&7 zdCSS|7I{^m2MTeVo2dKc~Mlz!~Taat1p?oT1JzXSg%M z8R?92MmuAiv5dC7^OR9~5@Y)mXDa7}>5R%V8OvvL&Y0`G=gf27=R4n7;4E|&Ig6d8 z&ayZ5mN+Y&RnBr}h4X>uTkWhtHa#ndsjvTgwDm4?&;R5M;~$@+O;!<@>;7rx;hnQX zymNL2@0^{HS!EyQ!msSUw4~;}vNxSu&TZ!x=Z^EM^P6+mx#!$>9yq@{51mKOAI?kX zx%0v`T;)W#;yiJl+KcTSyodG~`J`s_TgV8Pl5uT>n!z}7(|E+F(VX#NxH(auGdW|P zuufX1tkc#R`to(J|Na!jYE$&QrxUaC|3>X#`wg+NQ)nsp?`w%(R7`Su?-$O6)sB_i z^z}m$vkz3@J*-iEs zg%Gc&zC$0aW!1LoGFvLgT&6N}rrykG1~7A)%)Dj>^QV>eYI_~CDBXJ|({J>sb3EpZ zN==dje>GqJ&uZ77|35qHkG!tH?8uMzKsj%=Z_(QOnS-AWUq+i68~ zOT8MCqDOP)ag_q|r-j}O$h)rki@A%w7AO&Ued1YMkatB8>|GJ0Qtxxkmd(5R;aL@2 z{Z#U$`QmzT4Eherx7kzqRmY_3gY_eJ4iz;xa~C zl8j}{e@Dh+PZfARa}BG8Ow^XeA7tuUb!Cdxz-k~+q4_h1}^O!SFkoU35$ub|yoGJ^j&Ka^03!Nj2 z?B(`yS&Xf&lqJ~fYFUcSu9Ic^sb>k^sWv!F+*+2vmy1>$2P8DC&+g1+G;=7_fgkYiRe}O3fX5z zaP5=Cx{P13M*V8U;YwG(2|V&`;H2a|^Xj>&S8=$=VSUhhQ?BGEgD=mMb+)=$ z-DAw3!mJ)vPb=K&W%aiDSbeR2R)1@NHP9Ml4Y7t=!>r-f2y3J@${KBrvBp~CtntyZJg@q?bzM?hw``LK1o-VB-jMy9eOHRw&v}DF@i%JxSM!(3-aIgJ7C7Hppv&aF z?u#k)18cRl##(8uik{K@V%=d@^IP=1=6CC%^~m;n^P8vEGv+uiqGvhEHf+-l($XM_ zZo%sG5Aoh2AA}~SaL$Ofn~X0dJ8zB8WM{Us*jeptc6R%1JBOXq&SmGe^VoUqe0F}j zfL+ioWEZxJ*hTGPc5%CeU6R%)Unhdf1@0SWQ9v zZ?htijP-16*kY~3;+_z`dHAMBhjHI05H{QMO}Az;uhXHCIT>7^n33b|t1wn24P%1A zy`1P+tg#s5|7!Hr+VI9C@kSlxjXEY4%`%mICEFz%66)Si5~BJ^Vf;Fk9>w%A?rcRL zD@cw;%oQm1;~VvjCUm-HMk}$6wnkf|8EcG_Sb7~w;&}PQGcFoecpvyxzNIB z&v_HG4Nu4FXZDwJ$Tf{8WuGC^|ko}&u9M0e8`i(o|(`2lckpBUC=#O zj}QEbA6FThaSciwPYm5pbw5u4LObxbc&?MS7+Z~xjh)6P#;05_9psAXbK{6{)Hr4w zH%=HQz3Zp*#sy+4#_wob;T~CjE5fr+ZJXLg*U~TcSbME~)@Rm!>wtC8I%FNTKIh6t z&%FPEWit0p>x4Mzob=4WGdLNYOipGem-Duh!^!Dnak4tu9PXs?R`;vsRqR&xOXc-T zAAMxGSoDf*j4|7-T|C<5s&$oTciplc@pgE>?U!`S@srXAoaFjv33K-jPCch2<;UXOlFdqD`Fy|ouKP~~?F)NvISX=rA?X6%%vy)MWmBCc3YhLG?b`#gUeN=v~j$PK>zLEX1 zTBgw!1}maZ&?aY6-+Ip{jK`*!zF9%ng06972}kgI8jn0OAn0(AUdb85FZ?4{bac4l zNm)wYvGlzgv@tri;j1Rx-Q-#xUy$xKk@*|G{8E8E>>y~^tI(iML7mvkMyTFK#*Mb7 zpn8EYy`p!O*0A*zSl~Qhc;^9&xkxqfGnO}C#B5|WV~yVw$A04`wfZYL3Fp5h(J4&tEX`eM#)zm;a6u#l)Y^mw~b)Yp^as~E>DMmX>5$+0WO5ucr2e?2*t#R%7&bB|#t}wP{q^KH9V6W2+&99LV@GtT zmScryGmHUxKnUcoTXcoBmKNR3y=aFXdrcBpk2#wq^lB4AFU`gJF7~N@SHF`|8HZo_ z$;Y!$uFk(QZg6ZZ^99crePoZ?V;tkiG+-MDH`GdF3n7f;YGtH9`p6^$t2J7Xljn;L z?_~@l92?f#K&UdXS|c~Pv`{UyKRUb);mOFOf`KnOlryqnK;)TO%TA3HtG?>j$ghzl zI<`J?p4I&G`fKQOR%C5;_%2G^#rM%`p(`nGrMmW7*bHKS5gnpfM^kDT@rJ2S$j_wY zZjoQkJ4$tf)n2%dl{CKG7f`rLP4hxcRa%vnaqO3d0bZE?l>#Gyo(HOSUWj)_;2xh+ z-1njkrOH7N{-`=Al^6dLj%DIoIuJ{*9NnOoC*VBIsS8W|&E{W!UJmSO*|`s`kEDsd z$B~|U9A%9*ys@L5F`D-X=(&C;W3n-sXEaWwjk~ZO_c6}|-f8TSS&Wp2wNIFu6=+x9bn0qs>Uayi_T>IRVovd4p zm&R7ZVbrL+ng$(VCCGYVQ$t*J&M)~en&8e1E! zjYexbg`L7^V}D|QYP7X?+q;eS_Fj9R(ZN1!A2vGKN9^N9XZxgm!RT&B*bzowC$Ce$ z=;su5iW`HRvQ8Cas8ijkV~lg^JI#$L?n}SVnCnmC&u1+15A-iLcKMI^zu^7`qmb(G z>KizFP$iDosz%gTn$XJHZX&I-pz^+~5i91ik)b{nqQ6*)!g};aj}Ni<)l4!%^o&Vc z8fRmZfES$+`l6M3ohxT4c8;7H_bE)m-y z@PCMV4>&7|b#J$;s=L=z4+91S1GCl)QNV}^6%!&NB4Wafm@{I+te6AYfS3?bF`)ut zR>Z8BF`=T)kQ~yOmF-rz&)aYJGHmxg=brn0=g$0|{@1Fm>aKpPszX(Eb-w1YbWJaO zWDVDd@|x-C8m@`yn%;j{Gc8?1czZp*o0Qks^vNYX^U*$K8AT%7)IC|AKsMsDG}T5@B>s)jQtm55iH}e2%Kapi`sLx+ZssR0 z4JTJ}4JWRpp)CXphW$yXCBLU<_4YwU)A3Dfn$}dkRyDY4 zaOKRP1w%+CRF172UU^C7*_DGTkE%SVvR`G-%5IfiD%YXyh(q`6ZE~@Ma%zao;A*=PvN-2 z0G>E{7j`Uc$@5NY`XZVZ!uS`S)EC8bf=FkrPQuI_bh1P;wqibpJxR92S6XzxW2B3+s1Wqom^|Tu3N(;&eF^Non2z* z@q9F!XUZ3N&YWiNw-fEn_FA6QFR|y^(|Be&!XCtPQ*Z7ewqcA#C)?VtYn#*OS79w> z+D0?e<&1PWJzY*?>3G(ZHKMXk^w~@$l>E-aO-+B#$imOa!q3RU&xkbqj4b?&NW(vy zh3CmOjZe!@rW*d)Ec~-s_~}{r=~?*cS$OW=(|FUf@YA#K)3Wf>vhdTg@SJti@YAyJ z)3WeWv+z^1@YJ<598bZS>haP}RjA=SfqjUPyJnPK&z4=&%dTlxVfFNR2ElU8dqEzS6mubTpCwgx?gNtj+@(A{PMWD zoy9MYo7#V?PW+gbebxVfFNPdRRGXYtG9=5`jpJT7TS^9YNU|-3b{4-pZfy_VzVb{4-pZfuNS)JkPp}qlg(}`d!e#Q{D%`tY9W>UGkiFGtceU@GNp}a9VHzPt^y}?$B4> zb74QSoW|0nN2{9uo|^j0k%+$>hpPHZD&@DNQFUs8o*mf^YjW4LvMXOJ+tanO#oU#z zm946uY&Uo5T4H8v#ieV-m9NdWEG0T$D>j#{Xz9&e`C6=+u9bY%{fbN1iYs56Z&~s% zUz=}P^GmiYu6(WJS7*03eHYs^@~xAraV=aE?$%e>M!SG}^>^(n_8IQf@3rIY_1vcq zwL|Pl+@&92``RAdqi@0p=~i|PhRv*`Mfd~8aKA)5=yWsLwC3v%zcAi*5o7LV(~~rV zmcRS$1UuFa=WhROM(Z8Ly?#I2lX25s7-L=JK0mR%;ANK4V=~9Q&6b{`-$csN`IMuR z7)5x9+1K<6y3#WEoo%pR*bjJ;m}wtlq~An4&W^N~@f0zbmd8VQhUmpu?9F+CsIe^= zH)zcY+Ts?NPZ?YI3Mrh%NV4(t$y`Ir>kzhmggJl~uO75uY|ehI@?_8Mf zy-m9JO4&Opk!P6|sm{ps*-WQrIxW+wXp^+YEKPM9@#Xhav>oMuGa(_=CEHS$Y)xIV zJ$1Wul}#xhsz=H8l6j<4SE*Ke-Czt~{=sp>w}HuGGBTPvTZ# zD@oPT%q4lCev%8~lKtkce68$P{iJs1u6(W3Uuie1KqaMk@j1r;%7-8#2-UQYil?tjS&Q$ zSifd`CF4u3p`AoVlZ~M-@cQ@$+D*pAH^n!{x5T%`w=pvBsXuH3i-nzdKd;-r{7+8z zy8fTTx|M;9b^i1J&@^QGzl8jNoLKb7f9bg)t!@7jZZa)^Gk*U!(s~EA z9+&^qi!5Pk{u1U0+8*<^ZTjB`QT$7Y9cg9TJNuUl>t6vwc#u8iJ3)2#n zwhuG6oh3gmOV>%;{_61^jDxy69kF*mFF8tnBQqtfKfqKUNZ52Y>n2<|R=(W;r2D%p1WA8>qf#9WBdEvbqvqinr~_~WnN!^Gt<8tTu#e-BsqLpTljSD)X*N?G~izoh-# zz7s+a#-^jQjI@*E!w7KQTN+zSwx#8L^}ju5$k}2%=Zl-=>_I#kCHAi)&SjLAjNMPi zmlG=A>IR&P){fWt+Zyuc63CNp<8|;4ENnJ_n?07~8y~Eg~I{IQG?hki&doeocSo&ed(DV8cBXSnF zU;l0DW%Qcb=J);v=_8X87<#$0nvcuy=IP(8=NnkrdRten>xOZQmYz4+iZ$!7hAmbke=y1VSUtL(b7?7FAynpAd8EW7R~yLdxB+w%Cb z>$bA%*0PH}%PbsY@-o+ivWwAqncujwi}(FAKi-PZTsM?m*Oy(_m0i5apM@J!b}>Ui z<~OSBVsvKaH=^tsUUu=GeYW#DNr%ChT$0-z(z5H4vg_iqYiQYZ zQQ382*>yqLb$;1(UfIPg16fMXDZ9=tyLhu9TRWueIdUT$W!HkTtFG)~^jfyP&&#g4W!GnA*QaF{b4z3~=9FDulwBW}T_2TQAC_Go zlwHh%k!@vm*~Ocdncur*7jJQ7es7muZk~ z%B~m7t{2L#=gY2{W!H0M*Nn33*|O`Ivg_%x>#4Hq$+GK-vg`4(>#?%y(XwlL+4V@- zHLdKLQg%IDc0E*faSxDLa&p=AK<3)+|Jr+05AtmGr?<}j^q!f#V^*${GGZ^QllQ0T z`cs|cepKpYdLNh`hoPpH<2HLK=-#xh=|@dpYI=Xuvzzv;8d^1^s#{gkA}4qv-?h<`?n>C?4#roE7MnVY$@?rXX+clMO*+QpSC+Mm&^a-S4NyQZ#m#6h?| z?qOjEX02)9C}hNpj051P!kAfQnuHN+mWLssMueP&l98chIXvVziBTzSfZV5d=T3b~ zMoDzy>8Tj3?UoT{v|B16-4a4Bb>FaD=Dx;fjBAu_y5%evyT$m7V4O6maKEq|>l*L} z84uw&U*OMqAsNn7*yiL#c^rnLcC!!84{VEb1AC&D6PmL_+~7DfuyxJ~$%t%Q)OSKs z*W+b8ho$M1F(7Vg>Q5;peUwnrN9iOkC6jctv zKy3y6-2#sLgXCMz)HYe^?!ki1-6WRQ-Hp!*cNg1S;_k%#jho1FnY)8!qnp5Tx#JEX z8R@78q=7m>YRBUq>uzKDgS(aGXm<3*_mW48=S^EC5?5m zgmi<_SSO`hIFaQDcLF{Qf3t<7()}KhZsG8B3jL-5ROIRwvYQw;DdfT{DT{n&KYm zs&Iehny_qe)L6>4OV3FaF67s8Cv|qXv<#3N*uzo}ISc$t*L9Vw+GnYK$w*7>i-|8a*>YvYXEw_b{62(n zVlqDIFUM@TrX-E_MV2e<3&dVxpHJhx#BvN{Qn99e7XPJ|>rlMRa^*>e*=JaeuutRD zV4q^SIJW^~S*d}HVwIH5B-Gfyo<;1#Y1$uR-7l6J&(^4agr@#+-1oB_X{An&`19G( zQV&Um)WZfVb#J7l))8YY%Te}*G=|uFl=!BWADy~Z|Ig(o_#2{7QQ|%Ns29 z4vTOviABz1xy+u6&lr0S?&X&H$o@}ZIo1whIowLE9A&AGr14nX!)#hBZCWes@%S(P z%kfApTxq2iekG&1$xo?;%PqB#?H$Z=X!C4RPFx_(cVBWzz{G}zs7-(>e-c|$oL zdZk<3HQi!QiIJ5GY6&T%4rQg2+L4T={QkGjwLvFKU8`K(R>>AE^#mIwY-@>ahWl$v ztw@$zsR1LcA!Kh$Ey8L|({wb)J=Xlja+IZ}kq&BT<$AUSKBH|V%VBn1{Fhm3QZmB) zio3zC#_}(=V_Z4*GC#6rx%nXt`#sB%<~x?d&9@SdXW~A*(Xk^VFaK~AGhd`3s6X*i zGmqsmBeiafsY|!@CCkNzdX)?_pRpWazGgYre2PDHQR)?A-es-SuHk>qAM;+ijdx{@ zc_$6^w!|}UrCWUypAp$MW~CutXZfcqqj@Fum)f(;q&3h`Gf9!u%mzkK{;@}79KD0X z=@S`B|KJ(4Zy!es-d}3Dq2?wl4Yjqhxp|tUWmG+RW}akwON`Xzkw)tK7(-pfmXF{b zYo@XM!Axa2+Du_N$~?^4rRE`)%glrL3^S8ijxf|&j*mJ^8lMYXyp%OF&}H~O5x8U+ z{*Q~YAI9kNyMakjMVJ2dyg-|_r1g$8XD6`5<>q#LhMU`1jx@Kjyupme|0{B~@89Gs z=Yh&~&Gl)_>(ZEG(wNsu9NrI*_@h{kFeCA4Fk?x_NVZR%i5tkve>p=lZe2>*mFX6* zNVh0g@)hPXe3qC?agQ{Yq+7pOwwUEDX9%`>cIxwobGA7OpXKIAmdnf$gc)NF$GzAL zOhajqS(dNErUSp0oAzmpcC!9o%34jjeeM;= z-|94UEj~9%>l=Gn51+4iKSW~wF$bHc;nz&VuOVyA>VzL@R+I2@#W8XY8$oOP`N0rc z)K6d}`T*WE+xuV6MAZMv=D~-AUO|sPdBvCP*%C9zka9+PlUK|qLt29OSdOJEkW0Zk ztX&$&Ib|8|R*+Z0TP#NeoLNZe8!Q)7X3|>r=Q9%}h^uXIPa0VS-+tez;U5EQC%Av%Rx;2WsbBW232g6d|mh{^E-edb;-n$2R+D90=W{e`sp6O(Z z=~ae1Q#qm?(s*Lugmt5NPMk}5c{6y4cGBsL5xMKXb={V7yE^j=%GN2j*mrfdyM*Ux zNoNa|W0>hkQc#6^ELSaPIqk7985@ zo7>VK+UrMTE%wuRuS(kNzs_3i*I^{~?&Ws->wj;#58KmrzXzo{>$xx|@mBIQ`$Xl= z_Blp>%)kL57toHfbK$;~XsClgrSl}uv!Kr)%-!^y)eA5WfO`E>FO%Nfb5EN3O3 zu$-IBW%)(&19I0gTMw+x2lh-HwdT-^jJ5FDJ1p8B4Q>kv?xT zCjS}6(oA8L&RvYs(SDCX_BclS9Ku+ae)I_SU`$O{M(fC!9~qS&(`ft)<9-@V9V2Vr zG&7jTYZ{|oCYcGe5na#7m#esAFkBV>G=I*&>0kPU(#pI#X?d+G_cI+zPtzd!nwV$j zzwB@NeMT+mm6M*QJEDoS#Qp31Jagr(C*GBiHoAW^k3x9{A6xjPg6mb`XV3W}ej=8; z$=hSOXG@|BSi0zZmNq(%rHRgE8ARL_@mBoVyi*NJZHiR<2mnDSlVc(Vn;Gn%EpQ?4t6;_XU(F)-eku!qGPRUD072XG$PutKib zhsGPQJS6VS^5D1=%Y)*MEDwy=XL&%}f#v>jd*b$IRt~OTg+|X+sj%F0J}oTsoJ$Hz zJ!hxF5?)EQg~b)CST0_p>)vte4N+DDyVPE&n)kqt4rC zuKiz~xAE|7*2cv$W24U3n9tVu*SQ*XrbajD_n8{q#X3{t|CxCjnMLLAMh5IA;}Fs_ z(EsrnTq@qJ>{am|Gj05FPMp7+bK?|djAX9J|1b~EuzU{5ilsUq&VOenj&!cc|Itj7 z^3=t(?LW^odFy|eXL8N*9FsD`*$5=gvQkG@KSa!l=Z@qQ|2rq9=I|@#*Lpt}vg=td{?22AJ|}m&F9v%vvPl_EMrZ&^Ai+0NPfG_`Yt?PKn<} z+bhv1w1X1OLS=4q<~-*O@*wD>#4_VRXC?Xum3bK;`V8GrVLW$`zJa?D{&H*^E761K zCeQ_cnF(Iz2j}~_(GKXAur=qd$hiZW+>I&ZwBN74=E z7L+;X(|PA*?har9EJpoc2V zs1R^(of21|VlRjzbf6N;HV;>ryDgx-AjL{P9I05b*-?s>v>&b5W6%tHCGKMtdo6mL zV#lM$D?Awmv}mO8>=V$&kzzkaPf~0hda`0yqJtDCX+A}9EzrS=lRP|CagqJnibFzlHxu>B^=0XQp}r`;ufGXiwd}3(eso@%Gmiz^bmT1 z5=}&I8Gf`Ow(Ku9o zzW`EZUd)hVd?~}BFK1MsuVie8zM3KB>NR)+2Ev>0HpuUHG9>J~ikD-2Pca*!Ql37b zp7umPRA@^MXw6LFJ}01kGbQ>Couil@=qE6jxRP(5EA+VpybqPaw}XN}$|blVXq`ge zP!QBB=2vu{LjO?^%vb193V5F+g+8VrkURj}0$rro68e>5D!53qZoO_lU|&^FUt&qIC!^%CAm?np+LA(lt$fQRMb6)H z{S)jdC}l;Eb2+^LDRwa0N};_w{SFCz27#r%o1#K%c={a^Lpiczan~rcl?SF)vE-v^ ztKk#)_SZZlXw?rq3nCPD8sWUXEik#lD4Zo*{X@g<>V{mKm#|TPciD z;Dh3d?}u)kA-3N}u?^_98Ilj(6h=h^X1ff@lkF8oNbo^#g+83X?5Hr7B9MER6v>C~ z3L`B7IX9(<4SFby>Ilp(8Dfu~3S&O_KzD}NrkBEqkihiLkmK83aTREvjNQ;Z6vn0S zQSJCd36g?p0QuIK@ z4L}dd7>P>V19t){+Y&~B#0PgGDshC-aG2tx4i3zax^TGSPDYQ&xD`E8VdPUlf2eXB zD)koJP*mzFcq#A4D(*t`xQvI;;}ynZ1xC^;JPeXPa5tlpCSeLlI>6n64$7E{o}xHO z+u#f-=cg)8(so+LBk1Xh8;_om@g{nv!sxfaNInUl!C4Ap?gDy*mAU9S8Q-GkDxpEo z%lHmGUt#ngANkGr9u->vV*~kkufl9{ff3t*m2xd*0_0hN_U07d0+qZ6E9Lz%#jlTE zu6Xj^T%pK)J$>IP@(f|FQfwdeYDMnB%{5ATe?JU2_;t|Xij}e^=>xwtD&-91o-!?4 zlrLY6Tgnso^-w88z}P=NhO0zUMKG>Qe_Wo3HQtm#>5ZljH7z@jXTNQgb`h`LZ zWMIBjY!kFjpUHbf2T;Bf%#sshoe6z zjCkNfs|uqHdH-IKXGQa~;x|B7WNeE5LvdoCUlhi*@PSpu%6@-Uq)mu1a4EJHU72w- zx=L}|P!=(nKwmvCb!8+do&r7jft0xvafgbuJ=iGYR5Vtk9l{nePD3jcd1kYuHRT0V z_5tz?C(lbMFQSx*v|Q|oRw=G6+EkJI7bzctYll*91i8*wIRnd`Nw(BX3u?{TfO2K1uTdnxMXi<^2 z2YC)mxgV`j{D!E+5e|aZ3g3ziEagw|lqD;+04XC@jvpL(YuhV~zX)uH46)z(inIyY zjv1$;ofNqTw4D_v+n~$|azAJ#P2gmEk`9o2L%Xr!WZRo4a-V27Rh;aniz4R$yP4va zpqpnw} z2f-bW?x?s`=uV24W9qK>f#}XkunXElk@LEh-{9rAdn(e7Wp~Xu4(+ALv#2~Xr@W5# zR^)!lN?O5*J^LtfFJt#mTz|B$BIiGO?iJiuXuk|8cXIsXl^0vewggF|bcoHO^AiN1ZA>(cI#0;^?Nf`^#lQR~egA^}i_!Jn7-2(K~ z49WM?6qlfHll&CBfXBY}Y{iR>r5u3UA3ay`;+8TEPHcan z;;_4wasci?bSPYmTWoWQ;=~r0Dt=q^GQ~?-xI*z#_ODdj1?W|Zll@<`|S+tKUv6Pc1lz2PzNq8y{m+C z9sN*AB>YE8B6j##N#xk)D6ynX?DZ*mQICG6#EVclHi$n*Gh*3>ls`yzLnS^WQWxr! zq!yKJ0b^4Fc@7lftx&NM#NE&ZO41u$s3be1vTsPFjD4jfyP^$B(i2^*Bt6h2u$1t! zpJlKdx7fc?iNxMtD~Z_i8~B+plGiJgMDqF{unK?5l?#-Z{Wzndv_t`_~rgah_Qj()5!e`V`Kxjfs)*ak{_-s{^QW@urqGzo$Col z;Jz6>5>COrCwc~)iCgwFM3MGFIsXctJd}GHIXC-v(F>JG{4Y{W6?&NxQ|@?^FD0fd zxGNOt-*#6jk@#PwNdK6-S_!70*C@fKsMr^zeangMK%SZ0C?#A275hRc$0c@$fU@eu zrV#W(#irOYB%h@JNeJYa#wzk$;>30!{aa3K2chKQO-dlgCHnwr7k9TP(zh+`VuI<2 z-lmux(D6!GiQcZ5W6%joNSTo5()8I>>~N1CY8Tx=C&z5eo67GjSsD!7X4=Lfrs2o3pa(t2>5OSV&Q zJ&s$-!V~Z$Zn57}ihm1zS_vgB&%m>krMJ-;N+{`iE@NACCOpq}B#ke?%eZBquPEWG zXwH1xufgkt0XIttB~RW^Lel5n%;<(nnjn<)N;)8vu;=5XTt}CX^+Zp z2xXriDPHWK;R)yFDqg~Hjurg-=r`~!?m6gpirnwI?-e=UyB`#3b9Fx|{!{cPC25CB zd4)jY{-Q{qhV(rPq2$$Xu!?&H+KjzXr}*;yf@stNC$AZ}!l{nxpLz`u6iLRyuo1m*_a4b@{gW{9NR^@81S7tr3YJ8mgkQWhbSvbG28 ziF-9v(yKk5>Ex=rM}F z8a-C=lhNZ8e+_!P;vYm$Q2a3TM8!XZo}~EU=*fzI7#*be5$Gw3pMnln{76*FI{2yR zX&F)mPgk7ev)EK1Kf*JWNb1%Q#g9VIQv5XZY{id8&rw`&bg1GdqEf!Wzlus8L(&?( z6tG_+`xAR%n?%wjwg4k#S&m;=4sy)k??mM|A!>rkv4Qj{g_1sSeNai0&>6;LEJCkU z{DtUsil2mDpCReLL5bEx$7XDd-k7lj9jAEN_Dza^1ie`?EznyszCmxz=z`vs@hv(* zkv{zJj*Rb6u^j{tpmNN@KS1gO1P`KjXZ(W7F$lZEJxbIZmGUj@0aETElC<5gc(I4< z1N?M!vf{-yvJdc&q7Nxv?DVkWA48`oUTij1@sFd^6!Re}c7e#CvJYV!knKX$5|wQU zaz2yz5Jl({8B!jeRH7JtDnrh5Pb*f^@=Qiw^jRfZ8=ax}vFLM(mu=64=Q+k3(H9gi z`*|^AGxVj5uhEwkFM0k-#t*2JZwSP$uVwrbeO(E}uCp?JMc+`ol$SRZFURzjBI6yx zw=;f5-%+f@eOC!KMc-4TeK(w~1YOYg6=~}YKTv`lQOOUGHr?JDc7GVUhF+r@wcL%D_(5>h2n2Rzf`>BLtREcv|fqULFXxETXeqSC9f7J zrW?9Y@se+g6muH-mEv|q8x-lUPOqtp$zSp~lzI#1OmwN@CC`^BemuHd@sj_IiWB=w z-h$xPMiQe>i|J zUD1P;cpLNxB_4ntsl>;kN5RpAKLMru32_G$+X-E|h0CFqVi#r8x)B_L0VWVj%0|HW7ddY}bG z`T>d+N(;#P6$py*HWatyVybrPDWcQ(*9jsTL}iCVi&MO&~=r7vRYhEv1g&J zlwdGQ!4~Y0Y^wpN0>(Kd>-trpuV!6dYuVke{RmEaz< zgCgy(#r2h7BHB@rcH$ysR0!@xJ1h1fbOR-rg>IeqL{1DLltQ+EDli2)#zc0I|?1B$atfoY!{?$u_)UDb3H2Y!3{SmqQ@%kH1s&d+=w2pxYN-S6d7AoJW+9%peHG20(!FIE=30^GUli#=>uuAEJ~U{ z#vc_W9U$$OMcFr)r%~A-NZVWS48=Tyo~cM1TycnEo<+}6q)o0U`2=PLDtQCaPFFlv zG0&mrDbj9NJYSJ0edQYJv!v5Ha-z`Tb_-h;Gf6|Ygu`{*#m)uF=`BWWI?xO#M?BI9U^ zqZGFk9j%xj&@qa%Llv)8%#Y}GinL1=uUE`ZsFWd)HmahO8?XTT$H*50d=hStRnr*#TiO)2Kt<0&qrq}!I|juioF1RK?#PSFDlYs zU3^Ii&O%>Sq~E&uiV~cSzN*-v=xa*w5&F6!{e?xTdk}n#zM)9}Vew7H9EeIi1h*0T zwqg!K-%;GgsMIkq2cz#PZWDC2BIAjRQm?>mihiKT*y7@citB<(`as4R7bQ*LHbdtq zGVZweiQ+a#KUK^?^fSe6fzDOT;ppdz+Y7HtW(U9XuTqRPQ`hOk-9Tq zaa*Gc6f+ZDs7Sv=agkyq55H2RkD}P17|GAYiu6|$mnbrZu_)&bkiLte)Mqe~N6QuI z!zeZ?M)K%uMfx*}-zY}%|64`+H;UgWGQO|)z2anhKPX0y`$xsewtrHL*x+Zy$$nNS zG6t~t4@KJki@zvd^7NmIKL-6(@sg*%DgIb=rQ#(|S1JCuK&oL-DsbNGk8)lpHN&F~ zx*Dv7Kjpc!HdNz(0a^nc@V^9IA2!CH<1K9hLWGE{61c{VSi37=HXdO_8q>Zg4c>tc@C5g{@MA{Tf zi{LBV)bUaSa858#TBaDW6?M2oeN8S$zg7~-L+X3!2jWUveuAHI%RW~qW)S)h#Yj2* zMUnoZ(mxgHvnu_nNIy{NHzm0mU8y7&20={#m9#BpjLSD$dj5@z_uguuco9#@1YW>mNdj@YbAONZKFiw zdkx1RB$BRnO7sQVUWu?(O$R067-}|F;&0JSU{kg)Y3Kr*lZM&o7O)j=%0f+7C25Lo z4cp*P9@cEDBy!x{l;~P?J0-da-Cjw=PCF=x*k(s15xefBB+bz7N>YXHtVB4^=NM;8jbF*#Mr&24{)B0sKYgVl|=GtPbDEQYx==n z*jDmwZzYki`@nwqOCIj8L?5DuDe-scU?rC0K2?b~M9)xSN&A^F1pAZjnzP^>+>*v~ zfpbGF`?&xv#4YK%NQt|kLzP(ebFmVihF$`f5?=OmnG(yksY^9i;Lkp4#Ez8rn0?f6 z-l-XmTf&Zjk+@~sVlRmMqhpl#AoN-#J_@}~iBCqw*5Kt>Z&2bxQOOU=LOc+?S&5HE zZ-HC!KMs}rgM|F3xkHIILMJNGCg`0?B<1lgCF+RYtwfullay!!^d2Q@hu*71TcYQ}cms*ogLQI~wCZB}34a)HgGWMi3c`U@_f9utixP+2_Li8=Vt`fIK zWm^zeqm%_9rrflq4hvB|+DnOkL^*yT`Whvzt&hgN48@kMv14I8dZSXf10AOnCU7h6 zl)_$Us1ztKZ8#30us2Hn359-WJEgE6+8#RKPdREs8ifL7tIbfQurGS0Qs|EkQwsZ| zZ!3iX=sQZ`&>(1w?S!Nc$}zP)j%{9rJ_dvX(wey{AF8E=z>yzgu<5SOr@|n`n*!;ioT?z z{x9Q()c+OSQ0R`nsuX&lvy`|k`i4^IhH|VzVOw;O5=nZ$Rtm=l0p?FB?23|C?PJ34 zjuKxe^h6I=3Y3@jM=OQhQ0yiYwh4j`Ybu3J(3VPJQ?$QQ*cm-aDfC8%0(m0&(t-2{ zNfVT^C=?3lPf9`je^v@>p(~U^g8oA(G(&$;3hcil=@AO+20M#GD5=S4o#Xg5}41#?H<(wke zS?HdMbD^lq4h8=KYL1NY+Qyis=_Jb-(DbTT}M zThjKB5+8#;ti&gwQ(!7#2BFl`x<_zdf^rTQVoC3#N_-9a7(9;ua8&FF@hJ33cnUYi zR!4oUN_+?Uxe{}1bzi`jgpp$sTR_b5)YZd0-1npNVIgjgr*4rF zOW5z>N5;eN90c{Tk}Namwl*%NI7P4Q>bHUIa8E+FhyJ)U5OTy|U{iY)x`ra} z*)%p+Eaj{bI}4U$YpjNL>`TJ8hh1<}9vgR6qJb#K+;}Q=;Ry6JCH@ROT`Am+o}m<` zqGu|F`6%ZOq0oSy1?S;jf^uFG3Sz4Z;6mJDlZ)UI+;Y5^Dur*+%ap=TsMrN6cS9xJ z)PuqbbTr(Ae{b|=C83-)-lA0Qi{7dfenxMDNrc}Iy$5Df-hM#eS1P-sAHYY1ISeJA z8?k+**gLPr6kNj{O!gmL0pAy0$Y;i`_Zj{<0wo+ zPe=)3zJ>cO-v)#rUNbN&`lSQ~W(i)gcS=y;o5E{7L>^XHNrWWh!eBCAoqo~oYtQ2g z*`t}=_iOvJ3thsOzMHu<-CAxv*T${yHgH|t&h7|iCmiA~;+s#y-5u^@_qqGY{SuxN z-WINkHg38@HKRkS*Q?&SdbjF5s{2*%U420H!PQ4s-&#GP`swOf)w8QVuKucearKYY ztBSW2SCtB-W~H@C)uj%ljZ0gVb|~#q+M~38>A=##r9(@Hm5wc)Qo5jYWocCD#?tMj zdrA+Nrk7qSy)jU`8MNLD^ zH?^kL)yB2HcFo$BwXJL0*LJSmsJ2V(wzYfI9#(r%?G?3G*WOTjQ|9bEe(AVor>>pj&TDpV)w%5k=WlS)hO0JOd(!Rqth#TbIW0=`SvRI`Y~7u8&(^(Jx2W#N`nW!+Z&tr%{o3`d>)Y0Ms^6-9 zyZZk12h|U#KeGPC`Y-G2=e;^_&b+yeg~p_DjmGAUEgRc4?$WqxY$3+)xm6h4VBw6AdSpGrb|O2SsI zJM%;z>CSNHy2~gDBi)^DuKULQ!~Gf#4JZ7mBN(X7 z)k~^>Ez%oTqJ_J(T50W4O=W9ZS7R{mLa_VClHh;L_02Ri)9Tais~RdrMPF zkCk32eN>uL`n>dA>8Bc6+iEsWOTyrqGipZHOsJV!^F+-vHJ_y=foIj)C@l#sYKxSF zPUVu&uXbSVrIduxwPS1VtDRE&NbOTuN%*36K~@qXO2X=YDhWrmzM+kyB(y7+gtsXP z1KU1ENti%MnAYyGc2BjN+3uxwv&tnQ`j?V0l#=jcRub0u+mc}Gn$&Gjw|iZ`xub}J(7C>A{r2_y*B_jggxU2Jg?X>d z`=rq{`o<<{NvLjYPf6(2cp4?)s>accH#gqacu(W|jdL2m%t``Rpnv_7`&iC4m$=Ug z7Zolnoa1Kk>%_w0d?9Y%nCq_m{l>Zd&ws04`RSKsCoel<*?tZ4f}nwOU<2p72F`yC z!y2w>;5@zTA8<7+SjO5QSlGXQy@kisAHDF1`l}b7Tz}X6-{#dUJbvEDc_ZcxpEqpb zNej4FTJUAv0}K9H-*({^3(i>ZxWroUzT77)SQ-S2IB&C+3m07{`p3V;vlllm{9xg4 ztX;9VtE`!~WZ|5JAAfn!f{zz+m1y9~uy8Xtcj4&^xf(4Tzi{h?-4=FVc-6w83y)oR z^nzCGVRfjOziR$Z^B_v+8B-?hGHeNy*T-GaKC>#FP4{c`!2i|Q)oaW(y-@c9XIcb&V@+`e;rd@}#T z^FH6|<4cbG^zgF=e%@i1nj31qsO8Gj`gWf7s@l$N*QOo!9PI|eyWHdV>zLZEZ{nc$}w+-9%>3D4W+V<_*ucm2fetYxR);e*m;Vsu`xo!}& zdSRWxt)4=kXtf8+Yl2|4ww3aA<$y`cU$CEsO(UMrneX}x=FOf=u6L7X)8GH($u|Eh z^e*(3)`G$jg{unR6uv90RsL&g$a<59not7s^{6I+?)kFNW7%gRsx zyryzd6E0~%&}41c26{E&{sEoQWM-2es+v@Fs_Ig;WmPxvlfNp$u+J>(#ZeP`-s^?$h{V`M;a+AOM{Oj*3 zjD>8^%%0Eezkv;Q3U&+j4Gs&g4n_ob1TUM! zG&Ad%wq^rnXg#Vl;6d|gSa3t^YUWoP+I9KVR13bsv4=h0o?uV3$MXH?r}>(~^l%@pt?Ri~uG+2b zT1MZyb=-|^oV&?g>l*BCZXw@_STks1ehaE>^PrFI8|-2C4EoxB!Jc-npuauXuWkDW zN7<8tqwUGTF?LXJtUV<-&JGSn+Z%#0c5HC1y)n4XjtlOz_XYRadxN*_W5GMTNBypy z5xi$-2D9xn-`c)qD(!5un*GH0Gwa$0vz}dSTG=JOt^LZ>+Iq8*YhpHbRb~^{)NJJ{ z%xOu9vyk9bztVhnh>>05j5^U`DwU<;&S-j62!f>Mk<3xuL#;yU;x3 zt}_q2o6Qt=i<#-^X#1$UKgjp? zC;8L;8U9RWP#R#5^leNV`;l*Fc65B3)Vkn(`@HGnYRz-jDGmUb7vpWWXc;J5MH`yIkF!ZX9O!XfrGd!66a-WtyHTiLtqM0=-wGQ5dz zh|ISO?E=5I3!Jg5c)}>~bg_%u&F|~`yZ!zCe1YU3cc44k9pjGk+xk6yH+KPdm}C73 z{zUh_o5QolKf}Q9=)3z~eh=TrcP)he@bGkdn;*oNQNFOhxxM{ves{h}a+E*BAK=cn zWBe}u2!EvS$-V47@do~!c%yhrf382|Wu;L31se}34o~;3Zp_)5hYP&-tfCnjh_Y`-%S5q%~hJIx`uPoRyrNoR*w!whNd0 zJEJD%T-PV;94_(~`blB$Xw7iraETw|A2BV$Z^AC&w`Q&IyWj!d?szc#CEPt~9_|sf z@Ynk3{yP7tzurIQZ}5+s4=bsEW3%~X^ zg+B!M^G?X*@K=Acf6A;M{%n5@Hnr737h4Q=}BDe{%L=Uf5zYHpY^x-8DYO@ z9kZ=}&UEuL&1r5|bB2H3oatZinc zPQb~G{~yFy|5NN)!C-rKFwBk)#@btgyBUW+i81&0Fus1Gy*;>>ar3hnBmbIx)U07Y zGi%zprn&vxRNJMdXqTCiU2bY@qiN@?Y44or;6l^It!6fJtDDW;8fFW(rrFPRGW}g= zv%lNG9N;!IXS=@U9Ji;r!X0j|bVr!0+>z#Lca*u#ono$cgUt=@R5R9{W^Qz+o7>$b zW`eua+~F=W6W!(JX*bC{T~*A>I?ci>Wlij>f8F~ z>JNsY{%A1uC&Sbh>&NSF>tASnw9}n>^+N1i?V}Yb>uWug4K$|o)huO0%~twh2kAzd zscfvSQ8odiys4%tn`xS|xtdjGVAp0Vc4)R?H)cC_UUp!Y$j-?*1uHW(Z5pP)xTEX)4y@z&SWRyOmULVRO3MB z4dV#sP2*(eE#qqEZN1ug$9T|r*Lcx+-)JaU_el3B_ZZ_E<67f7=ilyJ_h|Q6<00c=;}PdQW3h3w@u;y} z{apP*{Sr4+9qu0I9`BxLykoqpHmNPfMn+Ggzk3qyMc>rj3^!IR!JQSiyPLS1i`$}} zVb8La>?!s%d(JdX3%6O^<@RE4v$x!y=C|fE=Cka1_5yp6z06)=uewF-BlfYo9y^m= z;P!UccYC|;m$P%(dAL)3zPS;<3&Y*O?c?@!H+1)O`?(vr8=JeB+na$o z+#F%{tekDbl59IRfR(bXnXhHomTVAd9sgv!_{XK4z{opEB|5(n z>oV&K>vC&vYm7D88fWci?Qb1u9bhG`GONU@5KqD?wR~tC_)}%2%>Ix?E!_Rl2X}xJ zTN`Lg=|;L|^>O-sxC2Yx_f=!^JJmKc_cll4o}}@(H)%iIrZgFMJvHG5sH4qeAlIIZ zyP{6TJyK`jj;XV8|J1p-C29d~kh%u9OWlB*r*6WnREy1b+z-9L{mA{;{lpt)EwHY# zuCcDQuCuO(w%%30*81wd>LAkJh)=Pww~LSZkG6g*)XA@@j18g*|9;Aw7w|H4pRs3v2&* zO^8#K{*X%+DK{xAl*g1hRa146~mWnIBAa8kUfm)~*LGJ3I_EdYRz18*9KI;1F z25MhOV*S*O)Q!c>Ih(1Qt6P8r+zQg!0Ck``NZlIz#J1|c)a@Ya3|4ngV`^OGckB3S zi85a;Rm;?JWq?|t4pE1yJE}XWJFB}W1JzxXdgXUWcU4LjvJtYK>Z} z)~WSsMxCrqQKza6>cQ$XwNaf8ifvX~)ER0kxc+vvL)k{13Au8%vaLEtJw!cJ`B^uts~|&M1MccN_4-iiQ?^qVDNn15)tl8@lxMh%i2Lqt zRc}*oSAJFRQ18V3cXz3GtIO1T)O&Fo-u>zW>VxV->ci?I>Z9s%^)cMrxI%pbGVn_E zDfMaf8TDE9Imp8=s4uE7sV}RqKr(&}H|f0rN$D;1ZS~){SMOc*J@tL{1NB4oBlTnT z6ZKQ|Gd`y&+lyK4YxNstu==g~o%%iQ;rmhjNnNG>3|?^u+{^cyx?25RU881!VDD)v z)`FO(Lt16f`&j%+I3*6563Q5@Kr6&;e#Kf3t*6#Y>y5kp`aquDKm?;@m`eL3tLE<))B!H`lh%w$!%L`fCGlBj6xyYi%2CTkT)kcG~vZU~LC2rp0kr zU{dq760KA#!_9#e+7NB1wxhNa?h)KY+f}R7sS7{MrgZ%_f2ZMYa<~` z@2QP~BHF40DV>l~|%gWejlh&Dl+sO^Va2@g<8wFAK^{HA=M9i-JjDyY-ywTw0y z68Kc5jHK~KZMxQ^HES)}46Rjb14mH~j^btIMdc;P(lfPL+H7r(b_lE^hiQjHFMgyp zS364Zi^pomK}&u@gwjvdPSZ|TDqux9Lm2}7`B~c8+Bvu_@jPXycD{CjcA<8WcCoUf zc8T)2cB!&VyG*-WyF$oES3ypKgrZ%iU5^_UZ`2lQH))Hs#kggv3aZVk;dlowJ*+)~yCRoskAXLO9Ct=Op*;!dai#VY zq_1aS8Rv4+^V$pGnsAS#_OkYhayg{t*Kot*8@QqBEyz&+#yyMgYVSc-`vB6~N7~2Q zC%AXu-{Mxt@3kMaA8{Y!Dh>BpDQWFj?Kf?;_B(Ej%qm|g0hjTV zW@U}8ftSLWJe}dYTEkQoGM^2J)5S%b1$v=g1RE2-2dft>P3!4>l;KL7zP>U;X;E7B z4WJd-5ciTw4auhZX8Pv(7W$U@R(gMZfId(k1gq3G`nLMNV3F7!H&*VT$Mm=|LQm*P z-PcR>QrIcV^$Of(IaJwA-%;O5-&y%Z-v#$uR>FQ!t*7*~vODY;!}Q_$2z@u)e>zg% zL*G*$rSGNhjk_?%;6BuGIwVtNr1FYBUYP~ibfUhWzQ2Be(xJ2~GjV6;B>f=023D~; zy5cky+)UZ5?1B3=XXveZ8>}TA`b=d{eHQMkoCCS`P~5(GxPF8( zN_ig^wYj*%@@V}S*i()JM|T2l=R65kwo_nTITiMm({caT8PE%zg_}Fi(a$A4(S^!h z`bD_S^Ai11{WAS>Wp8CRta4Yv8grF?wSJ9$t$v+;J#PHGQD3Ovq%YDJLyvM8&I(+i z->e*>%ux>2Z_$_Nx9Yd)x8rWmJN2ddUHaYnGTan;uYR9?zy5&!Anpx)Sbs!+R9~(? zhFe5e=uhZR>MQl9aHr@q`m_3TxGDAp{YCvH+%ozKB=pzx*OgoJH}p4^F|ZB3t^XVM zkG`vn)!)6iLf`q!`=eT&;lzlTimqp}aA{8f-I zeo@v752GG1Az>*wYwg z>}Bk2j5fwV${uIzqf9XNHO3ngl!?YfV?Sem;{fA8W0G-@QKRf<)Eae0y^(=->uzJR zF-7^pm})c_2OHBkx0{pETa7lMUzrIF%WPO&4^a*<4mA!l4mXbA^1Jder23VXaX*V_4ZQZGUIaN3gb#+ zfpL{_wd9(O>x~wIpcZb1>;5ICF5n|72{RoHRE;T4dYGYE#qzD-v+pE z<2{n(KQcZxK7lO%8JFgbFCoo;4cYix<2y+8KNvqkvR`HVZ2SWG_%~y<@w>4G7HEa3 zknVNJ_l%iB#&?92U%(2vJTGN^)|;)z`mpuc2COgJko995K|{O=bi|vn&Dj>-D%%@_PSrdzd}K9%akf zV?vsG0``TKLYjI8a@BJrJG~^OC$6D`t>I1F5csyZmG@oP7T(8Qfgf_IihaU9UH2`$ z{60a&VZY#0XTK^7*l*CEt!BR~SF$xMt9VL*va_iuyPEt4;)9f}OwH6`7hqhXGi_-8 z?^SxiX7P!#i_$|`VmivL*vWMQ>;?s9p;-i}hs!>_%--gDW*>8Xa|0n|^fNb7?ojT; zo*ky#rrfSPsyqU#!zQqxZDww6Zeea|Ze{kzJ*@+kVx^~Yqd7>~glkHaO_hhuZOwlv z_bK-)*DE(Dn?bjCwsMZLxpKNvqqbyam_ssfciCGG* zW4UsbQl%WNJOpb?p;@7vtej#FF^4Mi%pJ|0%$*@g?h4uX66I3ZII5Hr&1&f6&Q#7< zE>JF1aHFP~QtmR-kTr)1X>&J{H}@2>CYPPY2+4CFlA$IFDe3^|btahyk#t#aX3WXv z6fR?OT@NIz=|aY8F=s$hZ4Ab zUp8McUo~G-eh@cOzN!4E`~>~)+t3BSBecQqn;*cA^pW{7ZZiKAcBAFyXXfY7@qTH3 zWqu9&*T0o&^Bd(f^IO;~UQ=FI-oUw!H}hc(lhWzDwcSch1LT8CMOW6!~n)?Di->uBp3>saeJ>v-z~*qBeU zPPR_5=2@p&r&*_4^Q|+iGp)0%v#oQibFK5N^Q{Z43$2T+i>*t9e&KRx7_Jm@`_-Z3 zZrxzrXf3pEvKCp3t(zgEFR^a5ZnJK;?y&B(mRfgNcU#M>d#rn{`>gw|2doFJhpdOK zN32J!<)l+YmQ3TefXGwrhKKfn8`9*~NAbyQkgD?rpCp?rGk@?rU#o_v?H!v%Mv5 zX6|nfum{?M?5*u>sthcGC9k61&tcv&-!Ydx$;M-qGI4-r3&8-qo(O ztL$n!WvA`H9%c`>N7%dByW1n}J?uU0QTAT;-u7sFj6K#KXYXV0Ymc`l*c0vj?EUQn z>;vsd_CYqkmATHYw=?$S+`Y`x>_%}TbFY(HW@YA?4Rvmdut*iYC`+AHm+?5A;)^0W4H zxJ~&5`$hXD`(^tT`&Iij`*r&b`%U{T`)&K*_B-~w_IvjG_6PQdxK;UM`xE<9`!oA< z`wRO^`z!lv`y2aP`#bx4`v?0+`zL#q{j>dx{j2?(z1sfWUSnq+#Zev2(H+BKj_FvA z?KqC>cus*+=oC4{P7kN2)641YtmpJ`)^|2=`Z^mr{hWE^&zcc^n1Yz#*@M}~GF z=UC@BSY%FsHRdE(Vore-=2Tc=W4RM{l)Id}VHvpxR+0Oh z`<(}z2Vnzw7&ebbo#oDBuz9VJ_ATcrZa0(GI@rNpu)+GrF3qyN1hL)3vxx)pcFZEpQ9nB50L+xINuo&@rzEE%N%%C38)( z&?7^04L!EIxw{1{Qd_zG-2v`EvUF|>yX1DTW)6m3GsZ2IZqoJL61UVXbIaWdcSvWO zC2XJ7uxh4Z+Z+aq=Lpz3cXvm+dq877%H7M|+a2wWamTvj+{D(sme>2o3$ou$#_vPjyd&4Rt;&sAsxoxn~O->Ur+@?gj3J?nUm!?j`P}?q%-f z?iKEp?gIBJ_iFbV_geQl_j>mR_eOW2dy~7!UF_cM-r_ECZ*^~TZ+Gu-?}Ux&F8A)7 z#VT*Fdc=LyUG6^SKJKn?pKzZPR;#DoXWVDq=iKMr7u*-!m)w`#SKL?K*WB0LH{3Vf zx7@d-Em_!uKX5;kdSv%g_cQl%_Y3z+_bc~n_Z#Xq# zyW0KTUE^jw#luz&PxlOud8TK1w&!@R=XnKQp;zP;dp*3KUN5h=x1QI>Ti@Hj>+5ak z_479JHug60HuX01Hutvhw)D30`g;Ssf!-i*YuL26g;jeyZ+mY0hK)N8OLr2s@Di`o zEAz^|3U7!v)Z5Y9$=li6#oN`Zgnqi(OL=K8@P>KAy%FAS-tOK=Zx3%zZ`j#(DdA`+DQO3Eo6+KW~5U0PjGl*Y;|?IBfPo9$M%HZ)DCsq1LZc*5H}rMW3H&8nPUQ`g$uWLDO;W@curN{a4i zYKXNv6ipp1&vdYGmsWqzVW$oer6*ZwLbS9%G&h~^M_S^Xna;HnOUPG*ORt!a5KhPG)#HRp z@i3nH6{mjDh^FO80;R%uLa}N>v1$ToHPu@kg0z&S+dEpDtaM9TLt}GO0l5*Rqo#v6 z4SHO5A&AqUQ&)mG4SF>pNwtKmA1{%kMbzZSX!KID3-JmO21ud*;9W6>0kEP-ydvoh zi;U^8JS+wNBogMZJS^h@y;~xt4;vgec{PLMj>uV&92_rBDSx#j1V0?M3L3Tws<(pj zRZzVZRBr{$uchoj!_*(%`B10@*A>I<(5wA5e);>>|) zB%Z7?N9Jabcv{vCH>{hGPofDH4}m6N7{YXkE@_|o;EO(hkW<(q`HbhM z2@GBs}i6Cp9tM2LicI*qZbH#nkeLe z#geq<1jI)8a{dSSON~*vd0wI_A_<*JXp~DND*KX6ip48^ee~334&iu^uD17{)Y{Gm z-5ec3oRU&=bRKaM#Hl6X1gH`MOi9=j0j5L%HW7~p+UVJG4E<_zjHuWf)0Or@Zljcj zsg!CjrH+>pdX|dTfP`|1k5|%EFV}`xB3@~Z6`d8UFoNOISxVh2rS6r|xR;3r0g37Y zj>T{X*VeRUtg#Xjvg66LKDMTVjpI0Mj+0BuI6~!d0+rb~E(8>g>x9tbqRUK*SjiOi zJSCUnxSZ%?ahl*0k`Ke1&>NQ#B+3X9WnnzQy^LN=k{=L|(!<#*L;*=iNT(MM2bt7k}K${m;#;p7?cowl@MK(5IvR9 zf=h4;(v*+j6{M@I3B*266ff^i%&mR0dliJM71a9*F+!M%d}AUg(U?e*LZZ4XFl!{| z!6_|WDVt7L5%N|Mdsih;1S~b7NH{lDiZIH_uBSt!uasR*S4!R>L40mJB^R(ng6gjf z=TvH^Qleramb7Xl7F#uWRE?)AD&3mAOe?(6}iuqKa5VU9}5!NHY_%hg#K zatHt#)daAC8_2P7Vg0EbUz5m}rXE3%gwL)amP&T5k~)bsiu(w%+$Vu2E9TI}oFsq> zeku>=Fq!wpt|4a3s12v|csUJP2_XpvPe4x2gb+97e3Br}AzohY)#ah%)^$N%DbQm% z5y%_!{kT;ho$^X(dLmLzB;sa$ZWTallA6*a_oqqTPsa(x;m zDN~L5X5uRnewme_xilki!p-FSnjsR*jt2(MFJW04Go}D7 zyeX04ozkg5ES^Z3Q}Q5-2lNVwxG`mLTb;`(H&GgcS0a%FAznq!B);U5kVVYAa0weK zO4wBXik|r2D3}FT_&M8v7VFEzNXZhDK+z-gs{P^m$nl3ddhfyiJrH4Tj- zpPLVR2X}!O^k@QdNg^L#GFh1P#EcKog4`sH3oTyhB%yASAe}5>)A)Fq(DnsF#pzj1s+Mjv#gTMk8L+Y|PK!@np5xn8PRtEHri`=cY-%O(#=kYpQ32*ZS#=mMX)s=0Z8V{atXW7-&4ky@0s+H4QJRL4PQg;mkp>(Rliqm9gebhzWj~q$^*lk0A}v7?p&sq&HuR-?0Ql{ux9-tx-bqQ><0Ylj6Tj zDQKPC&^DFNlduN}L`jsT?N&lJvo(S-C1qx79%CRw$?S4b#YhmQ5KEgTmNs1y7_IzO zz`M=lZdETKsX8}+b0WnQL4B~nR0M#K-Ap}%8xMEwrQ3%cy)X#+-c}xl6S4PY*?=jfsj!@Xj zl?#<|>9UX?#=GWHNu`JPCS@FYhphvh(iIiv zoTwm8ND@%9q3(#xTfWrQU@{a)0(o6dIDU|1bHdj}s?Z!DRWc_&LwDBl7Mfxz2;IS84y&0yy+*uNvchL$r$UO>#y4nt zHLx)aQ>NGGdrfW7_iB^cgd~Z;K~Uw6nx2^wHCsqk5lkAAVgk|t1vI?`qzMX2;lYpf2pi(MW0+RBBpxP$IgcP-sv`{BWQck9P zQ@k?)oo0JqsuuumIaBzg81kiJ2x?~Hmg8kmGuO>&6(IAa(F6)%0Z?dx%XxDN%fB(MEr%KXMe%AF7Y$QJ*a5zEqDPe}dHrOSbqQ;Tp(<9!Mn#IBco4 z2}q6($n+bKJRL|w3-U>t38bPBdUN7Fq)8ZIsz=J_2#XHGl{6QSjw~RO56A=`kkTh0 zQ+z}N3{OCmN#x(^F6WFPzWC%Q!qn_zI$F5?W+&2#9|U$Sf4loE%8=5HumwH!}GI zBtZqF&<)4}5fDEekR>7@3q+7AgFME!GHA0K^wyl#p3qJ50nq12`bbtMSeuwG+iNP&hJ*WIG8+5(vnK5|Bg?kaZy-E;*3GFO&`;>ClWxHj;oi{ygY9V=Cish{zrO5W`ck?)_}|f0dcMYnGFKsU;{EA1jNY( zr0fo8KTRMtq?pTzn5DW3VS+Bn0Rio^2}lDQNbOlXka9X)%1;{WASg2h-O|)9Q6%O| z6oE^Vv_xx6^)y+B7Dci=`Vx(iUjP&?O}dg@K$w;YiPl&es2*uKhh;KspWmFKtlBN9{}FAHvj6IXwU#)V@SqgsD97`M%T$ zg5x2$NhTaBA^~T(DI?v{D(q$mh{ABo&6Z3XCJ{a@ASlXWH-YF2+zO+yp*2ht1CLAs zUyvvOgm9v;t{J+(x{lV?OjF$)s!Gfp$R)57u2?hSir0c0ria}VU_-nZF4QX5*t8rj zge5rQwD~GtRqY;A@Xq;DGx9aogZt@f#)La@q{n>!#@BF1iOm@Pa`Q$v_Z6l|Yt z@=|cxXEnpIWzqJ@mMF{J2JAgRIwTgI8Rw?`iREr4MbibxzAv0pud`QG&%4t9UmTlWa|Urj@@lkIKyGsA)u?nBv2*9eTW` z=JwEUwKR6{O6=(kO~T_%?`UjqXla~-NTs2;gjqzJpM1^9#)7W(*DWsJPo23iiUB!0ir$;__9$`2(A z#n6N!XHjhEBQlq>b|M`w7nnrYIB7;lLt|sFD2m9aqRu#3+yxsc5V?--rl%nd!MSPgW`O>uciK@y1%(%iLQB$93b>$=`fY0A6kt}s;IO;cM8pO(jT{&q-(7$+r za0cLkqRw>Hb1kBB^AuIsiJY7qExCC@D(pl>UW9~-I@3>&uEbN^g>o=Y(9FQQX?DaR zkI02DE|q22iW1ZDjsl|CU}fXgk*PH2sWck~H+4*JHJgM(7*}Oyrnj`u!HR9x3~mG0 zYt~AqPCE6{!D7hMCQAp_Gai~M9oThv=wRtglMd_(JaW2ppu*=NEUw&XkqR^F?PL z9_}>z@QBW{kEmLp9rqF139m#_?iHh2D@PR$A5}PfRN?Sffx|}?4j)xGd{p7^QH8@t z6%HR&IDAy$@KJ@sM->hSCmcSiaQLXg;iC$Nk18BKs&M$I!r`L|hmR^8KB{o|sKVi+ zTH6+mYHe;*Yf%&5P$n5OWT8>z@ti4>qgtCERTK?JmGXq6TALfyT!I|c+Hh1+yBt-@ zkQ-IhAC4+INn?rnaz4?SPNRx`MAj7ka$QEXcCArG)ikPTPfjMh98EoW_|!KwPnTHF zeG&NZpn#8z58=Z@g!SA@@X6(o@Zmv;^^pV)A08A~&piS@xeNp!9wzv3uY?cxN%-Wl z%eND`<=Y9b#95I&zb5lw6n;GH5hp83N%mG`%X|4KB#S$T%Op;%v zAHs}>2xi<%Fw5nUFylc9vq%Dm84n7WagTsmE(5`ghY4ofD`Ccc5@xyV5@sT|gqiS4 zn2GFpnDJl~W;`6iOq7y<5!v$ID9m^g4+{YiCJtI?q@EsODYwO?;E1r4yy8;hfpAz( zvSe{7m?Blf$-MW zG&VN3&xY~4p=Ju;C)|z}F~B7_6eYr~5>HB`f+yfp<5|L|VfE$NIfNx8`}8{4@Fv(_c z=_%RKNKBDbp5#i*=-`t#G=0=+FRb{rklBHnVN_0ECMd9;v{5pU*G9>3sAiHOS60ed zSTUdpnyM2Okl+cZ;QN)BU+`AmzcISk7ja#oeC%9)X-MT*ML zPGPzAqO(*O>0nD9Uskb_VXGYZu+NTs*fB@Gbj}y#e8WPYhr^Gd-x;3sjmY_S%lUTC z`9|h^d*pn3=6s`azP)n3y>q_NIp3I^Z*0ytF6Y}P=i4{u8=vz{$oVFQz5DkDNDu|l5{0QQW;Iz3WT0JEF-X!%8+zSA;H|`8kI;ZlcIz{_2dbIB65U5;Rs=b zMRJ4@X2}x<#e{?rmdFtXMMMcBEJC_B3QEGDK#njdOoS0;l~NtOJqdO))MGL<=Yk?B zZ;y~hLimLG*RUnA(1T*_{_2E~8j8XI z@E?MgOiPwV17Zs*rT8V0l#)ocM7%tbA~&>#wTW%7$)2Jm+5;QO>qlyV_>oTmKNtHB{ZSC7NI^glXBZH3 z1eHlg0U1PlR~GFR*V7}-zDHMD$bvV zi!@dt-?3)|ZN*Ge)+3LRGJ?-U{KUSTiOPCLuritfcqxMtmvA^NKR`I&C2%RXS0|SS zkc!Dlf)wZAaw(8XqV?gVR=&QNgnV)Ss8lpDKSr1uWXnY3vi71(C&(4&4>3h*smc#K z&MC=07U$3IL~?fOb0XT!gzP3)Q*%mc=+vz25)QoNUke9VM1W4ehyYh^6QX06X0%gD z*;MWz1|gzclEOf=fFFe?>}X-V5Tc@(>_^9du)nBzt-gcs^MK}O6C(9hN1>kX)KWB? zTm-4BCDE>yhdt)`*DH-y4dp>3CE5-)ob!~T{7&gGBd|0| zj@U09Ny(EpvgHXCfhav=?{ruup2p7Td;nXUBLNAYqTDX!NLqQc=hz6IFI66eP`RwH zXwF(ghWQ~1e&~dF6~+0TzL6^NRTQ2miJzbJj0E{{O%d#oC+K{-An8bu6Lmf)$XW(D zqr^{di9=SA>iMBN8O)tA59}d{FL~&y^M99!=$&+C{2ZRq^qgXdpk>II$bSF+Yl9 zew0JPcO4=Xq@!ih5xm5%@JKdVBxDZ)iRI$W#HTkR8KX;|pUNW~dY4BkY{bgrIRbgq zMj%>-aF3eJ<8lmptVN@pwswk`u6rUyOBl;2E(4fm9nj)LWX0 zxP}|5uX>T2!zJY7%X-U=1AMC!-#Q>Nz;MKyzdM zpfK!*r~$h#=zxL<_Rb}e(2o|QCpiVEJD=DUwL=IjnqC@7`Fzk8GfD>lzCZ%f`QG2w# zT$y}I*D^Wt&|2-}Q>aY8uy67>PPC!!lEP9lcUV@Gs*^<0ubU(S%!a}^)v3_BQ>Z0@ zQzYYy#YzbL5#+-+fy6M#jLAG!#o`gJCkDG3mEmz?ZKr;+Ml5CpH#gzaxmCyo-oW zC88pJdAB0IFxBB>c*1;v!?AIE6tA_pM%WqjIPWJk*GLc==~$e05Mxc` z8dZd4eTjIrHexp!Rb6I`1ffu%QX4hG7#p5cikJKLKGBhotCT!qp$Nom)^4gu+n%X&H zU$|Y@)(*o&-Bd>$?BIz8riGcJvx)GN59Ot9rfq6tb8|~BiFQ20?jy6x0}(jh2EPg@QbFve$`&nh#9E`@afq(h$E$4V$hdpkuDjKiWpgU zJmnqCPnTd~L&XKtxaqa25ywUw>Ku8b0nx0He-}a&h*#n$FyHqt52?sXN_>7Wpb=lj zX!69VS^$)eY2zHO9I3e6Y>n3z1`MEy7$Kw!0|Z7;7$|VSozO&{UdKq%$kMs`MEFG& zI@^_|Ptc|5({pLLQ6JD2?ZA}=5};Ffd_r{8nkffezExFV)bMk1*k)8`VAD#S0n(}C zX^9vPu*ju6ZnQMCWGwy<;Bv_&e61`C0(UgW5^QkFw2Ep=(j~4qRV<=h0h)ZOPaop& zrNs?OJP8S(5XPtPYWeaT4LFY_aov|Eb`X}3@#(u;J{|2N6HtsyKX8S~3@+6leyB;F zx(ajsU_`PMYj+WPrlbB!;62a)Kb!7sA4% z#hF|ICGfZ8QaSprm@iLk04@?E{D9J>B@%mUshr&TfqW9vd?GNPFvyo@uyGV2d{63+ zG=bs0sa$x5TAo5gyu?&ro;m~^sGL0Si=!NpfPKP9pD^5)d;b9k%1?Xx$s~yP58s!{ zhu;sBCQ8m;OL*WjJ$&Ny>HLr{&ku7Zo8qZ|@(Ut(f2vPnJm4eSPZE5|@``gvvRsnj znG6CpjeiVJ)S9fS)|xt`R~}@>##Gtiq&!p$j-4vNS5^4=x3HPeEj2}TaRVozD2_gI z63{vEfR0cHbc8yfBh&$Xs34Gs{V~gnR|TsnK!LDm87J=~DkSL(TdAtRZpCL9n!wCS z4>)L7#0cKd5ngViBp=oknGePonNJvjq*qvgWJYe9p$apY+jtY$u!}7~(%(J{n@}>c z)Z~WA?Q;r5792Up2A}#KvQ@N25rrZGU!&+~!wkz|jHAzT=S^Hf24O+eaK#(LC3`oW zbBRl$M7%&+xPmy~iou5~hzhQ>8^`6?AuJ#VS3nMKI4x2;aykYhBqj-L$PxJ~E944i^aAS0;0qc1|l&7Zbve}sY773ShC_b!~(&c$JY`UguX;)W_ zc6G%{N{Xy=}ORN;}YQlPoIxV(02tOREhc$ zbf`E%hl~^9_Y=dffrOuZqa)r4`uq})XqOA>>Vl-rXKt}NudXiaVNOtGy|mLfJO zNuS|J(y{p@9fMEOG4~`LXHU{e%p@_ANjl+}Bs@*hNya3dWK7Zt#w49!Ow!55B%M@D z(n-Z6om5QHx5bijii#yeEG9N2NhcSR^eL7keQzpBpKVLh2cwcCCnQNuNYV$slEkJY ziET;J7a)>yPi8DBclf}iwUu`GK${{)BSkxnpitpqu1leKvC=?)DMu?s2d`6f@H$1` zQcKan>y+G?g#2N7frSXmH919}X-yIKqzG$Lge@sLG@YVD(N$6dm+U5zmn#(Jw_8kZYRYg>9Qy|nXq!lW_>PO!(X{_rLX78j*$yJh9y&!A1N|HjM z9}$f~ag!^U*Yk86+0kvo9r^P4-gngX$aPrgY$Cbx-Rr39nePZ?6Uzh$c5p|XBUP{v zO$$WLSZ{fzA`uW93wThb5ty(cv~DW$g-{cloFS9)j5w+W1`B~si4?#og@*bmk<>yN zLCM8+;Hz3|Y8#sBJ9u3^`OYqR2S6?d-$?;Z0FCe*J@60Pg{9cWBcS^1s{P{%!+d88 z1z@=Pg=a4iA*#G*ZuXjlG#nso@4;h`+EN3dljjd3pjl{b<`EwMY5|omXau{-*&Civ z90mxUxiDoelqeI1Fl9=1N3@a)QQ_YFTS&amQ)?P0QxR4HDiA`}3NWdut>A@mWhF~?1gkWNegzydB zNC;NHNC@BckAz^2i-ho%EfL}qG}h`;V7;8N!l5b@I2(p+`odOa=ouAy#)Y1m(31&0 z&7o(GOU3csvI22JnCQF+BZ8R%MyM$q;q4G#3gu2cnm39uc+>b9hiI73QWWmN(*^t~ zqL4?x04Pl`Jp|=&bfQ+huo=_-3EmnsW!EAT#G|FI$BFFZzfXpbcNDD5#CUAFmrOg>b<)LTdko{cG|6_5MeoJwW4# z$ zCl0)C&|ZU@;PT()^jj+2rCV=3aLw_L4ElB8(oVmHgZd6!f*)M?KEm(ufu{{zGH70| z{PQ^A)pudkGI6|HQy=23|Pm#axMN2I43B_7QJEJWE6? zAEDe*MH_oQe~*2(oG?$R#yzjy{$-c(=QY0l}PRQOvDH~^Z z$R4=n$Ly-?fg(>sc5e1ZN)rz1%_85H6q+Jkg%}AI3=+P^S41I500;!eiHb5~fL)|Cdw}){`BLm<~BJ z=kw%!*_V(mXNgTjY2nY#&FA30HEYfFJXT_&!~%rYp5N#4T>mqF;VjK5BfD7Q=DO%P z>#yC1t{A&^4%B;Z*I0s7muLawxjBe0?2_DRE$s@~u6h4B9JbS4&TL3yOS?<$J}&!g z_StSzICuCr{<06E#^>dGa=csDsN_?!U-4$L56Q-|^Sf)b>wMN-9@O#G8p(r*nW?)x ze}4_>uC_lDw{F_|Go>SWhD7{-$-C|m_)FFNS2K6d0cCIfD~;sVs=rd`Pvre;z4#Lq ziY(a%ne+cm)`QH8W8L<1F|G8MsEoIM_v;W_p3$hrVSmDQnAsJa=~0e=5^`TK3}ZGW+^p z$^GBT``;Y%f7++6bf10dKh>DMIs1%E@61O<%S5q2uJF;+|Bip%t#?;{_NVS*{~K}H z@Bd0Ie{S5nj>unWVcmJ7Yrwj5g(!cpUn3cvSnxfL1v+8TW6lub*6MlSX=+nCBq*C zyDy)8cwNOptm={uEz&wt{@NUVK)&9!F}3T8lF$1;>H9M?v((FFuUc0>)}6Ah64CfP zWdC@Mik9on-<{sLyL|sSF3atU-TY;*2Oj(>drLQk|8`X87CE#qKZJ63w^C84z5Gv2 zcBg0PE>6}F&g@9h2`_||G{<$pq-;ss$HY47vzy9~E~RrTe;mqw{>OR#LJoDvv3!4# zdk+39L*{b7$9F2J?ztkS9sf0Vj606+2^!Lfrxh`l`LX$>`IWWGDReJ#Z&g(DQ&@m3 zr0#=fe>}B#rs8SB(~jp5lsC*zOsvHAJNCN@b5C(kS1fNgZ#ShtDI5|E898M1knuwf z9CGxK6Nel&q<+Z3__Yj~$y;M@us7LT*3b5n_DcI1#PmT;pPHYUpPOIs+ECX#_f+>Z z_jGr@dxm?XyTo1UzU_X8+Q%w=(0T$-Ii8*I%)^tygB5MandHK1KV&a@x_g7;ktW}Q z!$=u|y;tVb*wcgm**g@~8V7zxHP2AC!2c7JL5j+j!NRMW3?rc_#d!F->nZ1AkJlB- zqu8ecF3YPElzPLJQeej*!(m&p9a$x-W+}EiTf}ab7{uOZpPS6=XYOvEU|wNfZQf+w zVcuyjwUSnuHN@J<+S%I0+SRJG_P2hv)<|qaoft=OVJaZKSd8`ge3ZcrsjNSb!GFZC z)4@aW|I0+oGK`0p{n0Ff{zPNXuKAQbw`LxE{jBksJA1R`YFa{e)ewj43s(- zrA)N9j6IHLr6|*3{Ra1U)NktNWEY@(2fHz=u+OqX*-zO8sB1As>`;#PsC_7EAF4R)rtD&jbqB_}17qER9-WUd?!Xv#V2nF3#vRD@ zIBH*k94p~I1^2})f3vyPYPi3{U4vX6hbeMikDT+6^Lpf*hn(}!;$q~!9=Ycs_w~p< zPw9a^%|-5jJppJvDIgZG5AgOTpkU$gvd?2Ao;Qo(UzA|nl z7|#xPhT{Eq%x=YYfx9c(u7q2SCygh-GYrpgq>q5R8`4L@U4U{oXAeYA7NgILf$JT> z^$xQD{yuQ~0rFcYk6J_V4m;wJy;zK10E?7K`Z>VcE!Y4ynC-xZ67~jc7%+G)JBl66 zj$y~M6WK}Z6m~j0i!A_F-@)!=OW9p)8GDdD#2#lW*c0qYwvs)?Ud&=*Vc`;Dz;SyMGN(*Q-7re)fuW4fki7MMk5vDwGm$UNJ;#(dgxEZ5q? z+LCBwsI?<#<2P%y^*bo!2}vXF4sHx{+-}MUWejGFNy>?s>#k64Qc9G?*cV%+&QxbA z$WyIHwHAFdBq zZqTRb9m*gSHkn)DPjk%5Trn#NDo${7hW|k{&o8!&#$~)#nbE5LDd4PF< z@}4=>Y*5}epEF-lKD79v_LvKCqkm2a#?)*|Iw zYl*c)`Odo6x=;Du`pWuB`O*5p`bqi8_H0l2#V)dYD!YRq}od0tIAKR7?ArLN%`YMEN@pWF49oQaq#|IROYn15w*Ve;tYiOJKF zXD2UAUY@)rxiD8#qu=S5T#^qb?@K{7P2T4>c8x{M5B@jn z_+95O>h4!k)ZNej>7V>iqb+~QUqfHs^_3EB-9O*-Tk$Ip|G6*!o9(atI|A=?vVSbz z`8NMzF;CHV+i=7N0oYTMz1It)dFQm<1V zQ{GlzRbN%ssIRMED_Qk7ZBw<6wv|??R%qk3@#=U!lc*E4TeKzW9PKu3xq7Jfr1rFW zn)a;roO-6dzCKJnTOXs3QJ3py=x3{s>F4UtsZZ)3=s#(e{;U40)(@oMY8x3PMv1nu zQEgOfn;5qn%e76J$2={@da|BcoX<8|!d&0nKuem~(5{v6Sw$-~w>P)f%FLvh)XL2Y zvqGydcQSXu_xk>nQ6eZBOes>o{$cb%S+-wilm; zw7socty{Ix)?L zB~Gb!FQ2uv`<>~|bnOA>Z08*9LFYW@JndoUa_4gG5k6~akMdbdTkbsQJfuDDJnB5E zJ>k6Lyr(_seC&LzJ?(tve5O6)eBpedJ?nh!e62m_eCvFxJ@2e?R%tIdzdFBaFFLE8 z)!Iu=*2!uwyI{7pSG;PkT6+~(cO0G_fq9k6aA4qAWq<6O^&}da}A7IQ9$F$$TXP$9^?%@-SfJFY3(#BcBCE4p)ERYn#@O zF z{REEL`i1&x{dfH$145X73CD8%Dx-(dU%%S$jS~HCV;5r={T`0-`n|?5W0-!QF~S(3 z-*1dGM(Ph3ql{7dgM7u+A2P-o)AfgqxyE_=TbwrZAB`uBcOk2MZ>%;pV=7aPZ8)_U z+p;27Wc-WMjIkZ38Do3CLK}nm3T?#LwrpD?&bDLQ8wtKn8$K&%LyQtmS4KJLYG;E} z&Vj~Q^B}X?IKWaZ!^2^x6BIM4dT`pLKg*zX!Q+XeR4#!`D*d#v#iu)Llr_7r{#bG=QwtpbCz=sJD%e`JIT4gxqzMQT;yEDPH`@E zE@ksT30JaHo!gw-*nCb8>`YD%>}=4(a(1rsjPpD@-+9G(g~vao%#? zVwZwW-e;F_I$;Z(&z;ZNRn9lgH|%QXd*^$0jq{_kie2mc=KRKP;0LPMjjrw*Y@rMG zk1gU9!WM%%&cFjnUQoq;%3^hW%c9PbUr@!l|j_qzd`f%jmwMgiwtj`2p3F~evzwl><04r5zmmT`o! z9j5`*a)EIJ@-8xNF%IPPVAKkFXg5AIJ~rkVpBi5oryJkkcaHHLDCAsY4SrV$I=PY= z%r+JXYPn8O%k``WsO1LMoAovpvOcVjaT6$KV`Gt^mRmtBI~cbKYFP?u8EV|cc4E62 z_d-S(W<0<~uo1?CY!9}F@emuu_A(x3`?7tFN7)26!B{Tn>M_vOuEq+p+DscC3hMd< z)HTES)@(OtGTl7UJPE?UJo7YWLq5Ked61E>WrgN4a~bPl-fP~=dYTWI53pY5L*~P* zx4FVx!TOj_nNP9x%{R<9*aqO24c3=aDcb~8dIH|bjRaj?OXRx8xS=L!>M^Ndb zY$xk6>v1;0dd7N&?Pon_J;(OvR0^uoL6eQ3$rbEr`%U{zGj4xke`Y2))tM!n>dX|V zZX+}8Z0c-o4s-fD{mtDt?U}oC+A~LT+B5fbc6BPvQBJi}ZSKwK&>YR_&>RCgY%#|| zhjOGj-Z{!S&OE?5-8tQ?1@C;0SqEyo(wypC#JJ4>9~%|k$Y%grM=*KD53>CK$a z>CHSF^!C1a4yQ5mQch#$Wt_&$%RytS%quu`nb&aYGOq=7ndWuw&+gCWLQY-gP2LD^ zgt^EY_y5>?7ceP`tN*{JdwO;TRzO5VL|yhWyEC&ZhM*Eqf(pc_ueyi?32qRLVSR}b z;}R|lu84>*B2mPNQB>9tVw6E*)X-=U6>o?JFE3F-3@TA$5YZ4tr2n6DW_o94FR%+3 z&F}e7J>S#SRn@1ftLjvpI#oU0es~~h{+Y?he!9r*xJ8lT$dbs?$b*p;9>2)#DPPn0 z*sOhVkyWu-k@q8OB5Nb-BO4-{B5%jGM&57Wv@wQ@Y!lkDix$K-mPaEyBD=w%u1(JS#!*iUy~tXUUL#O{kuj@CtIM&ptqdXt32cJ7~xE=*lt-g*CAtaiWL zW3f-#<{}GXx8a`q@?Cl7*z}Z(-sAb&czh}LQuN*!bl+TL;@(|!S!ANN!{x2I$CJ^A zVvj^uMps9liLQ&j5`8`TW^_yRgXs3?r_tRpf2>`sQ><&ON31YbSRR0HS?ritpV*nP zezAeEAsmOsM#RR)s$v(%E{%=%`W89nN}KjNIBz*Gi@? zQl*sLj%ia(%Y2sRYB@c_W3-+pvr^nfpVK*Z7vYOl1~=S!gY}Ed`a4l|ncvk5Q-b9`szRlwou1)!xi;td?cH6>ti7z%byf`*C z5Jgwknx<)Uboi;c;3(IwQvnAl~x zF1oe_HzS(uB-QK#=Spucol!bFd}erP>Acbf!ngGH4ENGS_?I;2N|zKp*@CN$Zfn*Z z8{IFLYT?o2vt4*X#zl8D@4{8l9i>adBT64ET_Gt-S4l|p#HKD*zJIQCP3roh&HLk0 z(ptJUb!>@K>H5<3(bM+F#R{Tl#Ripbhz$}~x+yj&dRBBpQy0ECd~xjbEEmu6d*L%< zEx72g=&Cloj4sV~rQ4!Qi#E3Gq7Oz_mF_6rRc7MLaYY+rb+Nj# zU|CR(I4@^%9F%jWG0|x)mF$5GkB-c^vJPb(;H^(r)}^dVvrqY2QH!pudvrx~MOlH+ zMs8KKsT&mM)632&8(uc5Y)skMvdhY@EF07~gx+gcbawdCSch1!Y;sv$%#3b}4cG^#GLn*% zR?+O%*OLF7SesngOmWfoqwg0z=Q+wUT(r3?9_vnDJge-c*eIU(c6#3jrS~XHlr1c~ zr|jOMxn;}B9x7W|wz}-~vh8Khl&ve8U-kkWxFH(NV`ac zk+Pz@BF9AfMEXSrMutW%jSMMT961x;h)7lBV$R1$CPZo@(<8GYb0hPM?u*1l!$nwZ*k&Tf@B9BF$SYgf#ayM!mix^S27u>!f9-mg5?yXYnSFU9&Iv4aQ`Nje9wGzFt^mlTXETwHKP!Nh`T#E(=yqa|(mu*UHkH?Lqu!R&&0(QwfctPJ6T1w9{< zV?}|ufiMXEK3syyz6s$>|7p&#zIkC|!Em&W$ zp&!S3wz$v^WL7zdOp;1Wnp3A zz{r}Ot9w2}?7C?0!ZIv1xWcaDdcIQFqp)kVchA@L>`jU3xuxd^J-7G#wCC(xh6@Yz+PcDHu!P`b_E*@au#X=5h0lsjm%K?nD!ND32rV2^{0hhN!ZQnpR+M`# zHmh(%VHLCrcX7k4byi_*;RM{Jh2x>M5+2!9_)_6rShhA{-@+A6S34KBF7Xu-k8iHV zOB!{B^9yfNpI$+{TrXEVSGc(FzQW~&j}$&u_@tyLd=9*%zLyGLE6*?7ShyMbF4!8` z6q{RQ2#SotPl}j_V_9Q9UAQw9mX4MAIZookmy{Qm7Z>G&&f??Pt>~zf58IkqbiBIa zq7#dvMaN5sgr$xed#va8;v}}{L^-SIP=riHI~1L!?yRC=(GC?|ibfWVuIM7JJX$ml zcSX^HqDzYAl}9-rr*siv6N{!5%_y2p{DPv}ix!nLy(?N;v;-^_SxUL`P1x@G7Cjj4 zTfV7iMbRofTSLg&@=X#~@<{onlI1BsDb^Ql5WnY&HWfrusfxB0yY*p#!9PM~YWAc-qqQ!%KdUCNKoAD$=vlq=~G9VD(eSlmI5UbymTCsabP z1me77ahKG2107R504w2eamC$1LF%kH%+Yhh#YaW;F7B&gaz32vhLlenq}}GOo1GV z#rGB`)ZJ8^XgFIaKGJO@_8zdT_@UyJdi}-}NAVZ0?#fxVD}FO|7JVgkEPkeVUGXc$ zugf*LF%{=joKw67_d)UYigUVdE}7ev{EDkE!w+`d9DXqTVDYD3Sn=+PLFH>odT2RQ z-qCg8c~a^E@s;=`CRKl4?Qq`7tKC!`$@Qta%B0Eo;D3y|!jdwMeMdfU7GThjK`*wb|n*1 zzLHv=^HM2+lIil?xD`?c;=H5yN@jWG;l)X8s^o}GU#r(#nw>JmL#UU&WL9`ZDn;t* z=*p7$B~M}lytm}GlDkS4tGh3HZ}eV~GLbb>yhi-WlIL(Ql{_XsJ?AP7A+}^=$>x%G z!@~%FMqJ4!C0oHK;x9L(Un|*JvNIeA=Z8CoyM>Pm)A+;3hKZ z@M+=G%8v@4RepSAQ^lC@FrcG!MThX{@Fn4KxGTaFOU-g4JS{w4AGlNONjSFjzdEv|f= zxbUj*s`44(HRV@?-^Z;DuNS9Qb?r5z=O~{|Z+W}8@CMwb@Y|%^#?_0;=fz$M?_dPk zg(kEqRx7$qd2y*JJ#%S?(qL(@{3woHN()N6;|d53mzJ0IF6~=7pmb2_veMJ}2gc#0 zqi|zzV@oe9y|Q$2Xyx)}ySjtgP&qvOZ<~$_ADVDLb=lXxWIeD%{0omzIq$n^0C; zHoa^XZf@E9vc+Zh;chFt3!3sTFMFiyv9c%82Y8;c=gMA+jE)4#UW*uI8_PDcIVZpD z-LkD^pF~cHl_&w=@vPP*l6U6$g%J^K5`=0T!F3mw8&YJVZ=|2 zjKhrvm#9zT1mdPeW<+L3=BZAGW`aJ!NS*FIH%o6q4{?|1v$3uf9RyKIR#xsR!jUD| z-BQtAAUck$xK)%#)}xNnpF&yP|ZhbJ%jZ-S$k-)zE~ZQ^?}sdz?0JV#GWf( z#M*$Y3T)MN0a+E8%_`(h(bQQN$rouV?~Inzt^6qUqaRgtFF&@N8B6(z<)@UNR(@9b zu=0_l7+rpeO7s=wSCmgIpH@DDad39|yz&Le?V|D}wdL!}MGJZx zo^O}GUyjaGzO8%*bXSEb&s8x<%9q}xJ9C*XP-ZCTz`ZN_Rt!K>A5?KVqw?^IQ59n< z##UU${*IH`%`}HUqrJtt#W30L5aQhd*+uKuU9$n?roG^;*U)aAey4knc zx8eV#{Y#^>eY<@-t>g|`Kxb_MhtLA<#=pp3L^!+Vd0XTj+C)g(L`d5N?-JPe8{g*5 zjio%xGJ6^CA3S9LfqSj6R~S+DyF2)$eOR=QXN(T)e1Dd=2H5>h>s)KECFBMB1wz)* zdfI90X|Js(Pg{>o>)CAB_S>|feA>_!qtO1l{dc3te%F2%|9iBmB3jjlJmYqIJ9lB< zJpRw@T~OC{c~`?1FnJGzo%2Rl**k9>%=W2vgtrg0H_F&W&s$Bj!VdU5(iXdFTRfPy z*qtqSN6;es&>{yKfxyXuQ&e(2jY3BrqW`!8j`Lv%p0BlLC|QPY&?DRbWbBig8k)Hc)GPD=?K7 zZ_?stLa*Z;r3%^WV|3Tnf3mjz0$TsgMt^qw&o}xa0}F_~6-hW&C83K-LRclCTqWU9 zc{70bVR|A7K9z)^Nyq$*48w8&l9e9gi zm(iX#3BJI;8<}aZGSd#3dCtg}cMFWX;0wWZh8=tnDau2N{$z9uzKkr{$Wlk6U8qy2 zlhHABaOhwo66zf4jQ^0(A^5u>X&qJ4Y?U-iCC!hdB~;QZ^M~dSjgBgBeN^7stGrdH zymeH0v(o*aygy*6jPYg_GIprSSV5MIm8pz%<9(x3iOopbF|sp@5RtS3m9#RIv~F3F zcC_}FK9w%ho?%~03lkZ$?d$C8@Qb9G_H27LevvoZo@dWPKBZ^vqEdI1N?jL~x}#Ly z4pvD!SY_;BTVyOROQI~5sF?i#J+2SAS^-aysB-OheJWRWmR#klTpg4pSNU0Tm9KIY zM6NdT&eB`9ycZ>Mb&$$c50$GURjzudTpg)$W!l^9k4P!fW!oRyALADpGwmJpRft^(X3snM-4Y<4q zXXUleI|$k9kjEQ_DuL})0^6$uwpR(vQwg;4&dWPb-jQQ;vQ!R(Du;nAIn2+J!~85c zJV@m*sB#!kISi^Cb_uQzt~Y!tje+2+!B>qymQ0qYOqQxlmZ(gYs!WDeCIc#yw#uYs z_`bN0KF8;iw2@A&z*d*2+P z=4tBxLa6bf`WGp^PHBbG_Ck%Dg?_P&cag}yP+x^`^UcsS8uw409&Dlb&i#6n94Oy!k zcv}>nztE66rTk|F>2FjzQog%D9>}5bq~>sz(t9O`#*>=cS(;nkXC+s>@e7@$*YYMX ze10g!pN$H=32EM0$Ep7j1y{bT|KD#ad9p$t1Dg3-4&C}f~Ex~`f`2DB*7Lxys z+BQxx8TYLMa{=@k{|V&tcT)1^TmHjH(_Q?Q<^L@qA8CzzuCc$9x-j43|E@?Qe#>${ zmo!>apKI)M)=$WPZ@FXM5z@xtJe|9rXFfrHQ>Njgh4StSPZW&m6TRw#kFC8#@Muk= zr58L})AZCdYc?<{^xWe_b*IP{fk zExh>q2=xz>GVynkQVRS1v^Vc@x`b%KIrk z|5+NYy_~sR!`~5VeX6l4t$v0^LM~CNceL(O{|uq#1?qoVs9#%}t^KDhS@+rPHMUY| zN-n;k{zH^Lq4aa1{vkq{+X0PYUT{J(|ZcWopDDxkorwFzFsQwi~O_epjmV>=g z!?kT#T7%}9n&#(111gEWZ;Rij+>D2J^ESCp(^zA9d)w!`Sz|Sa=BeVheyViy?jI1V zxivLEzH4@)-IzB>teKKfZO>*eO|SB6EfBvyPpOtRGdDu)bCfE7=3|oPUZqQgn$v|^ z9n`PS>pxBWfpJ28kBL0`-cfqJ%H88if2DMhP~+d~Uo2^idh<5YCnUtYT*H?s{f$s# zjmhX?-YwKvqv6*nJzVMUmEI}TJXQU3)&FDlU#}AVxYA!KT_n`_xB3@kAZ$DZOI(2X|z|d_-_WT)s|ptPRywq`;UzjCDcfjjON7phJ^T1_ex3bMVeAuwWT#; zXuoHcNw}rCHMCUCVKN?>EAr4Gp;LqgpH=#d($}Dm<^5Rwi-hJ4g1#f8w)Lq{f4 zC2wPu+tW&47V6(6ly~NYT1wjswJMd)5(=M5&?_|$H*2`9ep~Kt*?Qk?nv-pM_id8i zIz&@GA=E!aDDzIC6O>NV@V_Yin^3cZP~W*q+e^-U?S&guo@KOzTeFoeR60*-La3>w z>f5CLu0pwrJoxTcKKCo1`-P9yPp{Ja_%%QNYb3<4x$K$!;F8;fPaO;ms^Myb5D?-c}dY2i>^LCBZXECqWvi+r| zxn9#;uQ{Bn{<-SEQDf&Q{Ydltk>>v+z4jwb`H?>33eDkr5@NllrSzV1Se~ZRijwM2 zYWk$4@y`&>{F{W(l}eA+dnJ`~Qp;qpg!uIt%{t{!r?KI@VepLVtSX#WfIpn~9i>Br z209D1S1V;kX5B1)>t>mYSvq?%M=FOZ_)^b%Ti{`?Izj2#%HeF8&6#H_&$DHI z$D7b{ue0;yUT4dk&Ud5O=zKRQ9iVhp%Abc`>bp^>IaK)!l|0~=ybX~4g#UG*#-Wsn z@sZ9SKayDEBS{J6SqSGYP;%lsQ?By;DMgiMZ}oSPvhod6+Dl8)FBHyFFF%zu)zUFzp#?IIDKh!(!P`XfJeG7$yZ=t5?uIan0 z->+!~EA5sSBu%%xfaVH6dBbnsAhG7Tnul}sj>k%fb%H##rB7#_pgBK5d4|)JShJ7% z`{-4D^uB$xZ2Rc79kBZN%qiM#r)V4Qq3L@lw`Zh$tOY{-c}lhK!s00-8^mv|RXR%3Jgt81{ml0@FA;;+HmaO}eb46`d;~)^Qqgo>HCro8!c9ouVO|m8xaHEEd0C?F?qAhL5zn zz$ew0UaWVYuCzw!O-gSS>iZ*WA?SNbZ?HGv{}f9yGh+2$rv7l?IYRD}5My=VH_)f} zZxf;T&C50AGNr#!`l{03DLql?OriXT%H{BRKtt{nY7SQaT&0VY&Q|(@aP~c{^a-U; zD!svnhU5DbJ(?V<|1$N5O{_h>`y|9z?L(T3r_2(e;x{kXl*^RJf!%Ck}`lQlXRypBwlul4OSE-J?KJ9hwG6}Iy zNu|+Rm@72z3<`&3gH6)_nxhryehUbSLSxLfT7Tq;#r;m{T?ON)6eTq7uTmFJtJ98nRO;jf-(m z#*6E!T{|V|p zTVu~wZfA?EF$&Be%}@!U--8eRx!hNu-PF>i|CDE0sFL1Y{oMk8Bh3k#@&uLHu=wfs zlxj#1_4i0gbgHkAC#B!uTC_OHb9;S?_k~*P)IUM#E)B_7|I;$xUYkmW8LEhnbX=l%BM!aeCGJsh(d)mI+FqR=PsNN2&iqN$LBiHG8ve27^9{}0g-Vap*zaok5hLxFm)LY!-Ma{WMBXAoh)ioEl zr?6*MO)cJ2e3Q7>C-rUWCYMd02|lRXopM*ryk_X+l})Z~Hs{TxTG{mJzqF8zw|{W; z=t&!|Svh5N?G?3sDZ`DmS4g{IJ*G+2TXc2^^Xw%r7 zki;b1^kShcyUB~5=(;hD>) z9i_ezLZ__t&TF?^H?a1;>jtLNNvkKtq;Tq-l*f3!N#iCj`uU~uB$Gzenv)i1o=L-k zDlu|(qgJVTs%yyEh)k}%Fz%O_xNG9BmY=(Ym`7R&Z@x~-b5fU2<`dqN4aq~w*O+?G z)5>F$)|As;_8J=;JE^?xlu6~2hfNwYdC}A>CXK1PYtqI^8>dZ_-hnbF6gd$pIbJj= zAy7BQdoFm4@cffI^K5&)D=FZSE{UMl$A~nQqe9CF-xPziL9R%JkHc?HGNtZt`1CkV zk3sQIedgZhCUuY;O&$=xbW(S5lV^EuQkO{sz?gLSfNAR|&*HgeUA_92v8gmym3wJl zeRuL@lP}}hp=mc@((FmkeHqTXryQk^oID`osE4vt>y|bn?c$z`vqv-SQ?tesO`pDk z+R7b+sM~aZDE(ln2h8r*q*STnhFVS__Zm-$hduf9q%9Q#)vGczU9&3Hdu|PIE!VrH z(vF!lL35l)^OQTMTN5R-vS~?78uN?Q(~Bn?v_aa3P)bOiQJl2jjmj}0<%NH1ay4NS zC=+>luLa6oNFnE0_f0*M#xY*QE92zq)EWKufXR!PSE%jyp^=84KO7!Ab-`ERf?HbW zYR|fML+vM5?Wp~Pf0}-C=KNX3wG+m_H06USZ+dR(eNA26^s7eKO`p2DZb`~r{p8e* zQ?^WL{tiQ5G_4)e8nVj#V zrgf@)F4t+;hPqL5Y{}Jb&{WdjXp73wdv?Z0P3;@plE@EkSvsq(pGfbNl~+xZekZu) zn#EV$K4qnr!Ah@mq;IGlQ`b+=JJ+_^P3oV%vo)Nt*VmRw!8+8(r! zbY0J0H)VTkN>kEv&o;WNM`c<7_Z&wlq}noVR9a~Y(n7>{)r#6NbBjfmrgBZ)^hSOE2ihVUMx<0TG-MxTy=Ic7;yaE~O$4DUmeuucG$|ZV_#xuAiJ= zv-s*`p)^W9CM2;m)WinM#lDE{er+m}}X~xy{tLrBm$RBNDS`_`}M)^l~ z{yTpS_E-5|eoOxCpZ~>o_+R|~hPig3Xg+Ve^il_7s&Ogn$C+REw6|R*D90x&^UeQc zx50t@$qt#^pX@X7@@E)hawA(ha~~&0_D&fMU+6kB^HurWuSv=mIpsU_@|9cn4f*C& z=BIpw3tzd1I-t&rb>EZomVU0|ZuP!$FKJR=x_94gCcQi%zfAs8=Pgt3n?!E)p1$Ci zd6JATo2PtHGIgHwwXf_Xjr@Gm>_hJwiE5s2_Cwiaj zNppC3fgH2yPxgZC`FBBc(b-SkTnlhlX0A$o7s_4kKI(;e`O17TD#L?4r~Fc1(W0!g z_@u7Rr1ZF@!)3=#&d>XPYAUd$>oS*Q^2hzNOJBe5O-_19`zKDmrXk0qJJLN9zM<^W zAUrJ*?vGMFx5k}AXy!?J$kl}Ol5g?J_vH9KFi)NOG9T@^PIB($02%W5-R}O{z1%HK zU9B8`q$#y$(r?L$d~VOqjpon0K=$u6{Jbyh@l`k3(Pr=hoql!CeUm$GXh@d!q`&Rg z#kRTARjE8=&N!F7N~zxkZT||OcWJASx3)Uv zfk}8p>)3c-A8%dirm4~`v1)($H09h(NHgP;D?9$0{<7-5Y2+S5WM5iqejQtrsKqpy z{598VvoGu7{>@<59#UJNZ$ubIrWcMA+9{n_cJS#>Yee?>8FHO*FgTv;>p0RAAU$!u zom_xR{|Nb;=l71%M`TO87vAKUx{qNFu-5hFmlcg{+6e#Y(6&bxTE3BnBsnK_rK%{ z^?P~TB9}{!UD-b?4-pmWsr~S z<8n%v9(moPc8&D|lbB{Uk_vh5Lj zzelq=yIfYVr)#s?VSyoUakc)pSADNd->q9p%37g*Bv9c_QxdhL8jA(Xui69eBnq za7;f_3$YEs?&}TSW<0qT(k2PA#@s#gX>#S47$B{~ljQWg-Jpl)=MHUrQ?D=gQl;W^ zTbG2n%XGfmAD{A&87cn$?nXI!zl=;VKdyJb?=JUZNUQdP-D@RecUJzgQtYK=czMmb zqCOMWREqX2K^ZPzH(%?ul|jpel& z*-zC2Y0|$EjxUljF6Y?m9FcqXK{@k&?hEcuJ->IvvMG9I&iF)J$ct}u#9}I=Rr+@U z|Bcn?*EFY%5~NR?+%c1^l_Nyvf&=VJX|atln`R z>sog9dB)#_cN5!CCasafQ^GSNK~^gDx^HN_*capDEG!1W;So}le6Y@y4JV+?rDxKtze9Ae8-0Ks~da=#|*lXd)UIX{0RLt&ep5D;7o`~_}vQW~d>x8~MT~FOuyY8RMpH%bQyi8bXWyW<> z-$}L8Ok}pN(0zxT{WUu%?Qc>~?rrYE>~!ezySOj*$;KnAyUTmF?DWn1Q_s446Bg;F z&y+P+$y}XTfyjh4_BHOca^qW!m3G5AAHT6mvx$rdH>Lw5U26Q6(7DFabT|j!g&BX^ z*Q6wyq-Yw@c-|*>NaZY*Hj}@x++TjF5i3PgX}mD+tjV!8CE4Vn|6str z-mSS*Uu9ToXH9Mnkb5UL*k&%G#@4^qeLmP3BQD zE6UCE7Im3)Xml$(nd$KR$`|Cfch|4e1reLH zx5o2%M~TxJW0M#!g&d`QxtDt(?DN@^&gvygA2WAP1$g<(Bx{@^E2hqU)s63d*EkX@ zrAWn*rOXRA(uBtR0=Te+gHm6+ODQll*?rmCe zwp)9P+=P42U2ggVbC$JBH@*9)xqZu-du4i^mRBG%ssGcyRPI{RV%nOU=63XCp1-Z! z_BFkGpIn(czBbsB_JQf{_06mt)a?VK{kX(^L@s7$cPPDk3fqnJpso0cl}FI@*MjET zG~rhl(qyHsNmkN5O`=IeI-q%b*jLahb7#dZWw$lq*>mvLq;FN4ENgSK=?~PqLb=TM zzaE;|9bp)!<=&zTUa-m;Zy z7YAz0WgNiz`QPx{dtd%_e2Ug-%3sHvHD2w`KbTwDm$8@Mp7*FVUO{Zc?Em3ZxE^WNp-O}iSR!fHaN~0^A zoa!6@hh_z*E$~@AN_MO!Y-(}Hy1o@&Z?Dt-@@d7b_Akk>{k!~2zxqqgUrLwx?;H_Z zD*Kl7vzF5TXT!aBeOYb(QOkGE=!zL%%c=jvF=9(-X%$cQ$$5L_a%~Q|S2X|Za>aQd zb>)@#p3!qW^2^rsHQ)k6_jjw`Bi?=Hcme5@sqj?Za#wD0&b};JI8c`O1CzguMwI1a+iAX zIlCuXwA1D6u1TlL%$RtdR<)2OeUq1oCdbr@XV&U{!y=T8! zd@UHXLN;5S&##vbyQlZmTKz~_37gw*lokCS@p}L7+T8!cwwQsv|37Stmr6u)J?|bb z$c_BUb+H37KIJ}B&RK5x{&Mu8thbdLU6`_>q&PO^d#KUA z$=%XBTleQb1smnz0G$56@ove1cnaPPXKdwPw6fkMaPMg}-csU?L$c0u_RTie@41b% znXGGDs7o1#-8Wl^+*5Gbo+8^G*W7zf&4sm{bIVucNMg&$8e-hX8oW(B?rlwiz7_#F z|9s}X$F$!$+k4-=-?zAazd5v;f%|Erqz!YZM&apG@2xT zZIto=oPPOtH~^V!fra~z79#$$!M@NfZY6&R-p)M@I)t11{BNn7|StihSavqr~IH>0Cr1^O9>1Wq(M1y16BcP!G5Chcg=qu+ek zFihURHQ6N0*=kDUmMah7lT-rGyKXaYHL<#k6a^t=?!Qa4UH=eiK{k36}`@z|H z<4Fb^fyVa@{LGERC2j0Z_$9D&L?dVofVnzOA zwbW|3rqMm*`Sm_|=0n{52|&r%M;aaNqdArt9kql)=7Yw~)Y#3Wy4idbJO-WsYmJ+O z7lJX=Y5Rt!eZ#HeYfW{0t*Oo{i@Z71}^o+9NO;#Ca?f)DZA|j=ethu7FtpV zEfHE~n@gnwIjs~)B)661wvyadlG{phTS;y!$!#UMtt7XV>PA(h9XkL=yK%fcPCGaPAjz8iq^q&AK-G#vu!Ab6%-~jM#cTVU=cVXxs-~;eau$BLZ>gx4T zzV+@3-^<_?@G5u>{GYob-FL0<`mW$WcSZ1Iu*_W%x)vnB?ci>(4SWQ)GYTB$+d!}S zXYe|B18f9;0n+RK6=e0izPC7k8@vOyfPa7wz=v);cnUZb407Y4`QR3M-NSr`GVc5m z+z##l3&EY>E^s$k1nNP0eDeL;t@if=-v+0Ep>B2Fd2V&^8=$vaEu%~58gMhCjBt10 z?lhFnLi~4vyTILG5#T>X;qEY^IdFI2?!euVu?p@E+#R?(aF?;mfx82D$2bgWsiKWk z(MGChBUQAKD%waDZKO*2P1;BmZKR4eQbil7qK#D1MyhBdRkV>R+DH}SPdCP&ZnTvu z+Da8|rHZyvWpq1G#o<{T8J~Fuhi7nj28U;Gc!opWD$2i#@~@(HtEk;7%D;;8ucG{`DE}(Tzl!p& zqWr5U|0->%GQ#%5|7~yz7)q&p1N3&QsDmo%po%(>QaTh}EY>N-9A~B`B!`C6%C*5|mPcQc6%t2}&tJDJ3YS1f`VFx>=9)rn$Scz9-&GFPy$h z(&*mIvmDS`Pv6Ngj%aBlZOhoaCtbuFvsEYItCmI`8gfTgK!gre?e_xpqahPAM8ABQ z(D1QJ2NV8Vb{hMn7ePCIh!J5my}xL^gH?w!JzWc(7|z^d4!)~snPTCSusW`-!B;`p zyYyzJO3M3aW1>$dY0*i1e2ZJ zL-ovWKjYTeYu%c>&%qbu&hJN(v#v-QlBSL{b)=~yO&w`EqSxQ=*7zTE`y)MZ^#4ut zq;G=1fz8}U()9B8*YZmEkq4b4X!)c|F=5!0Vv>@r@!tn1O}jH!+S%OgVmG08ka@rb zlyyCMs}Y$B97)=M{SqORul)+oK_3+ep^+R)dFR9gf*RuxlQaT3cU?*jy*mde?>cHR zRg!;ktGSm$IaM(qkZ~$q6IrF>7)3c+;J)f6p7xm5QCiX>MR!q6GFyK^s}vnZHOnHj zBLnToKsz#Wv`o2`jZ@=0_>PZYvNHL9I@>Y<(*s@EFIlNzOkIJAyBJ4UP9LTi3kTXceJ*Wr7S zdgkl3L7$rlnzV!tpd)Q5Pvn>s)ugBk*VnW=dn!8kUmxgx4ovOK0HFv7!PSr+8N0IC{{1#<_mL2f#{#Qd=Znwu&*=5>3 zr_M7oi?QC!g1o)u)fMYZ=h4el8Mhjfz`bCpajUN{I0*~@bBtT*z2X6vxc0t$H(%<^ zI+v2Tk$mzcVIBS6!T-M7%id0TdXkx+BTKC*i-VfY&GF6Wh&k3^%~c02n`fzk7eJ;Y zrA9I(nUQ}dkC`H4mcuNpaUH$^htwC6o75*Y=TLJFHRn)sj?qc3*3!ss1#RNc>gSbt zlgyc9u0(A#JbP!&LwEA=BR63k2D*X&1MeVJ33RCYv<>(2FWvnw`s!XXI@tHqV*la} zwcpehTSbeA8=YzK_5Rzq3cH6NZP|~u>_=Pnqb>Vs-~M|5+Ol7?I49tofO7)Q2{D6-b3L5v;am^rdN|j^xgO5-aIS}QJ)G;| zTo31ZIM>6u9v!|O?)7l5hkHHT>)~Fn+>>xm!aWK1B;1p5Pr^M3_axkta8JTrH4?Zd z;hu!M*ejE8Pr^M3_axkta8JTL3HK!2H=yJE8N3dVcxJ~Avtvhfpucj=(u0^CJHEF8 z63Oh?VRq~=J9d~IJIv4=X2%Y*V~5$X!|a$DW^3dJOIH#rSJICSrcH8wcut=)o8I5+ z^^eD%)VSwgZCr_MX)t!J!Du86eGj&+!PvG2W7`^xU1=~HiT1_Vx5TD2n3>I?w1qad z8&79$+r0W-k0 zV5SlA-wz%He+TgN?=~WKJCF|!0*8Ta;BYX{h=}Dp?_WkFcnN@a5Z*y}huQ;XNTGoo zPX^>GGzyFdJXh%VM#SjhYv<w=3aXF=(iv6{v8cm{&B+*8aXd_9j(W6x-zMK($2Dlb@Hl6$Ndv={{O^m*& zH8!5WQsaVbI}hzCM~hXvPtR7BEV@@~t@Lf_*F4JrZ+Wyvqiu_A8r?!>=FQotMHgc} zl^+KrD2M^s^;7|l0lmPn;2WSf_$KHBjsx=6&IzC|_!j5~`hyd}NkHD#{5BZKN)@YC z)(#$(=MVDyL7qRz^9OnUAkQEAF<_5M=swc-@_o+Q*Dmk{*bQ9zOCn4kz+=M2go_Cm z6D}rPOt_eoqDd*5l%h!~nv|kRDVmg`Nhz9?qDd*5l%h!~nv|kRDVmg`Nhz9?qDd*5 zl%Poonv|eP37Q3f5;O~`IPPgn&d&t@1I_|xgL6P7nfykpS|R^Zt$iM=It^Nd56!{^ ztnjdQmh>|V_Xoh9W)*vHrSY6rI-eGK3)ow0?ZX;Mk~NehYbZ%pJL*~OsAsjKp4E~MWeL_S5@=-!tWyc!W~M zWeK!0vD+oFveo-f;8;oSP6PwdGEWBRv}kMzG`0k*DD|wO)U!5|M0-o1y(L&tsb@u{ zo)wk)&_4+O0Q?hd1=|Sw2rx@Fj%V~W7=2~r6+4Vt>llFzMqq;x*kI%}7 zVB|Fzc@0KhgOS%@r0W-k0U?yUIKX?%Q9l(>3*I?u| z7VB|Fzc@0Kh%xCUfjJyUTuffP`1mVrdYcTQ}p@HCJK%N+N4MtsqQP*J9 zHH=|~$;`n5ejszp0LTMD5CZK$dyo$f0v$j{&p( zNJ$hq}TuoWQh7Hc;aYd6+*!1|5#3HTS-2|lMk5LTgPQ>J-0XQ{XgTK(frr71%z-a3f8{#5X2N=!#a;#L=iny5 zN~6Vm5Q~XpEdaNI1o#EOMq~XFV5hO}0IY6W*lR58H5T?73ww=)y~e^`W5M6TW@BNq zv9Q@#*la9pHr8*!eE_}C;#)^n5-bDgh*qP$A^Wv}OwxTG*2fPf8U@ zYR-<=n$poy7^JHfhw2`ZO5ZOzvcJN22UrO11b2bE!6Hx(egzhTUo-Dt!g!UfLqEtl zd(m_ytLa{kN*&)GBR+f22kUN4^yNfQfZjlK@(WoV83Vk1B&pSs!J$&MRaAzq;dnDPi^13f9qfUQ`3FGHi9OK49_U~X zbg&0H*aIExfe!XS$6^l}_CN=Fpo2Zo!5-*f4|K2xI@kjp?12vUKnHuEgFVo}9_U~X zbg&0H{>$l8kUVUHHP{3lY=W`^;$RbWun9WY1RZRG4mLpto1kNJjXej<0}p|R!6Sft z+AF~0U^UnR-UX~hVu^Qb7warMu_-#(6di1e4mL#xo1%kF(ZQzZU{iFkDLU8`9c+pY zHbn=UqSLMyI2LI+>%U1_+SFGyr^oK6WO@CKO4ucgWo_OE!Khu$s9nvdUF~C?h`mD9 z>=mkJuTV96g{s*rRLx$YYINW@I&d5vIF1e+M+c6h1IHQZhcMC)VWc0zNI!&;eh4G| z5JvhTjPyeo>4z}V4`HMq!bm@ak$wmx{SZd_A&m4x80m*F(hu=%V-55pupN91J^}v% zJHWpII}v@Kft{?L%BpmPT{#uBW7%8UhvNyHXZCVb`v((NsXKS7{8wQ;Nbl=FQ>bR2 zQ#JdXs@dmMjh-Dx&z5~dL(m?Epgjx;4raIG5U>n=$lC{UE&dyI$4?d7#1OQJA?V(5 zbniI2cO1>48qJ~_&7vC3q8i;hj_w^t_l_GsH;-^%HFpEoebq971^mDU0gwlRAOzZj zd~gux06KzB;9$@h90Iz4Gr*bPEO0hB2b>Frf%Cv{FcN$pi~<*cD)0j^8e9m*fQ!Jz zV7dFM|1!W=Q~Xzh>EIeL16&Jcg6qI6a6Om}=72c30n7zAf_dQQ;3jZ0m=A6N3&5>l zDM*54-~sS^@DO+yJOcgzRsaXA1doEpz#qXX@Hkivo&ZmRr@$KUHh2eY0Uv;Wg00{~ zunl|!c7T6_Pr+wkC-@xf0$;eV+SH9rUD(uxO0p)H}<~C)Xw;g=!z8d^rFb@0} zP-eji;AdbWm;}gokh})TV~{)sp9asmuZG|of^Uer3UOWN9`GyhYp?|T2K*NAtRb!s z@ob@&!7J{oMh)`a=2aKuyAJuTL%!>f?AcM|zdBHu~mJBb`uBgfUqaW!&W zjT~1a$JNMjHF8{y99JX9)yQ!*a$JoZS0l&O$Z<7tT#X!8BgfU~#tyo%gKq3tuYvyq ze+H~vSXhp&jewO4YZG`2ybT!h(48H0X9wNcL3ei0ogH*%2i@60cXrU79du_0-Pu8R zcF>(2bY}KkfdLzreCP0U#Ldcc7kzm1DFfY+t9lm^lsU+7iY#HeMB8I z4%vHHO&?K>4(_0XJLupJI=DkWQAa;fM?XzS>75D)d4K4&@z(wFDm@Gy7;`~j>0 z4p<2u1&@J0f>q#guo^r8o&-;UHQ;UV4%h-d0RIGA!G~ZQ!0zGS0saj>1)qVP;B&AG ze1VO^1k{C1UD(ux-4RgkHf3&8<~DiC!_JZSF?Ngp1>?Yv0p%8)0DcCrrvxVf@*X6= zLGl?SpTVaAwwMsSL+}n!Um>mw-2;9Fehrp@-+j9+U@dVpO9 z%fau!!{8CH0?6)zmEezH6?hyx37!IL!1Le*@c*~O|Gt*ko;N@C)EY+{9hr@EWS3OO zy|&ETN%Lj2&INRENvw42?gQ9%vC<{6(j~FdC9%>avC<{6(j~FdC9%>avC<{6(j~Fd zC9%>avC<{6(j~FdvD+WegJY#jVx>!BrDNYII0EzlM}nik(VzhI1cjgo6oV2F2CTAU zrAuO^OJb!!BrAuO^OJb!!BrAuO^OJb!!BrAr#ef#U%dTdZ_RtaM4N zbV;mqNvw29taM4NbV;mqNvw3q=2q$UaWqdbnkN{|6KKpyH0C54a}td?iN>5nV@{$m zC()RbXv|48<|G<(5({Ax3t?AoYxu zF?*Cvpk*h~vc(qPE4UWC==Neioxy%OLr2peW5JdY^?HssV!@J8^*;Pq)EGe%jGzfd z(1fvt8Dr+1t|GU}ECwYY4A9@rG7te#5Ci3)0vrQ+fnx#l1CxE<<~Knfa2z-uoB;ZQ zZvo~PW`A%ZI0*~@>>W1;f|J4N;0$mk_#bc7lR*yOTdr7SWpcv1^)}ifggj*0B;YNBqP zfS-YhU=o-Nrhr;76-)zHfjV$Cm=4$zZ}K*Rc`djJus`0M4{iYqz^#CpuK5da8(?3) z$$NHISGS7&VpZ%HtFjIU-N6x{2RITO1&#&XYwKz~48TGXXQU0T$oMO|9d zrN#RI)~R3+7z~Dh?|{?5cL8;6QKuGl>ZeZG1y<#!PW{8d_rM5nJ{SqU52#Z=b?T>1 z{nV+SI`vbhe(Kauo%*R$KXvM-PW{xWpE~tZr+(_xPo4UyQ$Ka;{~dT5JOlm#T;BHR z1P%q1u}ygf$xD#D1j$Q~yadThkh}!ROOSj7X%j)(M36QSq)h~W3a$h-U;(%lB)~7g zZGbuq{sBA+9^;(|$|^)zg&qVBSP32ntHBe1{Dt0N5Azn|yYzpjgEPRH;D5kb;B0UX zs02g7xnQ>OlyL*cx!^`H5Bwb51a1bb-5Ixl1>jctHJJ?^Z#?BY8UHEZR4@q8ulVR! zeBS}50rKD@4?gnXBM(0E;3E${^57#6KJwrr4?gnX8wSn;!@>8!2yi|a3BC_TfeSzt z_yHIVE(Bx1Mc`uaLvRWB5f}@q!KL7T!8ky_=DQ4B4#tC@fGfaH!Ihu}OaRlsRiKUg z?0ieO<~QJ8@LNEi>AN2+1xc_BJOGx1-+>3g@4-XhVekm}16TnZuo6599s_>_tH9%6 zHFyF%37!ILz|(-1$rx3|7*)kyuBX_`^%OSMr`SXK6#KZIVjtI2?BjZheOym5TdZQX zSjB9yirHWlv%xB6gH_B1tC$T|F&nI6Hdw`Mu!`AW6|=!AW`kAC2CJ+O!8Y&_*bY7h zpMZaXo#1n%qaEX22OabJGv-zKD~v07H?7{^hvRXC(% ze%cFGBzZF^julmAk5#-OUC#`%D(_v!uJ+&%fNl{i0>z*Ngh44N1K!N?oA~9_BWmiO;3-H zHzIAwN(ed8G4hujmlO67co@70C_Bc?D#pwz#>^_?T`W=#YY}m*QVv!rS(AvfA{4hw z{8%NiPB~bo9IR6g)+q<;l!JB3!8+ww*o?7GIaUYI5p)6vgU;X(&;^_U&ID(Hv%xvw zTrdor2Zn=@;QIhuFxDvt>y(3a%E3D2V4ZTXPB~bo9IR8aQYCAhaaOA0tW?EWsfx2w z6=$U?&Pr9Bm8v)^RdH6T;;dA~S*eP%QWa;VDsJ5cu+gzn6=$U?&Pr9>;@MfLinCG` zXQe96N>!YdsyHiEaaOA0tW?EWsfx2w6=$U?Zan~)%VQyPu#h=e$Q&$W4i+*83z>t3 z%)vtDU?Fp`kU3b$94urG7BUA5nS+JQ!9wO>A#<>hIatUXEMyKAG6xHpgN4k&Lgrv0 zbNu&#r638Gfd|0v0d@qeWDZs`2P>I_mCV6P=3pgru#!1g$sDX?4puS;E183p%)v_L zU?p>~k~vt(99pk~rOd%n=3psv{2u`9EB>wEL$D2e1a^RbgHOR{0Q-tx>?^y#7uZl} zCpK%zSj!x&We(Od2Wy#wwamd<=3p&z?EYXNZ%v%a@jKvj{5-$STBc3D?R9Rx{UUgS zUHuz@?C$?7YYA_Hzk$tQi@VVNySssPhK2Tf9N%|`1Z*_U?W{9=tZ!2E&%-X9hg}%$ zGav0UAM2chbb0mCwP-=V0Y?u<|)r`5df#4tu5Qu;%lYm$4k2$h>|i zu0OjRMsYS7H`ut|h#NzUyNxyMs(RK~Ym7JkWV~!#X}o5u|HZb%b@A+0i=PI>$W88fpzS2U)|c@0o+G^R0``?^+YBN#=Rh6l;q4J!`6U zvpK@L#ae1!ZY{Hxo7Y<}TQ8e&Ym4=F^9E~&wZpv8AM^*!dH#0(cIMCh`TmaPP5zj_ z++5&4#(#{N@PEVK+x&&UkH4?^OMicVfAcQ?0RKSqZvQF%@0j=a&+wmN-s>OgKiB-N z|EK<+n)myE>;J8})PKMK0W<0Uo&Op0LH|4ccg^Sg|M36QT<8DL|DpLO|HppUTyL9p zXY;Ri7yD3in|+ww&D?JHu#Yr9v5&ToHh0*CcA@!iyTmRrKec!(wSbMCw%f8gU)cnF8XOA;?+n3vyo31^>zSc7A>+I_+)1Gb5wk-RX_Af2JeTRLA zW!rb#cUu9w-oD4mvlrWot&qLMUShSgm)c9M_VzOS0W05LVXv?{*pJ$eS{?1T?6<5= z_CM@@SO?qN?2oL@_Q&?eRu_ARy~8@x{=(jEbq%mR&*~Pi0+w}nzz*bD-2?f7eCx9=e z4<2M)7VI4CY>f|g33j!95_m}m7$^5w$S;Z^R4ZnABQgc|JZx)_^PVx|NHFI?!5^ufe<<*frQ>d z4pmI(og9jE1ZjezG%12qr3Mg?W(5^gnuuLd5qld)#6B~QWu!Vfwn0d4p7-A8-kXyU z(C_p5{{DHM-!Px+$-4XOv+G*xv-jHP8b61og{B$5hUSMB8^48?g_arlq1!?$jDpar z&}vf)Z3=BR_0ZPPHq#2-6}sCDgm#8@nxW7Gp@+@5(B9BKvqI>J(356j=(*5gvr_1# z&}(LL=#9`@X06bP&?z$|bT;&pSvT}s=r^-be5d$MX5;uC@jdZtbnWSgzUayMs`luJ zHJt9;%lWE7=!mzpp=gNX+At9((zFpG8(nad(gin*Ip~5t=z@o}Lt>A3Svw-$KqGuA zJ|-6MJEaR6p$i&|rg~GonP{W8&T}4YlxFdYgATRqo&dtwUpLKHD(*L^`=U9 zG*h~xh0+}@mG0=NbjLX3nDL1|(fG{xhkm{BExKcdao#A@Z!-nDV~trB-LcthU^YN& zG(vZ5SGwaKGt118ISoxQ!(61l zYjUnv|J=OOyi@#L| zK2|rYyV2KLZY?+ZTWhQ}My|EqT5q`4CTo*1z}jYQGX`3BT6Y?Q&=&`d!PZk2KAQEi zb<`MXy=lE=jJDpj-Z#cr$F1YWcy&Yw(kVAuKUqH+ z)2u?P(3ozUwq?w)d2W43QVJNyvbiFU|W+MzJ`c<^ykNbO+iQahMN z@M!Q2Gbr_fSuXfa@O`s<@WbGTW+kZ|%p|mfFe`_wkY!d01w$dTs?-r?^-#G`d9!9H zIh1VH4pj|RGwX!vhU%KBQfruLQfn}8(HdsGP;O{|nJ%@4*+6Oyv!T=)W@EI*BC|3?~=!ejc<~4CiaY^R*xMp$9%?V1U zOcGPH9Q+I-U9`@i3y}Zho9(-U9$*k4@X$3f@=Vg zF&b}J(I$Y2U=p|%Oa@cHb>Mn172E)B1k=EDFayj4v%pPYHkbqEf_Y#*xE0(6mV*^w zC0GU403wXE`@sX?L9h!DWu!d>9tMwqJzy``2lj&l;8Ac85QT(i)>Y#zGaBzg(w+po zQwQzaReJ_J3!Ved15QwCoQTzUPm)F?l6C~V432_Vz^mXj@H%)KyaPD-j5pU+I|exQ zgiqI1`viOnJ_9GgX`-Uag9;!4B!Y^d5~vK4K^0IHR0GvP4Nw!*0<}RMkOERc8mJ5E zfpm}o>VpQLA!q~|gC?LUXa<^t7N8}_1g$_8XbswcwxAto4?2J=Ku16XmBjlx7K0^VDOd*RPeFeQ`cu%Kg8mfrr&tZ>Q;`SmB%Z1h=mNTeZlF8p0eS-N z(Mhk9UMIc27OV&Ng8RS@z}V352aFB?g;9Kw=H~~(AQ{Xf>1I~i)!N0)|;1}>K_znCQoCCju^WXx= z=OkQTFdNX82JK={M}u}TsHZ_a4eDu7Ph%aRuEy;|t_464lmQJvBS1fz^rJ~Xnhv1v zO!~^CpUfQ48PKjKZEE%ay+Cgw;219!=`GS*?Er0KS68P~x(4X)0C@!H?*RD($R{uo zP~QOc4N%_z^$k$hz>nZRfVu`lAP!KzU|CQOlm`_6V?D@N4>Hb!NuV;Q3aSB~8>|6p zf?A+9r~^_!13+Da)HT=`3;+YcC@>ma4JLtW!DKK6+ym|f_kkT?Ctz#@85_a<;1HlL zY}R%S37`W5SilCfbBH>Gm=__&V~A&l=7R-b5m*eCfaQRG3#|rufc^??1#g14zzJ{? zoC5NBv{#6Bithn>GVbsCznU72xDU`P|5sBpA{gQSzthya(1;I%N5CGi7ceF+(%46l zeGLAL*8V@CvC$R=+T#C><~BBP-$t+r+yOR&EdXs{Yy;cDo!~BTH$a0KXfOi}W}v|g zG?;+~GtgiL*3F>JjNRZNK-(F#ok80fw4FiQ8O$dGjcz;&uy)2{XnnNW|C;qcTmFCA zf-J>CECaWK+W?wAxB{#Ms{k7Rf6dBZ0soI#nod4za{z1O!V6q@feSBi(YY?Xz=apM z@B$ZJ;KB=Bc!3KqaNz|myugJQxbOlOUf{wDTzG*CFL2=nF1)~n7r5{O7hd4P3tV`C z3omft1une6g%`N+0vBH3!V6q@feSBi;RP_51;KB`DxPc2daN!0n+`xq!xNrj(Zs5WVT)2S?H*nzwF5JL{8@O--7jEFf4P3Z^ z3pa4#1}@ydg&VkV0~c=K!VO%wfeSZq;RY_;z=a#Qa03@^;KB`DxPc2daN!0n+`xq! zxNrj(Zs3X}Z4~^#g&(-^0~dba!Vg^dfeSxy;Ri1Kz=a>U@BD)p4;pF8siSAGq)X7k=Qv4_x?x3qNq-2QE9dk|Vfq1Q(9r z!Vz3Jf(u7*;Rr4q!G$BZa0C~Q;KC7HID!jDaN!6p9KnSnxNrm)j^M%(TsVRYM{wZ? zuApz>2reAK74$6}!G$BZa0C~QAWxo*5_f}pz`fu;umkJ__k#z(F0dOs#EGd+oCfIv zx`J+?JLmy=0-gb!YRVW=@8Nxk?~j0&!BNe7`_ijizXo0hZ-6(! zJK$aL9(W%d10RCp;3L48!aBNe8W&FE!f9MMjSHu7;WRFs#)Z?ka2gj*bOd1>b{zgCD>z;8*Y)_%ApIeh25l1(2`aTq~`k3$JnEH7>lyh1a<78W&#U z!fRZ3jSH`F;WaM2#)a3oSSA*G^SE#x7tZ6td0aS;3+Hj+JT9Eah4Z*@9v9Bz!g*Xcj|=B< z;XE#!$A$B_a2^-VO+M!}O@c#;cGa^XoXJjsP8x$qJ3T^|-!3wYv ztO9GmPH;cqj5k)x!D=~JEeEURV6_~qmV?!Dw7p;-*bjLBI9AKSYB^Xf2dm{^wH&OL zgVl1dS`Jpr!D=~JEeEURV6_~qmV?!Duv!jQ%fV_nSS<&uEl?ZO z0VyCAq=CAi9!Linpgw2-8iGckF=zssf@YvOXaQP+OwbBsf!3f6Xbakb_Mii}0(1md zf^0Ac%mwqn&47NxS~*xN2W#bEtsJbCgSB$7Ru0z6!CE<3D+g=kV67ail!KLWbn1uY zaj-lNmdC;JI9MJB%i~~q94wE6<#Dh)4wlEk@;F!?2g~DNc^oW{gXM9sJPww}!SXm* z9tX?gV0j!YkAvlLusjZy$HDSASRRKnmJZS54$)~KM9IS_f^>DBr4%WlLdN^1Q2kYVJVNj?! zoTG3!N8uPIuz(E$AP7Pr9+UxPK{-$!F!l__9_J_=&QUl9V~=wb4(BKwERw@n3diUV za=}0_2n+^Sf#F~T7zsv!(co$@28;#QfN@|vm<<>!ST+aC=3vIObE}X~3A`9EW2vu1v<2$+$9K051Z@7w0$}^JQ=pyaHYY zj5G6f@GbZk_zs-JYMu@;N;c%|Q z;arDfF;*jRyizxdH$OK7!MZX0ZZ;+$sH`YgC%#cV9^~cxq~Hlu;dPw+`*DNSaJso?hxtkV7(ozw}bU|u-*=l z{|;wT9L}UToJnywlj3kD#o+WFP9jv>9b$77t4%Xelx;t2R2kY)&-5pK` zIGkN^IJ@F-cE#cBio@9z2dnR3^&PCfgVlGi`VLm#!Rk9$eTTCxPKb8F`a4*D2dnRJ z&c(sKC#-@*DjSbqoW?_l{IEWd;0cd+~pmfykZJDiJgI2Yq^F2>(+RHTN-IH*?MD z#+x!)#W*eZmyI)Wf7v)I_m_?D<^Hnqv)o@c3e9h=M6($YDpkzU>>$@L$H{mUbE4J6 zYGzK7d&lM!%ds5uI;*YK!MxtO(#kfcv5(x{oMH8}`k1q2oQip~j8ie^%QzKtfs9iz zZ?P6zYs`h#W^1##nmy%v%r(}1)_vv%B2*qUH_F%)bBl~kF}GU#tpnyZ8Jl9>DfgPq zyJc*OdB5CoHXmTe`F(Sj+-o-X%e`jvfZS^~AG5x;zBLcYy=L=C>#X&g`KH#h%fXWAXjQ+AHs$^6ysZTB|+YxlSNo9ArT zcFo`If%eVjc^R`}rOKEUE5kluAFvt{xe{kJB620wYA$!Htt`1)Z8_{#=UA-+g9BGt zSISrxD<^O=aMJ3;{`GHGXSrK#<+5Ad%^Jc^H1Rny62fSm)$kvvpqX zHCq?tUbD^Fui*Q(9sD5ppY>!}n`5Akp z++ViGvcLS6JznlC+c(OcWqTTNDnHrNWrT`-i`-eZ7s{Pwdy(8(wwK6g6?-YKvig9j(nRwvyxSr2HfycXw&(-{^ftS06>$S{Jlll2N*KaUOlbNOOa{Zq6 zSFU9QoFKyGBd$NjuN2xB{7crp)&9ljN&X4#6#tT$*{AtEqc~AcYe-C7C9acL87VI+ zT9pZEF-~J&>HJ2@dpKZXcgD1 z@dX8%Xg!}q=ONjs-$hE|Z;xxBif36N_^Y$z%9>3HlSPFQn;D*S zTYnCJFF}8SwS_eOCH%bvqIQmIBlK5zhen$IDqdfL{st=wHT1Xiw@LpFK460WE^7;E z=)d>4exLP)8u~F-6>8`o;}r_D;otcrHc+dre~IrWWW*2meusZ2WULR@zu?CSJ&YHJ z1IJo|f~LG;fseurr| z9+!b`y-6!WB+wjE&gI=ywy^@wN*EhhM-LJSv{@T%Y%w0=`Viihffhcd#mR_5%`%P~ zA94M$@rjlqBZT<;()e1dZG2<=ikwJ8(wsLgaGh`3T6HskpC-(Z8RA`)fi`Z9QJ zLTYfXE0`6uJ~Ea_t7$gGixXxOvx(M9#w2Q$(Cp2%zC;&gB5!53LQaekcS-+F5NXt! zyV{s-khI0y6J|TJomL(j(2?saSbTvmbsAYMdl)HvbosYN_wmgziWfl;S;OF=WcTkpTtgTor#^?&ztZM;13$+A@dM< zK5jnFvz{=YK>j5DongLVzKP^5^DS+R`L_9$R@VI5{921MzcIhj`kUY4_Zj91^91Qn z;{P==PnoAR87Xy!yUybI8Rk#sPkj3`o?j#LH~c%p4D;qO+blE-HQnN8ytLYuj?ZUU z{L_M#X_>6%SeDKE-vju5h81VU@iMY_e82>&j8&HFa`=LcEP2M#AO@=f`6S>K8djo} zsFkrQ;u#u5WmQI=Y*i&aQCV7?Ro$wt4InnFhL$PqtCl3~t5#Q9R<7H!ij*WHw6u0I z0#xgUmAz8yX=Pj4+7(u3t21&Ux3p`lu2xrVpw->#j--dxgOok3o}}z$^&(|&tG71Z z>SOg`jcT$rSxdLBv##TFsx_5Qtg==CtGrB0lrf}S69J}Gm64=eue4SnC!UmRV#1K$ zZr!e3CGEG?R>p*BoviKFcCLvGUL{NTH-yS3Ib@|0_=JJ-aYa(%ybKi3af4^YQl z)-Im@ko6Fsk64fJx!>BaO^^|()S3u0t)KOn^%&26%6g9HKF{gtTwT@h^bfekKhWyoAAGIVk&&(B@U8VN>A$nS z)3U7-)(I`gI%%Ec`jmBw>$BDm)cQy3Kcpm5jb{=4%JnbSugHmk<@$FkpKBsvxh9TH zt19DR`DHp`>slgFu_o8H9njj?K|7>n$@n(X#M|*&cNr(E)x{Sn$8~wTJZX~bB+?N5 zrZu;#+3eNW)mb}CvQt<+9Eqort~IkW>;@V(-EOE2u^ZWqxNdAWM&873q7Ap3+D)}l zb~C#vzv$!T2m+RJcYi+FE#%`lcwAd(nZ>=IxfdAo|C_r9+@Jrwqtqie%ziM{i zH`tZH$2zCg3;Z59uhj`$2wc!w2J!>>T9ZIQpnz-Qh_xnoV7gXcMiX;jKLi&O#16)5 zCXvO7TvrTMo z`uIvMHJBaD(JBQy1-oeFf?b2%v}$;Jt`-OmfRl;fpx|&`$vGl8LTelx863&=DEOHO zj)9>i1jq7zsI=fXSXx4G0_S~d1Sj&=LpwMLo+gM^ME3@74Dy>V!Pzh~BY1Ogp;m_Y z#U-R!%D!PRxF)zpiwouj*J&xi^}*Y<+Qc<(LUKoNvsRr5Xe@s4ZWvuc@E*9E!28_K z=Yik>YWir9GuXj{!Go0l5R6X92uLJ&rQCHGerE(<2)@Ym5qO@AhCHfOmJ!ojzZQI* zYvQK4CO(qSw}WqMO^K3xSF0C%FZdU&PVoKU`&@qz{6K3fqpGzEGI|pEN5PNyJPl72 z(!13#k#IzTsWi2U#8U>iCZ3WPUBrdrv@%3h#%p${417_@*h;N_C?S-|oP4I;JKvkVP@Z3^ib^DUk+pHq4F zsuj8c25LzE7|DECs0b~Be~Qr3&{E1t95+uPB2%jrS`H@_p_Q;wK}`25?p+N#6`?%1 zsgRzuRx@;a=yvYf7~06^CRVD;hwcb%*2>{qZzT;8oaA#?=q|p!J9IbK_}P3u2%|MZ zyLqpr9eM~xYv6P5<#Qizg|(#Tt;OMaKSAxDtz z%CoPjeEOQor?08J`I^d)58}sH;a9qy8AZ=_Dam|_v0eLdybCo9jsr}FHR@$Bg*u^s~#p_xNqUPV=T6+8qmB(KnkN-Ga;$!^&8u!%<)DP*1C8MWupRx)7!wF7CeTTfNF{Z z=x~59k$ejis25`b-*No|e87qD0m%e(m;f)KF$8=d1wLRS39z19kJXeA-n`@nI%_Bu z`K$y(2+5NH`1jR}YTEVkd;r(I;0$%)47psphD$!2FhVkrwcL7~7`O@#euy!I9EKW0 zkx2faEB+9t_(KK7AClxr0-kuYaWiS=8}qqdU@Xv@aH`;Tt~VH)DCZsUis6b^Oo3N0 zl8r}=N0D$+DZ(z|ee5D$v5O4FE;1CmNQYf~L;l|y|I*?))ASwXJOST`SA3(c;v4Zs zzEOy8&E(SRaZ*>!>(J zLwTYK`5-t(dBri36vvpPIL0-KV^mP=LMSd#U2%!(ic3^iT%x+-5|+8o+^5a=F$w{f zcmkeK#3F=Z5jQFpk)T*aJpAEXt%5vVK}yLV9K|2f;SXo2(f6z*+vdMnORgr*b0PT= zE>R6G@iW)Iz$k1O#cy2ym-S@ZJjaT%ZvM`ivTdGcRXNVQz`C+-=CiVFn+0$WU2%^% z#XW@L9_fmEIEs51ihJmA4-3vqNO>d`U>!oS4xQDz zN=TBdBqWvL9!=mL$#~(M3qc|oNW5Yo;}iqQRt)3@7)TTCTB|8Mq_14(!zY$(WV&J_ zy%ihjq1Z?R#YVa+Hj=A&$V|mUy2^7TS`WoQx+(^es~E^lih*1w*9Gy>Sr_D!bwNHQ z14&d2q@rRViHd>LQ4AzeF_4Oifg~yhQb%!*Zi;&hR@|eT;vRz)_voa!M`y)7Iw|ha z*^=C&lj0to758XoJ!n11b9Y<2`DCq;Psu%IEAG+2lH8-Y;vR``k7wZw&%r>(CW-A&EyIttmA8VNK?f_5)}_=t9Zx& z#X|-v9x_1jkb#PaR8%}9QSp#EiiadB9#T>9kVM5pDk>h5sCY;n#X}Mm52+|m=fOrK z59y+KNRHwmbrcU7s(45{#Y2WF9@0hekQ~KB+AAK?K=F{NFpv!RNqxJ%*3WJL56MzI zWTN6BE)1k8{(UpMnKs#O4g={2_h>~L$vRxcI;JVsF+;JAId)sStu|M2j}eM{%v0Rs zX2m^5*d6VT+5)*23(sLKR+}Z)VzoYUEtXHoL#|RhWCG_3Z`8^u9#YkwZO_*FD>hQq zo@dYFT5^)Aij7oNY^18-wFL%2sM5C@Ck)FIa?fmE&~8)>fCNOQ$TnkzQaPO*^& zay3`$rPxRV#YR$LBd4`0fivv()DC?2CT$cm>7bZN8^ugAeas|DF_T*I%p+cSIrfJF@`RJtO0kqw#ZPK- z+Nru$C0K)S?TN=Y#CcuTFjAatcM=};2#xk3G z=fGIv6l1C2V=RVZEb)r5WGKdxt{6+aVl0&vV=)wC3Bg$I;pv>vg6&A&5>ULQrjNJO z@G%yl7>lkLiwM31W6>33sqSMe)h}W!Z53l_rWi|8#aNmt#?nnOmPEx^n#%KD)KoH- z?uxOb!dUpd364{E%}ToSNF9f#cIkbR#Qo??&FuUx=)^x&s0!+rkdh2mf|zI zkIz)_@flt5nK<~&Lai)(W)US{0;_4HSWPX(YRW2BQ-gD6D|m`zHMQhPRrm%w1C&Ja znTG5PY|uI?K2uBanX-z{lu=BkhL6eAP)w!pNUg^CO-5`=ozgEdkfETm*h4=aT{H68xeXHZlf!1ldiapfZM#OwFOVe zPQ2WO;8~n+qvV`#(`qOt(^N5;I*Q2{ipex!w=Yig2#+z&5wT^!Iuc+oDf}i#3oT1) zPh60elai1kl2WqLT4$vsWf}=734ye%Ou>KCk}|V0lhU#>?2&jRI&@TBa6dF7H4NK7TJrBi0s>M4*3dG^=+Nex_|ZX+kNz3tz+k#8gmBy=Y?3EVDSHoz* zgDaAskLDBilUS>1(dP#L8I{M^h|0sc)`CDZeTJ7_3&an>50Bj!=n}opKBIl@drqvp zaqDR-^?aG1bu5~`L_S67*ZIpEye67H?u0g_D1V>)JMotyc_5aLeOjC0liwZ7=bWhS zlh2RI^Wc}NoCT+4N5|wcc1YDXJfxUBPyFTg-qH3edT!hXaj+==;9Jpr;_Hf9rONAF zLY`hs{!lERjHq0eC-&S7ub&xD#mW_YGj?AJ`){#v;FG5olNXh@S26kSSUzb{d5Pz% zelGcZ+7Vt^G+tuuYRCQj^EsOQ6(2$=iiMyr2zhctQW737S-u9&SZPw>z z20NWsFn8>@MS-Y%dqh4!$rs1uYa;S4O1>l}-xQJaK3DQt8kJi=2a;v}teZ&rvLPQw zO7mMFq~wn(`K?3VM6OgqtOo)`BVDSBQf7wyotEx)Akva{+{&-v-@FyE6Vi2DNlmHc zh_sMiQoESb3$ER*Z_B@?G%aJVJNHDO&+juz)iz%H9*ec)&oyPMN0eFdzPV&gF4k2; z!im0K%Lsj|OI;T0J@sw0_lmxa^kO)%STFLuLA{H5lkfe#DZiIi#g2>gD&PBiH9d}A z)dHldp~e|yv;Dm*@2{)wkMuI7^Y?PP>}7eszASpbWj)DSyXnWGHp6^-@|oS*_C3(^9gyl9y>I`}M@T-ka3v zhAzEUkC^h$$HI4svHS1pyKY=~v&bE|w0GY6@LucKg`CoHGn>cIi2j`>L7GVA`9e`-=);<{_r}#9P@MV_hIByo(jD0 zr4Nu#eA~hk;lr%wv|;jnMaAN^+WatCrlt+dYUO0&ZKT>sNfrNGHQQ|4I(k&6j(`8- z+M0c?*|cF-$IfFeJn^S0u6lrqM;jvgH{)t%;TgF93Hj@4T~q7oS*;T5X11)QC#N+v zC_8#!3)>Pzv+KO>0w~3Ki1J_3v5m;GZawabDa#f2nC~)vwT#S`PGVLoovtjW2hutk`s+tU)4hAcAHFiT*W2 z$aM^(R#nr+DsACCSzdaD5(#Mu`YqvYV&qdl&c5lyf$(av>~oQ|Yj^l)ICOY-p0J*L zTs*Y>!m4IYmwk|6CTAd{Sik>iaMCK;Nvr)U&Cls+Sr9 zgVfMlr@icHDp_Bp!901MnB~cFlxPQrSX90!s=@qnG}s+VgY6GjEG9o8FyivLVL}&t z`K;C6|2-Eu#*-igY2pWA=j`~&-$c|%XtgJmr8M%br=#kHAhm!@jXcAXqi@%-0WQs$ z)WdyM%Sz=RGerB1?USZg*A`}9)fT+uo@sDO`zhKM#pEa1@{!X@ z&N})nf9^98`$qPZ(k(ef%JQ`HSRn z&qU?nF2(X$t9_;Vcz<}jUk*9E_4od2Oz_-Td!mlybLq`kdtg2N<;oKqys;;HId)$F z(lXOmKS@GW{dmrjn0%xnGfEy`SNkd^@9UG-yM#QwnEat={_z=6xh!X_JdhdL56TOm zyr|Xvg`Ld4O08nwm0IQcMx7T-N2evb`py403UK~qaldMFbrL%UkU)5tKG!%+sa6$T^ay_Ecx zK$OEgpjgJ$Vz81gjIs>-T~DqrQt~1e^HoH?Ny&>?%!d*AZAxCmV!nvT*DAU4Zwv3H zoGDCj%A@fs1~lTz!daKCL1fCuYFc8FmYCF+nxwJG;tjrtDrrZPhm;=~Q6>A0wFS-e z^##*q=LB{a%;XjJ|Dg9}!t&Bmbk3@bL*ry6WF}Oy(^Av%%_6s^CS;W9-{J5b>*}<9 zDVj@o^u4;?qLoMse>8uYDRxD3jp&+oN*}LM&5$Q{a{u)ek5uk^-HO~5 zpDtYVMc%@rH?|FT(cA9`pGkT_3{fsv_hnB^?Qzq1)$J!Jpp+ZL4Ts*WtQ(_}u zZ1-F=8aq7c!ViDC26NS{RyDxs8mF{|teIcFUfkl7OYQBY&(kY=ax5af5S7~}#P@1I zm&|8f>G|WF)$!#ot%=ItJ|h}4B2+`L?J~w=Qo<(@b|G*`u*ZpNK z*7IBcWcmMe=a*g|ucuXgN*Mv3d_7?!QMY@pnzNq#;t`Kme}%clbC;0+H`cI$ z_ztFA8B$atFwa+&41WOv^9q(P&Tks)svSEdNho@rv_yqxgAVuCl+!ysJ&W0!7O6rd zWo;tn#!bay90e-F-?Y1(tsI4&B^?EOk#ZDvmUI;C)yk0XEa@l&p77))9fiP~5qSwm zA@E#8Ucylb9F54O-t#SY22#~>=T50lk;j%h1NGE$=T5=TI3ZUHU(&H)LtHu*J1=%D zO0BUP`Tmw+Y0lpQa%qm%;t3I#iSPX_A-^A|zK^(0;lyGs!uK4al=nwmD8Bc%jQm~> zFyGQ)u@;iwdo37oskqJlhuvm_7mG%)ZIA$uEJuN{@KI{=)Z*Un4)l7MfqygUYUs(>a!dI9U1C=sR^0+nO zXQVPHNFGacA zr^BzP^#9wwYO&cARzef6}nue$#jV^2=^>`R?5pZkD7^B~;Uj z!ehhN6b@HiuH^z_RX3)UHjHV-;}}pXHPh+|HLbkdrHA0jPidZ4 zQc`|Wd$TBaH2}S*uGQjwayhM{`FmTAUOv*h@#JR;kNeWco(ni{D zebqqq@{yj2_uM?~g;Mib=aolI^TMKX+NTT0`_jva<>hluE9;XNm1l!?zb6k5@wXcj z>xi0IOdNBqnh2+bHy~pao8zu>V4o(SSnAmr#fCX4xqej1V-(x^I3oW<$zv3oz%(!Y zTS^|A2-aUC@(+}}Xrr*Xnh2-$=1Ly3oK~iq2&eT{O77|X68&4${&H9kGtnm>OfuC5!5z3O>ponbi z)m}Y!c*qG+&*n*T{gj+3DSj_& zk|)Idss!qZss>6Pw~iyKQk|EO+b6|`o*WO@mk%53Z+m`tc%5I)y6u<06V|N!K2|lT zTLsVGmQHFWb&|pJ3HpeFe6i>72jb*61t001zYYgJvyMev)j9gAf~G{aK3FhPmH&t} zQ?@8CqoDja$DR4F$5CACjM^epIXh0}?%=te0{Oole5s{OHY%|mQ7mto0Q9mpz_J^ zHNlMr+ zf?`J5l=Rdx4a=t1ODij`7olebQTB;R%^PQw3m@DW-dnC-lNMEzbVJvzWBL74u9bJb?24{=OQ+P$-(y^rcJ1PIJ=!cRYdBAfP}0kAiPfdynl>#|oI#y>r_1yney{ zcP?va+(Xp|It%v9ji2`X>iYS6BO|n%bRGq(GSu}fKd2I&uieyO^yDY?$UJ7=QOvrF}eCl4R7%c>D6(`R`3pt(Dk^l*7YtyOITHSG-#*dg$jhS9zJ1}3US9s$ zk|m!lU;g=$C7-L!|I=dV3onEpK7A_u@Zke1MewB~M5(R{pF8qW`1e&5M=q;AD4k%D zql&RcdsbD`lgkOI7$L0gZ? z9W3lO)s5KJ_$6u*>d?boJwI{JpsVV49&_KK%hb!7xWo?GiLV_VJUFgg{Q2AD9{HcE zrtE#MI;X@EUp0&5C-wTJ$?<@^fJCoZ~Fc;iJn78jMP ztDI)o@{;4slV3b8l{~IkJ(QjF(#NKye7^k%`=3(VO+MmL>mI%9`)xiiK3x(q`}n z@gF$7DcIC#ec4Kl#*E1&km$~8N|I6ao$&i?W;K6H)CqsR_3t;|{Lk$oxo6Ri`m>_{ zS6_?%&+J|oE;w{BoWFjze|H@YW$xo?$;wHNgbWr%2ofTguwOU!d_8UY*L%V%#LSr+ zx^&q%D}2;CHvjnY1gyJtyadxKGVXPcHp3C5I$zRyB=2 z+fX|d0y-3n<||$TBU3b89)>QKRubdIaPzCgU-P$JR3sV`vu(Xf_2T6K@&_BSIbCXH zW08WT)Y%thbkVb-)mP4fr{;$+@>As^1m~+|#nmE*8rHMqKwwQcS#D3~WF{w9Ov}g! zM7uf?VpY;T(BFA|cf)h9-{0{0%M5RE?DKZvr#^3U*B_2>S&;A$JBNZ)*3x+B^U4#DzQD^XAwI zZ*E=nml?C(Gp<{E$DuC$%VtzafViA*dgkU;)87|09@#CD-<`gE*1yibaknV@$it${ zJ=?>-?te1;>&Ai?m(Ab1w^a*W-(r~3-DQl*2+(AYt(4wzDHzZFdVrBSgONI3Y@K;~ z*RC68g*Arqmi0XUp?+V%*hsH^r3UTd$e?}D8?-QF z9?GEgx&*hVaDN0Y^(TUg~_sKEZIX<~G+A5#8bJ{7NTuus4p2w+r<*LG{ea~gG z9Ey+?L=!P*W z{gNi1nvy0m_ly4?`un8ZI|;YQ5(5`+?AUVElJIMXjRWC&e-&SaQ;&&%zGoe~<*&EQ zJUF{n!oFUM-EJ#}(UHWY=<9hNYh&imJWuKq{Ouf`!Xc$nr@4Hjs24sfDowmh9rbSa z7gQ;|c0#+p@H=x4vj+!RZcVa^fRC)gs?#$v*s6pJ5qw)YIZZ|1+N{@APEKZ}0a~Q5 zec(UK14ryJ_l%yjb5x((u6pdHHxcQr<<}+oUT0Lf?JM6Yj+q>Vfu-}rOs!e_6 z)?stncbe0`+lUUyEn2;>dC?1#r#v@j*5d3Ny7wH_Cb2={6{GX6S@7y~2D2)Y8k}nx zBvJ!=gSmv9%bsX>4`#HsRnt;^wB|nJ(I$ho)x;ezVvc1d-+5M^Ysj7 zUwQ`Z4mFs){Le(w$JY_#i}DHH8yYU(`2Q za=oW@N3@#i3!-P~Dis|c2$Ulb+uKPd5L?$*KJxS8z6&>ZyR-AHL){HyS3OncS!?On zn>U?Y{??0!1~2b3dgsKe?;dNtY_I&G+Voc!T$4BY3VNW;sGi+#$X+~a&T~^Hzp!ZY z3$0otcNo#F|C~z06zg++Q_v?4Z$H^ z*In4dW3V~qD?BD3t(|ld(h`Q4*M}P%wi@r=eg3%Bm_agxPA3hGEVoDlbh@Y6)V*r( zc=C1HS=IPM=yYFtI-T`3nU8#y>J%@1Uc}rFq0@co>7<>i(}$p;d~!6@Q);a`GVIN zIgwk$E4oZwf_D5d(Qtlyz&CFNq2T()oS`CpJHImC)`uV&9JFIU1ATaA(o>frP zDPG6%NZFV|+go^&mk~%WP_7$9N+pXG33xE||2!K0{r1CGKQ{m7gQE}M9==dz^^e<) zPx9Mb|L~Zx4_$A(ncrahkElx)p~ha_z;c(g?BR_FDK zq@Uka+G7|Es=#XWJS;~QOEnYK=qV{bse4;VCFGE-Twi+WPOE%yi|_TxU|5@!< zpInl7PkttH3bADV>m#Jxlb?^K=XB(&YBZNf54rcY+V(=$y#-g5fBZ0=UcI=!kbYnJ z6^ZuBe?r??R8F}O8L8K|#pG|ti_)LeyoKSC)dK0>-B=KS@~SmJO8;Ce(!C}Ip37grEXSB18=IZ9*;788)Hh=+^hB zg?d`b@yQ|?rMf`I6ls>9d`ANg{$>sF-@>wIc_~g>V@Z@>=c~2f5A}j8b=c@+6E8~?e z$D|)PlkGx(LuAnu@<@$br*5ftEBNBGPn%rf)TvX)$!`3&j|#rfvp)@=&uHJOc7s|@ zhX&zuM)n$`X84tyF{7^NFlua$xFY||>ilQL{Px3!wQnoeC->@ueUNf*bV=Wu zJ;1RE#fDdc9ABS_`Yrl5=+mHoQ<3pm!Qb?8#*N|H{WndTd`BPgRsM#8doT=aQd&!S z6Qo>Tk%oa*5~3p;L2_pj^Qv#JTQKWmJjmWH7H{a)GHW!1;IMunHC6wl;0nEoJ1CI4 zwQ|+$g1H5E>*FJ1wuKr3pQmo_9>w3dQZ}JuEERkz*3fv- zl{&9Ta`bpbjW!YK1GZXP_*8tP(#PT_f?Yg0um6(y#Ns4^9V7CxN*;@c2<((`58_jO znvzG3%Brp@YNGY9MmcJ|@?6w4;V()5hKzfloLk6;qYFg5U$$nLRe$Gf5yQDpXfyDG zHq+QRV>7da+)iRe0wR|r>~Eh`UyYIwol&1wb1T3nY^dHCbQ^yaIL zuR7hD-OsTOGMJj=`4piSy|2`2C0#R0c=ny|84eAf}fYG+UD(tcu< ze%q#AJ-cgaT-kCd4Fa9VWVYzrsL6!+ZBqtpol}sqEn`6Id;2cvvZYL=3h`zzqt}&f z`exAMs*Gx3`a)#nbW(%5gq*OT`-^W(EdF(kH4b*NcDwj{bwn%PTzWnR*e)-bMtI}Vly!E2@ zl_+m|F?qdMc{BX-Sh;Nb^gq6#K$oyzj_d9aprLAq8gZE zd~#n)$8rh8eJ9$L)(bJ~g`xpg#JO8ZPduRVvFfuhpH57$|V zi;b_0tW6vo#E$3S7IgYT3^0Q9H zN_)1U+_c_W_#ZRTyw;nUIckvtSBZ6!*g^?g{Bj!@s+^RG!;oro62GGnSzyt(MP}ys z1?{sY_sN~6X6B(W(V2N$&iGZnnQ30Dre+s4H5&#xjcL`QU&AKj7PPIy)YQM+n&Gy- zr{Dapa%z?_Lg~G)Y}2QHv?-{PEvH)a@7Pe1=O}$sviM*^wK3uBf1!Mcol(aMSRcwv z2{@6Jy5jX@X|JMOFG3T^Js7!==h1L)F)5;_aPN(@a)$Wyh6Cf>ReOe+J>B@OYsZh< z&~MPT30OrJbuE;F0CdNDX@Nb&#O|W zVq)&JmC3o=ri@tFH?70ez1NJn@9NR>_Kv9EuWj0}<$VTh9HUpiYGKzaR~+BewZXtE zuNs2#62ptsTC>?x3Kq8<+)>7hsk+J`sn=FY?2LFW+G6&kR27bW8)dU+ ztHPC#)6E|i$)zUKReChB-U#7hEqW6_3Z+bC#y;ylO{PI}2(7cIq5~GYK=WQ#R0FANVsc+g$8wRHcWP-Zgywy}G&!0#*C$8w zM)E%w@m{>L%VF+~m^0eaNO^{uHx!izow(AM4>~iF&tCCHsq)(ePH(hrr0(_7$5)Ta z3r@@F5tYZuU&f22m%3G@x9zi|J>7bW-wrw!n@s5$X)>Ta0X52$lJ9%4m80XIOm~i5 z(l5EfzC-bq`z#ut_uQJWwqVD{rtf{DqN%xQ~1$>$S$PXOHl=ow&hZdB1*j4MBjAHlD<_cJh&%5x%cAn zdB^9@IsU-4>wEWFKY7ynUcJ|cmu%eqzpK2!i>1gvY)c0yHN;%5K*R06BH{5K+@%PE) z7M*hZx3chFqVu8If7tnblj9Fvrt=G)2+X>4?_=!77M?WgpukxhDpKF95Cxjb_t2<~ zDfJ$jrJ{8h<%ZUPQ6-lZVrHZC=kCxy zT3ygsH2cG+6K}`5xmf*Y3PH73^FmxayKsWDxBoxKPr%MTf3IGY))-jzEaDC z)(tDR319JtMKeMl@7!$iK5_kuoNw~q{-Y8~l_Hx-sg7RCC~cO9f}~t_m{QJ44q?5i zG&uw`YGg>SRI!&&o|sZ<`j}CY&yD3T-#x)|m67#5hjQ6E`9;eU{abdgG&VjZ=JN;` z%|&d@uf$rfIX-93N2|x<5yT(bS0TCI;vN&$_UgHIBB~4}7WD{nhSt-YpKt1O38p+d zr^qGvi&A2$wDDub5v9b&vdj}lY05{U7)oy8#D*R&8gs4m@|pLq-Egq83R1`_n0eW< zs*Rs(!`mY931Z_Pm7Qroi}70OzsJg0LVFdBMp?#7c~!>B?dwvq63W*v%j=o)FY93$ zvx@1k{F|a>yoAT)6;X9+#1_cS^Ww$2RMQ#%D5!4a6ck*x(Xa;R|FDx~DTwL+j?S!2 zKuR~c?!ZPm5BAQwv`WuNXB9|fv{qZ|ESUVc5}Jo+tnPSdfRlM;i>9d^TUQOAop!R@ z0e8XuJM(|KY^lSeM{nvQhJ?5D=#$f;QOAH?E`^(CpYGmsXV}@_N#3ip34vLvA*o(&(Cyyz}#0OG?+xg$9o~aW3z;ORRTW+my%@txf%3=enb9Y zvsS@Q{l@dDmu=RiMta=6tMb2!yY~-=gwj5gn6*W(#Ju-Nl^7SBpL$Xy5BCc!q2#G7 zB=y9^)xv3DsYogxR;(hmh`a(%Iyd$HTy=UR4!^a})mw)2e|Y(9v7lgqF(W)-`SJP7 zzT2|k`1(rWhc8>hk?q}v*$YPuT0LN+_($B1Tf-!AcKCuhrbbr+a&sMozXpcN1%#DgwZgHkQe6?bo`-a0h(}yhQxR0_-Z4^P^h%Kwj zYx1R2EA3btdce>cU2N_zU8F^N{^|l-4F98oMQ#3{EuXS%vV8KbR^H~Ww5V--@{>`r zQd}-)NzCH4QJh)T(>^mhY6pF}KqlFQi?xcRLh7l^tn9EN^NNS+u@gwv~Md^!5 z16lMvH719YMd+6-yCghb?&tWKa`jZ%>P5L@#rqcJ9_SlMZ^sXs6DwnpH0~Ksik9%_ zu~vKB(-OS3$|q-Y_$swIta>_DLg_j1Oz3H>CD2~J`)G-4A~d^L`$Ois7L`?zB(I&% z4k#+CB=cUo)s@l9(RNm(*^}41ggm{NT#{ceeMVF+OX;hH>UBl3eWg|Y4);Z$8%tke zR282M7pL~xhABI$@JD{eP%f)!EUGE0rxwv>XXUw7!8kyJPg#a;2z$+w`Hg$*tk9 zn4$;mpgh|sBaf6D98fD=NeN$FwRS+e{s|dnTcqT6H~&3yVRnVUHZ#<}--Xu9>x$g@ zG6oF}TKV#)jUjRi|SB9FGY(L8c_Bgw&wa@tBx6a6_E7v+=_exPr$(v+SPWioD1 zwP7w@R3FrR(s$%iwQ*unT;+28dxYm3BUEV&<33%;dKu4b+eY0ekx~1S`N)W!tt%-mYrD)*Q(V#S`aRr)L*x8nHCYGJQm za@qY$zR2^f>VI5NtB$1)>9FM2eR~V?vWKT1ys&-avsdY3MjxCv=gEna zpPV)O;8^A9_24&Jq)VKop$sm5S1G3Ye^^>$^|p1A<<~Eh+cRA2&zIaN?k%4`{^u(q z=X%%43ze;ujjyzZl4DzxlJI0=-)TKZ-!XX5mTQ*nXc~S~JTvcD`PD+(cd&5v=-pGV9Mx*f zlS`L8HRkSRf!i0ad3QPkQa)|B{MjKjAmQYtkn+nHz}S&lwa)ZWDVY^2q?L6uQ_F^1 zU%o{84{r=!m8;%Sd(DcO1^VR*DCEpDE8xmhmLpQG%@DIA*Sgt+OyGUVw20_==uq9v z!;7Zv8~gb3n{pTRLI)CDUNCdpvQf{kE9j>0Te_jka>m7C>@!)>Z}-afj@x`%_Ok9h zAR?7ZadQ=m)v-zP!IH%`RFgHszcVc3F-j{8dP@Hxr8Q%|Y`-ptHWj>AqHy77^;^l= z*dpJxw^wcpw=bo%;&>iEm!>LTKEhJ9fh9PqY=O**7d6M$qJ_0XdqpkTFnh-yQVmmL zT76w`gP9xtwOBKu2jn7}_fX0CB;zYl@#60=zCx{hr$ zV*BVPR{vP(ns@K}`PTJamXA|C@s}Z!M?JSbT)1di)190-Z@F@1`2KL8x0#w-!wdO^ zjjhZC*>9x^$gFF&$@=^ms1ZMVqM7tTdE#~!bgjK>1RfQZp-QB zr|ULd`te=*m8oALzD~MYhI#sO1-W3UB^r+FUuxd*0t0lisa}kvW+`!Nh3egJ7s=Oo za_uISo}iIPdikU0GB`vI164lj z{Bn5ye8ux4iU|!&KBQwiOkA(5#UZ6#@`Tjpq!K+DGKTbxG848QGIK8+l)2*V)aR$? z(sh|SpdS+dl)1>#>1~w}t zD#uEbPVIRL-~Ly!hC=nNnM)f)@ABja$St$vU1-F5g;Tiel&p)gZ{A&}3MZ(V%R2rR zliT*mGO{L*kyrDWRQ6+kRkFV!x0KcMaye62Maln)Ji(WKec@y!|Ctw7-4ZJ+FRa=k zb5~MdNs*$_sbwioUKe?C6UzB{(Gz%S6|GHr7^z-U@i|qZxO4vxYu^D@)zSRTo^$TK zDi#nCjj-V(G?8jU0R>Spu@^LU#U8Q7G^6S5pTxu@#w2Qb zGr~Q5zu9ve1QXxq`JM#f%-OR$J3BKwJ3Bkus6_q+p!_OBd3Br0dnj#|iIi0fO$FJ4TW=Xj-L3F zugK^(qpJ^uDDbs8_}U~5)VMQegzl$LZx(x3S2ujI8O>0~6|o?c`4D0E3sB*2e6Ab| z(%ZdQcj~JxN_=}hG`KF4a4CrbfM*rac!Ze z9?_{e-7vXA`;5ihrAG9Bd#GaE)6>OO4O`{W>$gRRo5U_DA8RPh=aRtuTQnuDqKkJVzD{ zVQErJQ{u3HA-aJEdR300gDNLW6lSMBwP52zh{P|PJT&T9G(IFgI}91&5tRZ#xfGk( z01p4I0M9Onrj(vn0RYWcLQigQVo?dB%aBou;`&5t@Z8#}+6h7J?JX=uR@BmLYdDgr z$>quX1v;D}jz%T?c%Nr)yX)>ez@>k@1tS4yLZVyL6HB=sjpc~jVe>7@D6vBb8@OaW6nal z0%w14UgK~BuX)N=Y?6`8>Tf?G^$lJSO1b-=mqtFNuEfb)tE8BP>#^#hT!u&0Pp@!SQ>)ItSD11K{qSV*#wPS zLs|0(8mU0t2|DjxT!>9j@fnO74=n(P8JrE)xG0XBft)q0#pZY|%Geg?M0|VDuCrG7F4@|-oAum{)8cl{s4a;Km_PPJ z-i+~!yrqy|r!F3|VR1;7XT-r`?X$w2C3D>u`e+@6Ut`n92h+kpak|G9j7 zIz&`sltpZ?S8dhfxBmqz&&A$N)^H4=9RGi8F8pcxU21zljNT2 zE!@(!k6AP>CkiGFDcV@xGVTkbwjC|4p0a&a&EeMnjsa|)-PkYv^}q3ekb;%eQQQoX zNg$B4q>5`I4(DbIhvQt-Y6T9TBDe`GsfU;HgEc5Gb&x0=kk3mkxCAg&HS+|Ji{u|` ze+4J3{ShN2f}tUF52xs+Ch1$WK(3H~OikPo@=rH}R6w(pl4PLaK=byS%wFUH`3Euz zA7i<#Z`RcL0#Bbk;g+iU2Num;QvVnb;tq14(Kd`!sOMCv1933t7COhVK*y<8gq#8XEpuzB zJhs`eNYrzkNj=}2y=H`yBq;U6TEra3@!*{|f%Bz3@LSuYUBglxn`$Qb!0~%lQRdJD zxWigf>Zg2#`$!UzuW&zpbLB40f{wxh75bYW7}TfagD>2XoRHN&u*xw}{kjSwJMq1| zVC?jMT}4R>(XNQz6Xu+f<{{+5h|Zg_H#Bt5Ozko-HEn!G@PR|{uQB@T36X}n=&$O# z1qFAj7gYNAR4(A*Uxt{kb+Y#G0a;$R0|8%Y7t@r;FOY~gLw*DiY5~hGec&h>=i<$w z>W48zPXEUDq`6X_zO1K8+cbu;HJS@5k@#h z-58t|K@JE`8p_vE)KWdzCVaOgxcVcy@dR_yEmN_PI)*wlxRyx=hvG7!4sg*&BNOJNOB5U{ zDJid>%CyC-z;RvD#TMaqPk`Gufn)1gSC4}pPWTwwbx^p3J35SbE#&W?;aq3RU8ZnT zZZv^oV;%j#l3fv}?{wni`@aj670!2HyF(lKQNXun^0U7^aBXW31b*Yu1yRCwddM| zrUv+F^}|V12n~b&*-%>3<~L?4j28OgYLmxc<`~Kw;rXnvMR?W`$43Z#an?Dv@~_PnoByiZo^&kP zBI#JtjvYJ5R%ds&Y+-M1-%ezdr?$FoL`u9XLB}e#x|9k+QYbo{T>NyCJ^~#^puGcJ z3(a=Os}Ear4nV{?h2@vU6(LrejWmhQC-eNMNn}3a;kBoP0jBZ!)Xgt)1}H$QWZ)|KaOiA@mjokHKv|(9)P% zbDN-oczxVoU}uZNvW-a$Gn`ZbILTxZ+;_BLlya;|iD4!y2mGMlVI2ucZRSU{dMZrB zVr735VGh3P|3K;=)O{KpBUSo!5aDR*o}rXDM$PgsvC7hc^7Y5PLfScWuo$VG_phu)5fOWKXI-)TZa=S=LVNTedkyQQv2e5-Gs{)@qGG={Ryi*SLZ9hVwYG|K=7T0uZU87} zOR=S)KX|a&=x`qiZPGLw7Vet8$aM%G7I*^Bb6o*PuE}u06UZI8u^7bLhSKUbYa1{h zH-17xYk+ESf5v29M;hQaXpfH%xw_3GnPzX;6v9QWop_7z4uUlR#~z^~RIR-h9S-;bbMz-@a|#yxVo;{Hr*X;iaoAAR`I z$8rq|F3-y=m$gD4`K?#jC@JL1wyozk#mB@XH2AaMCsA}SnQlzSuL%Xct( ziIIkc;%#69*w`o+nF|)_rxsftv|cu6(dzJ!v(+1t&Sj?WP3W|z&6+qSpyMFh>WmqcVZMbCy*(46z1{pe_v^cHQT(RK=u`qEhj6f@y+y9$ zTy*gdBsx|!**_&(Y(;t1{=neYU7l%U-pe85DRAT(?>duRorW`kZGy0|%BMy<)`)gE zzZ9Bh$RK=01nNIE){kZ^4RP(>&#%k)UTLoGv!@LFfUSKB)LH8T?=KoStn=dDo-T7I zu|@AZ4c=gJKIB|b=IADDOfCO}V?C6uz_Bfi;ei10@(47>UQ-L@*BZ)OnU=?nq1srU zi=0wV83WT%J<9MK>Mvu1`OYB6Q!`u-H}AsojrC}rHNd-?Ej89-s2?Iq_3-tat{sKd zu>hg=nc2+7@5Kw!On%x@7dq5Qt8-U2w=n*(T8}?)l20%yY>btIvc) ztX`B{*y#`?A*0~>{I$;?jEO3qmAtT{IKFUuSmlh2&9(!r{Kt6DoHnid>{Z?o)p4oS zcKtg0xp_y$k4N)IAj+w#dn9=V6%6g5u~sBZ7bO5K!NUb~?IRuG!s~^3uPiQ1t~fdG z<>h;8GD@FcUNn7D(&&<`)G8@F?{r+u;q0Zy?Ao_~`T5KjlWR)nrB%J)m*F+1Vr6pu zF=`ig%{+&89r;wJSUpf3lzUOhh>1=crY?z^T{dZW&n=xs zO!gl$B`|zJkek0-pnFQFHI{36)|tPb!l@3rK%_3PaHud1^z<+eZB&TE1Hto0!wRkJ&E7~U@b==zIS=ABZ+Z_9W@-M=2#JACs8zCu3!>!w1 z<2j0en3v#r%dD#nv^CxWi3~lRw2H;*`2~JeS@7^Kn7B)~|w-tKSh^EMkqj$m?wW_fz zQe%B5!4>^Dp#;<(>EU!q*iu%eozhYt^aSL_6A_TMB6J&8L~awfVMDAb97fd%lYyYE z2xTB1EHMm^u@-DZ+%%S^q(Z#3rcDKdQ>+DSJ~CUWz_H;lfuaa?bqBcO1H|N$;_-VQ#3GugIvZ1{)hEbyy>A z*97ZQGv&_aYMg*Gf#U>RG(Q1XjmNA>XSb{ZdG13?58dF?P^ zfelv+4+%>O@-nIgU2Fu8DHX!YU!7a)nl*K5mg~u+S2`tLTO-tJBV+R1-Sc9E1KOZ9 z*An^oqaKdE5`oLH=i>5ECoTwyB5e=Pl1^MqTjA2O1TCEyikS zTF>N7>rj{+@hrfCghwf>6P{)@>w?gsfvGfUe%_*q0%-zQ7==&74|Te^7lx)K-E2BS zI@K(@HDL#XX8t$6P`LD^rd(~{M-B%+>bBd_3DfdMZf^oN;dbf-O*>SNf!mvr!l1o4 zoIEzlpcwQ6!MP8H6I!j;nSgS+R>-fH>kyq>^E%99nXHpvA~R`HrEa4prMd}2rEY}3 z=;g(PX>^*zppSSR&__{!2SZAS>zp&~;F3d@TV0l6E2LccNRGPpu&>jzW1n@leQ;Gq z^piFt?&|dESL1}z`r}PM^<=gpdO2;1Gepr2(Q`hFSiAvylF0(A99pR&FkJ$m+V2TClowxV z{@V$u9e(V0JyKjC6{O19M$Rc5StA^&Y!TJ_&+-V~3NymF`$4IvOi(oaI0SF;h<;HI z_v0!?sh>DefmaA^I57~pCs|>9x?wSc%_l5im2A~6j>t!9>!4!|&1iO>$ur(Aa|{^} zJ-)D(GzFuvg?$p|tlk~0++UAT))XQVwv25%Ft<*U+CTl*1V zo?Y$bXn!1H*H%x&L+aWvWWv8SEcRHrvfl5Bt?3EFC4mS-JA5IW zT{yvvaM)i}MmX2goRI1V(Ihttc`2le*8+Vs(O8;mWCdQqG$wGGD3w;fH#?w%>-Czh z9wC;?Q*&%88P0AR+9l0S}YT^mD>KGKo)9w4;7e`dj_s6a$Z^Yw$2jfYWIs{wflwS6=IJz zd&(Q`x895X=!%25$Y=sQMw?w|oJiF)c0$OLZ4TKp7oDKja=N8>aCGnftK#V zK>TGAp3|3t4f;Ah;jTAnqPC2={>wdY;%HoBSo<`WykY-B+ONZu3xg6wL*CqcseeO% zaYe&$scTJ*uyfx&p}d9;5}@Y!3GtQet$vf-I|tc}^~KHOl{JFY>shy8NBN+aym$1>!0x>yp@viR zvbscVh53;O4khSs&eM|<0si_h9eol5E7BmYZ_Ag@o@JhIuY8+%o;f35etYH0T@MNi zAM9GW>tR8`!;-V(jxnd#UuVuokIHYn{?Sj@2;i0PaEJex=Tiym zS1x>^et$&7{`wbKpYiVVerF#wlhrU3&{GNlx-S~4D-CF?s*8q-t z3mO&e6e)L9oA%71^#5waNl)#mr)HYgccp1u`=S0OJ;wJE z!r6hDuIG4f+H-n8lfTzd0PjsHK<_d2<9i(?@ZOXXcrU)n-|HxX_oft~_iym`$_)^U zIFoySMK(#8Q4$a2F2R^;D^9EZc>qYwHCmHnDB{ z11eQdJM9$hRh=}1a4xfFahx{4)xDv>rJpGkumq1Nc&5}0S3d#0cCxqhH)sPs38xhb zi7Ppnosb(HQv@dyGy^A6yIbthn5iSi=T0fAvg|d~wJ+1YsXb)p++&!7{jhFMcB*&l zr>r`kH#BI$n8M5+o}r1$!li?ccTdg;>E379@ZLRwGboCIrm16Su|4e|A=U$$!7%yR z>BBURh&fs>7POFg^J!|6fBPyExMzD9~Av^gL2r5|1Q>5v1>aTS!I&5z~_iJ#Ux%*UFz(nF+^&Y^> zf(|nvRtE&I>W%tnGQl6cMO-=_xx~(MWdC-K;+~pQ^}DuL4;tsT+OAZ*m8pf9+MNAn`}MH)gQYCcX4rySr} zjSr*@)wj6h)E#hTX6%lBwIymh;*bV!*4FY!-8<~NueBctUSG+$;`m){matkoL~ADO z)kZ6HRdvK?A_!vU2{ItYjECV6&X(-Xu3hr2Iu^sGd@5(NJ@3lv6$wS+Qh@0xP?|8KtI=M8lX4JM#;x(8BgB#voel33-h-SPX8MZv0kKn2! zBxqIJn@{4zbrS?mOxRG;>u3lp!W-gCYuF98BRk^eDu|11{T#n(ox5xQxb?DQpr@_# z@HsvwU)O58hm@r6AL`;G<;z}+U%NQFgxS`zi}kiX3*EVGa71mVw)>xL1M{2mhr4&P z0XK_pvHrVv%OBlZlD_@Z^z=`+r*Hc#J^izs7gM&f9@nq49$UA{UtPa0f3@|AE{kNH)NN+m0>N9=!c>Y5=$e3f zQ{b-b`$kHcM#_c~;V4a;CPktYG7LN-GHSYRJ#Sk6vrgPM``vx=NcOs}X<-mQ(G6Na zRiJ6m!J1#YW~hupzVx-JFp)?_TRzLEi>Qjr`DAv?mvVib+)p|tmQ%IC?D&FJ9wC!n z923Z{KCV>-_0}7&>IbMAwk{R}Is#kvK2j&BE(q$nzj1RlQI!ACO>5vzO)jT`he8E) z0DeP-0mUw;hwtXmU79A#3K}&LHcn2a;giGX zy;@TCW-6L|cW24uTrX-5_2LLHs7{8C#_VP3HhwEB4%~vt&s7?9I7eUK5_6$;mgIc7 zh1!`_bug$rUQQ8LG;2e_y;TF7>vua=rf#zpFJxOmr&4CIM8r-G|@MF;>)VgdYvHHWPmo=Gzb5-muCR~O&dmE7r~ zMbN?$|I$zL@;)i`FIgC5ak109oi`R;V}lPJlHZe$3MZ{67M@89%na*a%39T3WUWj4 zhh+vWK2zv*Q9khCd-=dc;R1L}LO9a{4c*`^qMOPc-sBF00O0Pxe_huJ`{kMOb<&J~ zTiLnq+$mI%oILLO-|IWU%mY%1qvOB>YyCLrX}%^vL?sAM>VYyoVr|MCrNwpfrA6z# zy(`lhb~g`vou2yD0ijjHzE9YK_f>-@Et@vAaN_>7o2yp7g=L7agLt)Zzw|2cYU^C$ zRdXM+TZ%?++}}#k9GD0RE@8<6z9Y?_Y#bRqEl8*HT=TGqI%4MtYeh#~m4cBG+h3F4 zJ#>f-xt4xoR|-ZbFm+YHnoslcK3x;AYGIINofSq%n=I5?6Dc0B$ctWuXBG$12+4n4 ztdk$n2nD8}E}FnchRoCZkmhf7F-7EG(Wf}1s}8nc?tfL!T-=s_t zh^L+&d0@R`()KBNx7SsEp10`wBl*{5pUY=Y9cF{Cto)>?_|HR|Sf@`50!pHzSNVrO zm*l)+`iL|y7rUu*I`{S%p_DSb{-K?M8Y}vySWVC90pdO7*^rI|!xc!w_imY33H26%P-?u3- zaeWvNR{|Qu8&~BI4#d^|vEdrCs;Vy$ACHc|vaa-#g8ctm8L}!Ru=CYU^LD*i|L!r% z(PKuAu71w?BAfR8G4{-B^G?av-m0kSw)NN2GWpSkI{DAi!QmOc^G_A~N+YL@ceU8N zeJfN|4u(08^*cYjvTHwJy`Qeo=nm{xSL9YjiaX*<_h8C)6r3CzP8I=c>Mf zv&+mAR3Z`}M>rTBEN?j?H`B>x+>mGFpf|Lizsg>0FP$mhaU{sMJ`M(UXVezixL=LS z{czvn7KN3CQ2bWTqmEJ!#$P?mU=z}{>Ey7}bCZoD)D8lKJGOWlRlK6zHxw5*U)pp# zC;N@15qVR(SoB){#lcB=zrC8dESxs7L(U5u%tv<` zpR~EldM55afGt3utO}o0En>=k2qji&*h zWo%4~@a4?K2si0h39D4#a!1LCCsxdqMH>A!^zRAp4gGw=dqaPp@ZQkxC%iZG{|WC6 zbfEVJIvDXc(Bld3pF|hqs1{9$ab2|X#uTlr3*CynnDzH7o? zwD-)v!|qBf=L-7D0M)m~-$JKS9GDMW2hWmDr88~Nr;3+Y<7`-|BL-Q2z^_>ZG?Z&~ z+H1>;t}mQ@GOOU7yo_5b!&c4g+?Dl;E}678w>&g7)1xdqw_-f9eyV=XIXgGu#Nywm z^*^o}3aa{i>C(@u!V9JhPWy8Ina|@3#@c$P_yny=7`^oNhWIl7fYSK5a(}<_E=#}N zwdd<>TgyS?!bVQXaNE&sJcW4$iM8^Sx_4h1{c{X znfIGvykha9lJd-XE8R}8J$6=A2%VcPY7V)K=?K zrar&=^6Z&G!HLb#ia(W8{APu}9xsvTrAh`~mY6_&FtKA&chET4_0Vs>(JcSJkx;rH zG<}%G5s^LHq36gUT?V!7*26<9{10ShZ@JF%kE`AqyDqeyl-0hY{Or@x*}%UvNh8p3l(R7Rncx9cmF?XtugVc+nHe7whs=G?%9m29F?_E%KM*3s6l1L38qe zV!uQ&r(6)<;G%(ZiCoEwlN16^yQ_lM+HsJ7)rX6NHq0p=HaVat>l;3GvYq^*Xd#!! z=8g@iig#RBE`NEY?*PjgCzd4at6RQrT#R3jaiU6MJ4)m}Ia_l5rp|ZFEW8c`Xa=Bj zt(}1YmTE2J(J>}2GR^Ib;2kAtas6H?NSE80*Tt1aAv#Bvk3uX3PDEGWd29zKVsm&t zD`^p4VD_*@c%d#wc=LLSo>0D6w@~Bd`HZjNvy6>5)^7q|&UCIHqLE3zN_3W6HRe_e zZlO^<_O0)>Q|umn?^=3m>|;xBjXiDYt+Br?y*2i_rMJeu+x@ikUt_zmd79A-&g0Oh|y}Z18+qZw4m-p@Vokx%E+;Q~i7oynZ z6r1$bgs<47Q>Wy!_ycR$9jqHuEoUm$P5wr%UyjwrH-Zh)+R||VWXR*e17tz`Ysx?| zs^OL)WnklFeArv#2ckU(vS`fxD>@yICVIv(^noW0Pin@6yty{*+myMQG#da=}Q9wCl)eXYj# z_rv3MJLm4>5DofJniAnV(V~s`yr#{_g;N^by?rcOOV6v#@xb=XzA%GFhysCmQ1{t)>k)%ezLWp-n75+To%wAp3Z8d(hP+(t zQgZLtoty4HI(G5+-l?SculDV4`QEvhWm6ryFl(WUTUt~~k%wB-u6kXoc1xBeFWpgI zv~(3|f80#e_*go^x!Q{M%9?Yw9ogAHNOCUBukAOc>$B~U6tiJft^B-HtX`HQ+Gz#9 zu{c1dYNeLC)qf+NeeOBfIHX=FZwHU-Fj-UxYMR_y$`-QZ_Qp-Omt?)aW>!%Er3#r_ z<~y-;ZsJ;))QCsCakR0twDH&xWY`)XWv3(x$<8>Rl6Wq6NgZ-xD-Ci6#>sKM^1MoH z@DF%CJILANwt^togO^9@fry3}j1DGasXGt{d(dR8n~O=eU~OP}FC;e;two>U2~WBi za{A1*A@Y~IT)me4`Oj-1Pb7NfPuW8aLYZ9y`4KF_6&d$RTKCrIoNnBlCZmATe$j>& zE~31fkBcaUx$=-r$`zDKKC*@9SNRv*rMs$LIM7g9-74jD+QJJ9Q(s(|b~?qyHS1)~ zw!hY*Jse>Z_nI`3PI?|7PGNZ{1?^U9QWn~PL&-pZ2UTAu4WJI5 zr%UXkMCcpg`7Ec!@&#ttEoh;hLbD4k!i%0zzS!)tu{`NA9X`t#1Xf?a34A#lYJ`(6 z*Of0}2%FHCH-Q@u5mAUcg)(yLn1;`jmQ$c@R+vz-<#2dtI9-dY%*wciqf{!PQUbZb zD2GEk5qm>eQq0ea8>0 zA3~l&AR?&V!#vG{kreiZDDin3)ASR9k@Q!s9^3qNe*V{+j~$y|>El~5|Cqw;#|$a= zDrDJ^axcA3mvT@0ktX9&siJ{YIC`mg|9R6;WQI=D-#~zz2*Qg;{wS)Qyf3phdv9cI z@gGNPYde*))^$QB?T?jL*T*m<#Y{u)W~J_XBC}i8*X$Zz;9191w{>+ zM#z@O1`L;4uJuraHDsMn2}pH*%{@CPH!Wr1k=Y9t=Y?jEea$g7D5WkZzBs_DPsfmA zi}bG#Z2IxYaIfQc)*d)_aQ9C??LK(+VCCH-?n4j${M^B>)4X$o!j{mcSzIGf;U>fl zuJ?Wu_=K*5ld+({^MnPQ9hLG%c)oz`JVQN3cmY$yrg`~7hH18i@#7p}$XfZ|L_E z-W&S=g!cwI(0ctq&Q#vvwpSRc&Cn;`P^;to#Y3* zsjr!nPV8XwV}Xo>2#yr}huuv_z4u1_&%45-D(~t&3-gF_(Lh6}Q z6Px$)qQX0CXO#y`S{*-o^W>1KF018Z_D2{ywP*Cs_9!B|7?2%YE~!i@}xW~$lCXFgmM z=dsv-+TKMtYdkA5K0Xq^dnTkUN|f(PAv;#ZrCGI}m^nGJBydltcSP*QaNmi6AUB#4 z1JINq`c@3kYH^pz-i8Q3c@TscpqdS&T9fv7v>eXkl!V;}c`?>We#nc0BLsO0RRvD@ zh9n6VP&NX)5vSCNoM(OI%MvC0w;-D^yrnoLb>HYdBl>o0V<)_M@I=F{#e%iGyo-g4 ze3{*|4NKph&W<-smY-ps1VG&H@5e}Uj)oyH6xHJBK)c56y?8=97>Ezle@)}`;&_f=`KA<1%SK2pmjH^HWpev&hR8>fqxJciVGB6*f z3c+t?Hm&hju|k@m+YsWJVo5>kvv|-Sl*rM*HTrBH?6>rD^TpHB^612ZlEwc1GxL(C zPAL)&2}#FO7S=@NOwTJyzR=!pm4 zen16msBk8EJ863bQE8UQ0%BWT%Z9LV zZ^_kc;al<-@^PdkY}x~yN_Yjez+#mk8{lo!LUAWFdmBh##nwkPp@Mj6{rZOSO=|)P zQHw}7p6G(Opg{CNT!O7tzu{i}ZPcBFpKIk5%T^w1m)7*1d7_NnuRksxmUGYjnS)MR zVT>+txuG$kvH3qfGZ7q}1QROoabgsQLu6G2D6ej<1btzQ)ff!5Jw4O+U{G!-qI>Ol zIc26MUf53cwZ=0swMf7Dog*roCI=Zb2<4%@U66w5!Yn8rO{_y!Ll5oq18c?16GR31 zW=#!y1(#3a5FS266*!EDrtmQ>`MPn{-Q;dDE0gMkA9M-mC!AQ{T30K+H@P^?(X@IU z8@$DPs9-);_yL%6<;9!TkZMw*=cRVF+8_B{OyXfuVMJdH5Edw)S1P_ztVo<0_@AxF zm`&dl6@9bmWcA&mqPx{6PtLFM_ph3-|7EZu^;wRq7hg_Ie#X#q-fotUumAXc1H6_A!tlwy6FGmRuLVtMztJ8iY*LcmJ|-nzwR$h-2h`t{sv-MgcVqcX}ZSwN_S8dgcVhNtOymKsp&;jS!Oen}Z= z)k8E4y=NL~21(y}g6*t*e28vl5D(FhzJ4|CmO}mD4eFRG5Wf|U(V!ko7oIIdXW90K z=0M!b%(&BqvVASHPberzkS`0bP2Z82xMv16GI#Qn6rJvX(1M-1D!Rj_#p6tT67f~-9W)~LDmM>AG>sbf+7dg9W(}pG}1n>Vt6UB0V^Cr+c>_({nqTWfrbwTe` zt%aY_JFb4zmqAG-KoR|mGnh5_gX|t#ip?XZE9qzPM>c8Bf(3KrQ!McGyYHT^pHVwd zNbFx*6B-Z{vG?$X>J$8E@K^ix$vx;Vj8!x|BHiGEF-Ebu@F+IrxDrLNIZzavlBwMa z{tKZ`_=c>APDk1f^Nks`V4bh;x&KJ_^Y5wFs}J^Lm2A6`J9zWm z{QSF{7gYNCRxX%F3FCP`KSJoE<-g_+1_1_ui*A6#+#ja|uF=wxhpl1#t^}=#H%`df z^1S>q7A)`m2zdqBHSM6AY}6~_Y2+1*-$Z!@apw9rQUhS7d#JCQKB0`ss^8Y&AV91% zW)TY2do3htna{YikdU;o4xVe%7Mx1XdV2*M0Si*Tv@>Y^+?Zl7ui}`cHF}f)Ifmu|`Lk`^ zwq?#L_1nqP->;guVMVMefu>%aaZ9eUUPi{y4@NV`90k$YC_!|3PeS9O$F*;2Y^eXtxq=ip!rk0KYIBpkmeKU!1p4g7bLV$QCPxn7enI$@$$ z;;68a;30|q-4cd4x-FlW`0C^(Tjre2%04@{-~;(_7u?T!{4f*O?ErR#=i`UW-jN!T zJ$`(4=-e$yJvy5|W4)?J7sp`}e%rD1>VgGVbIU(oKZ#TYVwousst9F_CATMLb_Rat zGRlLiaX54=Yh5c?p0xGTGAU%|PO=gd)r=Dcy69?3O6G^YYs}#8!c<6m){fW_<`H;& zE8QdjPnV#YBIMy}Vg;wqZlAe;J^HWf?Agy349hM?;}*ay|wJz8DE+7G35RM+*x&^+MirB#gcx&$-5i zwfWv|ZE}EraCaFKK|{5$BPniMKm$3e=QY|%2h~SFB8m+GaU94;?Z`L2l`|ugGoBFDo@y)HAom&e;_cuwu%I%df5cZvDoui=}gI&#albK6KiO@z42e z$PX&|^;1pBqwK5mUs^sb%XNHKu=n(_Hp87RZY;Q%l>B1W;zExFK9gcbb?o19Tx?nV z((80NFpk(ry;GWa`SO#8;wF{ z8>`OkMGc=~D1$F_6egBAkztaJwBXJ1jPsf84tH3!FeY!TU?U6-jh)t8Y&FFGK;DW5 zt=KDb=&_Cg2qosv3c=6uX7)8P?!*@X|*4kCTIw`kYHUH$P76D!i;k1k>Xo=BnEbb@${CDpk_n=+DG;XXLA= zJK>)*;1sO4R5&4}v+ALF^&VbQ?0N$P2PK_FS8W^NkEE63$FH0#AFmVI?*G5!$EO{eqO${9_9Sr2(!)Vd8=m69;yov_me)xVOd%JPX1Qb*7)o#E8Xk;_&wocSdD|% zuLrvgMmy;`q}$ViS5f|81g6no6RPyb7uc>kDOx+E-PcaS^BuImCJ7~C^1gk- zTne>s_}#LBjS;>;-23%)4WuLO&=oqNqL@cYogjr3DP8^_+#L8%6ZhzC&9FyrA7>-m zGuL<67z^fjS)O}UzR*^_a2=;JZo?GXw{xc$Tpu8vq92?#-&CKBnx$r%0W;3i>jt9P z)9D8|Uc^5CsUz4AD_1i66_l5f=;@>DIaVZO*z-|BuWfPWlJ6I@5b0*U6Mig(o*xUg zugEX=2y}693GRl2o}X7#$j7l9$C%)(T~@>|7cc!0+_0MZ3)$KX*;4L&fPd2yX0Ek~ zV@k25SZ`+>5p?d2c^5i~Bu%&FKn_r0c}Tot$2Zutm-no`oG{Y)a9@{fwx#|pX*m68 z@4fqntINgFLzgej-1K_Jdn(TXUVc3j4<`jbpEf>ssMFx6;bF71kM_tz_GsNt9q=sd z|BPdtYxvB#d4>B!(RWC^ijFwZsB=B3Xniaq;oZd)B+%N@mQn0Cmcy2+-yS=uZa#L} z{FyM*W_+MsSB*xv@VWNuKK^kNdZ{#$SPMG5vVOg`1T5xdn^qRoD){+j1H^40O!&y9 z2eZvnFRba;a0n`GQR84$wwg2$<_KkW)@v8E+~oKK3{D%6hfcQ$SBr+fD!+H|02_EU z^UdA!tgdtpOj#AO>f`)^Ps{SJEgX$hkghxB2k&XJAC+9X$Rha}3ZG@C=KE%Z52iB| z7vx7}^Ilomre^n9`KM|$zo>D%Dp_5|NdQ(rZX{xJCK{Aob97*c;xvbJOM3+gRrm6L zBma4-!@u;?y!=ns_!~}jsLLL`sPTUEqNMTudsg$)9cA)gbr-M~H6TgqhgWD;uJQV^DRk7gSPG*Q6_tZ_?8caC)i#lACjdSdT=?=NTJ~z%0 zJp>gS+ir;6^!y3Ji4$!tCTDmK3d*{G6E7?MR(@Pi@bSt?D^mk4>a6GOd^Pow{2@-o z47il~+KzeRu)P|K_zix@(f-}Vk+H7NToD2?Li(4;kFH#n|1Rklnh_u@I-TdKpFGCr zodS^}p~~qv8F4as&vY{`E%0cP6o1f;5nj=bd0Uz%_pd|xT>1WnPS4BVZ4lk|?L&fx zLHH0Gn{9T26Fkf!%w~h;TpVE|=$5I@PJBw#MUzUppd@5LK&SN*3u!@&DKpuhC9A`} zg~$lDG;6PKVc3k7$%zHi7Viji%=_fP*xj#Z4$OGx#gH4U>(Suc9}nm3S~zXGo8u%e z=LsI?2TfTjJ*Iey&vFq@F}pCprY&>f@f1l3>-;9I?X-FMlsxaytn82tjXTEYy|X#I zy`Ammz5_eWe^vf{r^ko#*>hn_mV}KTJIZ7FC{@bim&WELdCKW61Yyj-U<|?lCssEW z<9H5DaXR_H@lBJvHTCRZqu;0DZ^@dL3bi+n3(oCN&yVp~RuXHJ!U8_Vgd zLSvqvesbchp=r4)v&Y4%eC!k|q zODE*tHf~~Vj?^;qO&5~RWu=~F!!Qtf+tjwVFsVrUwa{_PtvTZtOz>MXJ^9WFc?*j>vL$fsEO{df2`~09TPGj7 zrkV2SAP%V})uzVg1$1hwj@*&H_;k{~6>|deChc@f4@_7Ikv`Js4MY?zP4019@S+RE z9HUMDG2QQ{M2#&gI8Y!@!#~<(|Baz)B~#0f>F4h&`oC~^{@)PhM+2owoEhstd#HvO z37vZj=MZ!U$CN{PLh-%_`S}lPYJSMed$51Omawob3;4g*XV{cqeqmG2os$p#^0R#4 zOwBsh_TnYheqDw9+og;0@9VHeo(!ij()xf(pa<=?Iok<^=URcr!DN!CddYvk_6y3D zVvxOo5)k4EZ1^S-t$9toMH;O75MKW0l+;mvh2hhRyiYE9zhu>0nd2%8gAk>ZFS4#U2VuT*0lh~@i=03^SNdZ8cWf#F0Fumi`R4!ZajBF z8l&m9oz4TFz2WXk@uDhX?X;OylOwh+%*mNn;_tOIJalE4a9S+>zZGk4rLI~$qr`Xl zx7NuQmIkGd9iI~%x@6*Bx3qwOv~gqR2j!fbyYSrn+~c-wI>c{`$-R~ec~1g0&Vp*K zX}Sn^X7DOd*O{dYcqvJEF5ORVBQBGdvXgA=bE5WMLt~p7`8rFQB=zD+YcgQm;aoF? zlwo+AI2OqxY^kgE;26Fq6I@-%J3 zfB%Kxu=4!^71}QfjxG-kuZYpNUocedR27kUxWK#c4svAFJbvcCkifB5X%QHy4=viL z^W1Aet7&9W)cH~LlL1UvlOQ=HpfJ7 zoa!8%J$~BeXs{XC09_S9SOg^sHOejto3%{5rcGAawy8Px_%<{dVZV@z!BASW5&k@U z%DIU4;!^9Bi#aoP&ug#lc0MQl)SNk|(sRytQ@5YDdq&R1l)avr zA>jpneg)wnnV!N`_jLb&h2zF84De5P$IvTO|K3P-2V9Ygjqi@mw(T)jDcIZe5cmmA zwb;yaDTEfhl5ct5raU8~W@;Pzv^7D+A0Q5^de(~@A}5vKNCmkaBPym7?aQrapUO;F zwwjrp7&~>CLTiEf?UjFf<+}Xl2MdL(&M{eDQ#VFWtBwZ!btENaN4q3gMkF{e8>16Z z@#r^KdF)88V3#W&e}dxMHCBok`}ohNQGZLJ{%Ac+E<#HdURYY*j z@)EXEyIf3{?Oyn~XxTmMgbPI>X|Cf7rbm>7ZDJ?e)KqAH>18qhjlyL&7MW-o4a^p< zX%sy}mP(Q^uTHyLTrTI;axK%)Ri|Xw&keD`~$k>F{bP@m-5q0I3s^>B%%D4W z8s|{VaOd&KV%4{jS~YG6?cG)Z+x7g}{aJcW#rsPzq|_=84Lu)*zNTy&YLzEX7+j6= z)x5k{UwVHA<&n7p?hN?+wFDG}BY(f049ndP!@MQU*!; zU+BfdEHJ?}kvC9H`-6$d?Q9}+=u9y>Q_MR%iRY*{63dSh>m1(Wq&qt6Kao0r4#dgi z#0cQeY4SmwOnzI^s9taU>amOx>l=SXBw_=;_>Avd^Jl3icAW8y?M?hE+EO{Q3v8?O zo#epbuj}4C#2c#f6uxj!>f~>_&^N5Dd`~#59)P;HcoH^Ac<#iXx1#%^S+w#VDv$fQ z!{O(~ZJvlavbM+dZnO-fvz-);#Dlyk7@NV_9lc~`zo-o<1u;FsH?gl%HbnKCxg>j2 zc#oI@ZOsWGe^x@~w+S0&4Bf;s3gkPZW4-Mg<_?~*A@Q56IkWnC&m1LxU63i9Iz>xf zLg>1BfF1!)YL3=ryg70qF+$0bqXgYRryrTd*+#goG?!8p)juvrTf;HQUNW91+4q1ncn?ovBa!z5;x2kEKXvl7>G2~K%{Lv z0WEN1zi=Y$_zOg1Z5w?+#8OVgI3gmRJMrhO9qBn*d5`1Ju5|tX?XwR-}L@{vmsKH6pQDd`12{VSyHyt z56?gI@-MMprDS0%o_|rE&C<|bcRc^9d!8gEOB3<@8-G^Xn~CS&`SWJ8!_p@4OFaL< zpQ~Agv{5w2^Plw0)Iyom7hI0t+5WQGThaoRgg)Pg#@@sG_?9%ELmuGLK7w2} zdk19%lzG_r$gHdhx42tHvY}OdLLg!sTd$X=^&?=8ohl3ke~4t)%=pM zQA!rw0r^D_VV%(9D}el}hnPJ?pT7g-Hyz}%*=L~oUx54$NHyx&%z~v&(sDrl(3Pnc z2BGKs0r?YfUkh7FrKRyg{R_KnB_uc(>@3wKW&F?Fo` zVRV3RQP{NIMgE7~rr6s0e$r{)iy6^t@_cJ-M*9!*i5(i|;vBcoui=WYFJg#2@pph;?K`tYM zKr4JJ-TQPDbakq}gG$!dxlvbV5ZXGAS-Us)5pjFk7>-~Xi-{$GwP|~7l}qA8@5$p+eaEN! zN_A4!E4jtjQj%wR2KAZB(FhD=XZkE#J9*&XrC~k^qj2u3=xlt#iS*24ZvA~{Mf8xz znsgaxAPXg-w~=>wqlBFpkOFYkITLs#!9j{ya+3HKoIVQLoH_Kofjuuo_Ur6wVku`U zB@TuUfUt2Sq3xOOV}ka)6a{UJ2u4MCzW1_q5rYQhg?T5A8ksr;oBN5!3GD$sv%`C^ z>nKEI4#YSR_4dNm+8d4_?0Dmd5?X&U{cM}@0eilCu;0`mpEM8G1^((Y>WnKHMHlB5 z&kXgkDC~T=v-_yPz)?Vm?N)oQUGC(zDj_&!K|-B+ z;<{zd<4QPE$&+)B#p;oY_ET%)h3)LP0V!s71M^KL<~$2XgHy+jxT9Sl|8qyIJB1tL z7x^6tNOg6dKlw;ru@JqNB{IJyTjV41$*NWIkMg_6K428a7Ku7UT^%Dkk|PEgwUo>@ zn#jocj;;#>kN7Pbzig>|?<5<>ES6QVKo-7biF`(WZqJwUI7$Zm9_olj9q_q?Bjp!4 z#ouGAZ{L>hX(nv``$08$OJ9USH26|o)D;w>VGPhZ7fLCL6TZK?>lgK{0QKH-s#BM1uydyc+Mn;m^DD?=i6Sem8$!)EK7mR~KP+-2>J7Ih@CE(M#X(xl0$)9EbEw%%!)6 z;kSt2!j(Ta{oe3WS800J29Mu2{r-PlrD@nfe>{Y3-LOHP&G%DxB9=pKmWC|6LDIL} zy+GFXP{s2^^E!q3ldGn^(A-tS99(Qf{HS!lg-@4$yeB$(&&U6dxAy?6>e$|g&&=NY z9Ka4DSb`z~8U>~ICcXEr2q=mj1w~QpU9l^+Sh1U!s7Z`TZW1-d#F9kQ^(LBPOiVAv za?a*^XZAjDz+-Oi^Sl2qPZ0JyYt~w`rmg8SYd&olI<(=_8Pm>GR-Tzw{pOS@Z_=Jq z&)3wPpL+4(rYYyAPd`89V&~$+UoTwv^`T+I4&nDVhllN+{lVPe zCfU42ckJ#2%e5$@FUhw_v1%-~yVl7D1TXE!e|V}RTl>8spYCrd02k*Fs^bD~Xz$%; z_XRJ7H*81GbN-kc6zmL>>3MAPAn5CyL``#usL|t#hZNIT@dCYLj3ZunxPjhjXi(x< zn8dNr+~aXjmcWgLAn#8guZiY2(oUEx)G>b|LY;_WlYy+qpq_bab8^<^<*&)fSwq?x z|H=o7RR2b-5pgXdqOi1%iu_dkIE-alu*pxEI58!qqJmh1UEZ49+%~{x=B|8q;>34Xl2h^)GV<6<2S)sB$>M(v-)Z=1r;wq41{^kkL+>UI@aWjG zzG?_Z^bs^yjv*rv`_!sS6DMAhN1r-%U?;WSIs9LXm;7slkOBQV5qBY1sN$Lr45wiBr5M`S34t*Ca~d|V(ZKQ6@b6>%yOjgFa(8UrEr9>yPHurGSL96+_E@NI6_?vpP32EdwWY$*gL{O zvRI7=$LB_uSGx!YX+Oiqkc%*i4NdU(`1v;J9g@WTJ;)L|ar``_>!kCfv=#m~$KO`* za=hhw2Qv$77T~i7GGe? zRPngq=9*{aFSrlkTL1Qyx*QFoTS|hw-5(mLIG-xw=E<*Sy7qw-EgikMhwYq8aWgaU3i^;;e~g zv9N*UHRs6bp!WlX4K&1X20RfacyhHqyWoZQ~%_JJ#Cr3aYjQRa0pi$e!;?$_bFGh?x#&V{CM%=@r#1;@NkPb^5aoDW*3 zxdOaVtIP-8K-v&43M|HBLs$xqx0)%k1=ivKi7ms`Wj&oz0}Ykr=mj;G}kbjQ-Wf|$WRo^(5o^+{!>YwX^=DIvq~g)anQr} z=ngd|#e)&U#W`YMML-!_g+@G@aKP}6aCW%xrap+Q-@Y9tyMXzi$#fg&_%J}6tk0+0 z4jqDu7cet41w&D;Vj0`Fhm{kN>ff#u{P;hWlLWsh`nM`8g@7rbx}b>{CyUYeOX)C` z^}nX7V>+&8QpgonmHd*D*l?8nrdtmlG(3~y>zkbH>z7RbO!ia$0z)N}in%H<12Z)@ z0g}ZHjU4)LKeKZ3{{8Z&^iM+@=)V&)RojJ|=)AEOpQ-HeRkp}uKxNz`kJV5+b^ou~ zuw5i`VPZl}x=)yESRCCWWG+sMo1X3+(Kj%e?m<{W;`9u^P!}a^QF44uhG&FpP!gqe zGd4sGj&$o=m^*z#bV!W5Yg&mMU$Y@{aFn}qe%`c=G2t--T+)h=13NH-_LR_uZyNa< zEqBq(MO>3`m(Vz}hh{BGNScx28|D(2M99e*8zMtv-CYWDYc@tkL=AM#U|#h>%)I4F zA}ad#s7-7(>}w$FIC4it-a_{=RidC_CyCh1TQl;bid@6YTZxF%#nMk|tZRr>A`u`z ztH>*wMwGX+Kz;$jX2UMP0hla=0r^#>xqz9sDdOuuelsGNd7CWW2J*X#yoH&!sp4Ni z{s6*e-gaW!UFCMO-Ab(Et83$lnDVDARV zd2}|MeL*u8Jd6?0Vw5yTVTIBdO@9wj4~S1b{0I?~H*O>^Au`n@;!4=D(jKruDUM)^ z`fRxjegk>k+gn~v+|CiVXK#1-MoLSN`_d@}Y;};Wp#507Lcp#?ewe~Wa&>n0lMu<- zPLg!aHsn}4`S)1TO}-UN+O=ENmb8t0zkWl&bkvVJE&N?EW z2E$h{Q&}XwkBu{!+`|~e*%mBy;OPpcKH%zSLqpqo%PUea!k?B#CV98r(Z-idO2d-~ zOHdu0KomZ#VoUg7vUo-vf>gu_sg4W%veU_dmJy2_N5XV@Ls`HpXJV>p-u>!oSbpr3rhNiQ%OQ`?6ml}>eyk4C0p{RoHa~q6^*lc+9V=pU?x=<^RP)nG=NRB z2W&rxx?=lJQY9r$Ia@vI;JEhBcASzOKQ%UXYJB#>_RqGje0Egz*(q|)KS~uS^S7{6 zRma6oW2xGdUv*Z$^uJ9Nn>PwVUa?|>;hbrqvH1rXMC(cX+8w*rUwbPZ|E(zd2da~N z0nd0;tyvHo7#L%ykyrmSl_k{tQ(`S@uoh3Ovmsm;s4aNeV5q^j2BO_zzgedyB>qs+ z&`|Qde0dnDDA89G%Q9&YPEmVQy(Na*)L!`^w`F^BHY*EtEm3$uESEmwD}A2qJQ2pK z{HzooqPn^wzg@WhimiIAzr#it{J>yrGm)2~1>dJ;J*+&}Dx>o_Hjn~f1LKjNt~~4b^rSKIc36Y7V_MnLWD@`V_l8`a{Q4U!ioazx z$dXrQ8B_l6tgLTgmEbI}&IRk`U~S3I9oyrShBEVMi#FEQ3#$lDnA3rA?a~38Uq8#8O^8mW=0uwq<<8l7Okpg4(2{TBF$&S>kGwtaN=O zE15C*Mi0Kg%Kqx2)3K^YvVSZ^-rkYlEh3YOhm`nk4NOW3+`heZ5h0(=vr@lGcm;&OGMN5{H7JB;(8B$o{?wwPLo@|B?Ds-1VhpX3p?m zE3L#mKRahsr(NB~ug?gYHY#kdMc3I0i6ed6rVe#X8as?G44Rx#Jkrh5vQ2jfF)}%J zphNPAK|Kaf%r;!E^2}W1o8wV#W!*-y3J41bg--F%X})ylAEx`(%$G-we0k=~m&(gu znwdVv*Jo^c+E`!Tv2@{tGjr#k9Y6l;{JCexuS%Jpk+C2(Wg-4ehKzK2n@GAYXudIJ zm5H)s>N*$hOm+74b?)QiLvIWA`rAUSZyzUbZzmUTbnE%_HvL#@fnD*4$EN-H7C*iZ znVamd?VGgSrle0=wLK>;cBuG~Y%SQ&2~Ai#+wd#-BPD+*gkx{HXoJ$-maSq$VC!d-q0Q0N8n zk`g=X#3{tyJLBr2#UIuP5X$d&RHu?@V2P)(YW{=^lWues1k8dVJoid(| zL)UC4DI4N+>IFD9cpMns10uO7OJ;U-K-Iod>jspc zchYfNx8ky4V?5)XNVmbmyaGo=P`98bN}g%gvmm->u$%m4udGmq&_VK*q}3yO4H=W3 z>X+bc)8I04YDnf*Rc@!;Aq${ku zW(@8Z6VkQ8rblK(-`MaT$lemoH+TlGEe>CYYBG>u?C4^RGhbdaPEqpi8VSM5aMOaH zufWfVVi69uK=Y19T`T$W-zfb$&V{W6kiU}VjP2iJVCPOQPlP=`t>T5TV_&F9Svao0 zy;~>if%g5zFG#^LzwsxhlhCo@(bZnPJZw99Ik``d3m+RQrIOI{@Wkl@`wnVr>(SF| zT5NcEsMv<|k9o42x4qqf9-+g>Jzp{D#qn-&Puls|+4|Y_i+3CU;-rcf#>o>9Kis2d z@DsM-j_whmv^eiJsP@~h*N8&!G9 zV~dS;Y%dpDF~w!^ai?@b&QAXhE{!fsDD*NgJ%yMd?VPmV1ArM^qu=X3TJY{=_Ux^Kjkw`*g26|qyx ziSlpaNfbji-bLAuX5&}>L$K!#n`6Y2xN7n}eH)>@!KZ>wFs!W@y?3uRXzyPA!2tcU z8|laN(=Dij#~b@gTCl;mo^5zW4eBImUpDl4MY?i7UrX*cYB5#b_=W6B|Amz&OoL^b zocwHY>Jz6zvmJt-Xy0kDb$s;U-7c|xth>ZJq^9hVU9}cF1#Nn^l=SQ?mbz_%Fs$ss zv7LvZdNiuvn~*M|D`XQIe0*VJheJc?{ir*6>pjEOMYZRg7s(grlK8vgbsT=OHhlG` zV7+%QJMWa+cv-R&SK==__Nrhm-z@pKl?-La?A{cyBF(US+WXt{^S8f0ZR)!_3JP|- zJ9X@_vG_l8^|+AHCp^={ilm1}3bwyrUH$Hk{QMp7R#(5jz2Jqh$HtF4J~m|hs&xE9 zsYrzO?YQk&%SXI$klN144<{|z%WjEe)naS;J>uJ8i5nRtztwIb*&t6^iYD*-4Ti&p z!#Ks6O|v%`x)|)iDn^)0kK@kOUVP1;cZt4i)moXfSNaY(K}~&!j}K~&(sg*2Esm*4 z$uEoy@lR^M)jD%#O6uaw-P-QsddC-aE^>~E1n+`^@Dx|A&c;F8r(d65oxPHt9GD+2 z7w5bCPKxviwQMh1^y}s13Oy=>D#1>B5KVF~-lVxYF^8v<(l8r)OGPOZaq>s*s4f-< z4oS8n3)APNCeL4zo|~JVT2LTVMY)d_2FT~!;-i<1DO(=5U_@F*X=z5<2o$+AVG_N; zD^pW9$(j##L=Kfo>Hqn%AUQNDn+HTjmg!WG#Iy8Min5*`wHtF+(NvSJ` z7OhP685-QfVPLn??5q(z203&O9_EK+qzP5@FOy^#`+GaZ*HQZ4=44FHSdf&mAhmF0 zXYJtwmR-twr|0FRr{`epAZ~ftm}SxNZX{6u!RUdJJQ>5&o09PxlF?gf`&rfJtsp8P zduE5Va;zCZ%f^l&lB46F%FfeAwt z5AQ#sYfP4FqBedTw4Z zBKvaZWprHEF)K2ypq*`5PWpo6l!dAJy;jI(Fbh!@Vf`2-trn_2BD-FGsq~iNd&apMXg%p5G|>1l|iWIf&j%k zup`_?huG*S!2d=4Q)&qsl0&1nWoK_2J$hSK*0#|Z1^tqJeN*}s3X{_6hN51i)fEk` zOPfD3!!N_#J;N`36tcg9ZWipIC2y%<7}a^?Z4`<#jtSbBl;Da37Pcb`cpH_8HcPu# zZK%K`dZMwBigK5alVMFQ6{|Cw>0d?}I8yd#O(7;S=wFAmcB68Xwj^(~t;GTPsM?UA zz$FiiR2q|*K@;U}Oo-Z!u==2bnXGyKs>!gk<+icyRIyiQTN>J-Tf4RcI>r{U?3)lA zC<^OEDK=0(gxpF&YkCUN*%=nrHFIAJrey2h?R#2w?dsRFq>^~aqqP$a zul4LgL|Uu0AS5e~yoLsfwF^_|uNb4TV#mCAR3P4ZvOjPSE}GCW36nz)rA9{O9Nr~Z zcAq9p@gEgM7Rg)u!XpC6aKn!J_#E)@6l#Tb-~)dmqQ!eSBt-u6!~*+Xs4_k6=N*^- zVAhn+1yn~xP4g##h8>7JP^cA$BC-=A3tr5x@b+k!-{-dKM?QCtWdRp1qwk>5se)3iZ8 zUazzaN5`zxo@^@vG^TeTluZK=TcQZVL1aJdIwnrYM}BTz=Sl44yC+k6yLSuk)T@tO z`U&}0()Gn*z1_Nn_w3ULWrNIIOvVlGUpUYqETsEzdHq6p(bWF=9>K{2rZOp5@$LvI z%!l){>jHd$UCT;+(5<% zwWVZ=xM;k*L0pKklZOopgSE#Ifz9liw7p8}pm+o1(z9=#JNoRKXP+w?IkISI`6yxX z^|NnY|M%Im*Xvd;T)2A8f(5J4ZkqB!5+BD+$6UnL9Q~rnxHvOx`}L%S-I1_-*!CN= z&Z?8_r1SA3-yG3>dWAL8bmQzzwIC`6(P!ENyxcYIr20N-A|;gNw;S1uak6rGUchiysVTMENEgRdJmgmD6uIXp@~$GZ&UJSzIS zrfWUlDBE60o*^R>lS{`GBqb%6WJg68ll>&GzNmP0+K7zN6Nl^+CWlUl8yFBa$TMVS z@F4fVz=5&jLW0X9q6*te?M5U;j}K8baDmCx`4(4Y3RlrUdHGv19tk3+A(TENmL;T= zj4dv@Ef>>C@~h)^6%5%WOb(d{QIY;W@e_}WD{32E+67c3Bu!#-8$(ggqadNxp+m`s z%@YnKo2SIe-%&sLD{;5jW)Zc2Sw5m&y7I+f8leBcBzB8~M8>#N_RH(2KdzbG&Z8QB zoTjr0yfb zvKObmE=&%a5bGI|77>xwtA_)5X2l1N{iCL)&e);Wj$X*bo|*wnlro>dJ>{l@fvV@! znN24#+rsBWn?>G=n_nKBcVOy}x-84XR%zjpL)^6v3(~U}B)zsZsjMu?JKbFnP)7O> z+cbW`>2cxXBl~-VPfhoYh@6r*bNj-~kdSoOh`vFYT!~5aK2qDo#Lx9ZW6|=|lp>pr z2o4X~I>g`akE8OX{_O(N2jwhG%&jj;96mfTVfb)iQuxf=h?+>^DzCM6>JlY*&aGA<^wOSe z8qOJOps%U3tzL}bWp2b74~M&hd$JOZ@TpO=^*UC)E5? zrnhv06KUR$%_c0AaSE(N_6^;_^6weq=Fu)7%_C<~V(zA5Zagzr1z{@^atVYY=Z^!EQ&xfMqHm&F}8Ja4#q7K>u8kW6&fYEtym%7U%{5EW8~BHIPrmZ z>aQXdp!t>aL6gF1Mq()9M4b@}eFZ}r?-{-$eK>+64xEO?y2oP;Y(_{ojtpu>#ED!V zY`SlX^(&W-`EZUvXF=dJIP0v?7`{W=nKitPG=}exQik{#X&_z4V_~g`$4WukPjLiW zB{-_UQgx_(jw7I#1Qkg_ zO0RPSGhIz7?Z;zbZ=j024n7u0MRF5ziQcE!#-UA7k)?7ld53xeNmY?}Aj>#{nRCu( z9gycalCB~}K<01+lg*q44;0a_NG|wfs>ssD`}9lAVjx*YK8;i9$HWpyHbXQ-F2)v! zHhiL#UwbGQi<-lF4cicnTAS2(m!1`T6%W*O>s(WZ$~v_rn;Q1k*YDl4IVCkUB{?n4 z>Z7;MfBM;5=RaP!Zt>Ff8kl2O-*oW6=H!fw!0XBi@KrJLB^7&F&V(yCJaNF|M~QG-RUE2 z3W4rkwUu4(2y2s_AZmwg9xeP&VyW{_A0R|buv<#kirofuo1NkTABbp%Vu#%-Xxxoo zV!@XnSTAJsJ~4!2-sf=A#Wyt3%QtfKj_MhkdgrCjAgL3GgMaoQFaHp~$qN>i_RE@- zGs|KjI2OSMuYe<37o^|V>#^SVZ~acPJZE8wxJyd+4bDhTPEQYv3-KP9BWxB^=cQ&% z4ND5o$vI->Qyk_rAb5~Z)XY%tfqriN!b|+Z$^xT{+FMwSPAW4>e*+f4)MZQQOuXWB zk|w8fMf$>Y-5!ffub^y)*zklS@&VdYmUHICrLC~?92VKn#XH0yxbMU_EEi6Yf9TOJ zcw%JixL}kR6f}AXLb%&m&F{JpU?pS;+|DeLGAjH-@~JFrTsoWfc~RbJIe+QVKpM$? zPGeN=bF!#xbYzU!qh9`zd}*mP zB(*-ggbZ}x7t}oJbDGSuG}RE)wJv0ZPepN1&W52W(^IUrwU6{pEwb!AGCXcdOvAFU z?DTNg!B2`(u2t{st<%??E)E_Q(bs!kjhA2awAAW7^K!yNa$Lgu1{JFc)FQ1mY~;XF zi#ZJLZ*o@`4ndPxRxwAV%}wwOJJKNQdv_}In7nw$w4m77py0STtH_$n(8a?Dm1p!G zFndb%`q@dL5eW%lVeE+rw63%UGEki=RX1}6$&)KETG&swTP|E?U|Sej({LQ7=m}Gx zpHX%3{coMj*@erKpGX?*e`5xm-LUPvS4;b865OX5KGgI4y zln?Ib6ErEqD=>O`O6I(HN_u5(ow@!@G1>TJl(TVSI=bPmnW-$>!V?oik!@5nO*4z` z#K{frVX-};+(%2EBOj)HXR%G}AJ^0%cc+q6x`kW?8Q&sGw-~OIs|yx@!9vYtx`DF0 z`jGQR7i}T!X!wP8Bv)q7mi2SyfI)5Jvt+k$lus+S@?vXUBA;9v7T+Uy(AwA}vOfR( z$-xWc8`Uu@_m8f+i>N_}I!uY0+ro3qNb5v^CMn5pFjQX z6vUg@_$P_PJDr$0$u*e<`E)>N=m7k*2_4`T8tOJ66r@@5&m=;_ZrtQs|Fhy7W&wE- zZ~fuaTcZ^z!r5(X*U81&q>r~Zd-%o4gyXJ0(NP`yIP?n)^Yn|ZxEt?OSR#nDn|q-9 zz@W6aO7{HOg2p}&@I1Q%Bo})@um)v^#v3)yX@Omzz8x!HEl9bZ96WGP`xmbkjDY`| zM7Pni(si@~OHdH@0Z9OIkt0i0B!=!IPfJ%ovrI)&fZ#d^Aj?%`DBVX_@mMQVqzHUo z;Yghc@&=E!Qbn>5>k>y+J%X%Ok#f3~?&maXRD|jL66d_u1i_9c=(A2mBEh+aBkN6Q zj`LU>nvkvF^95pUY(nF&=xh=xWbyenlRP$-8amsw*pPK3LP#(zHb78pkYAHX zJt-10A!`RXuLQDyJPYawAUlEZaSJY5%fML#S0x8EI_+W#?-I0dPPBtfLR^&)P^@6j zu7LtAsV+dej<~#pXh0!!)Zk!+woa^7cr@PCR+!~cC!F9EL%}16^Wdd}^RQNV%r@#F z-8IW&m4Hzb^uYNB?Pkt{mnX&pCsmjptAvZH9)D`t6(hz}Hm7Bc@PVqwpSl>cdaMz* zD^hT$t%F%9Ys6|!kp&9Hmb}dJw4k?x9&5zIsvdW>Ho|fM^ zkG1MK4wf%F%<@<-V(iIs4puKSOR+(f0)I|tX*Mkz#2n6p>A_A1J*vkBRgXwWapDxb z9&t@2uIUD$Le=BX$E0?nss}W+SZqd9Bz8TTR7DIw-XFfZe8!X-FrXQ(J>2)iswf~&&PA>5y^?sQNq1p3u0P; z2{BRn?tFY91Z1tuj>@jf%BsV!m09!0Bt*rJ9UC8&Fh<|^u|cEwutdjFVOejIige8| z&G7$0O8%!Q73>t73|pYsWN;E}azE|=ko@`o!7!H0Fl|d(8D^4Zwr2kS561a_Z#zj4#z}5^o4GO>=loY#S{ms-Ijt^{ox- z51a?FD<&>o!FU1w{)9*Tj<-Ks@m+(y4$n)oZ_-9;D{PhSiTpuUjljO8Q0X3@Byi#d zO<7n)Q{rgS@0$KCo*xtwGsq)4TEE02n*Tv6Jz`=!JffncwlN-qqN4|~&z8TTTB-Ou zy~$LwLKlVaYm%+dQ@V{b{=mP_NCu&#`L2@MlCjc#@I$wW z@c!afI!L<=-*Arb-7F6d6;EmhF+%$RPim`GG}Gi^L?#HA21P{;!jEB|`p2_QuMC$u zaE#&V&vs^ah{8=Qcxz=4M8kW+_YgUa+|qQ#H+&~}Goxf1GNEyq(L))P{=zIE)$&q= zsab6d+qmNVX(d0IzpCUW3u9Z0N%R}%SEzxM1qzsWLLi$vL9io!=+47$O_klRpFg0` zF$KX$wJm4F=5~KFd?cN&t3!yI3(6{`vh|N|!%+4$VriPragGi1vFX+o1EG{O*TI24 zUAuOZYJ^P5wyR@T&nNPyc603J(!XdD(p@FzlCh0ZqK;+B{X`AX-!Xh(xZ5}ZQwrF| zhbeF7y89UJCMH$6_^eINcsV>JXj*IrHpxA$(O9&VZXR8Z>?@Qir@E-%eN=NFBVFpDXtc&D(mRs`A2?ycK_Jqc+{;lO7=f!gvzt zi+com`2?c?oPjU3RkCMm=Ox(W$6nIJ&Ti$sA?&np5bW%PR6`YAQF+yCcCB~kCo?nq ziS(h|FEjJW&aMR|1+G`I{c1(!6|afq{rc$I>Kb(Iw0#^BGcsJ*4Jcr5dHla*uj_X2 z)<0$6_^|VM=?RSm$``aJSfV6CmSG}YQz?5{SN>Q>E=hC^`|@s(y;1(pG(JRfpMX2X zw`HOmumbw3p!q1pn31!lmKfEZsaaOo-;^M!gh? zyR=Z+tFfephM(^%vcs-67#vTH*>?I_*dl)vcVJJ1t5an76ZCSG!Se}UPy0^VHWu0! zge?sg?JcakdippRyr$604q;KfT|K7M&hV8#g3&(%dv}F>8FsH>qYG{t)e%?he_(&H z%$3|5Bugu;wY`^-MR;Uy*N8m_;^dEnEiaHKeP`58@o?=O73N^@oI)=-_;_};hD>(V z2F}QXeKyk`N3E2Cf~}>luqJyzH&DR+f^mML%+{WqXTG zT|In}vMESeM5L3e$JE*yU-Bdb{0sq+iU78G5W3@)P(RcwB&6y6(nO$?8xIdP+h-$;8ThVaezCjz+TtQeU+Q=4-9G3t)unPA$30B*UweGI7 zsRXMl4S!x~uw3@{5_ox#u3^Z7@|;PCj_p3f&BS~&CfmZ*UeM{zJhpQYis%ZK^WL?y zy_DAR2a)$qYOln~8{RFuhV?PtCL6yHId+*$9 zOBP+5+oB%72$wQjRi#C%!Y4#<9+7QhXNOr#J6!2)qt%&2z2lNE=FR(JNm5eY8m5o% zg;J$aBwBU9bFVF0a&7M3>^1q&+{Bhua176AhQjBVf^wnN3{}42J+N<``ke1rcYF3e(e{#d#r>*RQ~d(c(twa-sy{^Kv!Mt@U|+v)o&s*f%Tf(UbYePI%$$#InG~oQ|Dd z!j(wM28!kYlrt9OjB61P{NSp%m3(Y>< zFPp#RPo%&H=XRPEBHFLj*Zl7!Ns|AA^V`fyqDgDzn8!$BHOpSts6EBZ9WUq__1^lv z*oAgmKS+2_V5jAp!`15m<2y*#$tV?h8Lw-S`?aA5UOoKDC|~bRzvsu+EFRIr22&*6*~)#qAo;oK zEcw6AmXv*{zi1n#6?sO^(ChU=AFjj!Iq&XWz0iwmh!2FwpO}P@Kt*HVdi@}#t(<`f z`kH3AhNVqGF&TNdF-vzleaMLfAyEaXCO)4KO{6(uJKImRO6) zd==JevZZFWZ5-e1K|nFAU_*ZXhJwQN`T6S$Ju^K#GCe)n|F$bh;7AfY;wEmjJ0qVR zi9=&DHmr^yfg=n{$*Fw#9R4IbS=5dB`5OztX1zy-hgXJ&M}`;vpexBMa@4DwzI5eE z!wRsI&&h9qUCgK8hW}TtBrI4yhmm=6B8_G@CiRgd{#Qxb{of};=>1=4PBp6=Z|hd$ z4jpzQ2AXUa7k1VW!+HL1mo8czA-=xss4)6H{;yWs#ZK&G=v)vKoHykA?+Qkg7To@> zurN3{-_V6t2Rc6)60l}{@P{7;uHP6E=-Sucp8inqbnTibT^YF}X^JqONVF%B3_mo-Tx{q;zos^5td7fP zR6*=A5ljw!qjkwNMs+^w8NtR%gm_QFIpv&5 za(AKYB${J5G>LqLLUD#X2Rh~tpj`8i1b&fKj>We-L$L?wsYnyEv%vV2Zy2sSA%~k9ZAbe z^y0rzZ_@4`mvT%eJo@P8&)k z_6*e3y^@<+l~^=>J06C@JPM-P9w(pE?KD*GV<;u3=}AztIP8S`P54d6xuLb}1ZV_a z6h^(V^HJ_w#yw7WB@%Z#dEo{p<$-l~O028s%t?kcDtx<@w7XPUc}f0#>lXR9%T;>| zc6@+~rnVR4@3<1t`8P&!v8w7KX}_iVD$c}jXIE0~D1_Er1zjI> zrM;oR-$@qWeP8$+nTc`|If1nOjXZfZ9tk_qT53tN`)sS;SEar<01Lq3hgA8uOI1~u zNc*k0Kno}Ok3FMmU6>b$=Pl`qKFSLbNToPE1fO+3emUOQ4(SyEl{C@g^^p-!T zmV(BBD-SSK|3qgEu@sc7NyohnggHy>FgJ6OXf?~{MbZ;H_84BGk)PmslM@F0&Yi+W z{TTZD9yTa#$-DL#G6}W z_ilMRi>eezv75GibM|FgQZ*iv3R^&^K-bA5cgZ8^IqJ&Ca=-3x7SeGq}8|OA@Xjyv968~QbZbV-zIy=P(teJy75YPYu zC1AHazE#1}RWRs3i54l9HgE;1%x&YbzBd`Vt?@q)qxx`!KfTV1qpM@4gB zy0h6#W$cVCnU4&f10T$(vDj2IXHLyu=@#egdpdOeE3j*?UWVOzi;Wx|Gh^;|2IWtjk*<1tOT4MdXzuuKp&XJy9&x4xYm67 z5WQLY0aV%f(3(C1vrgvZRts&-;@{xjtb7t`n0hQbgeb{68gwxAr z=_7=0X7NY?b695hM+pUH@n``R*9?8kWBA0HFaJ^;?&UVeC*FMiN(uBsv-F8Vs98Me zG58vBnpye{g0iWob$%UYyL-)~_c`G;es_Lr`tRwd{JP)P_#MI4EdCSyRkH_OkV=o9 zPzUD}o79XKN8@JD|E_@#-EHU~g&Ru#Ls#>6?R>lx{@`_*wR!V{5C2nt^tTPZ8N2<; z(<_*yw>ujbv0459Hl?C8U)VO^@bmv6p(`ec$rJ8v{$D5ZH~O>L%>JGJVh-2Y({OWz zOxN`>csFXc(Cf7124A0PRrqvm$RV@%Alg~u|7a%ZJZKwD7;t0f>B2fs-dTRk^=yqt z(4WlW@pPtHJd48HH^YAz?&3CwFBLq^;&l`o#?A1lr;E+vyVOprwf=94GtAMW6D=`| zcc7=t;$6+_N%~;3pcy^M0ID=Pt@I%g)Vw~`0w&QPO)r^6m7@o(`OKlRS^7Hq&@8@{ z-ZzUMFmE?>qDmv)TJ8n%lvzGk=vA}$C)8}YLMN&XVAbw*q3Y3xK>6N??%lQ6Ld!(^KPe= zWzZe%GK&wQI^G?%ruU$fcSn_m`O-ADk5*I1x+C3RAG=fNOPsea=u&Q`}k#>Yo+VK}E$5VzJgmkW?OnjXdb99M3Z0QD&Ggyur~? zDz!3twE^#O(lBs=*UKc1F-nZ&D7s08f;G{sg?1Yor?LZ=ndT2%zU7*jVD0MY79Qgh znmEHSKeO-1@q#2cctv>m49-uVw*#qJEng(hY3_^xcU6cgJfVhC5gb@YBD@!=9m%!^QenTT$j(PMbtM)Qy)!)U)N_eT6RlGlAHEdNY zR*y3nnHD-aG&3qVImXj@KxgL!Kj9@9XIMc?$yX;-9R$*ppo5cd%t}iDO_W>66eQHqp)(WoJO&@r+Kv* zH=(~tdnh=Q!+SZk+#2~aTze3FqSf@7$h9!)rL)jur+`|X#Uq@5wL(wUH{mxqK1anj zD>&?H((A&$M-@9UoZFqzGrI$4cGo=1v>+agW3+G(C_8x#UPQjH)Nwi-wMA;?;H8(_HsT7 zJ#elMhW?T#=W|81+b~Yw8oxuWZ=4tIy}_K2wAjkg870inj%THLtG ze8fD4YHu{5_daeyn`qId5nt{_aUGcd$#vj3mtEiZomin=4c`_nCa*h(H2(tkde{(m zB$(hzUs%$3TkOU01eJaa=U)Ooft*jaipO$3CEydt>5X_km&+!4!&>yjarQR^&zlXvn|twulC%*6$%Rnov)fyUplvyt5)qQr8s!V)L! zq)7S#r(e@}*jtKxF6W3`M0q`;40UZ&(&*HeiO&gI3KiPCL>tb;z^$7GKM$NLpsBG+IhUj?{Ra~ zIPFcyna1Vckl!R7pqEW6{HCU_f+ID}BVwddvz*JRZ~RrMYr-1vLB1)afT67JsOWE0 zu`KGPn5Lr4lNOqJ=;E5F|4^t~@wnTJ@)DuQyC<6Ce3Oh+IHN?o#&do>Ph}I%cx(S` zlK5~2ul{$)Z2|>bm4HZ7(_zgTt$>>|-H)*Vb!&CL(d=z@%oKFDSD?C&L zsb7GPvBzkM!m?`L;34OUew-T881+p>M^lthM{${)n#*L=z-b|uS&b9@yq6O63eLP_ z=w8iwKTdDN`*M2TGw>e33G}R2#MHBCoq>%4O~UImt?My*9Hup8@tf;0>xWQ}Rn%CI zO=($Ln(J{O?>86^j&t33%gc0DTjZM>#q(k{b0qIi&}K1uO$zT%>Kh-ZZRF1!H@1;z zQ%z=$7=0My&)Y~XZzCDKtAaPTk&SPlZbb4rvzYT^{Sd1&t6Jf#A7VI@(>!0qS_XRNCj0dkX3bnD$wB%u|GeCKrQD|G&8HKa4 zrDi>jQfJQaR5syEBCj(F7p2a?KWH%uR!7H=8+B@?V{}!^`f@QAy!c=-Ejzg|G>ho9egB<6hG)7M*s`yJB9|O51oImO)%E%2(702D6 zKc96F{Zza!$5E!le{=dE6<20EAXgi$%Mx91?g(W>^QQQ3C7f9Dc&)wA8>-hjwDA|w zo7W5GMIz57Rtr)0wF4O{ey2+LlGo0ByuZDg<98J5os634#M=+?2LW8-lRzjE{uXcqa9Im5*?bqabDIT@{s}q39x60(GUw1SW-9Y8BTh7t(*>7Qvzr z$J1DfQ7ax$2d?X81(%=V^nX*zwI84jR7%nHXCb?BJa|lCsHpJxH?Fc{_?y6c>)JNq zUo`e@=54~|-$!@~cnNLATdgVJq1+N>@r7mu=g}aHBg@d5z;{9$IS%uU4XC%v(6$TM zpNZS<8g704HA8W=9z)x+P0mes;oe%eOUOiweD~#RcUxc8d$__AEBx2)(9bOQCXSA)dSypxrZV z&$oN$7P%x!4=oSzE?~EcP229g{t{ zB5v^HIPn#+`rM3pr^k*N9+A@fy<6mk-t$*x_jj9_5nJZ(J27YOi=}1HO`du*v|rrt zEC)H^>#tdoXewDIcqvAtsZc^qB+O+ECD@cN-eqy}!<@7i+o$P8mUSzdaBI2EAh+(f zZplB-svx?rtwREngdaFRmV_o#z;x=v^9`C$VY+)-7zZZ#R57a985-<_aTXzl*5U#m zjO>JwmG6tnHw(uz^Dr)YI&f^%^UUCM90#2#4oQo|dmJ>?idM>E}TSS+kpmQBC z?FwF~5txRBvR|K%d#qYovY|#q}uJ|3yPf2ThLS&RmZ>_d0Fa{FeIw#pTys^1F2GoV0 zPBrf0$KR>w*raNgErZY0oJd zwE;(6+|i>t`FGMzXE@199~jz!;YQ5R=`8+uj9NH%OH^J|c;l@VaveBnxkBUn|<8G3Y zMbtd;PhL$6rts^ehE67_irPjQC}e2Rkjs^ZJ*LaPmhGsv6%L0&qie#S2EEC$uVp*( zGN%V_I*|_?Ls0N%^d`%`z%g~8;HDGM!1r)`9Qc?_7z4);4)pcFO{O@2M{swXVT_o3#DlGa? zKDcFJ;H*&<=jY8kJ&tU7_Q7A&dgIvG+GHqTP7u*xupv%{!{v>WhN=|PtHJG@cr91N znOvg*3;5yf6*_6%t%Y$*hgB`9xU_81r}Kp8g^WdMSu>)N7Uxf&KH;gX?DfM6>axXu zi3`86tvEen=!Vh`R!+~(7=LnN`N`=i;{!t`CE*#QhUl8~)aiqRC#TGOc??t?htz+` zx<0WTYexBqYZjD*=N!%MNyE7eM?Yo&PhV@ivEt%{!&Gn%1su3w$1Ozq4WB(t(u{tCge!+G?U1C&x*ct7KY~X|*^l$JXYpaf>@U zqr#Lh9`j`S`AHotAZ^#eYuj^H8}=Ih5c}yrBR^EE{&(&07iWxpu0l6>!#8t=?EiZ4 zQPN=tE+TVWdZ>-8-BNjV)trmdayE@X0h}QxiOZy0cvJzy%S<2NK1z{i*Ql7hnIsZC z)W?pRiaI07Cx?mUiJ=>o7cNfth%C6hVa=EGmfn+(lIr0(()SU`PD z3TL|FrqHG~$`Qwx*o72q%LdqyOR+jtB^H^Cg}Q+TTYZt5e3{B zHBV>wC)fYpdBU~5!acpk{%za#(?g}HRNQM}_2OYePQ&#H(07!)S<1yD zHv+EFa8$f*SEKGa9jcYm!b0~>PJ}%$G3t{~$en%PRHxOC36LX6^oT`Kp$o^#KYk*% zG2FaKoumoxyfdQg%*v4sRSsPaMOP=JS4Td$CT|vGeOoJt+0poec0Ejm-S*6EY;&NK zjUUu!xy)~g;+gF0?AN6g7D}b_7f)-4WIn%s%&vlqez^lD&h#)$7(%j7t{=BE_p)!6 z`;f{I+mA&319x(d*8j9L=j~_2t`EPhnl-3wcL4)(-#MZJQDbKy-^OBkek-giu(Y#x zQf*k}vBrU5rLom@V&+9t#*U7OB;un{XGo8i3-F}*kjiDT9fS5%Jx46pt&{J`xO)EH z3SHIx=|_4V{DHJz=s)G~MDNr!!^zTt{!8WWKlDB;AC!M(mtsu9gE8wMvlBnL&R$zZ ztEp8pvI~y8Idu}(y_dRa+}Klb(dVWt`*nl<&_%ge+E!6FIK*qeN96cV8^}lO!Fi~i zh~n`)XVy~#M<;^S1J(*Sacg4GZpXTCHeBw)ilD37XuA3+jYSigpRIB$6Ron}_F(PW z2ix##-I|Bb+iy8_Y76`4RNun-f}ukvofuPpe@)*t_v^<#KcT2_NWD;1c5eOV3uR^R zY+nCX+3po<*DfTB)~#7_tY>Ly@yITFq?~=THXe-m|9E>3xTucpah$pL?ydqBIwB&l zz|z~&r1##7NH5ZR?@bWJiiicfqSz~%sL@1|XL>Zo6g4I>#Z;5LB<3YX_s;&#+`Efd zlJ~x!&;Rp(FSvVW=gypIr=K$+2RC%>oiEs}SDc-nKNn^p0nYk;0NhZ;m;)P-nnJlS zpdd$*n|Eq!OC zWxUaI-ts?QG;TdoC5>qfO04&eZl$gF&0T+EG0>j;J#QAx+llnQ+G=`8zjWKX-F;n0 z(}R05t2bxB0VxDjTdjb97S}TzKpJ9);Up^g5XE{=zQjbAx`7V;$hPU>bLef`9`+uZ zo4YkDYilm#TFu&;?VFb78<1udN;%QH9$Y~g^mSQT2z~2;LFU%n?5)`a+vn$Q$vT@5 zn*jfF2{VJS6O>QES^=W2Cz;R4Cewr5hui{@D`L45%bgjZ#R|l%airwRNcrilq4?DB zyOoQ+91dL-=4DeIx@vP=O|kx3Bys4u4mPyBK_HWG>z9U zDyVT%-GNq{_!Nz1%EYiRob?&>_(D$WPk|;5tlCU2cuIl>k)THYW^o-Uq)~Myd;mqV zvQKEN2<@P@;0u_EB&Gu5Cc#`o95kiQklv1)39&18!Ym-~92_}w z5kCz;(#h%5QREsRjFIkloz5#3M7NZe`x}Kguj#X#W1Su26dK}a>czx&8+w`8M=qRi z6KHN^-Zd-@HS(>fXp3I3j85tH&Wv64Qk~24wE@MQ2awGyarN`d)2gJlncZeKK07x= z_Z5q0Ss&;u4j5YDR&!xhOa?4nDh{Q81%L^91Inqqq;Fys#mdzp)8n1DLi7sVdZ}^j zdTH79J-x3~(XWWQE2~-^9a{0-x}==3qV<2QML(?lb3^ILn(Sq@M>DW)#*rGB7(YC~ zs6#)-gnbPFvH?j23C&jQ7C0Ob{0V3Y67$iDbIx66uE!&Lx`%Z`XVpUzOh(_Xzp-ia zt%mZ~musl0+hk@Wi32@53-UU=Dnqrb42l;+PdhY%q70TJtpnP?(5;s3)oy*eUz4IF ziHV8Uw3&A8sgmMTb=r-`>_vk7or<&j2LJ~w8*CKic2(ebawCr72<(-1HbyX3vl zc8_%lgF8%v3j@pg)TwOQhj*_I-)igpQoEu!KQi>~gO)i!{x%HH98E_QGFyZ3Vaw6q zmT>AF3H6fDS|}94ltbTnIGg?zF3_`qzIh@Ca{_(o>5<&0FePMr0DC|LEGXr-jSlsy z-?F-8yGGknjs$&IgMWkzv&`+C{iSY+heyrB%(S#4jpAbpqldHCHnpsc3i+W4Y3rHJ z5Q=6}6!5#>0Gz9ZBH0mg{C)7hP4APB-Ik{MF25D%P%U{Yc- zD)vf9z+ACzh~eNGJSTG-K@ad>?zT*n#$%B*rZDXRE=W|}J!Q(NLjo#yYbIcktZg~j z+x_AQ3W-gKgR{%oE0_{EyTo1rH?4vf(``7r5@NI9t+s=;9simr#IdDO8p$w8o8iHb0=jZP1vPW|l;x9aR7e%MWvXnarP26R+) zYyjI$6@}Ier=%ZQodVmjo*xO9ctb%#{zh^kGG)`o?-sYb*M}?VHsUqk+P610WnFgq zs`-M8qUB%V(z-XgJ1#W}Rnh0m##C|e#`vX4>Fohtb@7YN6~Qv6LjIk5Fcl&aT$lv1 z5e(Nvi`(yjiWD2pmE`N?vfhZr?y%bYEydHR6?6&vW(8_yFRic7^Qntw-Y$A$?V=Y; zJ<2v@CFI-ZRK%+Xcb+M9FB#6vE0O@_#QKX+;KTPq3d2OhjT;9;^t{VUXnWnluh1$C{tIG-&rDq)&8LD`tJ7Gs7Q~9tZH8nZJGhEZgY^X73 zC^5Vz*(9JaGt@Uk$I@)5K7TM4@?ER~M(72>Q8=i4$_{@?B!Em7PP)xx9>(-*^Rx8H*Qdpn#1Y#8HcNDQgX)%*Z*h8e&|2z$Jm>c1cspO zg9btXI+Oqa9oF<8c{|#NZU5x&Iz!UY(ck~JKj5W-EBgeDxqNRf_6)a@MvQ_pv2f+} z1Y`9ZAc~JX(Y9;b{=K z2)!tlZw^Pxi2?|=sgl0xDe1Tlr0wCdk$5{gK1Ic~=T&Q7ro?0G{~U6JMJomxzO?pm znxkt^Qw;klC1GzuKi!aJ;~gNDdW5fPcI8hdr)=OU1q_UF2aZo?riy_7^~1wIw-@9E zEOdLP?Be3nADqS~->j&q$9;Io>-VaI-yN~;+?A7_YoFp-u|4(l)@64Oy;*E?VHK9) ztJ0lz9mm`92PV-?3yyBW#!|QSdc10p3ah5_8BD1xy+wf_uct zLfT+n*p$#fX5jt>5sesC^gxYuM*EFt`^QNw#9ATd(QP@#sAjf*ywu!GE6^$u9=Dsj z&hasHG5e8`u77#s!|BZY|HF!X~3(n6(vVMyN&n62KO!kqJClRt;6qhxakT96N8VCX0@r5hn(lDni>_g^Wu`2J6-}Yfe+0C)2zB0*`Igw7~Esnuml7-w#JKE3s;=gP*$ z%AWhGHU0TbnTY!7-1+g1Wqal?e!m?E1b3zZK2x$d3SHwf&w? z`4QET<`>_gjP=Lx&=4NeACs?19J#ViKY6Ii+q-foMZfyFa^K3Khu@2`?~lSVgG;5cPwr#)^*0bhPrZ_%d4xBa{#>Wa{vrF+MNBhfp} za1;IMEkk8znrinHYmaGER_6C+WbWUvuK2}XSrEPMyUkrp*u$?9<|l>D1Hn(cxegep zutoFd`W0t}`bFwkh*vchu8yM5LhdM9B?i=Bxyu8~BkLjF+_dsa7Zk z{#FYnC2Fw0hKByys=o!aVSk;{-*W!^7X6WzUK&at$;!#9Iavy-CdttK4P~dQGV-!U z(&?J~SBE#hQ&8~s$okh7Zb55?)(#B~4-Jl4R~MEyiZ?O&&n?<;GB)<)hTiA$m`&ow z@`7rTj}Q)^@LdiAm;w&)REZ=9hvSSvfClnP1_G)@41rc zYX0Txa|d26yW-YV(7V`BdwEYms~hE_y+=M2vGT26pTocRu99EWu0BU)gC!?F zWAFoe%2@lbd{yu7hkbhAS{X4UKciiBvg9CCI8=6u3=QWB-vtyZ07b7QWYUa+p=r2F z^&pv-LgxHC&;|c(253i(Lz=R@e}Z1H>mP>?`c%93u0FU%U;O(qRR8_>;Us`0OJ9QF z5hJ`x(gbe=TaNSV;?A><9uW5aii;gDejImVotu4qR(5r9ZS$2Y)Qih67T&m0bG)Eu zc}?A>!S;vP7dTA9>}Y|UDP-;dhskhaN-D=B5ave%P&7dhM2wP(33>(cEJ&4oPfula zE2y7J(`4!~<6~oEw4Ka;9sL&E;nKjViKh_j0bL~+hqX`zcXL@5JPPCdJr{ux~wz`j%@_Fh8% z0rST5+ylC_zBv^a$I}?KaRl!c|35xFiOB@pZ;HUUt!M!2-Vk(C+4J%g)}O z6V{pSwnz=l`x?g>r1de`tO-6ghtYblk5}!+CY;UMngi+_x15eJ_ucg84}Okr@c;jb z_2KPWYvK5SLu?`8Z@}O}Gy$L~C5W16REgb>FXVV1SJ1SQLh>WkQ`8lxFOf<#{g=1u z`8!BlBo2+2w{5%J=xFI`>Ey59y4+tA3iX4!hCDM0tV0?rN<9n`;@$R4vvqLKF6>E< z8g7ksr((!JRiS2cVufj$IWb(W1Cn0f>{+8HXh1;dk;0nW`HH?&>_dJ z2GkMkr8;H%j5(RBqjBG!x3i(g6JQrggdQ1AIRhJ0O2cI-6T&0|otZt-jiK%<=S}j} z1FN={EPpYt>N5WHG_@a18S(1KpWhHduYPb%fN^A8=cVQ0D;|tFZvA3clXOhd|Itpz z;$x+;J? z_HFrtS7X>QWH>wwTb5ugp4H$T78RI@oGp4WyV+)(ZaP6N2W<2j5zfe;LIXq8WPpPp z$0!25FnA5PLwZ#H0w>Tu5xhhIGNN@fHX=1d>zdBiS2tGEGZ5<{6*PSuf$CLH-_F(S z-qt_N-0bvcBZ@*G{jGP)PEE45Ok9xbMwJ7=KHjxeHD1Z9RxM!vMfs$zTAl1tS=;2x z{tLhWeumx+V2FU5As!vzU4r`X8hQ<0OP|=WLly^hNZv^wG{7y(WYL~;eo&}ydH#*; z2XwafSV7f+^rjv0R0WPrOiRmc&I(;%+9cVE1{NNvG44B)S8}p8fAiGng}q(I6&-<+ zig}rUvWDfqp)KH{Boqt*^58IXZTx+axENJQ&Ii{TnWu@*^6 zOFo%3Ygo@!%Wte?YF1oZNpBiry;sp&R=&S#)hdB_@IkC#ocp__e1grCL9L`61c#wz zU^fHEHJQZjtzc{wDiL67sPqp$`~VN*O)soUiVBQ+?M`-oXyi&J5v9vMr;XW;07pCj z%~7pkp{4b*J3b&d4w{o7KSR-6m>^#}uFtE`z`7hdiqm?At# z3FojfrtWybno-Q48zsqpCD5N2)rCIeh6TG%F)VVeI+25jOfL4)cQ#Cjbo40;(UyF1 zW+exGOE!W2d8$G7IqVIvQZf*h3`t=gx{e- zE}-BDzDV8!6&6>n<^xy6lZ^wOo0H0gY9%ib=P_tSFo(3lfHc9ia*=NZ%1LqnA*0T_ ze!$y@$unx^o=Mn5nc!QY5VEI}c2ePY;x!>8!PxL@2hVSE?Yfh8AuQ+~*Di<@0%xw? zH7Muoj^r9=P6$`7vqIniibzVhIg@bzS{ymQB(-Sqx%QmwRp;w2G!^ZTx*sTRK3j^0 zn1F{Dy4%~kCx5OwmlYNs5t?-l-~k5~fdR4kGQsl!9<>tr9Y~)-6T7F#7Br@`iWPww zX4=(UWDnf75U7wQ@ZzAk2FowF8xa>ENHS|gAy^B6R3;Mqc5Y^D_0ov`o88?v`y-ZC z$Id*bQ@-={+6(xjg9nlIh3eOLRz65PPlu0UtyP3xXzuTpbwP~z9{t41;I z68E^-_^oK_Ka?fh5CDIK!J(Uze|_<#|HR3bq3e+Qmrwl+N+f7n1PmfM#ia44IshR~ z2bG>@3eT|bQpV87>-aUpI1hOATYlDs8CW`^>}3re^npTMD#A5<1`N4_2}P>m(G zz4Cc5z)-2j)o%uMQX`-cMWmMMdI-ZHsG}aIaHz$CpcX(K>nTS5CzPiGVw>!kvv(SVHKJk6dem`f*%4 zD;|qnBjtSiD;|qngK|D{O?8C|AGyXyqLKPxigx%&G&o`qaYo9yexd&4h_g0=>Y%&wq;C}_=X8Cw$47kL;v;AzoXy>V7kG>w2BA1 z&K@-PY~{H3fenQjRau(IkYFF9&LR zkSnK1{Ym9{@)mgr-wvb2weuslSc58;`F3EjCY1}nS9x=y+>xvQv!Y)=_yp@Ec%i%v zOshHX`0_YOL#s7FBFZM*b<=v~vzrQvYT( zb8BmJ;eKl6rv_NZ^AD7j9w52vH-fePeX!OO$^ffE$RN=?fJL5=JvYv7Am;t$6vl1h zXTq$M4cU&*y%D&PfF$!-yI? zfatYpyDD5VJPOZrcAYCO&ng}zdRt&WAQnF!sFV(Iu*^d=@RxwtB(9(r0A`IiufY|> z5MDwL;}1)=qBHC!-{63HGqd`DAb)lfdVX^S{+Je`$cM-828YvITq7b}x6q-XcbRzn zyzBw+QLx5vt2ya{#JR*K9~~`jb#-Yi8KwN#m(k1AAUkYo zNfooT=-)ec!rHA+snHUunzcXS&L@ zS3pm8_KN5rU*Djeq65m1Bo0$#3x*)spOE2CHrdeKXXz1LZLce!?{9Iie%

=(yz@O*K8Sz}a-+D&3^4H!)XVVw(P>=y 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 +#include +#include +#include + +typedef struct ViewportHandle ViewportHandle; + +/// Owns the iOS view's wgpu surface, iced renderer, and App state. +struct ViewportHandle *viewport_create(void *uiview, float width, float height, float scale); + +void viewport_destroy(struct ViewportHandle *handle); + +void viewport_render(struct ViewportHandle *handle); + +void viewport_resize(struct ViewportHandle *handle, float width, float height, float scale); + +/// pushes a single-pointer touch event with implicit-Left button into the iced runtime. +void viewport_touch_event(struct ViewportHandle *handle, + float x, + float y, + bool pressed, + bool moved); + +void viewport_key_event(struct ViewportHandle *handle, + uint32_t key, + uint32_t modifiers, + bool pressed, + const char *text); + +/// drains the pending-picker flag (0 none, 1 folder, 2 file) and returns the prior value. +uint8_t viewport_take_pending_pick(struct ViewportHandle *handle); + +void viewport_apply_picked_folder(struct ViewportHandle *handle, const char *path); + +void viewport_apply_picked_file(struct ViewportHandle *handle, const char *path); + +void viewport_apply_picked_files(struct ViewportHandle *handle, + const char *const *paths, + size_t count); + +/// drives the import progress bar, cleared by total=0. +void viewport_set_library_progress(struct ViewportHandle *handle, + uint32_t current, + uint32_t total); + +/// pushes parallel arrays of placeholder titles and track-number tags (0 = unknown) into the sidebar. +void viewport_set_pending_titles(struct ViewportHandle *handle, + const char *const *titles, + const uint32_t *track_numbers, + size_t count); + +/// fills in the file path of a pending track once export finishes. +void viewport_set_track_path(struct ViewportHandle *handle, + size_t idx, + const char *path); + +/// pushes JPEG/PNG artwork bytes for a track through the art decode thread. +void viewport_set_track_art(struct ViewportHandle *handle, + size_t idx, + const uint8_t *bytes, + size_t len); + +void viewport_free_string(char *s); + +#endif diff --git a/ios/Info.plist b/ios/Info.plist new file mode 100644 index 0000000..62d2ece --- /dev/null +++ b/ios/Info.plist @@ -0,0 +1,89 @@ + + + + + CFBundleDisplayName + Yr Xtals + CFBundleDocumentTypes + + + CFBundleTypeName + Audio + LSHandlerRank + Alternate + LSItemContentTypes + + public.audio + public.mp3 + public.mpeg-4-audio + public.folder + public.directory + public.content + public.data + + + + CFBundleExecutable + YrXtals + CFBundleIdentifier + org.else-if.yrxtals + CFBundleName + YrXtals + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.1 + CFBundleSupportedPlatforms + + iPhoneOS + iPhoneSimulator + + CFBundleVersion + 1.0.1 + ITSAppUsesNonExemptEncryption + + LSSupportsOpeningDocumentsInPlace + + MinimumOSVersion + 17.0 + NSAppleMusicUsageDescription + Yr Xtals plays audio files from your library or Files app for offline visualization. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + audio + + UIDeviceFamily + + 2 + 1 + + UIFileSharingEnabled + + UILaunchScreen + + UIColorName + + + UIRequiredDeviceCapabilities + + arm64 + metal + + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..2f05da4 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,53 @@ +name: YrXtals +options: + bundleIdPrefix: org.else-if + deploymentTarget: + iOS: "17.0" + developmentLanguage: en + groupSortPosition: top + generateEmptyDirectories: true + +settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: org.else-if.yrxtals + DEVELOPMENT_TEAM: Z9CXT3ZVLD + CODE_SIGN_STYLE: Automatic + SWIFT_VERSION: 5.0 + TARGETED_DEVICE_FAMILY: "2,1" + IPHONEOS_DEPLOYMENT_TARGET: "17.0" + SWIFT_OBJC_BRIDGING_HEADER: ../include/yr_xtals.h + LIBRARY_SEARCH_PATHS: + - $(PROJECT_DIR)/../target/$(PLATFORM_NAME)/release + OTHER_LDFLAGS: + - -lyr_crystals + EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64 + INFOPLIST_FILE: Info.plist + GENERATE_INFOPLIST_FILE: NO + # clears xcodegen's auto-emitted INFOPLIST_KEY_* settings. + INFOPLIST_KEY_CFBundleDisplayName: "" + INFOPLIST_KEY_LSApplicationCategoryType: "" + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: "" + INFOPLIST_KEY_UILaunchScreen_Generation: "" + INFOPLIST_KEY_UIStatusBarHidden: "" + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "" + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "" + # permits cargo writes outside PROJECT_DIR. + ENABLE_USER_SCRIPT_SANDBOXING: NO + +targets: + YrXtals: + type: application + platform: iOS + sources: + - path: src + - path: Assets.xcassets + settings: + base: + INFOPLIST_FILE: Info.plist + GENERATE_INFOPLIST_FILE: NO + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + preBuildScripts: + - name: Build Rust staticlib + script: bash "$PROJECT_DIR/../scripts/ios/xcode-cargo.sh" + basedOnDependencyAnalysis: false + dependencies: [] diff --git a/ios/src/IcedViewportRepresentable.swift b/ios/src/IcedViewportRepresentable.swift new file mode 100644 index 0000000..5147a56 --- /dev/null +++ b/ios/src/IcedViewportRepresentable.swift @@ -0,0 +1,15 @@ +import UIKit + +/// hosts the IcedViewportView as a UIKit root outside SwiftUI's hosting chain. +final class IcedViewportController: UIViewController { + let viewport = IcedViewportView(frame: .zero) + + /// installs the metal-backed iced viewport as the controller's root view. + override func loadView() { + view = viewport + } + + override var prefersStatusBarHidden: Bool { true } + override var prefersHomeIndicatorAutoHidden: Bool { true } + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .all } +} diff --git a/ios/src/IcedViewportView.swift b/ios/src/IcedViewportView.swift new file mode 100644 index 0000000..148aa11 --- /dev/null +++ b/ios/src/IcedViewportView.swift @@ -0,0 +1,207 @@ +import UIKit +import QuartzCore + +/// hosts the Rust-driven wgpu surface inside a CAMetalLayer-backed UIView and forwards UIKit events through the C FFI. +class IcedViewportView: UIView { + override class var layerClass: AnyClass { CAMetalLayer.self } + + private(set) var viewportHandle: OpaquePointer? + private var displayLink: CADisplayLink? + private var isTornDown = false + weak var controller: LibraryController? + #if DEBUG + private var dbgFrameCount: UInt64 = 0 + #endif + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + /// configures the metal layer for opaque BGRA presentation at native screen scale. + private func commonInit() { + backgroundColor = .black + isMultipleTouchEnabled = false + if let metalLayer = layer as? CAMetalLayer { + metalLayer.contentsScale = UIScreen.main.scale + metalLayer.framebufferOnly = true + metalLayer.pixelFormat = .bgra8Unorm + metalLayer.isOpaque = true + } + } + + /// creates the viewport on first window attach, halts the display link on detach. + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + if viewportHandle == nil && !isTornDown { + createViewport() + } + startDisplayLink() + becomeFirstResponder() + } else { + stopDisplayLink() + } + } + + override var canBecomeFirstResponder: Bool { true } + + /// allocates the Rust-side viewport handle bound to the view's metal layer. + private func createViewport() { + let scale = Float(window?.screen.scale ?? UIScreen.main.scale) + let w = Float(bounds.width) + let h = Float(bounds.height) + if let metalLayer = layer as? CAMetalLayer, metalLayer.device == nil { + metalLayer.device = MTLCreateSystemDefaultDevice() + } + let viewPtr = Unmanaged.passUnretained(self).toOpaque() + viewportHandle = viewport_create(viewPtr, w, h, scale) + print("[YrXtals] createViewport \(w)x\(h)@\(scale) handle=\(String(describing: viewportHandle))") + } + + /// releases the Rust handle and clears the local reference. + private func destroyViewport() { + guard let handle = viewportHandle else { return } + viewportHandle = nil + viewport_destroy(handle) + } + + /// stops the display link and frees the viewport. + func teardown() { + if isTornDown { return } + isTornDown = true + stopDisplayLink() + destroyViewport() + } + + deinit { teardown() } + + /// drives renderFrame on every vsync via the main runloop. + private func startDisplayLink() { + guard displayLink == nil else { return } + let link = CADisplayLink(target: self, selector: #selector(renderFrame)) + link.add(to: .main, forMode: .common) + displayLink = link + } + + /// invalidates the display link. + private func stopDisplayLink() { + displayLink?.invalidate() + displayLink = nil + } + + /// renders a frame and surfaces any pending picker request to the controller. + @objc private func renderFrame() { + if isTornDown { return } + guard let handle = viewportHandle else { return } + let modalUp = window?.rootViewController?.presentedViewController != nil + if !modalUp { + viewport_render(handle) + } + + let pending = viewport_take_pending_pick(handle) + if pending != 0 { + controller?.presentPicker(kind: pending) + } + #if DEBUG + dbgFrameCount &+= 1 + if dbgFrameCount % 120 == 0 { + let presented = window?.rootViewController?.presentedViewController + print("[YrXtals.dbg] renderFrame tick=\(dbgFrameCount) modalUp=\(modalUp) presented=\(String(describing: presented.map { type(of: $0) })) " + + "firstResponder=\(isFirstResponder)") + } + #endif + } + + /// keeps the metal drawable size and Rust viewport bounds in sync with the view layout. + override func layoutSubviews() { + super.layoutSubviews() + let scale = Float(window?.screen.scale ?? UIScreen.main.scale) + if let metalLayer = layer as? CAMetalLayer { + metalLayer.contentsScale = CGFloat(scale) + metalLayer.drawableSize = CGSize( + width: bounds.width * CGFloat(scale), + height: bounds.height * CGFloat(scale) + ) + } + guard let handle = viewportHandle else { return } + viewport_resize(handle, Float(bounds.width), Float(bounds.height), scale) + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let h = viewportHandle, let touch = touches.first else { return } + let p = touch.location(in: self) + #if DEBUG + let presented = window?.rootViewController?.presentedViewController + if presented != nil { + print("[YrXtals.dbg] touchesBegan at (\(p.x),\(p.y)) WHILE presented=\(type(of: presented!)) — viewport should not see this") + } + #endif + viewport_touch_event(h, Float(p.x), Float(p.y), true, false) + becomeFirstResponder() + } + + #if DEBUG + /// logs hit-tests on the iced view during modal presentation. + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let result = super.hitTest(point, with: event) + let presented = window?.rootViewController?.presentedViewController + if presented != nil { + print("[YrXtals.dbg] iced.hitTest at \(point) -> \(String(describing: result.map { type(of: $0) })) modalUp=\(type(of: presented!))") + } + return result + } + #endif + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard let h = viewportHandle, let touch = touches.first else { return } + let p = touch.location(in: self) + viewport_touch_event(h, Float(p.x), Float(p.y), true, true) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let h = viewportHandle, let touch = touches.first else { return } + let p = touch.location(in: self) + viewport_touch_event(h, Float(p.x), Float(p.y), false, false) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + guard let h = viewportHandle, let touch = touches.first else { return } + let p = touch.location(in: self) + viewport_touch_event(h, Float(p.x), Float(p.y), false, false) + } + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard let h = viewportHandle else { + super.pressesBegan(presses, with: event) + return + } + for press in presses { + forwardKey(press, pressed: true, handle: h) + } + } + + override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + guard let h = viewportHandle else { + super.pressesEnded(presses, with: event) + return + } + for press in presses { + forwardKey(press, pressed: false, handle: h) + } + } + + /// translates a UIKey into a keycode/modifier/text triple and pushes it through the FFI. + private func forwardKey(_ press: UIPress, pressed: Bool, handle: OpaquePointer) { + guard let key = press.key else { return } + let chars = pressed ? key.characters : "" + chars.withCString { cstr in + viewport_key_event(handle, UInt32(key.keyCode.rawValue), UInt32(key.modifierFlags.rawValue), pressed, cstr) + } + } +} diff --git a/ios/src/LibraryController.swift b/ios/src/LibraryController.swift new file mode 100644 index 0000000..cec3459 --- /dev/null +++ b/ios/src/LibraryController.swift @@ -0,0 +1,360 @@ +import UIKit +import UniformTypeIdentifiers +import MediaPlayer +import AVFoundation + +/// owns the document and media pickers and forwards picked results into the Rust viewport. +final class LibraryController: NSObject, + UIDocumentPickerDelegate, + MPMediaPickerControllerDelegate { + weak var view: IcedViewportView? + weak var presentationHost: UIViewController? + private var pendingKind: UInt8 = 0 + + private var scopedURL: URL? + + /// presents a folder picker for kind 1 or the music library picker for kind 2. + func presentPicker(kind: UInt8) { + guard pendingKind == 0 else { + print("[YrXtals] presentPicker(\(kind)) ignored; pendingKind already \(pendingKind)") + return + } + guard let root = presentationHost ?? topViewController() else { + print("[YrXtals] presentPicker(\(kind)) failed — no top view controller") + return + } + pendingKind = kind + print("[YrXtals] presentPicker kind=\(kind), presenting on \(type(of: root))") + #if DEBUG + dumpPresentationContext(label: "presentPicker.before", root: root) + #endif + + if kind == 1 { + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false) + picker.delegate = self + picker.allowsMultipleSelection = false + picker.shouldShowFileExtensions = true + #if DEBUG + print("[YrXtals.dbg] folder picker style=\(picker.modalPresentationStyle.rawValue) " + + "definesPresCtx=\(picker.definesPresentationContext) " + + "modalInPresentation=\(picker.isModalInPresentation) " + + "view.userInteraction=\(picker.view.isUserInteractionEnabled)") + #endif + root.present(picker, animated: true) { [weak self, weak picker] in + #if DEBUG + guard let p = picker else { print("[YrXtals.dbg] folder picker present: deallocated"); return } + self?.dumpPickerState(label: "folder.presented", picker: p) + #endif + } + } else { + let auth = MPMediaLibrary.authorizationStatus() + print("[YrXtals] media library auth = \(auth.rawValue)") + if auth == .denied || auth == .restricted { + pendingKind = 0 + showAlert(on: root, message: "Music library access is denied. Enable it in Settings → YrXtals.") + return + } + let picker = MPMediaPickerController(mediaTypes: .anyAudio) + picker.delegate = self + picker.allowsPickingMultipleItems = true + picker.showsCloudItems = true + picker.prompt = "Pick tracks (tap an album then \"Done\" to load it whole)" + #if DEBUG + print("[YrXtals.dbg] media picker style=\(picker.modalPresentationStyle.rawValue)") + #endif + root.present(picker, animated: true) { [weak self, weak picker] in + #if DEBUG + guard let p = picker else { print("[YrXtals.dbg] media picker present: deallocated"); return } + self?.dumpPickerState(label: "media.presented", picker: p) + #endif + } + } + } + + #if DEBUG + /// dumps the responder chain, key window, and presentation chain at the moment of picker request. + private func dumpPresentationContext(label: String, root: UIViewController) { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + for (si, scene) in scenes.enumerated() { + print("[YrXtals.dbg] \(label): scene[\(si)] state=\(scene.activationState.rawValue) windows=\(scene.windows.count)") + for (wi, w) in scene.windows.enumerated() { + print("[YrXtals.dbg] \(label): scene[\(si)].window[\(wi)] key=\(w.isKeyWindow) hidden=\(w.isHidden) " + + "level=\(w.windowLevel.rawValue) bounds=\(w.bounds) rootVC=\(String(describing: w.rootViewController)) " + + "userInteraction=\(w.isUserInteractionEnabled)") + } + } + var presented: UIViewController? = root + var depth = 0 + while let p = presented { + print("[YrXtals.dbg] \(label): chain[\(depth)] vc=\(type(of: p)) " + + "view.userInteraction=\(p.view.isUserInteractionEnabled) " + + "modalStyle=\(p.modalPresentationStyle.rawValue)") + presented = p.presentedViewController + depth += 1 + } + } + + /// dumps the picker view hierarchy and gesture state once present(animated:completion:) returns. + private func dumpPickerState(label: String, picker: UIViewController) { + let v = picker.view + let nav = (picker as? UINavigationController) ?? picker.children.compactMap { $0 as? UINavigationController }.first + print("[YrXtals.dbg] \(label): picker=\(type(of: picker)) view=\(String(describing: v)) " + + "frame=\(v?.frame ?? .zero) userInteraction=\(v?.isUserInteractionEnabled ?? false) " + + "alpha=\(v?.alpha ?? 0) hidden=\(v?.isHidden ?? true) " + + "window=\(String(describing: v?.window)) " + + "subviews=\(v?.subviews.count ?? 0) " + + "navStack=\(nav?.viewControllers.count ?? -1) " + + "delegate=\(String(describing: (picker as? UIDocumentPickerViewController)?.delegate))") + if let v = v { + recurseViewDump(label: label, view: v, depth: 0, maxDepth: 6) + } + if let w = v?.window { + let recogs = w.gestureRecognizers ?? [] + print("[YrXtals.dbg] \(label): window gestureRecognizers count=\(recogs.count)") + for (i, g) in recogs.enumerated() { + print("[YrXtals.dbg] \(label): window.gr[\(i)]=\(type(of: g)) enabled=\(g.isEnabled) state=\(g.state.rawValue) " + + "cancelsInView=\(g.cancelsTouchesInView) delaysBegan=\(g.delaysTouchesBegan) delaysEnded=\(g.delaysTouchesEnded)") + } + } + attachDebugTapSpy(to: picker) + } + + /// recurses subviews up to maxDepth, printing frame, interaction, and gesture counts at each level. + private func recurseViewDump(label: String, view: UIView, depth: Int, maxDepth: Int) { + let pad = String(repeating: " ", count: depth) + print("[YrXtals.dbg] \(label): \(pad)\(type(of: view)) frame=\(view.frame) " + + "userInteraction=\(view.isUserInteractionEnabled) hidden=\(view.isHidden) alpha=\(view.alpha) " + + "subviews=\(view.subviews.count) gestureRecognizers=\(view.gestureRecognizers?.count ?? 0) " + + "layer=\(type(of: view.layer))") + if depth + 1 > maxDepth { return } + for sv in view.subviews { + recurseViewDump(label: label, view: sv, depth: depth + 1, maxDepth: maxDepth) + } + } + + /// adds a passing-through UITapGestureRecognizer to the picker view. + private func attachDebugTapSpy(to picker: UIViewController) { + guard let v = picker.view else { return } + let tap = UITapGestureRecognizer(target: self, action: #selector(debugTapSpy(_:))) + tap.cancelsTouchesInView = false + tap.delaysTouchesBegan = false + tap.delaysTouchesEnded = false + v.addGestureRecognizer(tap) + print("[YrXtals.dbg] attached debug tap spy to \(type(of: picker)).view") + } + + @objc private func debugTapSpy(_ g: UITapGestureRecognizer) { + let p = g.location(in: g.view) + print("[YrXtals.dbg] debugTapSpy fired at \(p) on \(String(describing: g.view.map { type(of: $0) })) state=\(g.state.rawValue)") + } + #endif + + /// claims the security-scoped folder URL and pushes the path to the Rust library scanner. + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + let kind = pendingKind + pendingKind = 0 + print("[YrXtals] documentPicker didPick kind=\(kind) urls=\(urls)") + guard let url = urls.first else { return } + guard let h = view?.viewportHandle else { + print("[YrXtals] documentPicker: no viewportHandle, dropping pick") + return + } + + if let prev = scopedURL { + prev.stopAccessingSecurityScopedResource() + scopedURL = nil + } + let scoped = url.startAccessingSecurityScopedResource() + print("[YrXtals] startAccessingSecurityScopedResource = \(scoped) for \(url.path)") + if scoped { + scopedURL = url + } + #if DEBUG + let fm = FileManager.default + var isDir: ObjCBool = false + let exists = fm.fileExists(atPath: url.path, isDirectory: &isDir) + let reachable = (try? url.checkResourceIsReachable()) ?? false + let readable = fm.isReadableFile(atPath: url.path) + print("[YrXtals.dbg] picked path exists=\(exists) isDir=\(isDir.boolValue) reachable=\(reachable) readable=\(readable)") + if let entries = try? fm.contentsOfDirectory(atPath: url.path) { + print("[YrXtals.dbg] picked dir entries=\(entries.count) sample=\(entries.prefix(5))") + } else { + print("[YrXtals.dbg] picked dir contentsOfDirectory failed") + } + #endif + + url.path.withCString { cstr in + viewport_apply_picked_folder(h, cstr) + } + } + + /// clears the pending picker flag after the document picker dismisses without a selection. + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + pendingKind = 0 + print("[YrXtals] documentPicker cancelled") + } + + /// sorts picked MPMediaItems by track number, seeds the sidebar with placeholders, and dispatches artwork and export jobs. + func mediaPicker(_ mediaPicker: MPMediaPickerController, + didPickMediaItems collection: MPMediaItemCollection) { + pendingKind = 0 + mediaPicker.dismiss(animated: true) + guard !collection.items.isEmpty else { + print("[YrXtals] mediaPicker returned no items") + return + } + + let items = collection.items.sorted { lhs, rhs in + let l = lhs.albumTrackNumber == 0 ? Int.max : lhs.albumTrackNumber + let r = rhs.albumTrackNumber == 0 ? Int.max : rhs.albumTrackNumber + if l != r { return l < r } + return (lhs.title ?? "").localizedCaseInsensitiveCompare(rhs.title ?? "") == .orderedAscending + } + print("[YrXtals] mediaPicker picked \(items.count) item(s)") + if let h = view?.viewportHandle { + let titles = items.map { $0.title ?? "Untitled" } + let trackNumbers: [UInt32] = items.map { UInt32($0.albumTrackNumber) } + let cstrs: [UnsafeMutablePointer?] = titles.map { strdup($0) } + defer { cstrs.forEach { if let p = $0 { free(p) } } } + var pointers: [UnsafePointer?] = cstrs.map { $0.flatMap { UnsafePointer($0) } } + pointers.withUnsafeMutableBufferPointer { titlesBuf in + trackNumbers.withUnsafeBufferPointer { tnBuf in + viewport_set_pending_titles(h, titlesBuf.baseAddress, tnBuf.baseAddress, titles.count) + } + } + } + pushArtwork(for: items) + exportAndDeliver(items: items) + } + + /// pulls 256-pixel JPEG artwork off a background queue and pushes each into the viewport on the main thread. + private func pushArtwork(for items: [MPMediaItem]) { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + for (idx, item) in items.enumerated() { + guard let img = item.artwork?.image(at: CGSize(width: 256, height: 256)) else { continue } + guard let data = img.jpegData(compressionQuality: 0.85) else { continue } + DispatchQueue.main.async { [weak self] in + guard let h = self?.view?.viewportHandle else { return } + data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in + guard let base = raw.baseAddress else { return } + viewport_set_track_art(h, idx, base.assumingMemoryBound(to: UInt8.self), data.count) + } + } + } + } + } + + /// transcodes each MPMediaItem to a temporary m4a, reports progress, and forwards the resulting paths to Rust. + private func exportAndDeliver(items: [MPMediaItem]) { + let total = items.count + var skipped: [String] = [] + var done = 0 + + if let h = view?.viewportHandle { + viewport_set_library_progress(h, 0, UInt32(total)) + } + + let finish: () -> Void = { [weak self] in + guard let self = self, let h = self.view?.viewportHandle else { return } + viewport_set_library_progress(h, 0, 0) + print("[YrXtals] exports finished: \(total - skipped.count)/\(total) succeeded; skipped=\(skipped)") + if total - skipped.count == 0, let root = self.topViewController(), !skipped.isEmpty { + self.showAlert(on: root, message: "Couldn't load any of the picked tracks. \(skipped.first ?? "")") + } + } + + for (idx, item) in items.enumerated() { + guard let assetURL = item.assetURL else { + skipped.append("'\(item.title ?? "?")' is DRM-protected") + done += 1 + if done == total { finish() } + continue + } + let asset = AVURLAsset(url: assetURL) + let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("\(UUID().uuidString).m4a") + try? FileManager.default.removeItem(at: tmpURL) + + guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetAppleM4A) else { + skipped.append("AVAssetExportSession init failed for '\(item.title ?? "?")'") + done += 1 + if done == total { finish() } + continue + } + exporter.outputURL = tmpURL + exporter.outputFileType = .m4a + exporter.shouldOptimizeForNetworkUse = false + + print("[YrXtals] exporting [\(idx+1)/\(total)] \(item.title ?? "?") → \(tmpURL.lastPathComponent)") + exporter.exportAsynchronously { [weak self] in + DispatchQueue.main.async { + var landedPath: String? = nil + if exporter.status == .completed { + + let attrs = try? FileManager.default.attributesOfItem(atPath: tmpURL.path) + let size = (attrs?[.size] as? Int64) ?? -1 + print("[YrXtals] export[\(idx)] size=\(size) path=\(tmpURL.path)") + if size > 0 { + landedPath = tmpURL.path + } else { + skipped.append("'\(item.title ?? "?")' exported empty (size=\(size))") + } + } else { + skipped.append("export failed for '\(item.title ?? "?")': status=\(exporter.status.rawValue) err=\(String(describing: exporter.error))") + } + if let path = landedPath, let h = self?.view?.viewportHandle { + path.withCString { p in + viewport_set_track_path(h, idx, p) + } + } + done += 1 + if let h = self?.view?.viewportHandle { + viewport_set_library_progress(h, UInt32(done), UInt32(total)) + } + if done == total { finish() } + } + } + } + } + + /// clears the pending picker flag and dismisses the media picker on cancel. + func mediaPickerDidCancel(_ mediaPicker: MPMediaPickerController) { + pendingKind = 0 + mediaPicker.dismiss(animated: true) + print("[YrXtals] mediaPicker cancelled") + } + + deinit { + #if DEBUG + print("[YrXtals.dbg] LibraryController deinit (delegate would go nil)") + #endif + scopedURL?.stopAccessingSecurityScopedResource() + } + + /// walks the active scene's window stack down to the topmost presented controller. + private func topViewController() -> UIViewController? { + let scenes = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive } + for scene in scenes { + let candidates = scene.windows.sorted { lhs, _ in lhs.isKeyWindow } + for window in candidates { + if let root = window.rootViewController { + var top = root + while let presented = top.presentedViewController { + top = presented + } + return top + } + } + } + return nil + } + + /// presents a single-button OK alert anchored on the given root controller. + private func showAlert(on root: UIViewController, message: String) { + let alert = UIAlertController(title: "YrXtals", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + root.present(alert, animated: true) + } +} diff --git a/ios/src/YrXtalsApp.swift b/ios/src/YrXtalsApp.swift new file mode 100644 index 0000000..7a3ed1c --- /dev/null +++ b/ios/src/YrXtalsApp.swift @@ -0,0 +1,175 @@ +import UIKit +import AVFoundation +import MediaPlayer +import Darwin + +/// configures audio, stderr capture, and scene routing at launch. +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + /// configures audio session, media-library auth, and the stderr-forwarding pipe at process launch. + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + Self.captureStderr() + + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory(.playback, mode: .default, options: []) + try session.setActive(true) + } catch { + print("[YrXtals] AVAudioSession setup failed: \(error)") + } + + MPMediaLibrary.requestAuthorization { status in + print("[YrXtals] media library authorization: \(status.rawValue)") + } + #if DEBUG + Self.dumpStartupDiagnostics() + Self.startNotificationSpy() + #endif + return true + } + + #if DEBUG + /// dumps bundle id, sandbox container, entitlements, FileProvider availability, and reachable temp dirs at startup. + private static func dumpStartupDiagnostics() { + let info = Bundle.main.infoDictionary ?? [:] + print("[YrXtals.dbg] bundleId=\(Bundle.main.bundleIdentifier ?? "?") version=\(info["CFBundleShortVersionString"] ?? "?") build=\(info["CFBundleVersion"] ?? "?")") + print("[YrXtals.dbg] bundlePath=\(Bundle.main.bundlePath)") + let home = NSHomeDirectory() + print("[YrXtals.dbg] sandbox home=\(home)") + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.path ?? "?" + let tmp = NSTemporaryDirectory() + print("[YrXtals.dbg] documents=\(docs)") + print("[YrXtals.dbg] tmp=\(tmp) writable=\(FileManager.default.isWritableFile(atPath: tmp))") + if let provURL = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision") ?? URL(string: Bundle.main.bundlePath + "/embedded.mobileprovision") { + if let data = try? Data(contentsOf: provURL) { + print("[YrXtals.dbg] embedded.mobileprovision size=\(data.count)") + if let s = String(data: data, encoding: .ascii) { + if let start = s.range(of: "") { + let plistRange = start.lowerBound.. UISceneConfiguration { + let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + config.delegateClass = SceneDelegate.self + return config + } + + /// reroutes process stderr through a pipe and replays each line on stdout with a Rust prefix. + private static func captureStderr() { + let realStdout = dup(fileno(stdout)) + guard realStdout != -1 else { return } + let outFile = fdopen(realStdout, "w") + guard outFile != nil else { close(realStdout); return } + setvbuf(outFile, nil, _IONBF, 0) + + var fds: [Int32] = [0, 0] + guard pipe(&fds) == 0 else { return } + dup2(fds[1], fileno(stderr)) + setvbuf(stderr, nil, _IONBF, 0) + + DispatchQueue.global(qos: .utility).async { + guard let f = fdopen(fds[0], "r") else { return } + var line: UnsafeMutablePointer? + var cap: Int = 0 + while getline(&line, &cap, f) > 0 { + if let l = line { + fputs("[Rust] ", outFile) + fputs(l, outFile) + } + } + if let l = line { free(l) } + } + } +} + +/// installs IcedViewportController as the key window's rootViewController. +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + let libraryController = LibraryController() + + /// builds the window with IcedViewportController as the root and binds the library controller. + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + let win = UIWindow(windowScene: windowScene) + let root = IcedViewportController() + root.viewport.controller = libraryController + libraryController.view = root.viewport + libraryController.presentationHost = root + win.rootViewController = root + win.overrideUserInterfaceStyle = .dark + win.makeKeyAndVisible() + self.window = win + } +} diff --git a/macos/Info.plist b/macos/Info.plist new file mode 100644 index 0000000..4b4c816 --- /dev/null +++ b/macos/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleExecutable + yr_crystals + CFBundleIdentifier + org.else-if.yrcrystals + CFBundleName + Yr Xtals + CFBundleDisplayName + Yr Xtals + CFBundlePackageType + APPL + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1.0 + LSMinimumSystemVersion + 14.0 + LSApplicationCategoryType + public.app-category.music + CFBundleIconFile + AppIcon + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + NSMicrophoneUsageDescription + Yr Xtals does not record audio. This entry is here only because the audio framework asks for it on some macOS versions. + + diff --git a/scripts/android/_env.sh b/scripts/android/_env.sh new file mode 100755 index 0000000..54aba1f --- /dev/null +++ b/scripts/android/_env.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# resolves and exports jdk, android sdk, and ndk paths. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# jdk 17 resolution +if [[ -z "${JAVA_HOME:-}" ]]; then + for candidate in \ + "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" \ + "/usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" \ + "$HOME/.sdkman/candidates/java/current"; do + if [[ -x "$candidate/bin/java" ]]; then + export JAVA_HOME="$candidate" + break + fi + done +fi +if [[ -z "${JAVA_HOME:-}" || ! -x "$JAVA_HOME/bin/java" ]]; then + echo "JAVA_HOME not resolved. install jdk 17 (brew install openjdk@17 or sdk install java 17.0.19-tem)." >&2 + exit 2 +fi + +# android sdk resolution +if [[ -z "${ANDROID_HOME:-}" ]]; then + for candidate in \ + "/opt/homebrew/share/android-commandlinetools" \ + "/usr/local/share/android-commandlinetools" \ + "$HOME/Library/Android/sdk" \ + "$HOME/Android/Sdk"; do + if [[ -d "$candidate/platform-tools" || -d "$candidate/cmdline-tools" ]]; then + export ANDROID_HOME="$candidate" + break + fi + done +fi +if [[ -z "${ANDROID_HOME:-}" || ! -d "$ANDROID_HOME" ]]; then + echo "ANDROID_HOME not resolved. install via brew install --cask android-commandlinetools." >&2 + exit 2 +fi +export ANDROID_SDK_ROOT="$ANDROID_HOME" + +# ndk version sourced from .android-sdk-packages +NDK_COORD="$(grep -E '^ndk;' "$REPO_ROOT/.android-sdk-packages" | head -1 | tr -d ' ')" +NDK_VERSION="${NDK_COORD#ndk;}" +if [[ -z "$NDK_VERSION" ]]; then + echo ".android-sdk-packages missing an 'ndk;' entry." >&2 + exit 2 +fi +export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION" +export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME" +if [[ ! -d "$ANDROID_NDK_HOME" ]]; then + echo "NDK $NDK_VERSION missing at $ANDROID_NDK_HOME — run scripts/android/bootstrap.sh." >&2 + exit 2 +fi + +# prepends jdk and android sdk binaries to PATH +export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" + +# target device serial +ANDROID_TARGET_FILE="$REPO_ROOT/.yrxtls-android-target" +ANDROID_TARGET="${YRXTALS_ANDROID_DEVICE:-}" +if [[ -z "$ANDROID_TARGET" && -f "$ANDROID_TARGET_FILE" ]]; then + ANDROID_TARGET="$(cat "$ANDROID_TARGET_FILE")" +fi +export ANDROID_TARGET diff --git a/scripts/android/bootstrap.sh b/scripts/android/bootstrap.sh new file mode 100755 index 0000000..97b0c62 --- /dev/null +++ b/scripts/android/bootstrap.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# installs every android sdk package listed in .android-sdk-packages. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# resolves JAVA_HOME and ANDROID_HOME from common install locations +SDK_PKGS_FILE="$REPO_ROOT/.android-sdk-packages" +if [[ ! -f "$SDK_PKGS_FILE" ]]; then + echo ".android-sdk-packages missing at repo root." >&2 + exit 2 +fi + +if [[ -z "${JAVA_HOME:-}" ]]; then + for c in /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home \ + /usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home \ + "$HOME/.sdkman/candidates/java/current"; do + if [[ -x "$c/bin/java" ]]; then export JAVA_HOME="$c"; break; fi + done +fi +if [[ -z "${JAVA_HOME:-}" ]]; then + echo "JAVA_HOME not resolved. install jdk 17." >&2; exit 2 +fi +if [[ -z "${ANDROID_HOME:-}" ]]; then + for c in /opt/homebrew/share/android-commandlinetools \ + /usr/local/share/android-commandlinetools \ + "$HOME/Library/Android/sdk" \ + "$HOME/Android/Sdk"; do + if [[ -d "$c/cmdline-tools" ]]; then export ANDROID_HOME="$c"; break; fi + done +fi +if [[ -z "${ANDROID_HOME:-}" ]]; then + echo "ANDROID_HOME not resolved. install via brew install --cask android-commandlinetools." >&2; exit 2 +fi +export ANDROID_SDK_ROOT="$ANDROID_HOME" + +SDKMGR="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" +if [[ ! -x "$SDKMGR" ]]; then + echo "sdkmanager missing at $SDKMGR." >&2; exit 2 +fi + +# accepts all sdk licenses idempotently +yes 2>/dev/null | "$SDKMGR" --licenses >/dev/null || true + +mapfile -t PKGS < <(grep -Ev '^\s*(#|$)' "$SDK_PKGS_FILE") +echo "installing: ${PKGS[*]}" +"$SDKMGR" --install "${PKGS[@]}" + +echo "bootstrap done." diff --git a/scripts/android/build.sh b/scripts/android/build.sh new file mode 100755 index 0000000..c00258a --- /dev/null +++ b/scripts/android/build.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# compiles the aarch64-linux-android cdylib into jniLibs and assembles the apk via gradle. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/_env.sh" + +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +GRADLE_TYPE="${1:-release}" +case "$GRADLE_TYPE" in + debug|release|release-debug) ;; + *) echo "unknown gradle type: $GRADLE_TYPE (debug|release|release-debug)" >&2; exit 2 ;; +esac + +# YRXTALS_ANDROID_CARGO_PROFILE overrides the rust profile, defaulting to the gradle type +CARGO_PROFILE="${YRXTALS_ANDROID_CARGO_PROFILE:-}" +if [[ -z "$CARGO_PROFILE" ]]; then + case "$GRADLE_TYPE" in + debug) CARGO_PROFILE="dev" ;; + release) CARGO_PROFILE="release" ;; + release-debug) CARGO_PROFILE="release-debug" ;; + esac +fi + +bash "$SCRIPT_DIR/generate-icons.sh" + +JNILIBS="$REPO_ROOT/android/app/src/main/jniLibs" +mkdir -p "$JNILIBS/arm64-v8a" + +CARGO_NDK_FLAGS=( -t arm64-v8a -P 28 -o "$JNILIBS" build ) +[[ "$CARGO_PROFILE" != "dev" ]] && CARGO_NDK_FLAGS+=( --profile "$CARGO_PROFILE" ) + +echo "→ cargo ndk ${CARGO_NDK_FLAGS[*]} (profile=$CARGO_PROFILE)" +( cd "$REPO_ROOT" && cargo ndk "${CARGO_NDK_FLAGS[@]}" ) + +case "$GRADLE_TYPE" in + debug) GRADLE_TASK="assembleDebug"; APK_VARIANT_DIR="debug" ;; + release) GRADLE_TASK="assembleRelease"; APK_VARIANT_DIR="release" ;; + release-debug) GRADLE_TASK="assembleReleaseDebug"; APK_VARIANT_DIR="releaseDebug" ;; +esac + +echo "→ gradlew $GRADLE_TASK" +( cd "$REPO_ROOT/android" && ./gradlew "$GRADLE_TASK" ) + +APK_DIR="$REPO_ROOT/android/app/build/outputs/apk/$APK_VARIANT_DIR" +echo "apk at: $(find "$APK_DIR" -name '*.apk' | head -1)" diff --git a/scripts/android/debug.sh b/scripts/android/debug.sh new file mode 100755 index 0000000..a88092e --- /dev/null +++ b/scripts/android/debug.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# installs the apk, launches the activity, and tails logcat filtered to YrXtals and native crash channels. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/_env.sh" + +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +"$SCRIPT_DIR/install.sh" release-debug + +if [[ -z "${ANDROID_TARGET:-}" ]]; then + echo "no device selected." >&2 + exit 2 +fi + +PKG="org.elseif.yrxtals" + +adb -s "$ANDROID_TARGET" logcat -c || true +adb -s "$ANDROID_TARGET" shell am start -n "$PKG/.MainActivity" >/dev/null + +PID="$(adb -s "$ANDROID_TARGET" shell pidof "$PKG" | tr -d '\r')" +echo "running pid=$PID on $ANDROID_TARGET — tailing logcat (Ctrl+C to stop)" + +if [[ -n "$PID" ]]; then + exec adb -s "$ANDROID_TARGET" logcat --pid="$PID" YrXtals:V yr_crystals:V AndroidRuntime:E libc:F DEBUG:F '*:S' +else + exec adb -s "$ANDROID_TARGET" logcat YrXtals:V yr_crystals:V AndroidRuntime:E libc:F DEBUG:F '*:S' +fi diff --git a/scripts/android/generate-icons.sh b/scripts/android/generate-icons.sh new file mode 100755 index 0000000..b98a73a --- /dev/null +++ b/scripts/android/generate-icons.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# rasterizes assets/androidIcon.svg edge-to-edge into the android mipmap-density buckets used by the launcher. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SVG="$ROOT/assets/androidIcon.svg" +RES="$ROOT/android/app/src/main/res" + +if [[ ! -f "$SVG" ]]; then + echo "ERROR: $SVG not found" >&2 + exit 1 +fi + +if ! command -v rsvg-convert >/dev/null 2>&1; then + echo "ERROR: rsvg-convert not on PATH (brew install librsvg)" >&2 + exit 1 +fi + +# launcher icon density buckets per the android docs. +DENSITIES=( + "mdpi 48" + "hdpi 72" + "xhdpi 96" + "xxhdpi 144" + "xxxhdpi 192" +) + +for entry in "${DENSITIES[@]}"; do + bucket="${entry%% *}" + size="${entry##* }" + dir="$RES/mipmap-$bucket" + mkdir -p "$dir" + rsvg-convert --width="$size" --height="$size" "$SVG" -o "$dir/ic_launcher.png" +done + +# 512x512 play-store icon staged alongside the launcher buckets +mkdir -p "$RES/mipmap-xxxhdpi" +rsvg-convert --width=512 --height=512 "$SVG" -o "$RES/mipmap-xxxhdpi/ic_launcher_play.png" + +echo "wrote launcher icons across ${#DENSITIES[@]} density buckets + play store size" diff --git a/scripts/android/install.sh b/scripts/android/install.sh new file mode 100755 index 0000000..b1e18b8 --- /dev/null +++ b/scripts/android/install.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# builds and installs the apk to the saved or specified device serial. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/_env.sh" + +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +GRADLE_TYPE="${1:-release}" +"$SCRIPT_DIR/build.sh" "$GRADLE_TYPE" + +if [[ -z "${ANDROID_TARGET:-}" ]]; then + echo "no device selected. run cargo xtask select-android first." >&2 + exit 2 +fi + +case "$GRADLE_TYPE" in + debug) APK_VARIANT_DIR="debug" ;; + release) APK_VARIANT_DIR="release" ;; + release-debug) APK_VARIANT_DIR="releaseDebug" ;; +esac +APK="$(find "$REPO_ROOT/android/app/build/outputs/apk/$APK_VARIANT_DIR" -name '*.apk' | head -1)" +if [[ -z "$APK" ]]; then + echo "no apk built at $APK_VARIANT_DIR." >&2; exit 1 +fi + +echo "→ adb -s $ANDROID_TARGET install -r $APK" +adb -s "$ANDROID_TARGET" install -r "$APK" diff --git a/scripts/android/select.sh b/scripts/android/select.sh new file mode 100755 index 0000000..f56a758 --- /dev/null +++ b/scripts/android/select.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# lists adb devices and persists the chosen serial to .yrxtls-android-target. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/_env.sh" + +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +TARGET_FILE="$REPO_ROOT/.yrxtls-android-target" + +mapfile -t LINES < <(adb devices -l | tail -n +2 | grep -v '^\s*$' || true) +if [[ ${#LINES[@]} -eq 0 ]]; then + echo "no devices attached. plug in a phone with USB debugging enabled or start an emulator." >&2 + exit 1 +fi + +echo "attached devices:" +for i in "${!LINES[@]}"; do + printf " [%d] %s\n" "$i" "${LINES[$i]}" +done + +read -rp "pick index: " idx +chosen="${LINES[$idx]}" +serial="$(awk '{print $1}' <<<"$chosen")" +if [[ -z "$serial" ]]; then + echo "no serial parsed." >&2 + exit 1 +fi + +echo "$serial" > "$TARGET_FILE" +echo "saved $serial to $TARGET_FILE" diff --git a/scripts/ios/build.sh b/scripts/ios/build.sh new file mode 100755 index 0000000..b15a14c --- /dev/null +++ b/scripts/ios/build.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s), iOS build requires macOS" >&2; exit 1;; +esac + +TARGET="${1:-sim}" +CARGO_PROFILE="${YRXTALS_IOS_CARGO_PROFILE:-release}" + +case "$TARGET" in + sim) + RUST_TARGET="aarch64-apple-ios-sim" + SDK_NAME="iphonesimulator" + SWIFT_TARGET="arm64-apple-ios17.0-simulator" + ;; + device) + RUST_TARGET="aarch64-apple-ios" + SDK_NAME="iphoneos" + SWIFT_TARGET="arm64-apple-ios17.0" + ;; + *) + echo "usage: $0 [sim|device]" >&2 + exit 2 + ;; +esac + +BUILD="$ROOT/build" +APP="$BUILD/ios/YrXtals.app" +RUST_LIB="/tmp/yr_crystals-target/$RUST_TARGET/$CARGO_PROFILE" + +SDK="$(xcrun --sdk "$SDK_NAME" --show-sdk-path)" + +export CC=/usr/bin/clang +export CXX=/usr/bin/clang++ +export IPHONEOS_DEPLOYMENT_TARGET=17.0 + +echo "Building Rust staticlib for $RUST_TARGET (profile=$CARGO_PROFILE)..." +cargo build --profile "$CARGO_PROFILE" --target "$RUST_TARGET" --lib + +rm -f "$RUST_LIB/libyr_crystals.dylib" "$RUST_LIB/deps/libyr_crystals.dylib" + +if [ ! -f "$RUST_LIB/libyr_crystals.a" ]; then + echo "ERROR: libyr_crystals.a not found at $RUST_LIB" >&2 + exit 1 +fi + +rm -rf "$APP" +mkdir -p "$APP" +cp "$ROOT/ios/Info.plist" "$APP/Info.plist" + +bash "$ROOT/scripts/ios/generate-icons.sh" + +ACTOOL_PARTIAL="$BUILD/ios/actool-partial-info.plist" +mkdir -p "$BUILD/ios" +echo "Compiling asset catalog..." +xcrun actool "$ROOT/ios/Assets.xcassets" \ + --compile "$APP" \ + --platform "$SDK_NAME" \ + --minimum-deployment-target 17.0 \ + --app-icon AppIcon \ + --output-partial-info-plist "$ACTOOL_PARTIAL" \ + --target-device ipad \ + --target-device iphone \ + >/dev/null + +if [ -f "$ACTOOL_PARTIAL" ]; then + /usr/libexec/PlistBuddy -c "Merge $ACTOOL_PARTIAL" "$APP/Info.plist" 2>/dev/null || true +fi + +RUST_FLAGS=(-import-objc-header "$ROOT/include/yr_xtals.h" -L "$RUST_LIB" -lyr_crystals) + +SWIFT_DEBUG_FLAGS=() +case "$CARGO_PROFILE" in + *debug*) SWIFT_DEBUG_FLAGS=(-D DEBUG) ;; +esac + +echo "Compiling Swift (profile=$CARGO_PROFILE)..." +xcrun -sdk "$SDK_NAME" swiftc \ + -target "$SWIFT_TARGET" \ + -sdk "$SDK" \ + "${RUST_FLAGS[@]}" \ + "${SWIFT_DEBUG_FLAGS[@]}" \ + -framework UIKit \ + -framework SwiftUI \ + -framework QuartzCore \ + -framework Metal \ + -framework MetalKit \ + -framework AVFoundation \ + -framework AudioToolbox \ + -framework MediaPlayer \ + -framework CoreGraphics \ + -framework CoreFoundation \ + -O \ + -o "$APP/YrXtals" \ + "$ROOT"/ios/src/*.swift + +if [ "$TARGET" = "sim" ]; then + codesign --force --sign - "$APP" +else + PROFILE="${YRXTALS_IOS_PROFILE:-/Volumes/External/prvProfiles/All.mobileprovision}" + if [ ! -f "$PROFILE" ]; then + echo "ERROR: provisioning profile not found at $PROFILE" >&2 + echo " set YRXTALS_IOS_PROFILE to point at a valid .mobileprovision" >&2 + exit 1 + fi + cp "$PROFILE" "$APP/embedded.mobileprovision" + + ENT="$BUILD/ios/entitlements.plist" + security cms -D -i "$PROFILE" 2>/dev/null \ + | plutil -extract Entitlements xml1 -o "$ENT" - \ + || { echo "ERROR: could not extract entitlements from profile" >&2; exit 1; } + + # pins wildcard application-identifier to the concrete TEAM.bundle.id form. + BUNDLE_ID="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP/Info.plist")" + TEAM_ID="$(/usr/libexec/PlistBuddy -c "Print :com.apple.developer.team-identifier" "$ENT" 2>/dev/null || true)" + if [ -z "$TEAM_ID" ]; then + TEAM_ID="$(/usr/libexec/PlistBuddy -c "Print :application-identifier" "$ENT" | cut -d. -f1)" + fi + /usr/libexec/PlistBuddy -c "Set :application-identifier ${TEAM_ID}.${BUNDLE_ID}" "$ENT" + /usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$ENT" 2>/dev/null || true + echo "Pinned application-identifier=${TEAM_ID}.${BUNDLE_ID}" + + TMPDIR_PROF="$(mktemp -d)" + PROFILE_PLIST="$TMPDIR_PROF/profile.plist" + security cms -D -i "$PROFILE" > "$PROFILE_PLIST" 2>/dev/null + + PROFILE_SHAS="" + for i in 0 1 2 3 4 5 6 7 8 9; do + if ! plutil -extract "DeveloperCertificates.$i" raw -o "$TMPDIR_PROF/c$i.b64" "$PROFILE_PLIST" >/dev/null 2>&1; then + break + fi + base64 -D -i "$TMPDIR_PROF/c$i.b64" -o "$TMPDIR_PROF/c$i.cer" + sha=$(openssl x509 -inform der -in "$TMPDIR_PROF/c$i.cer" -fingerprint -noout 2>/dev/null \ + | sed 's/.*=//;s/://g') + PROFILE_SHAS="$PROFILE_SHAS $sha" + done + + KEYCHAIN_SHAS=$(security find-identity -v 2>/dev/null \ + | awk '/[0-9A-F]{40}/ {gsub(/[^0-9A-F]/, "", $2); print $2}') + + IDENTITY="" + for s in $PROFILE_SHAS; do + if echo "$KEYCHAIN_SHAS" | grep -qi "^$s$"; then + IDENTITY="$s" + break + fi + done + rm -rf "$TMPDIR_PROF" + + if [ -z "$IDENTITY" ]; then + echo "ERROR: no codesigning identity in your keychain matches any cert in the profile" >&2 + echo " profile certs:$PROFILE_SHAS" >&2 + exit 1 + fi + + echo "Signing with $IDENTITY..." + codesign --force \ + --sign "$IDENTITY" \ + --entitlements "$ENT" \ + --options runtime \ + --timestamp=none \ + "$APP" +fi + +echo "Built: $APP" diff --git a/scripts/ios/debug.sh b/scripts/ios/debug.sh new file mode 100755 index 0000000..330fdb3 --- /dev/null +++ b/scripts/ios/debug.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +[ -f "$ROOT/.yrxtls-ios-target" ] && . "$ROOT/.yrxtls-ios-target" + +TARGET="${1:-${YRXTALS_IOS_KIND:-}}" +if [ -z "$TARGET" ]; then + if xcrun devicectl list devices 2>/dev/null | grep -q "available (paired)"; then + TARGET="device" + else + TARGET="sim" + fi +fi + +case "$TARGET" in + device) export YRXTALS_IOS_DEVICE="${YRXTALS_IOS_DEVICE:-${YRXTALS_IOS_UDID:-}}" ;; + sim) export YRXTALS_IOS_SIM="${YRXTALS_IOS_SIM:-${YRXTALS_IOS_UDID:-}}" ;; +esac + +case "$TARGET" in + sim) + YRXTALS_IOS_CARGO_PROFILE=release-debug bash "$ROOT/scripts/ios/install.sh" sim + xcrun simctl spawn "${YRXTALS_IOS_SIM:-booted}" log stream \ + --predicate 'processImagePath CONTAINS "YrXtals"' \ + --level debug + ;; + + device) + YRXTALS_IOS_CARGO_PROFILE=release-debug bash "$ROOT/scripts/ios/build.sh" device + + APP="$ROOT/build/ios/YrXtals.app" + BUNDLE_ID="org.else-if.yrxtals" + + AVAILABLE_LINES="$(xcrun devicectl list devices 2>/dev/null \ + | grep 'available (paired)' \ + | grep -E 'iPad|iPhone' || true)" + + DEVICE_ID="${YRXTALS_IOS_DEVICE:-}" + if [ -n "$DEVICE_ID" ] && ! echo "$AVAILABLE_LINES" | grep -q "$DEVICE_ID"; then + echo "saved device $DEVICE_ID not currently available, falling back to first paired device" >&2 + DEVICE_ID="" + fi + if [ -z "$DEVICE_ID" ]; then + DEVICE_ID="$(echo "$AVAILABLE_LINES" \ + | awk '{for(i=1;i<=NF;i++) if($i ~ /^[A-F0-9-]{36}$/) {print $i; exit}}')" + fi + + if [ -z "$DEVICE_ID" ]; then + echo "no paired iPad/iPhone found; connect via cable and trust this Mac on the device" >&2 + exit 1 + fi + + echo "Installing to device $DEVICE_ID..." + xcrun devicectl device install app --device "$DEVICE_ID" "$APP" + + echo "Launching with live console (Ctrl+C to detach)..." + echo "----------------------------------------------------------" + exec xcrun devicectl device process launch \ + --device "$DEVICE_ID" \ + --console \ + --terminate-existing \ + "$BUNDLE_ID" + ;; + + *) + echo "usage: $0 [sim|device]" >&2 + exit 2 + ;; +esac diff --git a/scripts/ios/generate-icons.sh b/scripts/ios/generate-icons.sh new file mode 100755 index 0000000..6835037 --- /dev/null +++ b/scripts/ios/generate-icons.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SVG="$ROOT/assets/Icon.svg" +ASSETS="$ROOT/ios/Assets.xcassets" +APPICON="$ASSETS/AppIcon.appiconset" + +if [ ! -f "$SVG" ]; then + echo "ERROR: $SVG not found" >&2 + exit 1 +fi + +if ! command -v rsvg-convert >/dev/null 2>&1; then + echo "ERROR: rsvg-convert not on PATH (brew install librsvg)" >&2 + exit 1 +fi + +mkdir -p "$APPICON" + +SIZES=( + "Icon-20.png 20" + "Icon-20@2x.png 40" + "Icon-20@3x.png 60" + "Icon-29.png 29" + "Icon-29@2x.png 58" + "Icon-29@3x.png 87" + "Icon-40.png 40" + "Icon-40@2x.png 80" + "Icon-40@3x.png 120" + "Icon-60@2x.png 120" + "Icon-60@3x.png 180" + "Icon-76.png 76" + "Icon-76@2x.png 152" + "Icon-83.5@2x.png 167" + "Icon-1024.png 1024" +) + +for entry in "${SIZES[@]}"; do + name="${entry%% *}" + size="${entry##* }" + rsvg-convert --width="$size" --height="$size" "$SVG" -o "$APPICON/$name" +done + +cat > "$ASSETS/Contents.json" <<'EOF' +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} +EOF + +cat > "$APPICON/Contents.json" <<'EOF' +{ + "images" : [ + { "idiom" : "iphone", "size" : "20x20", "scale" : "2x", "filename" : "Icon-20@2x.png" }, + { "idiom" : "iphone", "size" : "20x20", "scale" : "3x", "filename" : "Icon-20@3x.png" }, + { "idiom" : "iphone", "size" : "29x29", "scale" : "2x", "filename" : "Icon-29@2x.png" }, + { "idiom" : "iphone", "size" : "29x29", "scale" : "3x", "filename" : "Icon-29@3x.png" }, + { "idiom" : "iphone", "size" : "40x40", "scale" : "2x", "filename" : "Icon-40@2x.png" }, + { "idiom" : "iphone", "size" : "40x40", "scale" : "3x", "filename" : "Icon-40@3x.png" }, + { "idiom" : "iphone", "size" : "60x60", "scale" : "2x", "filename" : "Icon-60@2x.png" }, + { "idiom" : "iphone", "size" : "60x60", "scale" : "3x", "filename" : "Icon-60@3x.png" }, + { "idiom" : "ipad", "size" : "20x20", "scale" : "1x", "filename" : "Icon-20.png" }, + { "idiom" : "ipad", "size" : "20x20", "scale" : "2x", "filename" : "Icon-20@2x.png" }, + { "idiom" : "ipad", "size" : "29x29", "scale" : "1x", "filename" : "Icon-29.png" }, + { "idiom" : "ipad", "size" : "29x29", "scale" : "2x", "filename" : "Icon-29@2x.png" }, + { "idiom" : "ipad", "size" : "40x40", "scale" : "1x", "filename" : "Icon-40.png" }, + { "idiom" : "ipad", "size" : "40x40", "scale" : "2x", "filename" : "Icon-40@2x.png" }, + { "idiom" : "ipad", "size" : "76x76", "scale" : "1x", "filename" : "Icon-76.png" }, + { "idiom" : "ipad", "size" : "76x76", "scale" : "2x", "filename" : "Icon-76@2x.png" }, + { "idiom" : "ipad", "size" : "83.5x83.5","scale" : "2x","filename" : "Icon-83.5@2x.png" }, + { "idiom" : "ios-marketing","size" : "1024x1024","scale" : "1x","filename" : "Icon-1024.png" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} +EOF + +echo "Wrote $APPICON ($(ls "$APPICON" | wc -l | tr -d ' ') files)" diff --git a/scripts/ios/install.sh b/scripts/ios/install.sh new file mode 100755 index 0000000..4af9d34 --- /dev/null +++ b/scripts/ios/install.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +[ -f "$ROOT/.yrxtls-ios-target" ] && . "$ROOT/.yrxtls-ios-target" + +TARGET="${1:-${YRXTALS_IOS_KIND:-}}" +if [ -z "$TARGET" ]; then + if xcrun devicectl list devices 2>/dev/null | grep -q "available (paired)"; then + TARGET="device" + else + TARGET="sim" + fi +fi + +case "$TARGET" in + device) export YRXTALS_IOS_DEVICE="${YRXTALS_IOS_DEVICE:-${YRXTALS_IOS_UDID:-}}" ;; + sim) export YRXTALS_IOS_SIM="${YRXTALS_IOS_SIM:-${YRXTALS_IOS_UDID:-}}" ;; +esac + +case "$TARGET" in + sim) + bash "$ROOT/scripts/ios/build.sh" sim + APP="$ROOT/build/ios/YrXtals.app" + BUNDLE_ID="org.else-if.yrxtals" + + DEV="${YRXTALS_IOS_SIM:-}" + if [ -z "$DEV" ]; then + DEV="$(xcrun simctl list devices booted | awk '/Booted/ {print $NF}' | tr -d '()' | head -1 || true)" + fi + if [ -z "$DEV" ]; then + DEV="$(xcrun simctl list devices available | awk '/iPad/ && /\([A-F0-9\-]+\)/ {gsub(/[\(\)]/,"",$NF); print $NF; exit}')" + if [ -z "$DEV" ]; then + echo "no iPad simulator available, open Xcode > Window > Devices and Simulators to add one" >&2 + exit 1 + fi + fi + xcrun simctl boot "$DEV" 2>/dev/null || true + open -a Simulator + + echo "Installing to simulator $DEV..." + xcrun simctl install "$DEV" "$APP" + echo "Launching..." + xcrun simctl launch "$DEV" "$BUNDLE_ID" + ;; + + device) + bash "$ROOT/scripts/ios/build.sh" device + APP="$ROOT/build/ios/YrXtals.app" + + AVAILABLE_LINES="$(xcrun devicectl list devices 2>/dev/null \ + | grep 'available (paired)' \ + | grep -E 'iPad|iPhone' || true)" + + DEVICE_ID="${YRXTALS_IOS_DEVICE:-}" + if [ -n "$DEVICE_ID" ] && ! echo "$AVAILABLE_LINES" | grep -q "$DEVICE_ID"; then + echo "saved device $DEVICE_ID not currently available, falling back to first paired device" >&2 + DEVICE_ID="" + fi + if [ -z "$DEVICE_ID" ]; then + DEVICE_ID="$(echo "$AVAILABLE_LINES" \ + | awk '{for(i=1;i<=NF;i++) if($i ~ /^[A-F0-9-]{36}$/) {print $i; exit}}')" + fi + + if [ -z "$DEVICE_ID" ]; then + echo "no paired iPad/iPhone found, connect via cable and trust the host on the device" >&2 + exit 1 + fi + + echo "Installing to device $DEVICE_ID..." + xcrun devicectl device install app --device "$DEVICE_ID" "$APP" + + echo "Launching..." + xcrun devicectl device process launch --device "$DEVICE_ID" org.else-if.yrxtals || true + ;; + + *) + echo "usage: $0 [sim|device]" >&2 + exit 2 + ;; +esac diff --git a/scripts/ios/release.sh b/scripts/ios/release.sh new file mode 100755 index 0000000..4e53b83 --- /dev/null +++ b/scripts/ios/release.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s), iOS release requires macOS" >&2; exit 1;; +esac + +RUST_TARGET="aarch64-apple-ios" +SDK_NAME="iphoneos" +SWIFT_TARGET="arm64-apple-ios17.0" +CARGO_PROFILE="release" + +PROFILE="${YRXTALS_IOS_APPSTORE_PROFILE:-/Volumes/External/prvProfiles/Yr_Xtals.mobileprovision}" +if [ ! -f "$PROFILE" ]; then + echo "ERROR: App Store provisioning profile not found at $PROFILE" >&2 + echo " set YRXTALS_IOS_APPSTORE_PROFILE to override" >&2 + exit 1 +fi + +if [ -n "${VER:-}" ]; then + if ! [[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: VER must be x.y.z (got '$VER')" >&2 + exit 1 + fi + /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VER" "$ROOT/ios/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VER" "$ROOT/ios/Info.plist" + echo "Pinned ios/Info.plist version to $VER" +fi + +BUILD="$ROOT/build" +APP="$BUILD/ios-release/YrXtals.app" +PAYLOAD="$BUILD/ios-release/Payload" +IPA="$BUILD/ios-release/YrXtals.ipa" +RUST_LIB="/tmp/yr_crystals-target/$RUST_TARGET/$CARGO_PROFILE" + +SDK="$(xcrun --sdk "$SDK_NAME" --show-sdk-path)" + +export CC=/usr/bin/clang +export CXX=/usr/bin/clang++ +export IPHONEOS_DEPLOYMENT_TARGET=17.0 + +echo "Building Rust staticlib for $RUST_TARGET (profile=$CARGO_PROFILE)..." +cargo build --profile "$CARGO_PROFILE" --target "$RUST_TARGET" --lib + +rm -f "$RUST_LIB/libyr_crystals.dylib" "$RUST_LIB/deps/libyr_crystals.dylib" + +if [ ! -f "$RUST_LIB/libyr_crystals.a" ]; then + echo "ERROR: libyr_crystals.a not found at $RUST_LIB" >&2 + exit 1 +fi + +rm -rf "$BUILD/ios-release" +mkdir -p "$APP" +cp "$ROOT/ios/Info.plist" "$APP/Info.plist" + +bash "$ROOT/scripts/ios/generate-icons.sh" + +ACTOOL_PARTIAL="$BUILD/ios-release/actool-partial-info.plist" +echo "Compiling asset catalog..." +xcrun actool "$ROOT/ios/Assets.xcassets" \ + --compile "$APP" \ + --platform "$SDK_NAME" \ + --minimum-deployment-target 17.0 \ + --app-icon AppIcon \ + --output-partial-info-plist "$ACTOOL_PARTIAL" \ + --target-device ipad \ + --target-device iphone \ + >/dev/null + +if [ -f "$ACTOOL_PARTIAL" ]; then + /usr/libexec/PlistBuddy -c "Merge $ACTOOL_PARTIAL" "$APP/Info.plist" 2>/dev/null || true +fi + +RUST_FLAGS=(-import-objc-header "$ROOT/include/yr_xtals.h" -L "$RUST_LIB" -lyr_crystals) + +echo "Compiling Swift (release, no DEBUG)..." +xcrun -sdk "$SDK_NAME" swiftc \ + -target "$SWIFT_TARGET" \ + -sdk "$SDK" \ + "${RUST_FLAGS[@]}" \ + -framework UIKit \ + -framework SwiftUI \ + -framework QuartzCore \ + -framework Metal \ + -framework MetalKit \ + -framework AVFoundation \ + -framework AudioToolbox \ + -framework MediaPlayer \ + -framework CoreGraphics \ + -framework CoreFoundation \ + -O \ + -whole-module-optimization \ + -o "$APP/YrXtals" \ + "$ROOT"/ios/src/*.swift + +cp "$PROFILE" "$APP/embedded.mobileprovision" + +ENT="$BUILD/ios-release/entitlements.plist" +security cms -D -i "$PROFILE" 2>/dev/null \ + | plutil -extract Entitlements xml1 -o "$ENT" - \ + || { echo "ERROR: could not extract entitlements from profile" >&2; exit 1; } + +# forces get-task-allow=false in entitlements. +/usr/libexec/PlistBuddy -c "Set :get-task-allow false" "$ENT" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :get-task-allow bool false" "$ENT" + +# pins wildcard application-identifier to the concrete TEAM.bundle.id form. +BUNDLE_ID="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP/Info.plist")" +TEAM_ID="$(/usr/libexec/PlistBuddy -c "Print :com.apple.developer.team-identifier" "$ENT" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Print :application-identifier" "$ENT" | cut -d. -f1)" +case "$(/usr/libexec/PlistBuddy -c "Print :application-identifier" "$ENT")" in + *\*) /usr/libexec/PlistBuddy -c "Set :application-identifier ${TEAM_ID}.${BUNDLE_ID}" "$ENT" ;; +esac + +# locates the Apple Distribution identity in the keychain by SHA-matching the profile's certs. +TMPDIR_PROF="$(mktemp -d)" +PROFILE_PLIST="$TMPDIR_PROF/profile.plist" +security cms -D -i "$PROFILE" > "$PROFILE_PLIST" 2>/dev/null + +PROFILE_SHAS="" +for i in 0 1 2 3 4 5 6 7 8 9; do + if ! plutil -extract "DeveloperCertificates.$i" raw -o "$TMPDIR_PROF/c$i.b64" "$PROFILE_PLIST" >/dev/null 2>&1; then + break + fi + base64 -D -i "$TMPDIR_PROF/c$i.b64" -o "$TMPDIR_PROF/c$i.cer" + sha=$(openssl x509 -inform der -in "$TMPDIR_PROF/c$i.cer" -fingerprint -noout 2>/dev/null \ + | sed 's/.*=//;s/://g') + PROFILE_SHAS="$PROFILE_SHAS $sha" +done + +KEYCHAIN_SHAS=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk '/[0-9A-F]{40}/ {gsub(/[^0-9A-F]/, "", $2); print $2}') + +IDENTITY="" +for s in $PROFILE_SHAS; do + if echo "$KEYCHAIN_SHAS" | grep -qi "^$s$"; then + IDENTITY="$s" + break + fi +done +rm -rf "$TMPDIR_PROF" + +if [ -z "$IDENTITY" ]; then + echo "ERROR: no Apple Distribution identity in keychain matches the profile's certs" >&2 + echo " profile certs:$PROFILE_SHAS" >&2 + exit 1 +fi + +echo "Signing with Apple Distribution $IDENTITY..." +codesign --force \ + --sign "$IDENTITY" \ + --entitlements "$ENT" \ + --options runtime \ + --timestamp \ + "$APP" + +codesign --verify --deep --strict --verbose=2 "$APP" + +mkdir -p "$PAYLOAD" +cp -R "$APP" "$PAYLOAD/YrXtals.app" +rm -f "$IPA" +( cd "$BUILD/ios-release" && zip -qry "$(basename "$IPA")" Payload ) +rm -rf "$PAYLOAD" + +SHIPPED_VER="$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP/Info.plist")" + +# resolves BUMP from --bump flag, BUMP env var, or fallback default. +EXPLICIT_BUMP="" +for arg in "$@"; do + case "$arg" in + --bump) EXPLICIT_BUMP=patch ;; + --bump=*) EXPLICIT_BUMP="${arg#--bump=}" ;; + esac +done +if [ -n "$EXPLICIT_BUMP" ]; then + BUMP="$EXPLICIT_BUMP" +elif [ -z "${BUMP:-}" ]; then + if [ -n "${VER:-}" ]; then + BUMP=none + else + BUMP=patch + fi +fi +case "$BUMP" in + none) + echo "Skipping version bump (BUMP=none)" + ;; + major|minor|patch) + IFS='.' read -r CUR_MAJ CUR_MIN CUR_PATCH <<< "$SHIPPED_VER" + CUR_MAJ="${CUR_MAJ:-0}"; CUR_MIN="${CUR_MIN:-0}"; CUR_PATCH="${CUR_PATCH:-0}" + case "$BUMP" in + major) NEW_VER="$((CUR_MAJ + 1)).0.0" ;; + minor) NEW_VER="$CUR_MAJ.$((CUR_MIN + 1)).0" ;; + patch) NEW_VER="$CUR_MAJ.$CUR_MIN.$((CUR_PATCH + 1))" ;; + esac + /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $NEW_VER" "$ROOT/ios/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $NEW_VER" "$ROOT/ios/Info.plist" + echo "Bumped ios/Info.plist: $SHIPPED_VER → $NEW_VER ($BUMP) for next release" + ;; + *) + echo "ERROR: BUMP must be patch|minor|major|none, got '$BUMP'" >&2 + exit 1 + ;; +esac + +echo "Built: $IPA ($BUNDLE_ID @ $SHIPPED_VER)" diff --git a/scripts/ios/select.sh b/scripts/ios/select.sh new file mode 100755 index 0000000..a7801a1 --- /dev/null +++ b/scripts/ios/select.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +STATE="$ROOT/.yrxtls-ios-target" + +case "$(uname -s)" in + Darwin) ;; + *) echo "select-ios only works on macOS" >&2; exit 1;; +esac + +ENTRIES=() + +while IFS= read -r line; do + line="${line%"${line##*[![:space:]]}"}" + udid=$(echo "$line" | grep -oE '[A-F0-9-]{36}' | head -1) + [ -z "$udid" ] && continue + name=$(echo "$line" | awk -F' +' '{print $1}') + model=$(echo "$line" | awk -F' +' '{print $5}') + ENTRIES+=("device|$udid|$name ($model)") +done < <(xcrun devicectl list devices 2>/dev/null | grep "available (paired)") + +declare -A SEEN_SIMS +runtime_re='^-- (.*) --$' +sim_re='^[[:space:]]+(.*)[[:space:]]\(([A-F0-9-]{36})\)[[:space:]]\(([^)]+)\)' +while IFS= read -r line; do + if [[ "$line" =~ $runtime_re ]]; then + runtime="${BASH_REMATCH[1]}" + continue + fi + if [[ "$line" =~ $sim_re ]]; then + name="${BASH_REMATCH[1]}" + udid="${BASH_REMATCH[2]}" + [ -n "${SEEN_SIMS[$name]:-}" ] && continue + SEEN_SIMS[$name]=1 + ENTRIES+=("sim|$udid|$name ($runtime)") + fi +done < <(xcrun simctl list devices available 2>/dev/null) + +if [ ${#ENTRIES[@]} -eq 0 ]; then + echo "no devices or simulators found" >&2 + exit 1 +fi + +echo "Pick an iOS target (saved to .yrxtls-ios-target):" +echo +i=1 +for e in "${ENTRIES[@]}"; do + kind="${e%%|*}" + rest="${e#*|}" + udid="${rest%%|*}" + label="${rest#*|}" + case "$kind" in + device) tag="📱 device" ;; + sim) tag="🖥️ sim " ;; + esac + printf " %2d) %s %s\n" "$i" "$tag" "$label" + i=$((i+1)) +done +echo " 0) clear selection (auto-pick)" +echo + +read -r -p "> " choice + +if [ "$choice" = "0" ]; then + rm -f "$STATE" + echo "selection cleared, scripts will auto-pick again." + exit 0 +fi + +if ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt ${#ENTRIES[@]} ]; then + echo "not a valid choice" >&2 + exit 1 +fi + +picked="${ENTRIES[$((choice-1))]}" +KIND="${picked%%|*}" +rest="${picked#*|}" +UDID="${rest%%|*}" +LABEL="${rest#*|}" + +cat > "$STATE" <&2; exit 1 ;; + esac + ;; + iphonesimulator) + case "${ARCHS:-}" in + *arm64*) RUST_TARGET="aarch64-apple-ios-sim" ;; + *x86_64*) RUST_TARGET="x86_64-apple-ios" ;; + *) echo "unsupported ARCHS=$ARCHS for $PLATFORM_NAME" >&2; exit 1 ;; + esac + ;; + *) + echo "unsupported PLATFORM_NAME=$PLATFORM_NAME" >&2 + exit 1 + ;; +esac + +ROOT="$(cd "${PROJECT_DIR:-.}/.." && pwd)" + +export PATH="$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" +export CC=/usr/bin/clang +export CXX=/usr/bin/clang++ +export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-17.0}" + +cd "$ROOT" +cargo build --release --target "$RUST_TARGET" --lib + +rm -f "/tmp/yr_crystals-target/$RUST_TARGET/release/libyr_crystals.dylib" \ + "/tmp/yr_crystals-target/$RUST_TARGET/release/deps/libyr_crystals.dylib" + +mkdir -p "$ROOT/target" +ln -sfn "/tmp/yr_crystals-target/$RUST_TARGET" "$ROOT/target/$PLATFORM_NAME" diff --git a/scripts/ios/xcodeproj.sh b/scripts/ios/xcodeproj.sh new file mode 100755 index 0000000..a516940 --- /dev/null +++ b/scripts/ios/xcodeproj.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +if ! command -v xcodegen >/dev/null 2>&1; then + echo "xcodegen not found. install with:" >&2 + echo " brew install xcodegen" >&2 + exit 1 +fi + +export CC=/usr/bin/clang +export CXX=/usr/bin/clang++ +export IPHONEOS_DEPLOYMENT_TARGET=17.0 + +echo "Building Rust staticlibs for both iOS targets (release)..." +cargo build --release --target aarch64-apple-ios --lib +cargo build --release --target aarch64-apple-ios-sim --lib + +mkdir -p "$ROOT/target" +[ -L "$ROOT/target/iphoneos" ] || ln -sf "/tmp/yr_crystals-target/aarch64-apple-ios" "$ROOT/target/iphoneos" +[ -L "$ROOT/target/iphonesimulator" ] || ln -sf "/tmp/yr_crystals-target/aarch64-apple-ios-sim" "$ROOT/target/iphonesimulator" + +bash "$ROOT/scripts/ios/generate-icons.sh" + +cd "$ROOT/ios" +echo "Generating YrXtals.xcodeproj..." +xcodegen generate + +echo +echo "Generated: $ROOT/ios/YrXtals.xcodeproj" +echo "Open with: open $ROOT/ios/YrXtals.xcodeproj" +echo +echo "Build/run from xcode: pick a destination (an iPad or a sim) and hit Cmd+R." +echo "After changing Rust code, re-run this command to rebuild the staticlibs." diff --git a/scripts/linux/build.sh b/scripts/linux/build.sh new file mode 100755 index 0000000..9d912ac --- /dev/null +++ b/scripts/linux/build.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Linux) ;; + *) echo "wrong platform: $(uname -s), use cargo xtask build" >&2; exit 1;; +esac + +BUILD="$ROOT/build" + +echo "Building Rust binary (release)..." +cargo build --release --bin yr_crystals + +BIN="/tmp/yr_crystals-target/release/yr_crystals" +if [ ! -f "$BIN" ]; then + echo "ERROR: yr_crystals binary not found at $BIN" >&2 + exit 1 +fi + +mkdir -p "$BUILD/bin" "$BUILD/icons" +cp "$BIN" "$BUILD/bin/yr_crystals" + +SVG="$ROOT/assets/Icon.svg" +if [ -f "$SVG" ] && command -v rsvg-convert >/dev/null 2>&1; then + echo "Generating icon PNGs..." + for size in 32 64 128 256 512; do + rsvg-convert --width="$size" --height="$size" "$SVG" -o "$BUILD/icons/yr_crystals_${size}.png" + done +fi + +echo "Built: $BUILD/bin/yr_crystals" diff --git a/scripts/linux/debug.sh b/scripts/linux/debug.sh new file mode 100755 index 0000000..486e4f2 --- /dev/null +++ b/scripts/linux/debug.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Linux) ;; + *) echo "wrong platform: $(uname -s) — use cargo xtask debug" >&2; exit 1;; +esac + +export RUST_BACKTRACE=1 +cargo run --bin yr_crystals diff --git a/scripts/macos/build.sh b/scripts/macos/build.sh new file mode 100755 index 0000000..0c7e2ea --- /dev/null +++ b/scripts/macos/build.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +# compiles the rust binary, renders AppIcon.icns from the svg, and assembles "Yr Xtals.app" under build/bin. + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s) — use cargo xtask build" >&2; exit 1;; +esac + +BUILD="$ROOT/build" +APP="$BUILD/bin/Yr Xtals.app" +CONTENTS="$APP/Contents" +MACOS="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" + +export MACOSX_DEPLOYMENT_TARGET=14.0 + +echo "Building Rust binary (release)..." +cargo build --release --bin yr_crystals + +# cargo target dir under /tmp per .cargo/config.toml +BIN="/tmp/yr_crystals-target/release/yr_crystals" +if [ ! -f "$BIN" ]; then + echo "ERROR: yr_crystals binary not found at $BIN" >&2 + exit 1 +fi + +# AppIcon.icns from assets/Icon.svg via rsvg-convert + iconutil. +SVG="$ROOT/assets/Icon.svg" +if [ -f "$SVG" ]; then + if ! command -v rsvg-convert >/dev/null 2>&1; then + echo "ERROR: rsvg-convert missing — install with 'brew install librsvg' or 'port install librsvg'" >&2 + exit 1 + fi + echo "Generating app icon..." + ICONSET="$BUILD/AppIcon.iconset" + rm -rf "$ICONSET" + mkdir -p "$ICONSET" + for size in 16 32 64 128 256 512 1024; do + rsvg-convert --width="$size" --height="$size" "$SVG" -o "$ICONSET/icon_${size}.png" + done + cp "$ICONSET/icon_16.png" "$ICONSET/icon_16x16.png" + cp "$ICONSET/icon_32.png" "$ICONSET/icon_16x16@2x.png" + cp "$ICONSET/icon_32.png" "$ICONSET/icon_32x32.png" + cp "$ICONSET/icon_64.png" "$ICONSET/icon_32x32@2x.png" + cp "$ICONSET/icon_128.png" "$ICONSET/icon_128x128.png" + cp "$ICONSET/icon_256.png" "$ICONSET/icon_128x128@2x.png" + cp "$ICONSET/icon_256.png" "$ICONSET/icon_256x256.png" + cp "$ICONSET/icon_512.png" "$ICONSET/icon_256x256@2x.png" + cp "$ICONSET/icon_512.png" "$ICONSET/icon_512x512.png" + cp "$ICONSET/icon_1024.png" "$ICONSET/icon_512x512@2x.png" + rm -f "$ICONSET"/icon_16.png "$ICONSET"/icon_32.png "$ICONSET"/icon_64.png \ + "$ICONSET"/icon_128.png "$ICONSET"/icon_256.png "$ICONSET"/icon_512.png \ + "$ICONSET"/icon_1024.png + iconutil -c icns "$ICONSET" -o "$BUILD/AppIcon.icns" + rm -rf "$ICONSET" +fi + +mkdir -p "$MACOS" "$RESOURCES" +cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist" +cp "$BIN" "$MACOS/yr_crystals" +[ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" + +codesign --force --sign - "$APP" + +echo "Built: $APP" +open "$APP" diff --git a/scripts/macos/debug.sh b/scripts/macos/debug.sh new file mode 100755 index 0000000..97ed993 --- /dev/null +++ b/scripts/macos/debug.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s), use cargo xtask debug" >&2; exit 1;; +esac + +BUILD="$ROOT/build" +APP="$BUILD/bin/Yr Xtals.app" +CONTENTS="$APP/Contents" +MACOS="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" + +export MACOSX_DEPLOYMENT_TARGET=14.0 +export RUST_BACKTRACE=1 + +echo "Building Rust binary (debug)..." +cargo build --bin yr_crystals + +BIN="/tmp/yr_crystals-target/debug/yr_crystals" +if [ ! -f "$BIN" ]; then + echo "ERROR: yr_crystals binary not found at $BIN" >&2 + exit 1 +fi + +mkdir -p "$MACOS" "$RESOURCES" +cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist" +cp "$BIN" "$MACOS/yr_crystals" +[ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" + +codesign --force --sign - "$APP" + +pkill -f "Yr Xtals.app/Contents/MacOS/yr_crystals" 2>/dev/null || true +sleep 0.3 + +echo +echo "Launching $MACOS/yr_crystals. Rust panics will print below." +echo "(Ctrl+C to exit, or quit Yr Xtals normally.)" +echo "----------------------------------------------------------" +exec "$MACOS/yr_crystals" diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh new file mode 100755 index 0000000..fae1a78 --- /dev/null +++ b/scripts/macos/install.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s), use cargo xtask install" >&2; exit 1;; +esac + +DEST="/Applications/Yr Xtals.app" + +bash "$ROOT/scripts/macos/build.sh" + +pkill -f "Yr Xtals.app/Contents/MacOS/yr_crystals" 2>/dev/null || true +sleep 0.5 + +echo "Installing to $DEST..." +rm -rf "$DEST" +cp -R "$ROOT/build/bin/Yr Xtals.app" "$DEST" + +echo "Installed: $DEST" diff --git a/scripts/windows/build.ps1 b/scripts/windows/build.ps1 new file mode 100644 index 0000000..f89a4eb --- /dev/null +++ b/scripts/windows/build.ps1 @@ -0,0 +1,42 @@ +$ErrorActionPreference = "Stop" + +$Root = Resolve-Path "$PSScriptRoot\..\.." +Set-Location $Root + +if ($env:OS -ne "Windows_NT") { + Write-Error "wrong platform, use cargo xtask build" + exit 1 +} + +Write-Host "Building Rust binary (release)..." +cargo build --release --bin yr_crystals + +$Bin = Join-Path $Root "target\release\yr_crystals.exe" +if (-not (Test-Path $Bin)) { + Write-Error "yr_crystals.exe not found at $Bin" + exit 1 +} + +$Build = Join-Path $Root "build" +$BinDir = Join-Path $Build "bin" +New-Item -ItemType Directory -Force -Path $BinDir | Out-Null +Copy-Item $Bin (Join-Path $BinDir "yr_crystals.exe") -Force + +$Svg = Join-Path $Root "assets\Icon.svg" +$Magick = Get-Command magick -ErrorAction SilentlyContinue +if ((Test-Path $Svg) -and $Magick) { + Write-Host "Generating yr_crystals.ico..." + $IconDir = Join-Path $Build "icons" + New-Item -ItemType Directory -Force -Path $IconDir | Out-Null + $Sizes = 16, 32, 48, 64, 128, 256 + $Pngs = @() + foreach ($s in $Sizes) { + $Out = Join-Path $IconDir "icon_$s.png" + & $Magick.Source -background none -density 384 $Svg -resize ${s}x${s} $Out + $Pngs += $Out + } + & $Magick.Source $Pngs (Join-Path $BinDir "yr_crystals.ico") + Remove-Item $IconDir -Recurse -Force +} + +Write-Host "Built: $BinDir\yr_crystals.exe" diff --git a/shaders/fft.wgsl b/shaders/fft.wgsl new file mode 100644 index 0000000..d0ffa59 --- /dev/null +++ b/shaders/fft.wgsl @@ -0,0 +1,54 @@ +// radix-2 Cooley-Tukey 1D complex FFT, one bit_reverse pass plus log2N butterfly passes over scratch. + +struct Args { + n: u32, + log2_n: u32, + stride: u32, + inverse: u32, +}; + +@group(0) @binding(0) var args: Args; +@group(0) @binding(1) var data: array>; +@group(0) @binding(2) var scratch: array>; + +@compute @workgroup_size(64) +fn bit_reverse(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if (i >= args.n) { return; } + var rev: u32 = 0u; + var x = i; + for (var b: u32 = 0u; b < args.log2_n; b = b + 1u) { + rev = (rev << 1u) | (x & 1u); + x = x >> 1u; + } + scratch[rev] = data[i]; +} + +const PI: f32 = 3.14159265358979; + +@compute @workgroup_size(64) +fn butterfly(@builtin(global_invocation_id) gid: vec3) { + let k = gid.x; + let n = args.n; + if (k >= n / 2u) { return; } + + let half = args.stride; + let m = half * 2u; + let group = k / half; + let j = k % half; + let base = group * m; + let ia = base + j; + let ib = ia + half; + + var sign: f32 = -1.0; + if (args.inverse != 0u) { sign = 1.0; } + let angle = sign * PI * f32(j) / f32(half); + let w = vec2(cos(angle), sin(angle)); + + let a = scratch[ia]; + let b = scratch[ib]; + let bw = vec2(b.x * w.x - b.y * w.y, b.x * w.y + b.y * w.x); + + scratch[ia] = a + bw; + scratch[ib] = a - bw; +} diff --git a/shaders/visualizer.wgsl b/shaders/visualizer.wgsl new file mode 100644 index 0000000..539faf8 --- /dev/null +++ b/shaders/visualizer.wgsl @@ -0,0 +1,252 @@ +// synthesizes triangle and line vertices per-bin from a storage buffer, with mirrors as an instance axis. + +struct Globals { + bounds: vec2, // full canvas in pixels + base: vec2, // building rect in pixels, 0.55w by 0.5h when mirrored + num_bins: u32, + num_channels: u32, + flags: u32, // 1=glass, 2=mirrored, 4=inverted, 8=stereo + fade_bins: u32, // count of tail bins fading toward zero alpha when mirrored + hue_param: f32, + contrast: f32, + brightness: f32, + _pad0: f32, + unified_hue: f32, + unified_sat: f32, + unified_val: f32, + _pad1: f32, +}; + +struct Bin { + log_x: f32, // 0..1 along the log-frequency axis + visual_norm: f32, // smoothed dB on a 0..1 scale + primary_norm: f32, // primary dB on a 0..1 scale + bright_mod: f32, + alpha_mod: f32, + hue: f32, + sat: f32, + val: f32, +}; + +@group(0) @binding(0) var globals: Globals; +@group(0) @binding(1) var bins: array; + +struct VertexOut { + @builtin(position) clip_position: vec4, + @location(0) color: vec4, +}; + +fn flag(bit: u32) -> bool { + return (globals.flags & bit) != 0u; +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> vec3 { + let hh = fract(fract(h) + 1.0) * 6.0; + let i = floor(hh); + let f = hh - i; + let p = v * (1.0 - s); + let q = v * (1.0 - s * f); + let t = v * (1.0 - s * (1.0 - f)); + let ii = i32(i) % 6; + if (ii == 0) { return vec3(v, t, p); } + if (ii == 1) { return vec3(q, v, p); } + if (ii == 2) { return vec3(p, v, t); } + if (ii == 3) { return vec3(p, q, v); } + if (ii == 4) { return vec3(t, p, v); } + return vec3(v, p, q); +} + +fn final_brightness(b: Bin) -> f32 { + let base_b = sqrt(b.primary_norm); + let bm = b.bright_mod; + var b_mult: f32; + if (bm >= 0.0) { + b_mult = 1.0 + bm; + } else { + b_mult = 1.0 / (1.0 - bm * 2.0); + } + return clamp(base_b * b_mult * globals.brightness, 0.0, 1.0); +} + +fn alpha_for(b: Bin, fade: f32) -> f32 { + let am = b.alpha_mod; + var a_mult: f32; + if (am >= 0.0) { + a_mult = 1.0 + am * 0.5; + } else { + a_mult = 1.0 + am; + } + a_mult = max(a_mult, 0.1); + var a = 0.4 + (b.primary_norm - 0.5) * globals.contrast; + a = clamp(a * a_mult, 0.0, 1.0); + return a * fade; +} + +fn dyn_rgb(b: Bin) -> vec3 { + let fb = final_brightness(b); + let s = clamp(b.sat * globals.hue_param, 0.0, 1.0); + let v = clamp(b.val * fb, 0.0, 1.0); + return hsv_to_rgb(b.hue, s, v); +} + +fn fill_rgb(b: Bin) -> vec3 { + if (flag(1u)) { + let fb = final_brightness(b); + let v = clamp(globals.unified_val * fb, 0.0, 1.0); + return hsv_to_rgb(globals.unified_hue, globals.unified_sat, v); + } + return dyn_rgb(b); +} + +fn channel_offset(rgb: vec3, ch: u32) -> vec3 { + if (ch == 1u && flag(8u)) { + let off = 40.0 / 255.0; + return vec3( + max(rgb.x - off, 0.0), + max(rgb.y - off, 0.0), + min(rgb.z + off, 1.0), + ); + } + return rgb; +} + +fn fade_factor(seg: u32) -> f32 { + if (!flag(2u)) { return 1.0; } + let from_end = i32(globals.num_bins) - 2 - i32(seg); + if (from_end < i32(globals.fade_bins)) { + var f = f32(from_end + 1) / f32(globals.fade_bins + 1u); + f = f * f; + return max(f, 0.0); + } + return 1.0; +} + +fn pixel_to_clip(p: vec2) -> vec4 { + let nx = (p.x / max(globals.bounds.x, 1.0)) * 2.0 - 1.0; + let ny = 1.0 - (p.y / max(globals.bounds.y, 1.0)) * 2.0; + return vec4(nx, ny, 0.0, 1.0); +} + +fn mirror_xform(iid: u32, p: vec2) -> vec2 { + var sx: f32 = 1.0; + var sy: f32 = 1.0; + var tx: f32 = 0.0; + var ty: f32 = 0.0; + if (iid == 1u) { + sx = -1.0; tx = globals.bounds.x; + } else if (iid == 2u) { + sy = -1.0; ty = globals.bounds.y; + } else if (iid == 3u) { + sx = -1.0; sy = -1.0; + tx = globals.bounds.x; ty = globals.bounds.y; + } + return vec2(p.x * sx + tx, p.y * sy + ty); +} + +@vertex +fn vs_fill(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> VertexOut { + let nb = globals.num_bins; + let segs = max(nb, 1u) - 1u; + let per_ch = segs * 6u; + let ch = vid / per_ch; + let in_ch = vid % per_ch; + let seg = in_ch / 6u; + let corner = in_ch % 6u; + + var i = seg; + var j = seg + 1u; + if (flag(4u)) { + i = nb - 1u - seg; + j = nb - 2u - seg; + } + + let base = ch * nb; + let bi = bins[base + i]; + let bj = bins[base + j]; + + let w = globals.base.x; + let h = globals.base.y; + let x1 = bi.log_x * w; + let x2 = bj.log_x * w; + let y1 = h - bi.visual_norm * h; + let y2 = h - bj.visual_norm * h; + let anchor_y = h; + + var p: vec2; + switch corner { + case 0u: { p = vec2(x1, anchor_y); } + case 1u: { p = vec2(x1, y1); } + case 2u: { p = vec2(x2, y2); } + case 3u: { p = vec2(x1, anchor_y); } + case 4u: { p = vec2(x2, y2); } + default: { p = vec2(x2, anchor_y); } + } + let pw = mirror_xform(iid, p); + + var rgb = fill_rgb(bi); + rgb = channel_offset(rgb, ch); + let a = alpha_for(bi, fade_factor(seg)); + + var out: VertexOut; + out.clip_position = pixel_to_clip(pw); + out.color = vec4(rgb, a); + return out; +} + +@vertex +fn vs_line(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> VertexOut { + let nb = globals.num_bins; + let per_ch = nb * 2u; + let ch = vid / per_ch; + let in_ch = vid % per_ch; + let seg = in_ch / 2u; + let endpoint = in_ch % 2u; + + var i = seg; + if (flag(4u)) { + i = nb - 1u - seg; + } + let bi = bins[ch * nb + i]; + + let w = globals.base.x; + let h = globals.base.y; + let x = bi.log_x * w; + let y_top = h - bi.visual_norm * h; + let anchor_y = h; + var p: vec2; + if (endpoint == 0u) { + p = vec2(x, anchor_y); + } else { + p = vec2(x, y_top); + } + let pw = mirror_xform(iid, p); + + var rgb = dyn_rgb(bi); + rgb = channel_offset(rgb, ch); + var a = alpha_for(bi, fade_factor(seg)); + a = min(a, 0.9); + + var out: VertexOut; + out.clip_position = pixel_to_clip(pw); + out.color = vec4(rgb, a); + return out; +} + +// cepstrum line strip in pixel space. +struct CepIn { + @location(0) position: vec2, + @location(1) color: vec4, +}; + +@vertex +fn vs_cep(in: CepIn) -> VertexOut { + var out: VertexOut; + out.clip_position = pixel_to_clip(in.position); + out.color = in.color; + return out; +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 { + return in.color; +} diff --git a/src/analyzer.rs b/src/analyzer.rs new file mode 100644 index 0000000..bbc4594 --- /dev/null +++ b/src/analyzer.rs @@ -0,0 +1,318 @@ + + +use std::sync::Arc; + +use num_complex::Complex64; + +use crate::hilbert_stream::RealtimeHilbert; +use crate::processor::Processor; +use crate::track::TrackData; + +/// per-channel analyzer output combining the compressed multi-band db, the unmodified main-band db, and an optional cepstrum. +#[derive(Debug, Clone, Default)] +pub struct FrameData { + pub freqs: Vec, + pub db: Vec, + pub primary_db: Vec, + + pub cepstrum: Vec, +} + +/// stereo three-band processor pool driven by a streaming hilbert source and surfacing one frame per call to step. +pub struct Analyzer { + main: [Processor; 2], + transient: [Processor; 2], + deep: [Processor; 2], + + frame_size: usize, + hop_size: usize, + + hilbert: RealtimeHilbert, + hilbert_fft_size: usize, + hilbert_hop_size: usize, + hilbert_needs_reset: bool, + last_hilbert_sample: usize, + + track: Option>, + last_frames: Vec, +} + +impl Analyzer { + /// builds the main, transient, and deep processor pairs with band-appropriate expanders, hpf, and smoothing. + pub fn new(device: wgpu::Device, queue: wgpu::Queue) -> Self { + let frame_size = 4096_usize; + let hop_size = 1024_usize; + + let mut main = [ + Processor::new(frame_size, 48_000, device.clone(), queue.clone()), + Processor::new(frame_size, 48_000, device.clone(), queue.clone()), + ]; + for p in &mut main { + p.set_expander(1.5, -50.0); + p.set_hpf(80.0); + p.set_smoothing(3); + } + + let trans_size = (frame_size / 4).max(64); + let mut transient = [ + Processor::new(trans_size, 48_000, device.clone(), queue.clone()), + Processor::new(trans_size, 48_000, device.clone(), queue.clone()), + ]; + for p in &mut transient { + p.set_expander(2.5, -40.0); + p.set_hpf(100.0); + p.set_smoothing(2); + } + + let mut deep = [ + Processor::new(frame_size * 2, 48_000, device.clone(), queue.clone()), + Processor::new(frame_size * 2, 48_000, device.clone(), queue.clone()), + ]; + for p in &mut deep { + p.set_expander(1.2, -60.0); + p.set_hpf(0.0); + p.set_smoothing(5); + } + + Self { + main, + transient, + deep, + frame_size, + hop_size, + hilbert: RealtimeHilbert::new(), + hilbert_fft_size: 8192, + hilbert_hop_size: hop_size, + hilbert_needs_reset: true, + last_hilbert_sample: 0, + track: None, + last_frames: Vec::new(), + } + } + + /// returns the loaded track's sample rate, falling back to 48 khz before any track loads. + pub fn sample_rate(&self) -> u32 { + self.track.as_ref().map(|t| t.sample_rate).unwrap_or(48_000) + } + + /// returns the loaded track's stereo-frame count. + pub fn total_samples(&self) -> usize { + self.track.as_ref().map(|t| t.total_samples()).unwrap_or(0) + } + + /// swaps in incoming pcm data and marks the streaming hilbert dirty. + pub fn set_track_data(&mut self, data: Arc) { + let rate = data.sample_rate; + for p in self.main.iter_mut().chain(self.transient.iter_mut()).chain(self.deep.iter_mut()) { + p.set_sample_rate(rate); + } + self.track = Some(data); + self.hilbert_needs_reset = true; + } + + /// retunes the three bands' transform lengths around a base frame size and routes the hilbert hop to match. + pub fn set_dsp_params(&mut self, frame_size: usize, hop_size: usize) { + let trans_size = (frame_size / 4).max(64); + let deep_size = if frame_size < 2048 { frame_size * 4 } else { frame_size * 2 }; + let hilbert_changed = + frame_size != self.hilbert_fft_size || hop_size != self.hilbert_hop_size; + if hilbert_changed { + self.hilbert_fft_size = frame_size; + self.hilbert_hop_size = hop_size; + self.hilbert_needs_reset = true; + } + self.frame_size = frame_size; + self.hop_size = hop_size; + for p in &mut self.main { + p.set_frame_size(frame_size); + } + for p in &mut self.transient { + p.set_frame_size(trans_size); + } + for p in &mut self.deep { + p.set_frame_size(deep_size); + } + } + + /// propagates the bin count to every band and channel. + pub fn set_num_bins(&mut self, n: usize) { + for p in self.main.iter_mut().chain(self.transient.iter_mut()).chain(self.deep.iter_mut()) { + p.set_num_bins(n); + } + } + + /// distributes the cepstral idealisation parameters across the three bands with band-specific strength weights. + pub fn set_smoothing_params(&mut self, granularity: i32, detail: i32, strength: f32) { + for p in &mut self.main { + p.set_cepstral_params(granularity, detail, strength); + } + for p in &mut self.transient { + p.set_cepstral_params(granularity, detail, strength * 0.3); + } + for p in &mut self.deep { + p.set_cepstral_params(granularity, detail, strength * 1.2); + } + } + + /// propagates the cpu/gpu fft crossfade to every band and channel. + pub fn set_gpu_blend(&mut self, blend: f32) { + for p in self + .main + .iter_mut() + .chain(self.transient.iter_mut()) + .chain(self.deep.iter_mut()) + { + p.set_gpu_blend(blend); + } + } + + /// advances one hop of analytic-signal data at the requested normalised playhead and publishes a stereo frame pair. + pub fn step(&mut self, position: f64) -> Option<&[FrameData]> { + let total = self.total_samples(); + if total == 0 { + return None; + } + let target = (position.clamp(0.0, 1.0) * total as f64) as usize; + let track = self.track.clone()?; + if !track.is_valid() { + return None; + } + let total_samples = track.total_samples(); + let hop = self.hilbert_hop_size; + let fft_size = self.hilbert_fft_size; + if hop == 0 || fft_size == 0 || target + hop >= total_samples { + return None; + } + + if !self.hilbert_needs_reset { + let delta = target.abs_diff(self.last_hilbert_sample); + if delta > 2 * hop { + self.hilbert_needs_reset = true; + } + } + + if self.hilbert_needs_reset { + self.hilbert.reinit(fft_size); + self.hilbert_needs_reset = false; + // pre-fills the sliding history with the prior fft_size of audio. + let warmup_blocks = fft_size / hop; + let warmup_start = target.saturating_sub(warmup_blocks * hop); + for w in 0..warmup_blocks { + let block_start = warmup_start + w * hop; + if block_start + hop > total_samples { + break; + } + let (left, right) = stereo_block(&track.pcm, block_start, hop); + let _ = self.hilbert.process(&left, &right); + } + } + + self.last_hilbert_sample = target; + + let (left, right) = stereo_block(&track.pcm, target, hop); + let (cl, cr) = self.hilbert.process(&left, &right); + self.push_to_processors(&cl, &cr); + + let produced_new = true; + if produced_new { + self.compute_and_publish(); + Some(&self.last_frames) + } else { + None + } + } + + /// distributes one analytic-signal hop into all three band processors per channel. + fn push_to_processors(&mut self, complex_l: &[Complex64], complex_r: &[Complex64]) { + self.main[0].push_data(complex_l); + self.main[1].push_data(complex_r); + self.transient[0].push_data(complex_l); + self.transient[1].push_data(complex_r); + self.deep[0].push_data(complex_l); + self.deep[1].push_data(complex_r); + } + + /// borrows the most recently computed stereo frame pair without recomputing. + pub fn latest(&self) -> &[FrameData] { + &self.last_frames + } + + /// runs the six band/channel spectra in parallel and per-bin max-merges them through a soft-knee compressor. + fn compute_and_publish(&mut self) { + let comp_threshold = -15.0_f32; + let comp_ratio = 4.0_f32; + + let (m0, m1) = self.main.split_at_mut(1); + let (t0, t1) = self.transient.split_at_mut(1); + let (d0, d1) = self.deep.split_at_mut(1); + let main_l = &mut m0[0]; + let main_r = &mut m1[0]; + let trans_l = &mut t0[0]; + let trans_r = &mut t1[0]; + let deep_l = &mut d0[0]; + let deep_r = &mut d1[0]; + + let mut sml = None; + let mut smr = None; + let mut stl = None; + let mut str_ = None; + let mut sdl = None; + let mut sdr = None; + rayon::scope(|s| { + s.spawn(|_| sml = Some(main_l.get_spectrum())); + s.spawn(|_| smr = Some(main_r.get_spectrum())); + s.spawn(|_| stl = Some(trans_l.get_spectrum())); + s.spawn(|_| str_ = Some(trans_r.get_spectrum())); + s.spawn(|_| sdl = Some(deep_l.get_spectrum())); + s.spawn(|_| sdr = Some(deep_r.get_spectrum())); + }); + let pairs = [ + (sml.unwrap(), stl.unwrap(), sdl.unwrap()), + (smr.unwrap(), str_.unwrap(), sdr.unwrap()), + ]; + + let mut results = Vec::with_capacity(2); + for (i, (mut spec_main, spec_trans, spec_deep)) in pairs.into_iter().enumerate() { + let primary_db = spec_main.db.clone(); + + let same_size = spec_main.db.len() == spec_trans.db.len() + && spec_main.db.len() == spec_deep.db.len(); + if same_size { + for b in 0..spec_main.db.len() { + let mut val = spec_main.db[b].max(spec_trans.db[b]).max(spec_deep.db[b]); + if val > comp_threshold { + val = comp_threshold + (val - comp_threshold) / comp_ratio; + } + spec_main.db[b] = val; + } + } + + let cepstrum = if i == 0 { + std::mem::take(&mut spec_main.cepstrum) + } else { + Vec::new() + }; + + results.push(FrameData { + freqs: spec_main.freqs, + db: spec_main.db, + primary_db, + cepstrum, + }); + } + + self.last_frames = results; + } +} + +/// deinterleaves a stereo pcm slice starting at the given frame into separate left and right f64 buffers. +fn stereo_block(pcm: &[f32], start: usize, hop: usize) -> (Vec, Vec) { + let mut left = Vec::with_capacity(hop); + let mut right = Vec::with_capacity(hop); + for i in 0..hop { + let base = (start + i) * 2; + left.push(pcm[base] as f64); + right.push(pcm[base + 1] as f64); + } + (left, right) +} diff --git a/src/analyzer_worker.rs b/src/analyzer_worker.rs new file mode 100644 index 0000000..f9a007f --- /dev/null +++ b/src/analyzer_worker.rs @@ -0,0 +1,182 @@ + + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use arc_swap::ArcSwap; +use crossbeam_channel::{bounded, Receiver, RecvTimeoutError, Sender}; + +use crate::analyzer::{Analyzer, FrameData}; +use crate::track::TrackData; + +/// command messages routed from the ui thread into the analyzer thread. +enum Cmd { + SetTrack(Arc), + SetDspParams { fft: usize, hop: usize }, + SetNumBins(usize), + SetSmoothing { granularity: i32, detail: i32, strength: f32 }, + SetGpuBlend(f32), + Shutdown, +} + +/// owning handle to the background analyzer thread with lock-free access to the latest frame snapshot. +pub struct AnalyzerWorker { + cmd_tx: Sender, + frames: Arc>>, + playhead_frame: Arc, + total_frames: Arc, + join: Option>, +} + +impl AnalyzerWorker { + /// launches the background analyzer thread and returns an owning handle. + pub fn spawn(device: wgpu::Device, queue: wgpu::Queue) -> Self { + let (cmd_tx, cmd_rx) = bounded::(64); + let frames: Arc>> = Arc::new(ArcSwap::from_pointee(Vec::new())); + let playhead_frame = Arc::new(AtomicU64::new(0)); + let total_frames = Arc::new(AtomicU64::new(0)); + + let f_thread = frames.clone(); + let p_thread = playhead_frame.clone(); + let t_thread = total_frames.clone(); + + let join = thread::Builder::new() + .name("yr_crystals.analyzer".into()) + .spawn(move || run(cmd_rx, f_thread, p_thread, t_thread, device, queue)) + .expect("spawn analyzer worker"); + + Self { + cmd_tx, + frames, + playhead_frame, + total_frames, + join: Some(join), + } + } + + /// publishes the total-frame count immediately and queues the track swap. + pub fn set_track(&self, td: Arc) { + self.total_frames + .store(td.total_samples() as u64, Ordering::Release); + let _ = self.cmd_tx.send(Cmd::SetTrack(td)); + } + + /// queues a transform-length and hop change. + pub fn set_dsp_params(&self, fft: usize, hop: usize) { + let _ = self.cmd_tx.send(Cmd::SetDspParams { fft, hop }); + } + + /// queues a bin-count change. + pub fn set_num_bins(&self, n: usize) { + let _ = self.cmd_tx.send(Cmd::SetNumBins(n)); + } + + /// queues a cepstral-smoothing parameter change. + pub fn set_smoothing(&self, granularity: i32, detail: i32, strength: f32) { + let _ = self.cmd_tx.send(Cmd::SetSmoothing { + granularity, + detail, + strength, + }); + } + + /// queues a cpu/gpu fft crossfade change. + pub fn set_gpu_blend(&self, blend: f32) { + let _ = self.cmd_tx.send(Cmd::SetGpuBlend(blend)); + } + + /// stores the audio thread's normalised playhead atomically. + pub fn publish_playhead(&self, normalised: f32) { + let total = self.total_frames.load(Ordering::Acquire); + if total == 0 { + return; + } + let frame = ((normalised.clamp(0.0, 1.0) as f64) * total as f64) as u64; + self.playhead_frame.store(frame, Ordering::Release); + } + + /// snapshots the most recently published frame vector without locking. + pub fn latest_frames(&self) -> Arc> { + self.frames.load_full() + } +} + +impl Drop for AnalyzerWorker { + fn drop(&mut self) { + let _ = self.cmd_tx.send(Cmd::Shutdown); + if let Some(j) = self.join.take() { + let _ = j.join(); + } + } +} + +/// worker-thread main loop running a 60 hz analyzer step interleaved with command draining and graceful shutdown. +fn run( + cmd_rx: Receiver, + frames: Arc>>, + playhead_frame: Arc, + total_frames: Arc, + device: wgpu::Device, + queue: wgpu::Queue, +) { + let mut analyzer = Analyzer::new(device, queue); + + let interval = Duration::from_micros(16_667); + let mut next_tick = Instant::now() + interval; + + loop { + let now = Instant::now(); + let timeout = next_tick.saturating_duration_since(now); + + match cmd_rx.recv_timeout(timeout) { + Ok(Cmd::Shutdown) => break, + Ok(cmd) => { + apply(&mut analyzer, cmd); + continue; + } + Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => {} + } + + while let Ok(cmd) = cmd_rx.try_recv() { + if matches!(cmd, Cmd::Shutdown) { + return; + } + apply(&mut analyzer, cmd); + } + + let total = total_frames.load(Ordering::Acquire); + if total > 0 { + let frame = playhead_frame.load(Ordering::Acquire); + let pos = (frame as f64) / (total as f64); + if let Some(latest) = analyzer.step(pos) { + frames.store(Arc::new(latest.to_vec())); + } + } + + next_tick += interval; + let now = Instant::now(); + // re-anchors the schedule after a stall. + if next_tick < now { + next_tick = now + interval; + } + } +} + +/// dispatches one queued command into the analyzer's setter surface. +fn apply(analyzer: &mut Analyzer, cmd: Cmd) { + match cmd { + Cmd::SetTrack(td) => analyzer.set_track_data(td), + Cmd::SetDspParams { fft, hop } => analyzer.set_dsp_params(fft, hop), + Cmd::SetNumBins(n) => analyzer.set_num_bins(n), + Cmd::SetSmoothing { + granularity, + detail, + strength, + } => analyzer.set_smoothing_params(granularity, detail, strength), + Cmd::SetGpuBlend(b) => analyzer.set_gpu_blend(b), + Cmd::Shutdown => {} + } +} diff --git a/src/android.rs b/src/android.rs new file mode 100644 index 0000000..11a522f --- /dev/null +++ b/src/android.rs @@ -0,0 +1,344 @@ +use jni::objects::{JByteArray, JClass, JIntArray, JObject, JObjectArray, JString}; +use jni::sys::{jboolean, jbyte, jfloat, jint, jlong, jobject}; +use jni::JNIEnv; +use ndk::native_window::NativeWindow; +use raw_window_handle::{ + AndroidDisplayHandle, AndroidNdkWindowHandle, RawDisplayHandle, RawWindowHandle, +}; +use std::ffi::c_void; +use std::ptr::NonNull; +use std::sync::Once; + +use crate::viewport::ViewportHandle; + +macro_rules! dbg_log { + ($($arg:tt)*) => { + #[cfg(debug_assertions)] + log::info!($($arg)*); + }; +} + +/// owns the inner viewport plus the ANativeWindow whose lifetime backs the wgpu surface. +struct AndroidViewport { + inner: ViewportHandle, + _window: NativeWindow, +} + +/// initializes android logger and the ndk_context singleton consumed by cpal's AAudio backend. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_initContext<'a>( + env: JNIEnv<'a>, + _class: JClass<'a>, + activity: JObject<'a>, +) { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + #[cfg(debug_assertions)] + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Debug) + .with_tag("yr_crystals"), + ); + let vm = match env.get_java_vm() { + Ok(vm) => vm, + Err(_) => return, + }; + let activity_ref = env.new_global_ref(&activity).ok(); + if let Some(activity_ref) = activity_ref { + unsafe { + ndk_context::initialize_android_context( + vm.get_java_vm_pointer() as *mut c_void, + activity_ref.as_raw() as *mut c_void, + ); + } + std::mem::forget(activity_ref); + } + dbg_log!("ndk_context initialized"); + }); +} + +/// builds the viewport from a Surface jobject, holding the ANativeWindow for the surface's life. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportCreate<'a>( + env: JNIEnv<'a>, + _class: JClass<'a>, + surface: JObject<'a>, + width: jfloat, + height: jfloat, + scale: jfloat, +) -> jlong { + let scale_f = (scale as f32).max(1.0); + let window = match unsafe { NativeWindow::from_surface(env.get_raw(), surface.as_raw()) } { + Some(w) => w, + None => { + dbg_log!("ANativeWindow_fromSurface returned null"); + return 0; + } + }; + let win_ptr = match NonNull::new(window.ptr().as_ptr() as *mut c_void) { + Some(p) => p, + None => return 0, + }; + let raw_window = RawWindowHandle::AndroidNdk(AndroidNdkWindowHandle::new(win_ptr)); + let raw_display = RawDisplayHandle::Android(AndroidDisplayHandle::new()); + let inner = match ViewportHandle::new_from_raw( + raw_window, + raw_display, + width as f32, + height as f32, + scale_f, + ) { + Some(v) => v, + None => { + dbg_log!("ViewportHandle::new_from_raw returned None"); + return 0; + } + }; + dbg_log!("viewportCreate ok: {}x{}@{}", width, height, scale); + let boxed = Box::new(AndroidViewport { + inner, + _window: window, + }); + Box::into_raw(boxed) as jlong +} + +/// drops the viewport and releases the ANativeWindow reference. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportDestroy<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) { + if handle == 0 { + return; + } + unsafe { drop(Box::from_raw(handle as *mut AndroidViewport)) }; +} + +/// renders one frame from the choreographer callback. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportRender<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.inner.render_frame(); +} + +/// reconfigures the wgpu surface to the given bounds and scale. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportResize<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + width: jfloat, + height: jfloat, + scale: jfloat, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.inner.resize_px(width as f32, height as f32, (scale as f32).max(1.0)); +} + +/// forwards a single touch lifecycle event into the viewport gesture pipeline. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTouchEvent<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + x: jfloat, + y: jfloat, + pressed: jboolean, + moved: jboolean, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.inner.push_touch(x as f32, y as f32, pressed != 0, moved != 0); +} + +/// forwards an Android KeyEvent into the viewport with optional unicode text. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportKeyEvent<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + key: jint, + modifiers: jint, + pressed: jboolean, + text: JString<'a>, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + let utf8 = if text.is_null() { + None + } else { + env.get_string(&text).ok().map(|s| String::from(s)) + }; + av.inner.push_key_event(key as u32, utf8, modifiers as u32, pressed != 0); +} + +/// resolves a SAF picked folder path into a library scan request. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportApplyPickedFolder<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + path: JString<'a>, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + let Ok(path_str) = env.get_string(&path) else { return }; + av.inner.apply_picked_folder(String::from(path_str).into()); +} + +/// loads a single picked audio file path as a one-track library. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportApplyPickedFile<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + path: JString<'a>, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + let Ok(path_str) = env.get_string(&path) else { return }; + av.inner.apply_picked_file(String::from(path_str).into()); +} + +/// passes a batch of picked file paths to the library in a single call. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportApplyPickedFiles<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + paths: JObjectArray<'a>, +) { + let Ok(count) = env.get_array_length(&paths) else { return }; + dbg_log!("viewportApplyPickedFiles: enter count={count} handle={handle:#x}"); + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { + dbg_log!("viewportApplyPickedFiles: null handle"); + return; + }; + let mut out = Vec::with_capacity(count as usize); + for i in 0..count { + let Ok(elem) = env.get_object_array_element(&paths, i) else { continue }; + let jstr: JString = elem.into(); + let Ok(s) = env.get_string(&jstr) else { continue }; + let s = String::from(s); + dbg_log!("viewportApplyPickedFiles: path[{i}] = {s}"); + out.push(std::path::PathBuf::from(s)); + } + dbg_log!("viewportApplyPickedFiles: forwarding {} paths to App", out.len()); + av.inner.apply_picked_files(out); + dbg_log!("viewportApplyPickedFiles: returned from App"); +} + +/// drives the import progress strip, clearing on a total of zero. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetLibraryProgress<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + current: jint, + total: jint, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + av.inner.set_library_progress(current.max(0) as u32, total.max(0) as u32); +} + +/// seeds the sidebar with placeholder rows of titles plus track-number tags ahead of export completion. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetPendingTitles<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + titles: JObjectArray<'a>, + track_numbers: JIntArray<'a>, +) { + let Ok(count) = env.get_array_length(&titles) else { return }; + dbg_log!("viewportSetPendingTitles: count={count}"); + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { + dbg_log!("viewportSetPendingTitles: null handle"); + return; + }; + let mut tns = vec![0i32; count as usize]; + let _ = env.get_int_array_region(&track_numbers, 0, &mut tns); + let mut out = Vec::with_capacity(count as usize); + for i in 0..count { + let title = env + .get_object_array_element(&titles, i) + .ok() + .map(|e| { + let js: JString = e.into(); + env.get_string(&js).ok().map(String::from).unwrap_or_default() + }) + .unwrap_or_default(); + let title = if title.is_empty() { + format!("Track {}", i + 1) + } else { + title + }; + let tn_raw = tns[i as usize]; + let tn = if tn_raw <= 0 { None } else { Some(tn_raw as u32) }; + out.push((title, tn)); + } + av.inner.set_pending_titles(out); +} + +/// fills in the file path for a placeholder track at completion of the export. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetTrackPath<'a>( + mut env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + idx: jint, + path: JString<'a>, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + let Ok(s) = env.get_string(&path) else { return }; + let s = String::from(s); + dbg_log!("viewportSetTrackPath: idx={idx} path={s}"); + av.inner.set_track_path(idx.max(0) as usize, std::path::PathBuf::from(s)); +} + +/// hands raw JPEG/PNG artwork bytes to the art decode worker for a given track index. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetTrackArt<'a>( + env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, + idx: jint, + bytes: JByteArray<'a>, +) { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return }; + let Ok(len) = env.get_array_length(&bytes) else { return }; + dbg_log!("viewportSetTrackArt: idx={idx} len={len}"); + if len <= 0 { return; } + let mut buf = vec![0i8; len as usize]; + if env.get_byte_array_region(&bytes, 0, &mut buf).is_err() { return; } + let owned: Vec = buf.into_iter().map(|b: jbyte| b as u8).collect(); + av.inner.set_track_art_bytes(idx.max(0) as usize, owned); +} + +/// drains the pending picker request flag: 0 idle, 1 folder, 2 file. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingPick<'a>( + _env: JNIEnv<'a>, + _class: JClass<'a>, + handle: jlong, +) -> jint { + let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return 0 }; + av.inner.take_pending_pick() as jint +} + +/// nullability check on a JObject through its raw pointer. +trait JObjectExt { + fn is_null(&self) -> bool; +} +impl<'a> JObjectExt for JObject<'a> { + fn is_null(&self) -> bool { + self.as_raw() as *const jobject == std::ptr::null() + } +} +impl<'a> JObjectExt for JString<'a> { + fn is_null(&self) -> bool { + let o: &JObject = self.as_ref(); + o.is_null() + } +} diff --git a/src/decoder.rs b/src/decoder.rs new file mode 100644 index 0000000..83e8c91 --- /dev/null +++ b/src/decoder.rs @@ -0,0 +1,263 @@ +#![allow(clippy::needless_range_loop)] + +use std::fs::File; +use std::io::ErrorKind; +use std::path::Path; +use std::sync::Arc; + +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::errors::Error as SymError; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +use crate::track::TrackData; + +/// failure modes for loading a track off disk into stereo PCM. +#[derive(Debug)] +pub enum DecodeError { + Io(std::io::Error), + NoTrack, + Symphonia(SymError), +} + +impl std::fmt::Display for DecodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DecodeError::Io(e) => write!(f, "io: {e}"), + DecodeError::NoTrack => write!(f, "no decodable audio track"), + DecodeError::Symphonia(e) => write!(f, "decode: {e}"), + } + } +} +impl std::error::Error for DecodeError {} +impl From for DecodeError { + fn from(e: std::io::Error) -> Self { + DecodeError::Io(e) + } +} +impl From for DecodeError { + fn from(e: SymError) -> Self { + DecodeError::Symphonia(e) + } +} + +/// decodes any symphonia-supported audio file at path into interleaved stereo f32 PCM. +pub fn decode_file(path: &Path) -> Result { + #[cfg(debug_assertions)] + let _t0 = std::time::Instant::now(); + + let file = File::open(path)?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let probed = symphonia::default::get_probe().format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + )?; + let mut format = probed.format; + + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or(DecodeError::NoTrack)?; + let track_id = track.id; + let probed_rate = track.codec_params.sample_rate; + let probed_channels = track.codec_params.channels.map(|c| c.count()); + + let mut decoder = + symphonia::default::get_codecs().make(&track.codec_params, &DecoderOptions::default())?; + + let mut pcm: Vec = Vec::new(); + let mut sample_buf: Option> = None; + + let mut actual_sample_rate = probed_rate.unwrap_or(48_000); + let mut actual_channels = probed_channels.unwrap_or(2).max(1); + + loop { + let packet = match format.next_packet() { + Ok(p) => p, + Err(SymError::IoError(e)) if e.kind() == ErrorKind::UnexpectedEof => break, + Err(SymError::ResetRequired) => break, + Err(e) => return Err(e.into()), + }; + if packet.track_id() != track_id { + continue; + } + + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + + Err(SymError::DecodeError(_)) => continue, + Err(e) => return Err(e.into()), + }; + + let spec = *decoded.spec(); + if sample_buf.is_none() { + sample_buf = Some(SampleBuffer::::new(decoded.capacity() as u64, spec)); + actual_sample_rate = spec.rate; + actual_channels = spec.channels.count().max(1); + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] decode spec for {:?}: codec_params rate={:?} ch={:?}, decoded rate={} ch={}", + path.file_name().unwrap_or_default(), + probed_rate, + probed_channels, + spec.rate, + spec.channels.count(), + ); + } + let buf = sample_buf.as_mut().unwrap(); + buf.copy_interleaved_ref(decoded); + append_stereo(&mut pcm, buf.samples(), actual_channels); + } + + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] decode_file({:?}) took {:?} — {} stereo frames @ {} Hz", + path.file_name().unwrap_or_default(), + _t0.elapsed(), + pcm.len() / 2, + actual_sample_rate, + ); + + Ok(TrackData { + pcm: Arc::from(pcm), + sample_rate: actual_sample_rate, + frame_size: 4096, + }) +} + +/// decodes a file and rate-converts to target_sr, skipping the resample if already matching. +pub fn decode_file_resampled(path: &Path, target_sr: u32) -> Result { + let td = decode_file(path)?; + if td.sample_rate == target_sr || td.total_samples() == 0 { + return Ok(td); + } + #[cfg(debug_assertions)] + let _t0 = std::time::Instant::now(); + let out = resample_track(&td, target_sr); + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] resample {} → {} Hz took {:?} ({} → {} stereo frames)", + td.sample_rate, + target_sr, + _t0.elapsed(), + td.total_samples(), + out.total_samples(), + ); + Ok(out) +} + +/// chunked FFT resample of stereo PCM via rubato, falling back to a clone on planner failure. +fn resample_track(td: &TrackData, target_sr: u32) -> TrackData { + use rubato::{FftFixedInOut, Resampler}; + + let in_sr = td.sample_rate as usize; + let out_sr = target_sr as usize; + let frames_in_total = td.total_samples(); + + let mut left = vec![0.0_f32; frames_in_total]; + let mut right = vec![0.0_f32; frames_in_total]; + for f in 0..frames_in_total { + left[f] = td.pcm[f * 2]; + right[f] = td.pcm[f * 2 + 1]; + } + + let chunk = 8192; + let mut resampler = match FftFixedInOut::::new(in_sr, out_sr, chunk, 2) { + Ok(r) => r, + Err(_) => return td.clone(), + }; + + let chunk_in = resampler.input_frames_max(); + let chunk_out = resampler.output_frames_max(); + + let frames_out_est = + ((frames_in_total as u64 * out_sr as u64) / in_sr.max(1) as u64) as usize + chunk_out; + let mut out_left: Vec = Vec::with_capacity(frames_out_est); + let mut out_right: Vec = Vec::with_capacity(frames_out_est); + + let mut in_l = vec![0.0_f32; chunk_in]; + let mut in_r = vec![0.0_f32; chunk_in]; + let mut out_l = vec![0.0_f32; chunk_out]; + let mut out_r = vec![0.0_f32; chunk_out]; + + let mut pos = 0; + while pos < frames_in_total { + let needed = resampler.input_frames_next(); + let end = (pos + needed).min(frames_in_total); + let take = end - pos; + in_l[..take].copy_from_slice(&left[pos..end]); + in_r[..take].copy_from_slice(&right[pos..end]); + if take < needed { + for v in in_l[take..needed].iter_mut() { + *v = 0.0; + } + for v in in_r[take..needed].iter_mut() { + *v = 0.0; + } + } + let inputs: [&[f32]; 2] = [&in_l[..needed], &in_r[..needed]]; + let mut outputs: [&mut [f32]; 2] = [&mut out_l[..], &mut out_r[..]]; + let (in_used, out_written) = + match resampler.process_into_buffer(&inputs, &mut outputs, None) { + Ok(o) => o, + Err(_) => break, + }; + out_left.extend_from_slice(&out_l[..out_written]); + out_right.extend_from_slice(&out_r[..out_written]); + pos += in_used; + } + + let frames_out = out_left.len(); + let mut pcm = Vec::with_capacity(frames_out * 2); + for f in 0..frames_out { + pcm.push(out_left[f]); + pcm.push(out_right[f]); + } + + TrackData { + pcm: Arc::from(pcm), + sample_rate: target_sr, + frame_size: td.frame_size, + } +} + +/// appends an interleaved source as stereo, duplicating mono and dropping channels beyond L+R. +fn append_stereo(out: &mut Vec, src: &[f32], channels: usize) { + if channels == 0 || src.is_empty() { + return; + } + let frames = src.len() / channels; + out.reserve(frames * 2); + match channels { + 1 => { + for i in 0..frames { + let s = src[i]; + out.push(s); + out.push(s); + } + } + 2 => { + out.extend_from_slice(&src[..frames * 2]); + } + _ => { + for i in 0..frames { + let base = i * channels; + out.push(src[base]); + out.push(src[base + 1]); + } + } + } +} + diff --git a/src/engine.rs b/src/engine.rs new file mode 100644 index 0000000..90acf73 --- /dev/null +++ b/src/engine.rs @@ -0,0 +1,525 @@ + + +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::time::Instant; + +#[cfg(not(target_os = "android"))] +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +#[cfg(not(target_os = "android"))] +use cpal::{BufferSize, SampleFormat, SampleRate, Stream, StreamConfig}; +#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] +use cpal::SupportedBufferSize; +use crossbeam_channel::{unbounded, Sender}; + +use crate::track::TrackData; + +/// lazy-initialises the engine clock's reference instant. +fn engine_clock() -> Instant { + static START: OnceLock = OnceLock::new(); + *START.get_or_init(Instant::now) +} +/// reads nanoseconds elapsed since the engine clock started. +fn now_nanos() -> u64 { + engine_clock().elapsed().as_nanos() as u64 +} + +/// shared atomic snapshot the audio callback writes and the ui samples. +pub struct EngineState { + pub frame_pos: AtomicU64, + pub total_frames: AtomicU64, + pub playing: AtomicBool, + pub sample_rate: AtomicU32, + pub last_callback_pos: AtomicU64, + pub last_callback_nanos: AtomicU64, +} + +/// transport messages crossing from the ui thread into the cpal callback. +enum Cmd { + Load(Arc), + Play, + Pause, + Stop, + Seek(u64), +} + +/// owns the platform output stream plus the channel feeding transport commands into the audio callback. +pub struct AudioEngine { + pub state: Arc, + cmd_tx: Sender, + #[cfg(not(target_os = "android"))] + _stream: Stream, + #[cfg(target_os = "android")] + _stream: ndk::audio::AudioStream, +} + +impl AudioEngine { + /// opens the default output device, builds the f32 stream, and starts the audio thread paused. + #[cfg(not(target_os = "android"))] + pub fn new() -> Result { + #[cfg(debug_assertions)] + let _t0 = std::time::Instant::now(); + + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| "no default output device".to_string())?; + let config = device + .default_output_config() + .map_err(|e| format!("default output config: {e}"))?; + + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] cpal device + config in {:?} — rate={} Hz, ch={}, fmt={:?}", + _t0.elapsed(), + config.sample_rate().0, + config.channels(), + config.sample_format(), + ); + + let device_sr = config.sample_rate().0; + let device_ch = config.channels() as usize; + let sample_fmt = config.sample_format(); + + #[cfg(target_os = "ios")] + let buffer_size = BufferSize::Default; + + #[cfg(not(target_os = "ios"))] + let buffer_size = { + const TARGET_BUFFER: u32 = 64; + let device_buffer_range = match config.buffer_size() { + SupportedBufferSize::Range { min, max } => Some((*min, *max)), + SupportedBufferSize::Unknown => None, + } + .or_else(|| { + device.supported_output_configs().ok().and_then(|iter| { + let mut best: Option<(u32, u32)> = None; + for cfg in iter { + if let SupportedBufferSize::Range { min, max } = cfg.buffer_size() { + best = Some(match best { + Some((bmin, bmax)) => (bmin.min(*min), bmax.max(*max)), + None => (*min, *max), + }); + } + } + best + }) + }); + let chosen = device_buffer_range + .map(|(min, max)| TARGET_BUFFER.clamp(min, max)) + .unwrap_or(TARGET_BUFFER); + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] cpal buffer: target {} frames, device range {:?}, applied {} frames", + TARGET_BUFFER, device_buffer_range, chosen, + ); + BufferSize::Fixed(chosen) + }; + + let stream_cfg = StreamConfig { + channels: device_ch as u16, + sample_rate: SampleRate(device_sr), + buffer_size, + }; + + let _ = engine_clock(); + let state = Arc::new(EngineState { + frame_pos: AtomicU64::new(0), + total_frames: AtomicU64::new(0), + playing: AtomicBool::new(false), + sample_rate: AtomicU32::new(device_sr), + last_callback_pos: AtomicU64::new(0), + last_callback_nanos: AtomicU64::new(0), + }); + let (cmd_tx, cmd_rx) = unbounded::(); + + let cb_state = state.clone(); + let mut current: Option> = None; + let mut local_pos: u64 = 0; + let mut local_playing = false; + + let err_fn = |e| eprintln!("yr_crystals: cpal stream error: {e}"); + + let stream = match sample_fmt { + SampleFormat::F32 => device.build_output_stream( + &stream_cfg, + move |out: &mut [f32], _info: &cpal::OutputCallbackInfo| { + while let Ok(cmd) = cmd_rx.try_recv() { + match cmd { + Cmd::Load(td) => { + cb_state + .total_frames + .store(td.total_samples() as u64, Ordering::Release); + current = Some(td); + local_pos = 0; + cb_state.frame_pos.store(0, Ordering::Release); + cb_state.last_callback_pos.store(0, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + Cmd::Play => { + local_playing = true; + cb_state.playing.store(true, Ordering::Release); + + cb_state + .last_callback_pos + .store(local_pos, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + Cmd::Pause => { + local_playing = false; + cb_state.playing.store(false, Ordering::Release); + } + Cmd::Stop => { + local_playing = false; + local_pos = 0; + cb_state.playing.store(false, Ordering::Release); + cb_state.frame_pos.store(0, Ordering::Release); + cb_state.last_callback_pos.store(0, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + Cmd::Seek(p) => { + local_pos = p; + cb_state.frame_pos.store(p, Ordering::Release); + cb_state.last_callback_pos.store(p, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + } + } + + let frames_out = out.len() / device_ch.max(1); + if !local_playing || current.is_none() || device_ch == 0 { + out.fill(0.0); + return; + } + let track = current.as_ref().unwrap(); + let total = track.total_samples() as u64; + let pcm = &track.pcm; + + cb_state + .last_callback_pos + .store(local_pos, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + + for f in 0..frames_out { + let dst = f * device_ch; + if local_pos >= total { + for c in 0..device_ch { + out[dst + c] = 0.0; + } + local_playing = false; + cb_state.playing.store(false, Ordering::Release); + continue; + } + let src = (local_pos as usize) * 2; + let l = pcm[src]; + let r = pcm[src + 1]; + match device_ch { + 1 => out[dst] = 0.5 * (l + r), + 2 => { + out[dst] = l; + out[dst + 1] = r; + } + _ => { + out[dst] = l; + out[dst + 1] = r; + for c in 2..device_ch { + out[dst + c] = 0.0; + } + } + } + local_pos += 1; + } + cb_state.frame_pos.store(local_pos, Ordering::Release); + }, + err_fn, + None, + ), + _ => return Err(format!("unsupported sample format: {sample_fmt:?}")), + } + .map_err(|e| format!("build output stream: {e}"))?; + + stream.play().map_err(|e| format!("stream play: {e}"))?; + + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] AudioEngine::new total {:?}", + _t0.elapsed(), + ); + + Ok(AudioEngine { + state, + cmd_tx, + _stream: stream, + }) + } + + /// builds an AAudio stream with low-latency mode and anchors the visual playhead at the speaker via frames_written - frames_read. + #[cfg(target_os = "android")] + pub fn new() -> Result { + use ndk::audio::{ + AudioCallbackResult, AudioFormat, AudioPerformanceMode, AudioSharingMode, AudioStream, + AudioStreamBuilder, + }; + use std::ffi::c_void; + + #[cfg(debug_assertions)] + let _t0 = std::time::Instant::now(); + + let _ = engine_clock(); + + let state = Arc::new(EngineState { + frame_pos: AtomicU64::new(0), + total_frames: AtomicU64::new(0), + playing: AtomicBool::new(false), + sample_rate: AtomicU32::new(48000), + last_callback_pos: AtomicU64::new(0), + last_callback_nanos: AtomicU64::new(0), + }); + let (cmd_tx, cmd_rx) = unbounded::(); + + let cb_state = state.clone(); + let mut current: Option> = None; + let mut local_pos: u64 = 0; + let mut local_playing = false; + + #[cfg(debug_assertions)] + let mut cb_count: u64 = 0; + + let callback: ndk::audio::AudioStreamDataCallback = Box::new( + move |stream: &AudioStream, audio_data: *mut c_void, num_frames: i32| { + while let Ok(cmd) = cmd_rx.try_recv() { + match cmd { + Cmd::Load(td) => { + cb_state + .total_frames + .store(td.total_samples() as u64, Ordering::Release); + current = Some(td); + local_pos = 0; + cb_state.frame_pos.store(0, Ordering::Release); + cb_state.last_callback_pos.store(0, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + Cmd::Play => { + local_playing = true; + cb_state.playing.store(true, Ordering::Release); + } + Cmd::Pause => { + local_playing = false; + cb_state.playing.store(false, Ordering::Release); + } + Cmd::Stop => { + local_playing = false; + local_pos = 0; + cb_state.playing.store(false, Ordering::Release); + cb_state.frame_pos.store(0, Ordering::Release); + cb_state.last_callback_pos.store(0, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + Cmd::Seek(p) => { + local_pos = p; + cb_state.frame_pos.store(p, Ordering::Release); + cb_state.last_callback_pos.store(p, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + } + } + } + + let device_ch = stream.channel_count().max(1) as usize; + let frames = num_frames.max(0) as usize; + let total_samples = frames * device_ch; + let out = unsafe { + std::slice::from_raw_parts_mut(audio_data as *mut f32, total_samples) + }; + + if !local_playing || current.is_none() { + out.fill(0.0); + return AudioCallbackResult::Continue; + } + + // queue depth = frames committed by the app minus frames consumed by the hardware. + let written = stream.frames_written().max(0) as u64; + let read = stream.frames_read().max(0) as u64; + let queue_depth = written.saturating_sub(read); + let speaker_frame = local_pos.saturating_sub(queue_depth); + + #[cfg(debug_assertions)] + { + if cb_count < 20 || cb_count % 100 == 0 { + eprintln!( + "yr_crystals[dbg] aaudio cb#{cb_count}: frames={frames} ch={device_ch} written={written} read={read} qd={queue_depth} local_pos={local_pos} speaker={speaker_frame}", + ); + } + cb_count = cb_count.wrapping_add(1); + } + + cb_state + .last_callback_pos + .store(speaker_frame, Ordering::Release); + cb_state + .last_callback_nanos + .store(now_nanos(), Ordering::Release); + + let track = current.as_ref().unwrap(); + let total = track.total_samples() as u64; + let pcm = &track.pcm; + + for f in 0..frames { + let dst = f * device_ch; + if local_pos >= total { + for c in 0..device_ch { + out[dst + c] = 0.0; + } + local_playing = false; + cb_state.playing.store(false, Ordering::Release); + continue; + } + let src = (local_pos as usize) * 2; + let l = pcm[src]; + let r = pcm[src + 1]; + match device_ch { + 1 => out[dst] = 0.5 * (l + r), + 2 => { + out[dst] = l; + out[dst + 1] = r; + } + _ => { + out[dst] = l; + out[dst + 1] = r; + for c in 2..device_ch { + out[dst + c] = 0.0; + } + } + } + local_pos += 1; + } + cb_state.frame_pos.store(local_pos, Ordering::Release); + + AudioCallbackResult::Continue + }, + ); + + let stream = AudioStreamBuilder::new() + .map_err(|e| format!("AAudio builder: {e:?}"))? + .format(AudioFormat::PCM_Float) + .channel_count(2) + .performance_mode(AudioPerformanceMode::LowLatency) + .sharing_mode(AudioSharingMode::Shared) + .data_callback(callback) + .open_stream() + .map_err(|e| format!("AAudio open: {e:?}"))?; + + let device_sr = stream.sample_rate() as u32; + state.sample_rate.store(device_sr, Ordering::Release); + + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] aaudio open: sr={} ch={} format={:?} perf={:?} share={:?} buffer_capacity_in_frames={:?}", + device_sr, + stream.channel_count(), + stream.format(), + stream.performance_mode(), + stream.sharing_mode(), + stream.frames_written(), + ); + + stream + .request_start() + .map_err(|e| format!("AAudio request_start: {e:?}"))?; + + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] AudioEngine::new total {:?}", + _t0.elapsed(), + ); + + Ok(AudioEngine { + state, + cmd_tx, + _stream: stream, + }) + } + + /// reports the device's negotiated sample rate. + pub fn output_sample_rate(&self) -> u32 { + self.state.sample_rate.load(Ordering::Acquire) + } + + /// hands incoming pcm to the audio callback and rewinds the playhead to zero. + pub fn load(&self, track: Arc) { + let _ = self.cmd_tx.send(Cmd::Load(track)); + } + + /// resumes output from the current frame position. + pub fn play(&self) { + let _ = self.cmd_tx.send(Cmd::Play); + } + + /// holds the playhead at the current position and silences output. + pub fn pause(&self) { + let _ = self.cmd_tx.send(Cmd::Pause); + } + + /// resets the playhead to zero and silences output. + #[allow(dead_code)] + pub fn stop(&self) { + let _ = self.cmd_tx.send(Cmd::Stop); + } + + /// seeks to a fraction of total length in 0..=1, clamped. + pub fn seek_normalised(&self, t: f32) { + let total = self.state.total_frames.load(Ordering::Acquire); + if total == 0 { + return; + } + let t = t.clamp(0.0, 1.0); + let frame = ((t as f64) * total as f64) as u64; + let _ = self.cmd_tx.send(Cmd::Seek(frame)); + } + + /// returns the playhead as a 0..=1 fraction, extrapolated forward from the last callback anchor. + pub fn position(&self) -> f32 { + let total = self.state.total_frames.load(Ordering::Acquire); + if total == 0 { + return 0.0; + } + let anchor = self.state.last_callback_pos.load(Ordering::Acquire); + let anchor_ns = self.state.last_callback_nanos.load(Ordering::Acquire); + + if anchor_ns == 0 { + let pos = self.state.frame_pos.load(Ordering::Acquire); + return (pos as f64 / total as f64) as f32; + } + let rate = self.state.sample_rate.load(Ordering::Acquire) as u64; + + let extra = if self.state.playing.load(Ordering::Acquire) { + let now = now_nanos(); + let dt_ns = now.saturating_sub(anchor_ns); + (dt_ns as u128 * rate as u128 / 1_000_000_000) as u64 + } else { + 0 + }; + let pos = (anchor + extra).min(total); + (pos as f64 / total as f64) as f32 + } + + /// reports the latched play/pause flag without checking the position extrapolation. + pub fn is_playing(&self) -> bool { + self.state.playing.load(Ordering::Acquire) + } +} diff --git a/src/gpu_dsp.rs b/src/gpu_dsp.rs new file mode 100644 index 0000000..bcc42c7 --- /dev/null +++ b/src/gpu_dsp.rs @@ -0,0 +1,322 @@ + + +use bytemuck::{Pod, Zeroable}; +use num_complex::Complex64; + +const FFT_WGSL: &str = include_str!("../shaders/fft.wgsl"); + +/// per-pass uniform block matching the WGSL shader binding layout. +#[repr(C)] +#[derive(Debug, Clone, Copy, Pod, Zeroable)] +struct Args { + n: u32, + log2_n: u32, + stride: u32, + inverse: u32, +} + +const ARGS_BYTES: u64 = std::mem::size_of::() as u64; + +/// fixed-size 1D radix-2 FFT running on a wgpu compute queue with a single staged readback path. +pub struct GpuFft1D { + device: wgpu::Device, + queue: wgpu::Queue, + n: u32, + log2_n: u32, + + pending: Option, + + staging_mapped: bool, + + bit_reverse: wgpu::ComputePipeline, + butterfly: wgpu::ComputePipeline, + + args_buf: wgpu::Buffer, + + all_args_buf: wgpu::Buffer, + + cached_inverse: std::cell::Cell>, + + data_buf: wgpu::Buffer, + scratch_buf: wgpu::Buffer, + staging: wgpu::Buffer, + + bind_group: wgpu::BindGroup, +} + +impl GpuFft1D { + /// allocates pipelines, buffers, and bind groups for an N-point FFT with N a power of two. + pub fn new(device: wgpu::Device, queue: wgpu::Queue, n: u32) -> Self { + assert!(n.is_power_of_two() && n >= 2, "fft size must be a power of two ≥ 2"); + let log2_n = n.trailing_zeros(); + let num_passes = (log2_n as u64) + 1; + + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("yr_crystals.fft.shader"), + source: wgpu::ShaderSource::Wgsl(FFT_WGSL.into()), + }); + + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("yr_crystals.fft.bind_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("yr_crystals.fft.pipeline_layout"), + bind_group_layouts: &[&layout], + push_constant_ranges: &[], + }); + + let make = |entry: &str, label: &str| { + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(label), + layout: Some(&pipeline_layout), + module: &module, + entry_point: Some(entry), + compilation_options: Default::default(), + cache: None, + }) + }; + let bit_reverse = make("bit_reverse", "yr_crystals.fft.bit_reverse"); + let butterfly = make("butterfly", "yr_crystals.fft.butterfly"); + + let args_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.fft.args"), + size: ARGS_BYTES, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let all_args_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.fft.all_args"), + size: num_passes * ARGS_BYTES, + usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bytes = (n as u64) * 8; + let make_storage = |label: &str| { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size: bytes, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST + | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }) + }; + let data_buf = make_storage("yr_crystals.fft.data"); + let scratch_buf = make_storage("yr_crystals.fft.scratch"); + + let staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.fft.staging"), + size: bytes, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("yr_crystals.fft.bind_group"), + layout: &layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: args_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: data_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: scratch_buf.as_entire_binding(), + }, + ], + }); + + Self { + device, + queue, + n, + log2_n, + pending: None, + staging_mapped: false, + bit_reverse, + butterfly, + args_buf, + all_args_buf, + cached_inverse: std::cell::Cell::new(None), + data_buf, + scratch_buf, + staging, + bind_group, + } + } + + /// transform length in complex samples. + pub fn size(&self) -> usize { + self.n as usize + } + + /// queues a forward transform of the input buffer. + pub fn submit_forward(&mut self, input: &[Complex64]) { + self.submit(input, false); + } + + /// queues an inverse transform of the input buffer. + #[allow(dead_code)] + pub fn submit_inverse(&mut self, input: &[Complex64]) { + self.submit(input, true); + } + + /// drains the most recently submitted transform into the output slice, returning false if nothing pending. + pub fn try_collect_into(&mut self, out: &mut [Complex64]) -> bool { + debug_assert_eq!(out.len(), self.n as usize); + let Some(_sub) = self.pending.take() else { + return false; + }; + + let _ = self.device.poll(wgpu::PollType::wait_indefinitely()); + + let slice = self.staging.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = self.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().expect("map_async result").expect("map ok"); + self.staging_mapped = true; + + { + let view = slice.get_mapped_range(); + let f32s: &[[f32; 2]] = bytemuck::cast_slice(&view); + for (i, c) in f32s.iter().enumerate() { + out[i] = Complex64::new(c[0] as f64, c[1] as f64); + } + } + self.staging.unmap(); + self.staging_mapped = false; + true + } + + /// rebuilds the per-pass args table for the requested direction, skipping if already cached. + fn ensure_all_args(&self, inverse: bool) { + if self.cached_inverse.get() == Some(inverse) { + return; + } + let mut all = Vec::with_capacity((self.log2_n + 1) as usize); + + all.push(Args { + n: self.n, + log2_n: self.log2_n, + stride: 1, + inverse: inverse as u32, + }); + + let mut s = 1u32; + while s < self.n { + all.push(Args { + n: self.n, + log2_n: self.log2_n, + stride: s, + inverse: inverse as u32, + }); + s *= 2; + } + self.queue + .write_buffer(&self.all_args_buf, 0, bytemuck::cast_slice(&all)); + self.cached_inverse.set(Some(inverse)); + } + + /// uploads input, dispatches bit-reversal and log2(N) butterfly passes, and queues the readback copy. + fn submit(&mut self, input: &[Complex64], inverse: bool) { + debug_assert_eq!(input.len(), self.n as usize); + self.ensure_all_args(inverse); + + if self.staging_mapped { + self.staging.unmap(); + self.staging_mapped = false; + } + self.pending = None; + + let mut input_f32: Vec<[f32; 2]> = Vec::with_capacity(self.n as usize); + for c in input.iter() { + input_f32.push([c.re as f32, c.im as f32]); + } + self.queue + .write_buffer(&self.data_buf, 0, bytemuck::cast_slice(&input_f32)); + + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("yr_crystals.fft.encoder"), + }); + + let groups_n = (self.n + 63) / 64; + let groups_half = (self.n / 2 + 63) / 64; + + encoder.copy_buffer_to_buffer(&self.all_args_buf, 0, &self.args_buf, 0, ARGS_BYTES); + { + let mut p = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("yr_crystals.fft.bit_reverse"), + timestamp_writes: None, + }); + p.set_pipeline(&self.bit_reverse); + p.set_bind_group(0, &self.bind_group, &[]); + p.dispatch_workgroups(groups_n, 1, 1); + } + + for pass_idx in 1..=self.log2_n { + let off = (pass_idx as u64) * ARGS_BYTES; + encoder.copy_buffer_to_buffer(&self.all_args_buf, off, &self.args_buf, 0, ARGS_BYTES); + let mut p = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("yr_crystals.fft.butterfly"), + timestamp_writes: None, + }); + p.set_pipeline(&self.butterfly); + p.set_bind_group(0, &self.bind_group, &[]); + p.dispatch_workgroups(groups_half, 1, 1); + } + + encoder.copy_buffer_to_buffer( + &self.scratch_buf, + 0, + &self.staging, + 0, + (self.n as u64) * 8, + ); + let sub = self.queue.submit(std::iter::once(encoder.finish())); + self.pending = Some(sub); + } +} diff --git a/src/hilbert_block.rs b/src/hilbert_block.rs new file mode 100644 index 0000000..b959cc1 --- /dev/null +++ b/src/hilbert_block.rs @@ -0,0 +1,73 @@ + + +use num_complex::Complex64; +use rustfft::{Fft, FftPlanner}; +use std::sync::Arc; + +/// one-shot analytic-signal builder for fixed-length blocks of audio. +pub struct BlockHilbert { + planner: FftPlanner, +} + +impl Default for BlockHilbert { + fn default() -> Self { + Self::new() + } +} + +impl BlockHilbert { + pub fn new() -> Self { + Self { + planner: FftPlanner::new(), + } + } + + /// builds the analytic signal of a real input by zeroing the negative-frequency half and doubling the positive half. + pub fn hilbert_single(&mut self, signal: &[f64]) -> Vec { + let n = signal.len(); + if n == 0 { + return Vec::new(); + } + + let fwd: Arc> = self.planner.plan_fft_forward(n); + let bwd: Arc> = self.planner.plan_fft_inverse(n); + + let mut buf: Vec = signal.iter().map(|&s| Complex64::new(s, 0.0)).collect(); + + fwd.process(&mut buf); + + let half = (n as f64) / 2.0; + for (i, x) in buf.iter_mut().enumerate() { + let m = if i > 0 && (i as f64) < half { + 2.0 + } else if (i as f64) > half { + 0.0 + } else { + 1.0 + }; + *x *= m; + } + + bwd.process(&mut buf); + + let inv_n = 1.0 / n as f64; + for x in &mut buf { + *x *= inv_n; + } + buf + } + + /// runs the single-channel transform on a matched left/right pair. + pub fn hilbert_stereo( + &mut self, + left: &[f64], + right: &[f64], + ) -> (Vec, Vec) { + if left.is_empty() || left.len() != right.len() { + return (Vec::new(), Vec::new()); + } + let l = self.hilbert_single(left); + let r = self.hilbert_single(right); + (l, r) + } +} diff --git a/src/hilbert_stream.rs b/src/hilbert_stream.rs new file mode 100644 index 0000000..938bd6f --- /dev/null +++ b/src/hilbert_stream.rs @@ -0,0 +1,125 @@ + + +use num_complex::Complex64; +use rustfft::{Fft, FftPlanner}; +use std::sync::Arc; + +/// streaming analytic-signal builder driven by fixed-hop blocks against a sliding fft-sized history. +pub struct RealtimeHilbert { + fft_size: usize, + hop_size: usize, + history_l: Vec, + history_r: Vec, + fft_buf_l: Vec, + fft_buf_r: Vec, + fwd: Option>>, + inv: Option>>, + planner: FftPlanner, +} + +impl Default for RealtimeHilbert { + fn default() -> Self { + Self::new() + } +} + +impl RealtimeHilbert { + pub fn new() -> Self { + Self { + fft_size: 0, + hop_size: 0, + history_l: Vec::new(), + history_r: Vec::new(), + fft_buf_l: Vec::new(), + fft_buf_r: Vec::new(), + fwd: None, + inv: None, + planner: FftPlanner::new(), + } + } + + /// resizes the sliding history and replans both fft directions for a power-of-two transform length. + pub fn reinit(&mut self, fft_size: usize) { + assert!(fft_size > 0 && fft_size.is_power_of_two(), "fft size must be a power of two"); + self.fft_size = fft_size; + self.hop_size = 0; + self.history_l = vec![0.0; fft_size]; + self.history_r = vec![0.0; fft_size]; + self.fft_buf_l = vec![Complex64::new(0.0, 0.0); fft_size]; + self.fft_buf_r = vec![Complex64::new(0.0, 0.0); fft_size]; + self.fwd = Some(self.planner.plan_fft_forward(fft_size)); + self.inv = Some(self.planner.plan_fft_inverse(fft_size)); + } + + /// shifts a stereo hop into the sliding window and returns the analytic signal across the trailing hop. + pub fn process( + &mut self, + left: &[f64], + right: &[f64], + ) -> (Vec, Vec) { + assert!(self.fft_size > 0, "RealtimeHilbert::process called before reinit"); + assert_eq!(left.len(), right.len(), "L/R block sizes must match"); + + if self.hop_size == 0 { + self.hop_size = left.len(); + assert!( + self.hop_size <= self.fft_size, + "hop must be <= fft size" + ); + } else { + assert_eq!(left.len(), self.hop_size, "hop size cannot change between process() calls"); + } + + let n = self.fft_size; + let hop = self.hop_size; + let keep = n - hop; + + self.history_l.copy_within(hop.., 0); + self.history_l[keep..].copy_from_slice(left); + self.history_r.copy_within(hop.., 0); + self.history_r[keep..].copy_from_slice(right); + + for (dst, &s) in self.fft_buf_l.iter_mut().zip(self.history_l.iter()) { + *dst = Complex64::new(s, 0.0); + } + for (dst, &s) in self.fft_buf_r.iter_mut().zip(self.history_r.iter()) { + *dst = Complex64::new(s, 0.0); + } + + let fwd = self.fwd.as_ref().expect("forward plan"); + fwd.process(&mut self.fft_buf_l); + fwd.process(&mut self.fft_buf_r); + + let half = n / 2; + for i in 0..n { + let m = if i == 0 || i == half { + 1.0 + } else if i < half { + 2.0 + } else { + 0.0 + }; + self.fft_buf_l[i] *= m; + self.fft_buf_r[i] *= m; + } + + let inv = self.inv.as_ref().expect("inverse plan"); + inv.process(&mut self.fft_buf_l); + inv.process(&mut self.fft_buf_r); + + let inv_n = 1.0 / n as f64; + let mut out_l = Vec::with_capacity(hop); + let mut out_r = Vec::with_capacity(hop); + for i in 0..hop { + let idx = keep + i; + out_l.push(self.fft_buf_l[idx] * inv_n); + out_r.push(self.fft_buf_r[idx] * inv_n); + } + (out_l, out_r) + } + + /// reports the configured transform length, or zero before reinit. + pub fn fft_size(&self) -> usize { + self.fft_size + } +} diff --git a/src/ios.rs b/src/ios.rs new file mode 100644 index 0000000..d7f73f7 --- /dev/null +++ b/src/ios.rs @@ -0,0 +1,294 @@ + + +use raw_window_handle::{ + RawDisplayHandle, RawWindowHandle, UiKitDisplayHandle, UiKitWindowHandle, +}; +use std::ffi::{c_char, c_void, CStr, CString}; +use std::ptr::NonNull; + +use crate::viewport::ViewportHandle; + +/// builds a viewport bound to a UIView pointer at the given logical size and scale. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_create( + uiview: *mut c_void, + width: f32, + height: f32, + scale: f32, +) -> *mut ViewportHandle { + let Some(view_nn) = NonNull::new(uiview) else { + return std::ptr::null_mut(); + }; + + let raw_window = + RawWindowHandle::UiKit(UiKitWindowHandle::new(view_nn)); + let raw_display = RawDisplayHandle::UiKit(UiKitDisplayHandle::new()); + + match ViewportHandle::new_from_raw(raw_window, raw_display, width, height, scale) { + Some(h) => Box::into_raw(Box::new(h)), + None => std::ptr::null_mut(), + } +} + +/// drops the viewport and releases the underlying wgpu surface. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_destroy(handle: *mut ViewportHandle) { + if handle.is_null() { + return; + } + unsafe { drop(Box::from_raw(handle)) }; +} + +/// renders one frame from the iOS display link. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_render(handle: *mut ViewportHandle) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + h.render_frame(); +} + +/// reconfigures the wgpu surface to the given bounds and scale. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_resize( + handle: *mut ViewportHandle, + width: f32, + height: f32, + scale: f32, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + h.resize_px(width, height, scale); +} + +/// forwards a single-finger UITouch lifecycle event into the viewport's gesture pipeline. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_touch_event( + handle: *mut ViewportHandle, + x: f32, + y: f32, + pressed: bool, + moved: bool, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + h.push_touch(x, y, pressed, moved); +} + +/// forwards a UIKey press/release into the viewport, paired with optional UTF-8 text. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_key_event( + handle: *mut ViewportHandle, + key: u32, + modifiers: u32, + pressed: bool, + text: *const c_char, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + let utf8 = if text.is_null() { + None + } else { + unsafe { CStr::from_ptr(text) }.to_str().ok().map(|s| s.to_string()) + }; + h.push_key_event(key, utf8, modifiers, pressed); +} + +/// resolves a UIDocumentPicker folder URL into a library scan request. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_apply_picked_folder( + handle: *mut ViewportHandle, + path: *const c_char, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + let Some(path_str) = unsafe { CStr::from_ptr(path) }.to_str().ok() else { + return; + }; + h.apply_picked_folder(path_str.into()); +} + +/// loads a single picked audio file as a one-track library. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_apply_picked_file( + handle: *mut ViewportHandle, + path: *const c_char, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + let Some(path_str) = unsafe { CStr::from_ptr(path) }.to_str().ok() else { + return; + }; + h.apply_picked_file(path_str.into()); +} + +/// loads a batch of picked file paths into the library in a single call. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_apply_picked_files( + handle: *mut ViewportHandle, + paths: *const *const c_char, + count: usize, +) { + #[cfg(debug_assertions)] + log_dbg(format!("viewport_apply_picked_files: enter count={count} paths_null={}", paths.is_null())); + let Some(h) = (unsafe { handle.as_mut() }) else { + #[cfg(debug_assertions)] + log_dbg("viewport_apply_picked_files: null handle"); + return; + }; + if paths.is_null() || count == 0 { + #[cfg(debug_assertions)] + log_dbg("viewport_apply_picked_files: empty input"); + return; + } + let mut out = Vec::with_capacity(count); + for i in 0..count { + let p = unsafe { *paths.add(i) }; + if p.is_null() { + #[cfg(debug_assertions)] + log_dbg(format!("viewport_apply_picked_files: path[{i}] is null")); + continue; + } + match unsafe { CStr::from_ptr(p) }.to_str() { + Ok(s) => { + #[cfg(debug_assertions)] + log_dbg(format!("viewport_apply_picked_files: path[{i}] = {s}")); + out.push(std::path::PathBuf::from(s)); + } + Err(_e) => { + #[cfg(debug_assertions)] + log_dbg(format!("viewport_apply_picked_files: path[{i}] utf8 err: {_e}")); + } + } + } + #[cfg(debug_assertions)] + log_dbg(format!("viewport_apply_picked_files: forwarding {} paths to App", out.len())); + h.apply_picked_files(out); + #[cfg(debug_assertions)] + log_dbg("viewport_apply_picked_files: returned from App"); +} + +/// drives the import progress strip, clearing on a total of zero. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_set_library_progress( + handle: *mut ViewportHandle, + current: u32, + total: u32, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { + return; + }; + h.set_library_progress(current, total); +} + +/// seeds the sidebar with placeholder rows of titles plus track-number tags ahead of export completion. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_set_pending_titles( + handle: *mut ViewportHandle, + titles: *const *const c_char, + track_numbers: *const u32, + count: usize, +) { + #[cfg(debug_assertions)] + log_dbg(format!("viewport_set_pending_titles: count={count}")); + let Some(h) = (unsafe { handle.as_mut() }) else { + #[cfg(debug_assertions)] + log_dbg("viewport_set_pending_titles: null handle"); + return; + }; + if titles.is_null() || count == 0 { + return; + } + let mut out = Vec::with_capacity(count); + for i in 0..count { + let p = unsafe { *titles.add(i) }; + let title = if p.is_null() { + format!("Track {}", i + 1) + } else { + unsafe { CStr::from_ptr(p) } + .to_str() + .unwrap_or("") + .to_string() + }; + let title = if title.is_empty() { + format!("Track {}", i + 1) + } else { + title + }; + let tn = if track_numbers.is_null() { + None + } else { + let raw = unsafe { *track_numbers.add(i) }; + if raw == 0 { None } else { Some(raw) } + }; + out.push((title, tn)); + } + h.set_pending_titles(out); +} + +/// fills in the file path for a placeholder track at completion of the export. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_set_track_path( + handle: *mut ViewportHandle, + idx: usize, + path: *const c_char, +) { + let Some(h) = (unsafe { handle.as_mut() }) else { return }; + if path.is_null() { + return; + } + let Ok(s) = (unsafe { CStr::from_ptr(path) }).to_str() else { return }; + #[cfg(debug_assertions)] + log_dbg(format!("viewport_set_track_path: idx={idx} path={s}")); + h.set_track_path(idx, std::path::PathBuf::from(s)); +} + +/// hands raw JPEG/PNG artwork bytes to the art decode worker for a given track index. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_set_track_art( + handle: *mut ViewportHandle, + idx: usize, + bytes: *const u8, + len: usize, +) { + #[cfg(debug_assertions)] + log_dbg(format!("viewport_set_track_art: idx={idx} len={len}")); + let Some(h) = (unsafe { handle.as_mut() }) else { return }; + if bytes.is_null() || len == 0 { + return; + } + let slice = unsafe { std::slice::from_raw_parts(bytes, len) }; + h.set_track_art_bytes(idx, slice.to_vec()); +} + +/// writes a prefixed line through stdout, flushing per call. +#[cfg(debug_assertions)] +fn log_dbg(msg: impl AsRef) { + use std::io::Write; + let mut stdout = std::io::stdout().lock(); + let _ = writeln!(stdout, "[Rust] {}", msg.as_ref()); + let _ = stdout.flush(); +} + +/// drains the pending picker request flag: 0 idle, 1 folder, 2 file. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_take_pending_pick(handle: *mut ViewportHandle) -> u8 { + let Some(h) = (unsafe { handle.as_mut() }) else { + return 0; + }; + h.take_pending_pick() +} + +/// releases a CString previously handed to Swift over the FFI boundary. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_free_string(s: *mut c_char) { + if s.is_null() { + return; + } + unsafe { drop(CString::from_raw(s)) }; +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ef73191 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,28 @@ +pub mod hilbert_block; +pub mod hilbert_stream; +pub mod weave; +pub mod processor; +pub mod trig_interpolation; +pub mod analyzer; +pub mod analyzer_worker; +pub mod gpu_dsp; +pub mod track; +pub mod decoder; +pub mod engine; + +pub mod library; +pub mod library_worker; +pub mod palette; +pub mod visualizer; +pub mod ui; + +pub mod viewport; + +#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] +pub mod shell; + +#[cfg(target_os = "ios")] +pub mod ios; + +#[cfg(target_os = "android")] +pub mod android; diff --git a/src/library.rs b/src/library.rs new file mode 100644 index 0000000..c63909f --- /dev/null +++ b/src/library.rs @@ -0,0 +1,258 @@ + + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use iced_widget::image::Handle as ImageHandle; + +use crate::palette; + +/// sidebar entry holding tag metadata, decoded thumbnail handle, and the album-art palette strip. +#[derive(Debug, Clone)] +pub struct Track { + pub path: PathBuf, + pub title: String, + pub artist: Option, + pub album: Option, + pub track_number: Option, + pub art: Option, + pub palette: Option>>, + + /// marks an iOS placeholder row pending audio export to disk. + pub exporting: bool, +} + +/// audio file extensions accepted by the folder scanner. +const AUDIO_EXTS: &[&str] = &[ + "mp3", "m4a", "m4b", "m4r", "mp4", "flac", "wav", "ogg", "oga", "opus", "aac", "aiff", + "aif", +]; + +/// reads a directory non-recursively and returns sorted track stubs with metadata only. +pub fn scan_folder(folder: &Path) -> Vec { + #[cfg(debug_assertions)] + let _t0 = std::time::Instant::now(); + + let entries = match std::fs::read_dir(folder) { + Ok(e) => e, + Err(_e) => { + #[cfg(all(target_os = "ios", debug_assertions))] + eprintln!("[Rust.dbg] scan_folder read_dir failed: {} err={_e}", folder.display()); + return Vec::new(); + } + }; + + #[cfg(all(target_os = "ios", debug_assertions))] + let raw_entries: Vec = entries.filter_map(|r| r.ok()).map(|e| e.path()).collect(); + #[cfg(all(target_os = "ios", debug_assertions))] + { + eprintln!("[Rust.dbg] scan_folder raw entries={} for {}", raw_entries.len(), folder.display()); + for (i, p) in raw_entries.iter().take(10).enumerate() { + let is_file = p.is_file(); + let is_dir = p.is_dir(); + let ext = p.extension().and_then(|e| e.to_str()).unwrap_or(""); + let kept = is_file && has_audio_ext(p); + eprintln!("[Rust.dbg] entry[{i}] file={is_file} dir={is_dir} ext={ext:?} kept={kept} path={}", p.display()); + } + } + + #[cfg(all(target_os = "ios", debug_assertions))] + let mut paths: Vec = raw_entries + .into_iter() + .filter(|p| p.is_file() && has_audio_ext(p)) + .collect(); + #[cfg(not(all(target_os = "ios", debug_assertions)))] + let mut paths: Vec = entries + .filter_map(|r| r.ok()) + .map(|e| e.path()) + .filter(|p| p.is_file() && has_audio_ext(p)) + .collect(); + paths.sort(); + + #[cfg(debug_assertions)] + let _t_meta = std::time::Instant::now(); + + let mut tracks: Vec = paths.iter().filter_map(|p| read_track_meta(p).ok()).collect(); + sort_tracks(&mut tracks); + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] scan_folder({:?}): {} files, dir+filter {:?}, metadata pass {:?}, total {:?}", + folder.file_name().unwrap_or_default(), + tracks.len(), + _t_meta.duration_since(_t0), + _t_meta.elapsed(), + _t0.elapsed(), + ); + tracks +} + +/// reads tags and decodes embedded album art into a thumbnail and palette in one pass. +pub fn read_track(path: &Path) -> Result { + use lofty::file::TaggedFileExt; + use lofty::tag::Accessor; + + #[cfg(debug_assertions)] + let _t0 = std::time::Instant::now(); + + let tagged = lofty::read_from_path(path).map_err(|e| e.to_string())?; + #[cfg(debug_assertions)] + let _t_lofty = _t0.elapsed(); + + let tag = tagged + .primary_tag() + .or_else(|| tagged.first_tag()); + + let (title, artist, album, track_number, art_bytes) = if let Some(tag) = tag { + let title = tag.title().map(|s| s.to_string()); + let artist = tag.artist().map(|s| s.to_string()); + let album = tag.album().map(|s| s.to_string()); + let track_number = tag.track(); + let art = tag.pictures().first().map(|p| p.data().to_vec()); + (title, artist, album, track_number, art) + } else { + (None, None, None, None, None) + }; + + let title = title.unwrap_or_else(|| { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Untitled") + .to_string() + }); + + #[cfg(debug_assertions)] + let _art_bytes_len = art_bytes.as_ref().map(|v| v.len()).unwrap_or(0); + #[cfg(debug_assertions)] + let _t_art = std::time::Instant::now(); + let (art, palette) = art_bytes + .as_ref() + .map(|bytes| build_art(bytes)) + .unwrap_or((None, None)); + #[cfg(debug_assertions)] + eprintln!( + "yr_crystals[dbg] read_track({:?}): lofty {:?}, art {:?} ({} art bytes), total {:?}", + path.file_name().unwrap_or_default(), + _t_lofty, + _t_art.elapsed(), + _art_bytes_len, + _t0.elapsed(), + ); + + Ok(Track { + path: path.to_path_buf(), + title, + artist, + album, + track_number, + art, + palette, + exporting: false, + }) +} + +/// reads tags only and skips art decoding. +pub fn read_track_meta(path: &Path) -> Result { + use lofty::file::TaggedFileExt; + use lofty::tag::Accessor; + + let tagged = lofty::read_from_path(path).map_err(|e| e.to_string())?; + let tag = tagged.primary_tag().or_else(|| tagged.first_tag()); + let (title, artist, album, track_number) = if let Some(tag) = tag { + ( + tag.title().map(|s| s.to_string()), + tag.artist().map(|s| s.to_string()), + tag.album().map(|s| s.to_string()), + tag.track(), + ) + } else { + (None, None, None, None) + }; + let title = title.unwrap_or_else(|| { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Untitled") + .to_string() + }); + Ok(Track { + path: path.to_path_buf(), + title, + artist, + album, + track_number, + art: None, + palette: None, + exporting: false, + }) +} + +/// decodes raw image bytes into a 96x96 sidebar thumbnail and a 32-color vibrancy palette strip. +#[allow(clippy::type_complexity)] +pub fn build_art(bytes: &[u8]) -> (Option, Option>>) { + let Ok(img) = image::load_from_memory(bytes) else { + return (None, None); + }; + let rgba = img.to_rgba8(); + let palette = Some(palette::extract(&rgba, 32)); + + let thumb = image::imageops::resize(&rgba, 96, 96, image::imageops::FilterType::Triangle); + let (w, h) = (thumb.width(), thumb.height()); + let handle = ImageHandle::from_rgba(w, h, thumb.into_raw()); + (Some(handle), palette) +} + +/// pulls title, artist, album, and track number from a file's tags as a flat tuple. +pub fn read_tags( + path: &Path, +) -> (Option, Option, Option, Option) { + use lofty::file::TaggedFileExt; + use lofty::tag::Accessor; + + let Ok(tagged) = lofty::read_from_path(path) else { + return (None, None, None, None); + }; + let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) else { + return (None, None, None, None); + }; + ( + tag.title().map(|s| s.to_string()), + tag.artist().map(|s| s.to_string()), + tag.album().map(|s| s.to_string()), + tag.track(), + ) +} + +/// extracts a file's first embedded picture and decodes a thumbnail and vibrancy palette. +#[allow(clippy::type_complexity)] +pub fn read_art(path: &Path) -> (Option, Option>>) { + use lofty::file::TaggedFileExt; + + let Ok(tagged) = lofty::read_from_path(path) else { + return (None, None); + }; + let tag = tagged.primary_tag().or_else(|| tagged.first_tag()); + let bytes = tag.and_then(|t| t.pictures().first().map(|p| p.data().to_vec())); + match bytes { + Some(b) => build_art(&b), + None => (None, None), + } +} + +/// orders tracks by tag track-number ascending with a case-insensitive title fallback. +pub fn sort_tracks(tracks: &mut [Track]) { + tracks.sort_by(|a, b| { + let key_a = (a.track_number.unwrap_or(u32::MAX), a.title.to_lowercase()); + let key_b = (b.track_number.unwrap_or(u32::MAX), b.title.to_lowercase()); + key_a.cmp(&key_b) + }); +} + +/// case-insensitive match against the supported-extension list. +fn has_audio_ext(p: &Path) -> bool { + p.extension() + .and_then(|s| s.to_str()) + .map(|s| { + let lower = s.to_ascii_lowercase(); + AUDIO_EXTS.iter().any(|e| *e == lower) + }) + .unwrap_or(false) +} diff --git a/src/library_worker.rs b/src/library_worker.rs new file mode 100644 index 0000000..b174cd9 --- /dev/null +++ b/src/library_worker.rs @@ -0,0 +1,180 @@ + + +use std::path::PathBuf; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; + +use crossbeam_channel::{unbounded, Receiver, Sender}; +use iced_widget::image::Handle as ImageHandle; + +use crate::decoder; +use crate::library; +use crate::track::TrackData; + +/// messages emitted by the background workers as metadata, art, or decoded pcm becomes available. +pub enum LibraryUpdate { + Meta { + path: PathBuf, + title: Option, + artist: Option, + album: Option, + track_number: Option, + }, + Art { + path: PathBuf, + art: Option, + palette: Option>>, + }, + + /// art keyed by sidebar index, paired with a vibrancy palette. + ArtForIdx { + idx: usize, + art: Option, + palette: Option>>, + }, + Decoded { + request_id: u64, + path: PathBuf, + result: Result, String>, + }, +} + +/// decode job carrying the request id and target sample rate. +struct DecodeReq { + request_id: u64, + path: PathBuf, + target_sr: u32, +} + +/// art decode request as either an on-disk file path or already-fetched image bytes. +enum ArtReq { + FromPath(PathBuf), + FromBytes { idx: usize, bytes: Vec }, +} + +/// owns the three background threads handling metadata reads, art decoding, and audio decoding. +pub struct LibraryWorker { + meta_tx: Sender, + art_tx: Sender, + decode_tx: Sender, + update_rx: Receiver, + _meta_join: Option>, + _art_join: Option>, + _decode_join: Option>, +} + +impl LibraryWorker { + /// starts the metadata, art, and decode threads on a shared update channel. + pub fn spawn() -> Self { + let (meta_tx, meta_rx) = unbounded::(); + let (art_tx, art_rx) = unbounded::(); + let (decode_tx, decode_rx) = unbounded::(); + let (update_tx, update_rx) = unbounded::(); + + let meta_update_tx = update_tx.clone(); + let meta_join = thread::Builder::new() + .name("yr_crystals.meta".into()) + .spawn(move || run_meta(meta_rx, meta_update_tx)) + .expect("spawn meta thread"); + + let art_update_tx = update_tx.clone(); + let art_join = thread::Builder::new() + .name("yr_crystals.art".into()) + .spawn(move || run_art(art_rx, art_update_tx)) + .expect("spawn art thread"); + + let decode_join = thread::Builder::new() + .name("yr_crystals.decode".into()) + .spawn(move || run_decode(decode_rx, update_tx)) + .expect("spawn decode thread"); + + Self { + meta_tx, + art_tx, + decode_tx, + update_rx, + _meta_join: Some(meta_join), + _art_join: Some(art_join), + _decode_join: Some(decode_join), + } + } + + /// queues a tag-only read of a file path. + pub fn request_meta(&self, path: PathBuf) { + let _ = self.meta_tx.send(path); + } + + /// queues an art read against a file path, matched back to the sidebar by path. + pub fn request_art(&self, path: PathBuf) { + let _ = self.art_tx.send(ArtReq::FromPath(path)); + } + + /// queues art decoding from in-memory bytes, keyed by sidebar index. + pub fn request_art_bytes(&self, idx: usize, bytes: Vec) { + let _ = self.art_tx.send(ArtReq::FromBytes { idx, bytes }); + } + + /// queues a full decode-and-resample to the engine's output rate, tagged with a request id. + pub fn request_decode(&self, request_id: u64, path: PathBuf, target_sr: u32) { + let _ = self.decode_tx.send(DecodeReq { + request_id, + path, + target_sr, + }); + } + + /// drains everything available without blocking, returning the batch in arrival order. + pub fn drain_updates(&self) -> Vec { + self.update_rx.try_iter().collect() + } +} + +/// metadata thread loop reading tags one path at a time and emitting Meta updates. +fn run_meta(rx: Receiver, tx: Sender) { + while let Ok(path) = rx.recv() { + let (title, artist, album, track_number) = library::read_tags(&path); + let _ = tx.send(LibraryUpdate::Meta { + path, + title, + artist, + album, + track_number, + }); + } +} + +/// art thread loop dispatching between path-keyed and index-keyed art decode requests. +fn run_art(rx: Receiver, tx: Sender) { + while let Ok(req) = rx.recv() { + match req { + ArtReq::FromPath(path) => { + let (art, palette) = library::read_art(&path); + let _ = tx.send(LibraryUpdate::Art { path, art, palette }); + } + ArtReq::FromBytes { idx, bytes } => { + let (art, palette) = library::build_art(&bytes); + let _ = tx.send(LibraryUpdate::ArtForIdx { idx, art, palette }); + } + } + } +} + +/// decode thread loop, collapsing a queue burst down to the freshest request. +fn run_decode(rx: Receiver, tx: Sender) { + while let Ok(req) = rx.recv() { + // drains pending requests down to the newest path. + let mut latest = req; + while let Ok(next) = rx.try_recv() { + latest = next; + } + + let result = decoder::decode_file_resampled(&latest.path, latest.target_sr) + .map(Arc::new) + .map_err(|e| e.to_string()); + let _ = tx.send(LibraryUpdate::Decoded { + request_id: latest.request_id, + path: latest.path, + result, + }); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..34c1e74 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,8 @@ +/// hands off to the winit-driven shell. +#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] +fn main() { + yr_crystals::shell::run(); +} + +#[cfg(any(target_os = "ios", target_os = "android"))] +fn main() {} diff --git a/src/palette.rs b/src/palette.rs new file mode 100644 index 0000000..0493826 --- /dev/null +++ b/src/palette.rs @@ -0,0 +1,54 @@ + + +use std::sync::Arc; + +/// reduces an image to N horizontal bands and picks the most vibrant pixel per band. +pub fn extract(img: &image::RgbaImage, n: usize) -> Arc> { + if n == 0 || img.width() == 0 || img.height() == 0 { + return Arc::new(Vec::new()); + } + let strip = image::imageops::resize( + img, + n as u32, + 20, + image::imageops::FilterType::Triangle, + ); + let mut out: Vec<[f32; 3]> = Vec::with_capacity(n); + for x in 0..n as u32 { + let mut best = [0.5_f32, 0.5, 0.5]; + let mut best_vibrancy = -1.0_f32; + for y in 0..strip.height() { + let p = strip.get_pixel(x, y); + let r = p[0] as f32 / 255.0; + let g = p[1] as f32 / 255.0; + let b = p[2] as f32 / 255.0; + let (_, s, v) = rgb_to_hsv(r, g, b); + let vib = s * v; + if vib > best_vibrancy { + best_vibrancy = vib; + best = [r, g, b]; + } + } + out.push(best); + } + Arc::new(out) +} + +/// converts an sRGB triple in 0..=1 to hue/saturation/value, all in 0..=1. +pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) { + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let v = max; + let d = max - min; + let s = if max <= 0.0 { 0.0 } else { d / max }; + let h = if d <= 1e-6 { + 0.0 + } else if (max - r).abs() < f32::EPSILON { + ((g - b) / d).rem_euclid(6.0) / 6.0 + } else if (max - g).abs() < f32::EPSILON { + ((b - r) / d + 2.0) / 6.0 + } else { + ((r - g) / d + 4.0) / 6.0 + }; + ((h + 1.0).rem_euclid(1.0), s, v) +} diff --git a/src/processor.rs b/src/processor.rs new file mode 100644 index 0000000..c446d3f --- /dev/null +++ b/src/processor.rs @@ -0,0 +1,371 @@ + + +use num_complex::Complex64; +use rustfft::{Fft, FftPlanner}; +use std::collections::VecDeque; +use std::f64::consts::PI; +use std::sync::Arc; + +use crate::gpu_dsp::GpuFft1D; +use crate::weave; + +/// log-spaced spectrum frame plus the real cepstrum used for transient detection. +#[derive(Debug, Clone, Default)] +pub struct Spectrum { + pub freqs: Vec, + pub db: Vec, + + pub cepstrum: Vec, +} + +/// single-channel windowed-fft pipeline with optional gpu offload, log-spaced binning, and cepstral envelope blending. +pub struct Processor { + frame_size: usize, + sample_rate: u32, + + device: wgpu::Device, + queue: wgpu::Queue, + + cpu_fft: Option>>, + cpu_cep_inv: Option>>, + cpu_planner: FftPlanner, + + gpu_fft: Option, + + gpu_blend: f32, + + window: Vec, + buffer: Vec, + + custom_bins: Vec, + freqs_const: Vec, + + history: VecDeque>, + smoothing_length: usize, + + expand_ratio: f32, + expand_threshold: f32, + + hpf_cutoff: f32, + + granularity: i32, + detail: i32, + cepstral_strength: f32, + + weave_caps: Option, +} + +impl Processor { + /// allocates fft plans, the analysis window, and a default 26-bin log-spaced layout. + pub fn new(frame_size: usize, sample_rate: u32, device: wgpu::Device, queue: wgpu::Queue) -> Self { + let mut p = Self { + frame_size: 0, + sample_rate, + device, + queue, + cpu_fft: None, + cpu_cep_inv: None, + cpu_planner: FftPlanner::new(), + gpu_fft: None, + gpu_blend: 0.0, + window: Vec::new(), + buffer: Vec::new(), + custom_bins: Vec::new(), + freqs_const: Vec::new(), + history: VecDeque::new(), + smoothing_length: 3, + expand_ratio: 1.0, + expand_threshold: -60.0, + hpf_cutoff: 0.0, + granularity: 33, + detail: 50, + cepstral_strength: 0.0, + weave_caps: default_caps(), + }; + p.set_frame_size(frame_size); + p.set_num_bins(26); + p + } + + /// updates the bin-to-hertz sample rate. + pub fn set_sample_rate(&mut self, rate: u32) { + self.sample_rate = rate; + } + + /// caps the rolling history depth of the per-bin db time-average. + pub fn set_smoothing(&mut self, history_length: usize) { + self.smoothing_length = history_length.max(1); + while self.history.len() > self.smoothing_length { + self.history.pop_front(); + } + } + + /// configures the upward expander applied below the threshold in db. + pub fn set_expander(&mut self, ratio: f32, threshold_db: f32) { + self.expand_ratio = ratio; + self.expand_threshold = threshold_db; + } + + /// sets the corner frequency of the per-bin first-order high-pass roll-off applied during binning. + pub fn set_hpf(&mut self, cutoff_freq: f32) { + self.hpf_cutoff = cutoff_freq; + } + + /// dials the bin density, iteration count, and blend weight of the cepstral envelope smoother. + pub fn set_cepstral_params(&mut self, granularity: i32, detail: i32, strength: f32) { + self.granularity = granularity; + self.detail = detail; + self.cepstral_strength = strength; + } + + /// crossfades between cpu-fft and gpu-fft magnitudes within zero to one inclusive. + pub fn set_gpu_blend(&mut self, blend: f32) { + self.gpu_blend = blend.clamp(0.0, 1.0); + } + + /// rebuilds the geometric 40 hz to 11 khz bin edges and the matching center frequencies for the n+1 output columns. + pub fn set_num_bins(&mut self, n: usize) { + self.custom_bins.clear(); + self.freqs_const.clear(); + self.history.clear(); + + let min_freq = 40.0_f64; + let max_freq = 11_000.0_f64; + for i in 0..=n { + let f = min_freq * (max_freq / min_freq).powf(i as f64 / n as f64); + self.custom_bins.push(f); + } + + self.freqs_const.push(10.0); + for i in 0..self.custom_bins.len() - 1 { + self.freqs_const + .push((self.custom_bins[i] + self.custom_bins[i + 1]) / 2.0); + } + } + + /// rebuilds fft plans, the blackman-harris window, and the working buffer at the requested transform length. + pub fn set_frame_size(&mut self, size: usize) { + if self.frame_size == size { + return; + } + self.frame_size = size; + + self.cpu_fft = Some(self.cpu_planner.plan_fft_forward(size)); + self.cpu_cep_inv = Some(self.cpu_planner.plan_fft_inverse(size)); + self.gpu_fft = Some(GpuFft1D::new(self.device.clone(), self.queue.clone(), size as u32)); + + self.window = (0..size) + .map(|i| { + let a0 = 0.35875; + let a1 = 0.48829; + let a2 = 0.14128; + let a3 = 0.01168; + let denom = (size - 1) as f64; + a0 - a1 * (2.0 * PI * i as f64 / denom).cos() + + a2 * (4.0 * PI * i as f64 / denom).cos() + - a3 * (6.0 * PI * i as f64 / denom).cos() + }) + .collect(); + + self.buffer = vec![Complex64::new(0.0, 0.0); size]; + self.history.clear(); + } + + /// shifts an incoming chunk into the tail of the analytic-signal buffer, evicting the head. + pub fn push_data(&mut self, data: &[Complex64]) { + let n = self.frame_size; + if data.len() == n { + self.buffer.copy_from_slice(data); + } else if data.len() < n { + let drop = data.len(); + self.buffer.copy_within(drop.., 0); + let tail = n - drop; + self.buffer[tail..].copy_from_slice(data); + } + } + + /// produces a spectrum frame through the windowed cpu/gpu fft blend, hpf shaping, cepstral idealisation, expander, and history smoothing. + pub fn get_spectrum(&mut self) -> Spectrum { + let n = self.frame_size; + let blend = self.gpu_blend as f64; + + let work_template: Vec = self + .buffer + .iter() + .zip(self.window.iter()) + .map(|(c, w)| Complex64::new(c.re * w, c.im * w)) + .collect(); + + let cpu_work = if blend < 1.0 { + let mut w = work_template.clone(); + self.cpu_fft + .as_ref() + .expect("cpu fft plan") + .clone() + .process(&mut w); + Some(w) + } else { + None + }; + + let gpu_work = if blend > 0.0 { + let gpu = self.gpu_fft.as_mut().expect("gpu fft plan"); + let mut prev = vec![Complex64::new(0.0, 0.0); n]; + // collect lags one frame behind the cpu path. + let got_prev = gpu.try_collect_into(&mut prev); + gpu.submit_forward(&work_template); + if got_prev { + Some(prev) + } else { + None + } + } else { + + let gpu = self.gpu_fft.as_mut().expect("gpu fft plan"); + let mut discard = vec![Complex64::new(0.0, 0.0); n]; + let _ = gpu.try_collect_into(&mut discard); + None + }; + + let bins = n / 2 + 1; + let mut freqs_full = vec![0.0_f64; bins]; + let mut mag_full = vec![0.0_f64; bins]; + for i in 0..bins { + let cpu_m = cpu_work.as_ref().map(|w| { + let re = w[i].re; + let im = w[i].im; + 2.0 * (re * re + im * im).sqrt() / n as f64 + }); + let gpu_m = gpu_work.as_ref().map(|w| { + let re = w[i].re; + let im = w[i].im; + 2.0 * (re * re + im * im).sqrt() / n as f64 + }); + let mut mag = match (cpu_m, gpu_m) { + (Some(c), Some(g)) => c * (1.0 - blend) + g * blend, + (Some(c), None) => c, + (None, Some(g)) => g, + (None, None) => 0.0, + }; + let freq = i as f64 * self.sample_rate as f64 / n as f64; + if self.hpf_cutoff > 0.0 && freq > 0.0 { + let ratio = self.hpf_cutoff as f64 / freq; + let r2 = ratio * ratio; + let gain = 1.0 / (1.0 + r2 * r2).sqrt(); + mag *= gain; + } else if freq == 0.0 { + mag = 0.0; + } + mag_full[i] = mag; + freqs_full[i] = freq; + } + + let mut cep_buf: Vec = Vec::with_capacity(n); + for i in 0..n { + let idx = if i <= n / 2 { i } else { n - i }; + let v = mag_full[idx].max(1e-9); + cep_buf.push(Complex64::new(v.ln(), 0.0)); + } + self.cpu_cep_inv + .as_ref() + .expect("cpu cep inverse plan") + .clone() + .process(&mut cep_buf); + let cep_scale = 1.0 / n as f64; + let half_n = n / 2; + let cepstrum: Vec = (0..half_n) + .map(|i| (cep_buf[i].re * cep_scale) as f32) + .collect(); + + if self.cepstral_strength > 0.0 { + let envelope = weave::idealize_curve( + &mag_full, + self.granularity, + self.detail, + self.weave_caps, + ); + let s = self.cepstral_strength as f64; + for (m, e) in mag_full.iter_mut().zip(envelope.iter()) { + *m = *m * (1.0 - s) + e * s; + } + } + + let mut current_db = vec![0.0_f64; self.freqs_const.len()]; + for (i, &target) in self.freqs_const.iter().enumerate() { + let mag = lerp_at(&freqs_full, &mag_full, target); + let mut val = 20.0 * mag.max(1e-12).log10(); + if (self.expand_ratio - 1.0).abs() > f32::EPSILON { + let t = self.expand_threshold as f64; + let r = self.expand_ratio as f64; + val = (val - t) * r + t; + } + if val < -100.0 { + val = -100.0; + } + current_db[i] = val; + } + + self.history.push_back(current_db); + while self.history.len() > self.smoothing_length { + self.history.pop_front(); + } + + let cols = self.freqs_const.len(); + let mut averaged = vec![0.0_f32; cols]; + if !self.history.is_empty() { + for v in &self.history { + for (i, &x) in v.iter().enumerate() { + averaged[i] += x as f32; + } + } + let factor = 1.0 / self.history.len() as f32; + for v in &mut averaged { + *v *= factor; + } + } + + let freqs_ret: Vec = self.freqs_const.iter().map(|&f| f as f32).collect(); + Spectrum { + freqs: freqs_ret, + db: averaged, + cepstrum, + } + } + + /// returns the active fft transform length. + pub fn frame_size(&self) -> usize { + self.frame_size + } +} + +/// linearly interpolates a value from the freqs/values table at the given target frequency with endpoint clamping. +fn lerp_at(freqs: &[f64], values: &[f64], target: f64) -> f64 { + if freqs.is_empty() { + return 0.0; + } + if target <= freqs[0] { + return values[0]; + } + if target >= freqs[freqs.len() - 1] { + return values[values.len() - 1]; + } + let upper = freqs.partition_point(|&f| f < target); + let lower = upper - 1; + let f0 = freqs[lower]; + let f1 = freqs[upper]; + let v0 = values[lower]; + let v1 = values[upper]; + let t = (target - f0) / (f1 - f0); + v0 + t * (v1 - v0) +} + +/// chooses lighter idealisation caps on mobile targets and unlimited caps on desktop. +#[cfg(any(target_os = "ios", target_os = "android"))] +fn default_caps() -> Option { + Some(weave::Caps::mobile()) +} + +#[cfg(not(any(target_os = "ios", target_os = "android")))] +fn default_caps() -> Option { + None +} diff --git a/src/shell.rs b/src/shell.rs new file mode 100644 index 0000000..e0013e7 --- /dev/null +++ b/src/shell.rs @@ -0,0 +1,239 @@ + + +use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; +use winit::application::ApplicationHandler; +use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize}; +use winit::event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent}; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::keyboard::{Key, ModifiersState, NamedKey}; +use winit::window::{Window, WindowAttributes, WindowId}; + +use crate::viewport::ViewportHandle; + +const DEFAULT_LOGICAL: (u32, u32) = (1100, 700); +const MIN_LOGICAL: (u32, u32) = (520, 380); + +/// winit application driver owning the desktop window and forwarding events into the viewport. +pub struct ShellApp { + window: Option>, + handle: Option, + modifiers: ModifiersState, + last_cursor: PhysicalPosition, +} + +impl Default for ShellApp { + fn default() -> Self { + Self { + window: None, + handle: None, + modifiers: ModifiersState::empty(), + last_cursor: PhysicalPosition::new(0.0, 0.0), + } + } +} + +/// boots the winit event loop and runs the desktop shell until the window closes. +pub fn run() { + let event_loop = EventLoop::new().expect("winit: create event loop"); + event_loop.set_control_flow(ControlFlow::Wait); + let mut app = ShellApp::default(); + if let Err(e) = event_loop.run_app(&mut app) { + eprintln!("yr_crystals shell exited with error: {e}"); + std::process::exit(1); + } +} + +impl ApplicationHandler for ShellApp { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; + } + let attrs = WindowAttributes::default() + .with_title("Yr Xtals") + .with_inner_size(LogicalSize::new(DEFAULT_LOGICAL.0, DEFAULT_LOGICAL.1)) + .with_min_inner_size(LogicalSize::new(MIN_LOGICAL.0, MIN_LOGICAL.1)); + + let window = event_loop + .create_window(attrs) + .expect("winit: create window"); + let inner = window.inner_size(); + let scale = window.scale_factor() as f32; + let window = Box::new(window); + + let raw_window = window + .window_handle() + .expect("winit: window handle") + .as_raw(); + let raw_display = window + .display_handle() + .expect("winit: display handle") + .as_raw(); + + let handle = ViewportHandle::new_from_raw( + raw_window, + raw_display, + (inner.width as f32 / scale).max(1.0), + (inner.height as f32 / scale).max(1.0), + scale, + ) + .expect("yr_crystals: failed to build wgpu/iced viewport"); + + self.window = Some(window); + self.handle = Some(handle); + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) { + let Some(window) = self.window.as_ref() else { + return; + }; + let Some(handle) = self.handle.as_mut() else { + return; + }; + let scale = window.scale_factor() as f32; + + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(PhysicalSize { width, height }) => { + let w = (width as f32 / scale).max(1.0); + let h = (height as f32 / scale).max(1.0); + handle.resize_px(w, h, scale); + window.request_redraw(); + } + WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + let size = window.inner_size(); + let s = scale_factor as f32; + let w = (size.width as f32 / s).max(1.0); + let h = (size.height as f32 / s).max(1.0); + handle.resize_px(w, h, s); + window.request_redraw(); + } + WindowEvent::CursorMoved { position, .. } => { + self.last_cursor = position; + handle.push_mouse_move(position.x as f32 / scale, position.y as f32 / scale); + window.request_redraw(); + } + WindowEvent::CursorLeft { .. } => { + handle.push_mouse_left(); + window.request_redraw(); + } + WindowEvent::MouseInput { state, button, .. } => { + let code = match button { + MouseButton::Left => 0, + MouseButton::Right => 1, + MouseButton::Middle => 2, + _ => return, + }; + let pressed = matches!(state, ElementState::Pressed); + handle.push_mouse_button( + self.last_cursor.x as f32 / scale, + self.last_cursor.y as f32 / scale, + code, + pressed, + ); + window.request_redraw(); + } + WindowEvent::MouseWheel { delta, .. } => { + let (dx, dy) = match delta { + MouseScrollDelta::LineDelta(x, y) => (x * 20.0, y * 20.0), + MouseScrollDelta::PixelDelta(p) => (p.x as f32, p.y as f32), + }; + handle.push_mouse_scroll( + self.last_cursor.x as f32 / scale, + self.last_cursor.y as f32 / scale, + dx, + dy, + ); + window.request_redraw(); + } + WindowEvent::ModifiersChanged(mods) => { + self.modifiers = mods.state(); + } + WindowEvent::KeyboardInput { event, .. } => { + let KeyEvent { + logical_key, + state, + text, + .. + } = event; + let pressed = matches!(state, ElementState::Pressed); + let named = map_named_key(&logical_key); + let utf8 = match (named, &logical_key, &text) { + (0, Key::Character(s), _) => Some(s.to_string()), + (0, _, Some(s)) => Some(s.to_string()), + _ => None, + }; + let mods = encode_modifiers(self.modifiers); + handle.push_key_event(named, utf8, mods, pressed); + window.request_redraw(); + } + WindowEvent::RedrawRequested => handle.render_frame(), + _ => {} + } + } + + /// switches between Poll and Wait control flow depending on active playback or loading. + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + let Some(window) = self.window.as_ref() else { + return; + }; + let Some(handle) = self.handle.as_mut() else { + return; + }; + let playing = handle + .state + .engine + .as_ref() + .map(|e| e.is_playing()) + .unwrap_or(false); + let animating = playing + || handle.state.track_loading + || handle.state.library_progress.is_some(); + if animating { + window.request_redraw(); + event_loop.set_control_flow(ControlFlow::Poll); + } else { + event_loop.set_control_flow(ControlFlow::Wait); + } + } +} + +/// maps a winit named key into the viewport's small-integer key code. +fn map_named_key(k: &Key) -> u32 { + match k { + Key::Named(NamedKey::Enter) => 1, + Key::Named(NamedKey::Escape) => 2, + Key::Named(NamedKey::Backspace) => 3, + Key::Named(NamedKey::Tab) => 4, + Key::Named(NamedKey::ArrowLeft) => 5, + Key::Named(NamedKey::ArrowRight) => 6, + Key::Named(NamedKey::ArrowUp) => 7, + Key::Named(NamedKey::ArrowDown) => 8, + Key::Named(NamedKey::Delete) => 9, + Key::Named(NamedKey::Home) => 10, + Key::Named(NamedKey::End) => 11, + _ => 0, + } +} + +/// packs winit modifier state into the viewport's modifier bitfield. +fn encode_modifiers(mods: ModifiersState) -> u32 { + let mut out = 0; + if mods.shift_key() { + out |= 1; + } + if mods.control_key() { + out |= 2; + } + if mods.alt_key() { + out |= 4; + } + if mods.super_key() { + out |= 8; + } + out +} diff --git a/src/track.rs b/src/track.rs new file mode 100644 index 0000000..21ccfaa --- /dev/null +++ b/src/track.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +/// shared interleaved-stereo pcm bundle paired with the track's sample rate and dsp frame size. +#[derive(Debug, Clone)] +pub struct TrackData { + pub pcm: Arc<[f32]>, + pub sample_rate: u32, + pub frame_size: usize, +} + +impl TrackData { + /// builds a zero-length placeholder at 48 kHz with a 4096-sample frame. + pub fn empty() -> Self { + Self { + pcm: Arc::from(Vec::::new()), + sample_rate: 48_000, + frame_size: 4096, + } + } + + /// counts stereo frames (pcm length divided by two channels). + pub fn total_samples(&self) -> usize { + self.pcm.len() / 2 + } + + /// reports whether the pcm slice holds any samples. + pub fn is_valid(&self) -> bool { + !self.pcm.is_empty() + } +} + +impl Default for TrackData { + fn default() -> Self { + Self::empty() + } +} diff --git a/src/trig_interpolation.rs b/src/trig_interpolation.rs new file mode 100644 index 0000000..82fd762 --- /dev/null +++ b/src/trig_interpolation.rs @@ -0,0 +1,260 @@ +#![allow(clippy::needless_range_loop, clippy::too_many_arguments)] + +use num_complex::Complex64; +use rustfft::{Fft, FftPlanner}; +use std::f64::consts::PI; +use std::sync::Arc; + +use crate::hilbert_block::BlockHilbert; +use crate::weave; + +/// phase model applied when converting a matching curve into a finite impulse response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PhaseMode { + /// minimum-phase reconstruction via the cepstrum and a one-sided lifter. + Hilbert, + /// zero-phase, treats the curve as a real spectrum and inverse-transforms directly. + StandardCepstral, +} + +/// FIR taps paired with source, target, and matched magnitude curves in dB. +#[derive(Debug, Clone, Default)] +pub struct MatchResult { + pub fir_l: Vec, + pub fir_r: Vec, + pub frequency_axis: Vec, + pub source_l_db: Vec, + pub target_l_db: Vec, + pub matched_l_db: Vec, + pub source_r_db: Vec, + pub target_r_db: Vec, + pub matched_r_db: Vec, +} + +/// holds the FFT planner and block-hilbert helper reused across match passes. +pub struct TrigInterpolation { + hilbert: BlockHilbert, + planner: FftPlanner, +} + +impl Default for TrigInterpolation { + fn default() -> Self { + Self::new() + } +} + +impl TrigInterpolation { + pub fn new() -> Self { + Self { + hilbert: BlockHilbert::new(), + planner: FftPlanner::new(), + } + } + + /// derives stereo FIR taps warping source magnitudes toward target magnitudes. + pub fn process_and_generate_fir( + &mut self, + source_l: &[f64], + source_r: &[f64], + target_l: &[f64], + target_r: &[f64], + granularity: i32, + detail: i32, + strength: f64, + fir_size: usize, + lr_link: f64, + phase_mode: PhaseMode, + ) -> MatchResult { + let cep_src_l = self.cepstrum_from_magnitude(source_l); + let cep_src_r = self.cepstrum_from_magnitude(source_r); + let cep_tgt_l = self.cepstrum_from_magnitude(target_l); + let cep_tgt_r = self.cepstrum_from_magnitude(target_r); + + let smooth_src_l = idealize_complex(&cep_src_l, granularity, detail); + let smooth_src_r = idealize_complex(&cep_src_r, granularity, detail); + let smooth_tgt_l = idealize_complex(&cep_tgt_l, granularity, detail); + let smooth_tgt_r = idealize_complex(&cep_tgt_r, granularity, detail); + + let pre_link_l = create_match_curve(&smooth_src_l, &smooth_tgt_l, strength); + let pre_link_r = create_match_curve(&smooth_src_r, &smooth_tgt_r, strength); + + let n_match = pre_link_l.len(); + let mut match_l = vec![0.0_f64; n_match]; + let mut match_r = vec![0.0_f64; n_match]; + for i in 0..n_match { + let lv = pre_link_l[i]; + let rv = pre_link_r[i]; + match_l[i] = lv * (1.0 - lr_link) + (lv + rv) * 0.5 * lr_link; + match_r[i] = rv * (1.0 - lr_link) + (lv + rv) * 0.5 * lr_link; + } + + let fir_l = self.create_fir_from_curve(&match_l, fir_size, phase_mode); + let fir_r = self.create_fir_from_curve(&match_r, fir_size, phase_mode); + + let mut result = MatchResult { + fir_l, + fir_r, + ..Default::default() + }; + let num_plot_points = if match_l.len() > 1 { match_l.len() / 2 } else { 0 }; + if num_plot_points == 0 { + return result; + } + + let freq_axis_size = source_l.len(); + result.frequency_axis = (0..freq_axis_size).map(|i| i as f64).collect(); + let to_db = |v: f64| 20.0 * v.max(1e-9).log10(); + + result.source_l_db = (0..freq_axis_size).map(|i| to_db(smooth_src_l[i])).collect(); + result.target_l_db = (0..freq_axis_size).map(|i| to_db(smooth_tgt_l[i])).collect(); + result.matched_l_db = (0..freq_axis_size).map(|i| to_db(match_l[i])).collect(); + result.source_r_db = (0..freq_axis_size).map(|i| to_db(smooth_src_r[i])).collect(); + result.target_r_db = (0..freq_axis_size).map(|i| to_db(smooth_tgt_r[i])).collect(); + result.matched_r_db = (0..freq_axis_size).map(|i| to_db(match_r[i])).collect(); + + result + } + + /// mirrors a half-spectrum into a full log-magnitude spectrum and returns the analytic cepstrum. + fn cepstrum_from_magnitude(&mut self, mag_spectrum: &[f64]) -> Vec { + if mag_spectrum.is_empty() { + return Vec::new(); + } + let half_n = mag_spectrum.len(); + let n = if half_n > 1 { (half_n - 1) * 2 } else { 0 }; + if n == 0 { + return Vec::new(); + } + + let mut log_full = vec![Complex64::new(0.0, 0.0); n]; + for i in 0..half_n { + log_full[i] = Complex64::new(mag_spectrum[i].max(1e-20).ln(), 0.0); + } + for i in 1..(half_n - 1) { + log_full[n - i] = log_full[i]; + } + self.ifft_inplace(&mut log_full); + + let real_part: Vec = log_full.iter().map(|c| c.re).collect(); + let (analytic, _) = self.hilbert.hilbert_stereo(&real_part, &real_part); + analytic + } + + /// builds a windowed, normalised FIR from a desired magnitude curve under the chosen phase model. + fn create_fir_from_curve( + &mut self, + match_curve: &[f64], + fir_size: usize, + phase_mode: PhaseMode, + ) -> Vec { + if match_curve.is_empty() || fir_size == 0 { + return Vec::new(); + } + let n = match_curve.len(); + let mut spectrum = vec![Complex64::new(0.0, 0.0); n]; + + match phase_mode { + PhaseMode::Hilbert => { + + let mut log_mag: Vec = match_curve + .iter() + .map(|&v| Complex64::new(v.max(1e-20).ln(), 0.0)) + .collect(); + self.ifft_inplace(&mut log_mag); + + let mut cep = log_mag; + for i in (n / 2)..n { + cep[i] = Complex64::new(0.0, 0.0); + } + for i in 1..(n / 2) { + cep[i] *= 2.0; + } + + let mut min_phase_spectrum = cep; + self.fft_inplace(&mut min_phase_spectrum); + for i in 0..n { + spectrum[i] = min_phase_spectrum[i].exp(); + } + } + PhaseMode::StandardCepstral => { + for i in 0..n { + spectrum[i] = Complex64::new(match_curve[i], 0.0); + } + } + } + + let mut impulse = spectrum; + self.ifft_inplace(&mut impulse); + + let mut taps = vec![0.0_f32; fir_size]; + let center = fir_size / 2; + for i in 0..fir_size { + let hann = 0.5 * (1.0 - (2.0 * PI * i as f64 / (fir_size - 1) as f64).cos()); + let signed = i as isize - center as isize; + let mut idx = signed.rem_euclid(n as isize) as usize; + if idx >= n { + idx %= n; + } + taps[i] = (impulse[idx].re * hann) as f32; + } + + let tap_sum: f64 = taps.iter().map(|&t| t as f64).sum(); + if tap_sum.abs() > 1e-9 { + let inv = 1.0 / tap_sum as f32; + for t in &mut taps { + *t *= inv; + } + } + taps + } + + /// forward FFT with no normalisation. + fn fft_inplace(&mut self, buf: &mut [Complex64]) { + let plan: Arc> = self.planner.plan_fft_forward(buf.len()); + plan.process(buf); + } + + /// inverse FFT with the conventional 1/N scaling applied. + fn ifft_inplace(&mut self, buf: &mut [Complex64]) { + let n = buf.len(); + let plan: Arc> = self.planner.plan_fft_inverse(n); + plan.process(buf); + let inv_n = 1.0 / n as f64; + for x in buf.iter_mut() { + *x *= inv_n; + } + } +} + +/// reduces an analytic signal to a magnitude envelope and runs the cosine-bell idealiser on top. +fn idealize_complex(analytic: &[Complex64], granularity: i32, detail: i32) -> Vec { + if analytic.is_empty() { + return Vec::new(); + } + let mag: Vec = analytic.iter().map(|c| c.norm()).collect(); + weave::idealize_curve(&mag, granularity, detail, None) +} + +/// blends source extrema toward target extrema by strength and weaves them back into a curve. +fn create_match_curve( + smooth_source: &[f64], + smooth_target: &[f64], + strength: f64, +) -> Vec { + if smooth_source.is_empty() { + return Vec::new(); + } + let extrema = weave::local_extrema(smooth_source); + if extrema.is_empty() { + return smooth_source.to_vec(); + } + let anchor_vals: Vec = extrema + .iter() + .map(|&idx| { + let s = smooth_source[idx]; + let t = smooth_target[idx]; + s + (t - s) * strength + }) + .collect(); + weave::weave_anchors(smooth_source, &anchor_vals) +} diff --git a/src/ui/app.rs b/src/ui/app.rs new file mode 100644 index 0000000..d2eb33d --- /dev/null +++ b/src/ui/app.rs @@ -0,0 +1,635 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use iced_wgpu::core::{Element, Theme}; + +use crate::analyzer::FrameData; +use crate::analyzer_worker::AnalyzerWorker; +use crate::engine::AudioEngine; +use crate::library::{self, Track}; +use crate::library_worker::{LibraryUpdate, LibraryWorker}; + +use super::player; + +/// owns the library, audio engine, background workers, and every UI-toggleable setting. +pub struct App { + pub library: Library, + pub selected_track: Option, + pub playing: bool, + pub immersive: bool, + pub engine: Option, + pub worker: AnalyzerWorker, + pub library_worker: LibraryWorker, + pub frame_data: Arc>, + pub current_palette: Option>>, + pub settings: Settings, + pub show_settings: bool, + + /// shell-side picker request flag drained by the iOS host once per tick. + pub pending_pick: u8, + + /// monotonic id stamped onto every decode request, matched against returning results. + pub current_decode_id: u64, + pub next_decode_id: u64, + + pub track_loading: bool, + + /// running count and total of the iOS library import progress. + pub library_progress: Option<(u32, u32)>, +} + +/// every visualizer toggle, slider value, and DSP parameter the settings panel exposes. +#[derive(Debug, Clone, Copy)] +pub struct Settings { + pub glass: bool, + pub entropy_on: bool, + pub album_colors: bool, + pub mirrored: bool, + pub inverted: bool, + pub entropy_strength: f32, + pub hue: f32, + pub contrast: f32, + pub brightness: f32, + pub num_bins: u32, + pub fft: u32, + pub hop: u32, + pub granularity: i32, + pub detail: i32, + pub strength: f32, + + /// fraction of FFT work routed to the GPU pipeline, blended against the CPU result. + pub gpu_blend: f32, +} + +impl Default for Settings { + fn default() -> Self { + Self { + glass: true, + entropy_on: false, + album_colors: false, + mirrored: false, + inverted: false, + entropy_strength: 0.0, + hue: 0.9, + contrast: 1.0, + brightness: 1.0, + num_bins: 26, + fft: 16384, + hop: 4096, + granularity: 33, + detail: 50, + strength: 0.0, + gpu_blend: 0.7, + } + } +} + +/// loaded track list paired with the folder path of origin. +#[derive(Default)] +pub struct Library { + pub folder: Option, + pub tracks: Vec, +} + +/// every UI event the player widget tree can emit. +#[derive(Debug, Clone)] +pub enum Message { + OpenFolder, + OpenFile, + SelectTrack(usize), + TogglePlayPause, + Next, + Prev, + + Seek(f32), + ToggleImmersive, + ToggleSettings, + SetGlass(bool), + SetEntropy(bool), + SetAlbumColors(bool), + SetMirrored(bool), + SetInverted(bool), + SetEntropyStrength(f32), + SetHue(f32), + SetContrast(f32), + SetBrightness(f32), + SetNumBins(u32), + SetFft(u32), + SetHop(u32), + SetGranularity(i32), + SetDetail(i32), + SetStrength(f32), + SetGpuBlend(f32), + PickedFolder(PathBuf), + PickedFile(PathBuf), + PickedFiles(Vec), +} + +impl App { + /// builds the analyzer worker, library worker, audio engine, and seeds defaults. + pub fn new(device: wgpu::Device, queue: wgpu::Queue) -> Self { + let settings = Settings::default(); + let worker = AnalyzerWorker::spawn(device, queue); + worker.set_num_bins(settings.num_bins as usize); + worker.set_dsp_params(settings.fft as usize, settings.hop as usize); + worker.set_smoothing(settings.granularity, settings.detail, settings.strength); + worker.set_gpu_blend(settings.gpu_blend); + let library_worker = LibraryWorker::spawn(); + Self { + library: Library::default(), + selected_track: None, + playing: false, + immersive: false, + engine: AudioEngine::new() + .map_err(|e| eprintln!("yr_crystals: audio engine unavailable: {e}")) + .ok(), + worker, + library_worker, + frame_data: Arc::new(Vec::new()), + current_palette: None, + settings, + show_settings: false, + pending_pick: 0, + current_decode_id: 0, + next_decode_id: 0, + track_loading: false, + library_progress: None, + } + } + + /// returns the picker flag and clears the slot in one step. + pub fn take_pending_pick(&mut self) -> u8 { + let p = self.pending_pick; + self.pending_pick = 0; + #[cfg(all(target_os = "ios", debug_assertions))] + if p != 0 { + eprintln!("[Rust.dbg] take_pending_pick -> {p}"); + } + p + } + + /// checks whether a logical-coords point falls inside the scrollable sidebar region. + pub fn point_in_sidebar(&self, x: f32, y: f32, viewport_height: f32) -> bool { + if self.immersive || self.show_settings { + return false; + } + x >= 0.0 + && x < player::SIDEBAR_W + && y >= player::TOP_BAR_H + && y < viewport_height - player::TRANSPORT_H + } + + /// scans a folder, replaces the library, queues art, and starts decoding the first track. + fn apply_picked_folder(&mut self, folder: PathBuf) { + #[cfg(all(target_os = "ios", debug_assertions))] + eprintln!("[Rust.dbg] apply_picked_folder enter path={}", folder.display()); + let tracks = library::scan_folder(&folder); + #[cfg(all(target_os = "ios", debug_assertions))] + { + eprintln!("[Rust.dbg] apply_picked_folder scan -> {} tracks", tracks.len()); + for (i, t) in tracks.iter().take(5).enumerate() { + eprintln!("[Rust.dbg] track[{i}] title={:?} path={}", t.title, t.path.display()); + } + } + self.library.folder = Some(folder); + self.library.tracks = tracks; + self.queue_art_for_all(); + self.selected_track = self.library.tracks.first().map(|_| 0); + self.playing = self.selected_track.is_some(); + if let Some(idx) = self.selected_track { + self.load_index(idx); + } + } + + /// loads a single audio file as a one-track library and starts playback. + fn apply_picked_file(&mut self, path: PathBuf) { + match library::read_track_meta(&path) { + Ok(track) => { + self.library.folder = path.parent().map(|p| p.to_path_buf()); + self.library.tracks = vec![track]; + self.queue_art_for_all(); + self.selected_track = Some(0); + self.playing = true; + self.load_index(0); + } + Err(e) => { + eprintln!("yr_crystals: read_track_meta failed: {}", e); + } + } + } + + /// builds an ad-hoc library from a multi-file pick and queues meta and art lookups. + fn apply_picked_files(&mut self, paths: Vec) { + + let mut tracks = Vec::with_capacity(paths.len()); + let mut folder = None; + for path in &paths { + if folder.is_none() { + folder = path.parent().map(|p| p.to_path_buf()); + } + let title = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Untitled") + .to_string(); + tracks.push(library::Track { + path: path.clone(), + title, + artist: None, + album: None, + track_number: None, + art: None, + palette: None, + exporting: false, + }); + } + self.library.folder = folder; + self.library.tracks = tracks; + self.queue_meta_for_all(); + self.queue_art_for_all(); + self.selected_track = self.library.tracks.first().map(|_| 0); + self.playing = self.selected_track.is_some(); + if let Some(idx) = self.selected_track { + self.load_index(idx); + } + } + + /// seeds the sidebar with placeholder rows for tracks pending export from the iOS host. + pub fn set_pending_titles(&mut self, entries: Vec<(String, Option)>) { + let mut tracks: Vec = entries + .into_iter() + .map(|(title, track_number)| library::Track { + path: PathBuf::new(), + title, + artist: None, + album: None, + track_number, + art: None, + palette: None, + exporting: true, + }) + .collect(); + library::sort_tracks(&mut tracks); + self.library.folder = None; + self.library.tracks = tracks; + self.selected_track = self.library.tracks.first().map(|_| 0); + self.playing = self.selected_track.is_some(); + + self.track_loading = self.selected_track.is_some(); + } + + /// resolves a placeholder track to a real file path once iOS export finishes. + pub fn set_track_path(&mut self, idx: usize, path: PathBuf) { + let Some(track) = self.library.tracks.get_mut(idx) else { return }; + track.path = path.clone(); + track.exporting = false; + self.library_worker.request_meta(path.clone()); + self.library_worker.request_art(path); + if self.selected_track == Some(idx) { + self.load_index(idx); + } + } + + /// hands raw artwork bytes to the art worker. + pub fn set_track_art_bytes(&mut self, idx: usize, bytes: Vec) { + if idx >= self.library.tracks.len() || bytes.is_empty() { + return; + } + self.library_worker.request_art_bytes(idx, bytes); + } + + /// queues artwork extraction across the whole library on the art worker. + fn queue_art_for_all(&self) { + for t in &self.library.tracks { + self.library_worker.request_art(t.path.clone()); + } + } + + /// queues tag reads across the whole library on the meta worker. + fn queue_meta_for_all(&self) { + for t in &self.library.tracks { + self.library_worker.request_meta(t.path.clone()); + } + } + + /// reports the engine playhead as a normalised 0..=1 fraction of the loaded track. + pub fn position(&self) -> f32 { + self.engine.as_ref().map(|e| e.position()).unwrap_or(0.0) + } + + /// recomputes the active album palette from the currently selected track. + fn refresh_palette(&mut self) { + self.current_palette = self + .selected_track + .and_then(|i| self.library.tracks.get(i)) + .and_then(|t| t.palette.clone()); + } + + /// drains worker updates, advances tracks, and refreshes analyzer frames each frame. + pub fn tick(&mut self) { + let mut needs_resort = false; + for upd in self.library_worker.drain_updates() { + match upd { + LibraryUpdate::Meta { + path, + title, + artist, + album, + track_number, + } => { + if let Some(t) = self.library.tracks.iter_mut().find(|t| t.path == path) { + + if let Some(title) = title { + t.title = title; + } + if artist.is_some() { + t.artist = artist; + } + if album.is_some() { + t.album = album; + } + if let Some(tn) = track_number { + if t.track_number != Some(tn) { + t.track_number = Some(tn); + needs_resort = true; + } + } + } + } + LibraryUpdate::Art { + path, + art, + palette, + } => { + let match_idx = self + .library + .tracks + .iter() + .position(|t| t.path == path); + if let Some(idx) = match_idx { + + if art.is_some() { + self.library.tracks[idx].art = art; + } + if palette.is_some() { + self.library.tracks[idx].palette = palette; + } + if self.selected_track == Some(idx) { + self.refresh_palette(); + } + } + } + LibraryUpdate::ArtForIdx { + idx, + art, + palette, + } => { + if let Some(t) = self.library.tracks.get_mut(idx) { + t.art = art; + t.palette = palette; + if self.selected_track == Some(idx) { + self.refresh_palette(); + } + } + } + LibraryUpdate::Decoded { + request_id, + path: _, + result, + } => { + if request_id != self.current_decode_id { + continue; + } + self.track_loading = false; + match result { + Ok(td) => { + if let Some(eng) = &self.engine { + eng.load(td.clone()); + if self.playing { + eng.play(); + } else { + eng.pause(); + } + } + self.worker.set_track(td); + } + Err(e) => eprintln!("yr_crystals: decode failed: {e}"), + } + } + } + } + + if needs_resort { + self.resort_library(); + } + + self.advance_if_finished(); + + self.worker.publish_playhead(self.position()); + self.frame_data = self.worker.latest_frames(); + } + + /// re-sorts the library on arrival of track-number tags and preserves the current selection. + fn resort_library(&mut self) { + let selected_path = self + .selected_track + .and_then(|i| self.library.tracks.get(i)) + .map(|t| t.path.clone()); + library::sort_tracks(&mut self.library.tracks); + if let Some(p) = selected_path { + self.selected_track = self.library.tracks.iter().position(|t| t.path == p); + } + } + + /// rolls onto the next track once the playhead reaches the end, pausing past the final entry. + fn advance_if_finished(&mut self) { + if !self.playing { + return; + } + let Some(idx) = self.selected_track else { return }; + if self.position() < 0.999 { + return; + } + let next = idx + 1; + if next >= self.library.tracks.len() { + self.playing = false; + if let Some(eng) = &self.engine { + eng.pause(); + } + return; + } + self.selected_track = Some(next); + self.load_index(next); + } + + /// dispatches a UI message into the matching state mutation and worker call. + pub fn update(&mut self, msg: Message) { + match msg { + Message::OpenFolder => { + #[cfg(all(not(target_os = "ios"), not(target_os = "android")))] + { + if let Some(folder) = rfd::FileDialog::new().pick_folder() { + self.apply_picked_folder(folder); + } + } + #[cfg(any(target_os = "ios", target_os = "android"))] + { + #[cfg(all(target_os = "ios", debug_assertions))] + eprintln!("[Rust.dbg] Message::OpenFolder -> pending_pick=1"); + self.pending_pick = 1; + } + } + Message::OpenFile => { + #[cfg(all(not(target_os = "ios"), not(target_os = "android")))] + { + let file = rfd::FileDialog::new() + .add_filter( + "Audio", + &[ + "mp3", "m4a", "m4b", "mp4", "flac", "wav", "ogg", "oga", + "opus", "aac", "aiff", + ], + ) + .pick_file(); + if let Some(path) = file { + self.apply_picked_file(path); + } + } + #[cfg(any(target_os = "ios", target_os = "android"))] + { + #[cfg(all(target_os = "ios", debug_assertions))] + eprintln!("[Rust.dbg] Message::OpenFile -> pending_pick=2"); + self.pending_pick = 2; + } + } + Message::PickedFolder(folder) => self.apply_picked_folder(folder), + Message::PickedFile(path) => self.apply_picked_file(path), + Message::PickedFiles(paths) => self.apply_picked_files(paths), + Message::SelectTrack(idx) => { + if idx < self.library.tracks.len() { + self.selected_track = Some(idx); + self.load_index(idx); + } + } + Message::TogglePlayPause => { + if self.selected_track.is_some() { + self.playing = !self.playing; + if let Some(eng) = &self.engine { + if self.playing { eng.play(); } else { eng.pause(); } + } + } + } + Message::Next => { + if let Some(i) = self.selected_track { + if i + 1 < self.library.tracks.len() { + let next = i + 1; + self.selected_track = Some(next); + self.load_index(next); + } + } + } + Message::Prev => { + if let Some(i) = self.selected_track { + if i > 0 { + let prev = i - 1; + self.selected_track = Some(prev); + self.load_index(prev); + } + } + } + Message::Seek(pos) => { + if let Some(eng) = &self.engine { + eng.seek_normalised(pos.clamp(0.0, 1.0)); + } + } + Message::ToggleImmersive => self.immersive = !self.immersive, + Message::ToggleSettings => self.show_settings = !self.show_settings, + Message::SetGlass(on) => self.settings.glass = on, + Message::SetEntropy(on) => self.settings.entropy_on = on, + Message::SetAlbumColors(on) => self.settings.album_colors = on, + Message::SetMirrored(on) => self.settings.mirrored = on, + Message::SetInverted(on) => self.settings.inverted = on, + Message::SetEntropyStrength(v) => self.settings.entropy_strength = v.clamp(-1.5, 1.5), + Message::SetHue(v) => self.settings.hue = v.clamp(0.0, 1.0), + Message::SetContrast(v) => self.settings.contrast = v.clamp(0.0, 2.0), + Message::SetBrightness(v) => self.settings.brightness = v.clamp(0.1, 2.0), + Message::SetNumBins(n) => { + let n = n.clamp(8, 256); + self.settings.num_bins = n; + self.worker.set_num_bins(n as usize); + } + Message::SetFft(n) => { + let fft = n.clamp(512, 65536).next_power_of_two(); + let hop = self.settings.hop.min(fft / 2).max(64); + self.settings.fft = fft; + self.settings.hop = hop; + self.worker.set_dsp_params(fft as usize, hop as usize); + } + Message::SetHop(n) => { + let hop = n.clamp(64, self.settings.fft / 2); + self.settings.hop = hop; + self.worker + .set_dsp_params(self.settings.fft as usize, hop as usize); + } + Message::SetGranularity(v) => { + self.settings.granularity = v.clamp(1, 100); + self.worker.set_smoothing( + self.settings.granularity, + self.settings.detail, + self.settings.strength, + ); + } + Message::SetDetail(v) => { + self.settings.detail = v.clamp(1, 100); + self.worker.set_smoothing( + self.settings.granularity, + self.settings.detail, + self.settings.strength, + ); + } + Message::SetStrength(v) => { + self.settings.strength = v.clamp(0.0, 1.0); + self.worker.set_smoothing( + self.settings.granularity, + self.settings.detail, + self.settings.strength, + ); + } + Message::SetGpuBlend(v) => { + self.settings.gpu_blend = v.clamp(0.0, 1.0); + self.worker.set_gpu_blend(self.settings.gpu_blend); + } + } + } + + /// queues a fresh decode for the given track, stamping a new request id. + fn load_index(&mut self, idx: usize) { + let track = match self.library.tracks.get(idx) { + Some(t) => t, + None => return, + }; + if track.exporting { + self.track_loading = true; + return; + } + let path = track.path.clone(); + self.refresh_palette(); + + let Some(eng) = self.engine.as_ref() else { + return; + }; + let target_sr = eng.output_sample_rate(); + + self.track_loading = true; + self.next_decode_id += 1; + self.current_decode_id = self.next_decode_id; + self.library_worker + .request_decode(self.current_decode_id, path, target_sr); + } + + /// builds the iced widget tree for the current frame. + pub fn view(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + player::view(self) + } +} + diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..bd84e55 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,8 @@ +/// top-level App state, message enum, and update loop. +pub mod app; +/// iced widget tree for sidebar, transport, settings overlay, and visualizer surface. +pub mod player; +/// dark palette tokens and the compositor clear color. +pub mod theme; + +pub use app::{App, Message}; diff --git a/src/ui/player.rs b/src/ui/player.rs new file mode 100644 index 0000000..d5a9822 --- /dev/null +++ b/src/ui/player.rs @@ -0,0 +1,790 @@ +use std::ops::RangeInclusive; +use std::path::PathBuf; + +use iced_wgpu::core::{Background, Border, Color, Element, Length, Padding, Theme}; +use iced_widget::{ + button::{self, Status as ButtonStatus}, + checkbox, column, container, image, lazy, mouse_area, progress_bar, row, scrollable, shader, + slider, stack, svg, text, Space, +}; + +use crate::visualizer::{VisualizerProgram, VizParams}; + +const PLAY_SVG: &[u8] = include_bytes!("../../assets/Play.svg"); +const PAUSE_SVG: &[u8] = include_bytes!("../../assets/Pause.svg"); +const BSKIP_SVG: &[u8] = include_bytes!("../../assets/BSkip.svg"); +const FSKIP_SVG: &[u8] = include_bytes!("../../assets/FSkip.svg"); +const SETTINGS_SVG: &[u8] = include_bytes!("../../assets/Settings.svg"); +const LOADING_SVG: &str = include_str!("../../assets/Loading.svg"); + +const LOADING_DEG_PER_SEC: f32 = 120.0; + +use crate::library::Track; + +use super::app::{App, Message}; +use super::theme::palette; + +pub const SIDEBAR_W: f32 = 280.0; +pub const TOP_BAR_H: f32 = 44.0; +pub const TRANSPORT_H: f32 = 72.0; +const ROW_H: f32 = 56.0; +const THUMB: f32 = 40.0; + +/// assembles the top bar, sidebar, transport, and visualizer into the active layout. +pub fn view(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let body: Element<'_, Message, Theme, iced_wgpu::Renderer> = if app.immersive { + visualiser(app) + } else { + column![ + top_bar(app), + library_progress_strip(app), + row![sidebar(app), visualiser(app)].height(Length::Fill), + transport(app), + ] + .into() + }; + + if app.show_settings { + stack![body, settings_overlay(app)].into() + } else { + body + } +} + +/// renders a thin progress bar under the top bar during an active library import. +fn library_progress_strip(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + match app.library_progress { + Some((current, total)) if total > 0 => { + let fraction = (current as f32 / total as f32).clamp(0.0, 1.0); + progress_bar(0.0..=1.0, fraction) + .length(Length::Fill) + .girth(Length::Fixed(3.0)) + .into() + } + _ => Space::new().height(Length::Fixed(0.0)).into(), + } +} + +/// builds the title row plus folder, file, and settings chip buttons. +fn top_bar(_app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let title = text("Yr Xtals").size(16).color(palette::text()); + let folder_btn = chip_button("Folder", Message::OpenFolder); + #[cfg(target_os = "ios")] + let file_btn = chip_button("Library", Message::OpenFile); + #[cfg(not(target_os = "ios"))] + let file_btn = chip_button("File", Message::OpenFile); + let settings_btn = icon_chip_button(SETTINGS_SVG, Message::ToggleSettings); + + let bar = row![ + title, + Space::new().width(Length::Fixed(20.0)), + folder_btn, + Space::new().width(Length::Fixed(8.0)), + file_btn, + Space::new().width(Length::Fill), + settings_btn, + ] + .padding(Padding::from([0, 16])) + .spacing(0) + .align_y(iced_wgpu::core::Alignment::Center) + .height(Length::Fill); + + container(bar) + .width(Length::Fill) + .height(Length::Fixed(TOP_BAR_H)) + .style(panel_style) + .into() +} + +/// hash key feeding iced's lazy cache, redrawing only after a meaningful sidebar change. +#[derive(Hash)] +struct SidebarKey { + selected: Option, + rows: Vec, +} + +/// minimal per-row identity used inside the sidebar lazy hash. +#[derive(Hash)] +struct TrackRowKey { + path: PathBuf, + title: String, + artist: Option, + has_art: bool, +} + +/// builds the lazy-cached scrollable list of tracks down the left edge. +fn sidebar(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + if app.library.tracks.is_empty() { + return container(empty_library_hint()) + .width(Length::Fixed(SIDEBAR_W)) + .height(Length::Fill) + .style(sidebar_style) + .into(); + } + + let key = SidebarKey { + selected: app.selected_track, + rows: app + .library + .tracks + .iter() + .map(|t| TrackRowKey { + path: t.path.clone(), + title: t.title.clone(), + artist: t.artist.clone(), + has_art: t.art.is_some(), + }) + .collect(), + }; + + let tracks: Vec = app.library.tracks.clone(); + let selected = app.selected_track; + + let inner = lazy(key, move |_| { + let mut col = column![].spacing(2).padding(Padding::from([8, 8])); + for (i, t) in tracks.iter().enumerate() { + col = col.push(track_row_owned(i, t.clone(), selected == Some(i))); + } + scrollable(col).height(Length::Fill) + }); + + container(inner) + .width(Length::Fixed(SIDEBAR_W)) + .height(Length::Fill) + .style(sidebar_style) + .into() +} + +/// placeholder shown inside the sidebar before any tracks load. +fn empty_library_hint<'a>() -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let copy = text("No tracks loaded.\n\nUse Folder or File above\nto load some music.") + .size(13) + .color(palette::text_dim()); + container(copy) + .width(Length::Fill) + .height(Length::Fill) + .padding(Padding::from(24)) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() +} + +/// renders a single sidebar row over a cloned, owned Track. +fn track_row_owned( + idx: usize, + track: Track, + active: bool, +) -> Element<'static, Message, Theme, iced_wgpu::Renderer> { + let thumb: Element<'static, Message, Theme, iced_wgpu::Renderer> = match track.art { + Some(handle) => image(handle) + .width(Length::Fixed(THUMB)) + .height(Length::Fixed(THUMB)) + .into(), + None => container(Space::new()) + .width(Length::Fixed(THUMB)) + .height(Length::Fixed(THUMB)) + .style(art_placeholder_style) + .into(), + }; + + let title_color = if active { palette::text() } else { palette::text_dim() }; + let title_widget = text(track.title) + .size(13) + .color(title_color) + .width(Length::Fill); + let artist_line = match track.artist { + Some(a) => text(a).size(11).color(palette::text_dim()), + None => text(String::new()).size(11).color(palette::text_dim()), + }; + let info = column![title_widget, artist_line] + .spacing(2) + .width(Length::Fill); + + let inner = row![thumb, info] + .spacing(10) + .align_y(iced_wgpu::core::Alignment::Center) + .padding(Padding::from([6, 8])) + .width(Length::Fill) + .height(Length::Fixed(ROW_H)); + + let btn = iced_widget::button(inner) + .padding(0) + .on_press(Message::SelectTrack(idx)) + .width(Length::Fill) + .style(move |_t: &Theme, status: ButtonStatus| { + let bg = match (active, status) { + (true, _) => Some(Background::Color(Color { + a: 0.20, + ..palette::accent() + })), + (false, ButtonStatus::Hovered) => Some(Background::Color(Color { + a: 0.06, + ..palette::accent() + })), + _ => None, + }; + button::Style { + background: bg, + text_color: palette::text(), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }); + btn.into() +} + +/// shader-backed visualizer surface stacked under loading and empty-state overlays. +fn visualiser(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let no_track = app.selected_track.is_none(); + + let viz: Element<'_, Message, Theme, iced_wgpu::Renderer> = shader( + VisualizerProgram::new( + app.frame_data.clone(), + params_from(&app.settings), + app.current_palette.clone(), + ), + ) + .width(Length::Fill) + .height(Length::Fill) + .into(); + + let overlay: Element<'_, Message, Theme, iced_wgpu::Renderer> = if app.track_loading { + loading_overlay() + } else if no_track { + centered_overlay_text("Pick a track from the sidebar", palette::text()) + } else { + Space::new().width(Length::Fill).height(Length::Fill).into() + }; + + let layered = stack![viz, overlay]; + + let bordered = container(layered) + .width(Length::Fill) + .height(Length::Fill) + .style(|_t: &Theme| container::Style { + background: Some(Background::Color(palette::bg())), + border: Border { + color: palette::border(), + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }); + + mouse_area(bordered).on_press(Message::ToggleImmersive).into() +} + +/// animated cog and label overlay shown during track decode. +fn loading_overlay<'a>() -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + let elapsed = START.get_or_init(std::time::Instant::now).elapsed().as_secs_f32(); + let angle = elapsed * LOADING_DEG_PER_SEC; + let bytes = loading_svg_at_angle(angle); + let handle = svg::Handle::from_memory(bytes); + let spinner = svg::Svg::new(handle) + .width(Length::Fixed(160.0)) + .height(Length::Fixed(160.0)); + let label = text("Loading.").size(36).color(palette::text()); + let stack = column![spinner, label] + .spacing(12) + .align_x(iced_wgpu::core::Alignment::Center); + container(stack) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() +} + +/// rewrites the cog and nautilus rotate transforms inside the loading SVG. +fn loading_svg_at_angle(angle_deg: f32) -> Vec { + let mut s = LOADING_SVG.to_string(); + rewrite_rotate_angle(&mut s, "Cog", angle_deg); + rewrite_rotate_angle(&mut s, "Nautilus", -angle_deg); + s.into_bytes() +} + +/// patches the angle inside an SVG element's rotate transform without touching surrounding markup. +fn rewrite_rotate_angle(s: &mut String, id: &str, angle_deg: f32) { + let id_marker = format!(r#"id="{}""#, id); + let Some(id_pos) = s.find(&id_marker) else { return }; + let Some(elem_start) = s[..id_pos].rfind('<') else { return }; + let Some(elem_end_rel) = s[id_pos..].find('>') else { return }; + let elem_end = id_pos + elem_end_rel; + let key = r#"transform="rotate("#; + let Some(off) = s[elem_start..elem_end].find(key) else { return }; + let inner_start = elem_start + off + key.len(); + let Some(close_rel) = s[inner_start..elem_end].find(')') else { return }; + let inner_end = inner_start + close_rel; + let parts: Vec<&str> = s[inner_start..inner_end].split_whitespace().collect(); + let new_inner = match parts.len() { + 1 => format!("{:.2}", angle_deg), + 2 => format!("{:.2} {}", angle_deg, parts[1]), + _ => format!("{:.2} {} {}", angle_deg, parts[1], parts[2]), + }; + s.replace_range(inner_start..inner_end, &new_inner); +} + +/// fills the visualizer area with a single centered string. +fn centered_overlay_text<'a>( + label: &'a str, + color: Color, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + container(text(label).size(15).color(color)) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() +} + +/// projects the App settings struct down to the visualizer parameter subset. +fn params_from(s: &super::app::Settings) -> VizParams { + VizParams { + glass: s.glass, + entropy_on: s.entropy_on, + entropy_strength: s.entropy_strength, + album_colors: s.album_colors, + mirrored: s.mirrored, + inverted: s.inverted, + hue: s.hue, + contrast: s.contrast, + brightness: s.brightness, + } +} + +const SETTINGS_W: f32 = 340.0; + +/// right-aligned settings panel built from grouped slider and toggle rows. +fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let s = &app.settings; + + let header = row![ + text("Settings").size(15).color(palette::text()), + Space::new().width(Length::Fill), + chip_button("Close", Message::ToggleSettings), + ] + .align_y(iced_wgpu::core::Alignment::Center); + + let body = column![ + header, + Space::new().height(Length::Fixed(10.0)), + section_label("style"), + toggle_row("glass", s.glass, Message::SetGlass), + toggle_row("album colors", s.album_colors, Message::SetAlbumColors), + toggle_row("mirrored", s.mirrored, Message::SetMirrored), + toggle_row("inverted", s.inverted, Message::SetInverted), + Space::new().height(Length::Fixed(8.0)), + section_label("color"), + slider_row( + "hue", + s.hue, + 0.0..=1.0, + 0.01, + format!("{:.2}", s.hue), + Message::SetHue, + ), + slider_row( + "contrast", + s.contrast, + 0.0..=2.0, + 0.01, + format!("{:.2}", s.contrast), + Message::SetContrast, + ), + slider_row( + "brightness", + s.brightness, + 0.1..=2.0, + 0.01, + format!("{:.2}", s.brightness), + Message::SetBrightness, + ), + Space::new().height(Length::Fixed(8.0)), + section_label("entropy filter"), + toggle_row("enabled", s.entropy_on, Message::SetEntropy), + slider_row( + "strength", + s.entropy_strength, + -1.5..=1.5, + 0.05, + format!("{:+.2}", s.entropy_strength), + Message::SetEntropyStrength, + ), + Space::new().height(Length::Fixed(8.0)), + section_label("dsp"), + slider_row( + "bins", + s.num_bins as f32, + 8.0..=128.0, + 1.0, + format!("{}", s.num_bins), + |v| Message::SetNumBins(v as u32), + ), + + pow2_slider_row("fft", s.fft, 9, 16, Message::SetFft), + pow2_slider_row( + "hop", + s.hop, + 6, + (s.fft / 2).max(64).trailing_zeros(), + Message::SetHop, + ), + Space::new().height(Length::Fixed(8.0)), + section_label("cepstral smoothing"), + slider_row( + "granularity", + s.granularity as f32, + 1.0..=100.0, + 1.0, + format!("{}", s.granularity), + |v| Message::SetGranularity(v as i32), + ), + slider_row( + "detail", + s.detail as f32, + 1.0..=100.0, + 1.0, + format!("{}", s.detail), + |v| Message::SetDetail(v as i32), + ), + slider_row( + "strength", + s.strength, + 0.0..=1.0, + 0.01, + format!("{:.2}", s.strength), + Message::SetStrength, + ), + Space::new().height(Length::Fixed(8.0)), + section_label("fft engine blend"), + slider_row( + "cpu ↔ gpu", + s.gpu_blend, + 0.0..=1.0, + 0.01, + format!("{:.2}", s.gpu_blend), + Message::SetGpuBlend, + ), + ] + .spacing(8) + .padding(Padding::from(16)) + .width(Length::Fixed(SETTINGS_W)); + + let scroll = scrollable(body).height(Length::Fill); + + let panel = container(scroll) + .width(Length::Fixed(SETTINGS_W)) + .height(Length::Fill) + .style(settings_panel_style); + + container(panel) + .width(Length::Fill) + .height(Length::Fill) + .align_right(Length::Fill) + .into() +} + +/// label + slider + value-text trio used by every numeric setting. +fn slider_row<'a, F>( + label: &'a str, + value: f32, + range: RangeInclusive, + step: f32, + value_text: String, + on_change: F, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> +where + F: 'a + Fn(f32) -> Message, +{ + let label_w = 96.0; + let value_w = 56.0; + row![ + container(text(label).size(12).color(palette::text_dim())) + .width(Length::Fixed(label_w)), + slider(range, value, on_change).step(step).width(Length::Fill), + container( + text(value_text) + .size(12) + .color(palette::text()) + .align_x(iced_wgpu::core::alignment::Horizontal::Right) + ) + .width(Length::Fixed(value_w)) + .align_right(Length::Fixed(value_w)), + ] + .spacing(10) + .align_y(iced_wgpu::core::Alignment::Center) + .into() +} + +/// slider that snaps to powers of two, exposed as a log2 axis underneath. +fn pow2_slider_row<'a, F>( + label: &'a str, + current: u32, + min_log2: u32, + max_log2: u32, + on_change: F, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> +where + F: 'a + Fn(u32) -> Message, +{ + let max_log2 = max_log2.max(min_log2); + let resolved = current.max(1).next_power_of_two(); + let cur_log2 = resolved.trailing_zeros().clamp(min_log2, max_log2); + let value_text = format!("{}", 1u32 << cur_log2); + slider_row( + label, + cur_log2 as f32, + min_log2 as f32..=max_log2 as f32, + 1.0, + value_text, + move |lv| on_change(1u32 << (lv as u32)), + ) +} + +/// label + checkbox pair used by every boolean setting. +fn toggle_row<'a, F>( + label: &'a str, + value: bool, + on_change: F, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> +where + F: 'a + Fn(bool) -> Message, +{ + row![ + container(text(label).size(12).color(palette::text_dim())) + .width(Length::Fixed(96.0)), + checkbox(value).on_toggle(on_change).size(16), + ] + .spacing(10) + .align_y(iced_wgpu::core::Alignment::Center) + .into() +} + +/// dim small-caps heading separating groups of settings rows. +fn section_label<'a>(label: &'a str) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + container(text(label).size(10).color(palette::text_dim())) + .padding(Padding::from([0, 0])) + .into() +} + +/// translucent backdrop styling for the settings overlay. +fn settings_panel_style(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(Color { + a: 0.96, + ..palette::sidebar() + })), + border: Border { + color: palette::border(), + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + } +} + +/// bottom transport bar with skip, play/pause, scrub slider, and position readout. +fn transport(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let prev = transport_button(BSKIP_SVG, Message::Prev, app.selected_track.is_some()); + let engine_playing = app + .engine + .as_ref() + .map(|e| e.is_playing()) + .unwrap_or(app.playing); + let play_glyph = if engine_playing { PAUSE_SVG } else { PLAY_SVG }; + let play = transport_button(play_glyph, Message::TogglePlayPause, app.selected_track.is_some()); + let next = transport_button(FSKIP_SVG, Message::Next, app.selected_track.is_some()); + + let pos = app.position(); + let scrub = slider(0.0..=1.0, pos, Message::Seek) + .step(0.001_f32) + .width(Length::Fill); + let pos_label = text(format!("{:>5.1}%", pos * 100.0)) + .size(11) + .color(palette::text_dim()); + + let bar = row![ + prev, + Space::new().width(Length::Fixed(6.0)), + play, + Space::new().width(Length::Fixed(6.0)), + next, + Space::new().width(Length::Fixed(20.0)), + scrub, + Space::new().width(Length::Fixed(12.0)), + pos_label, + ] + .padding(Padding::from([0, 16])) + .align_y(iced_wgpu::core::Alignment::Center) + .height(Length::Fill); + + container(bar) + .width(Length::Fill) + .height(Length::Fixed(TRANSPORT_H)) + .style(panel_style) + .into() +} + +/// small soft accent-tinted text-label button. +fn chip_button<'a>( + label: &'a str, + msg: Message, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + iced_widget::button(text(label).size(13).color(palette::text())) + .padding(Padding::from([6, 12])) + .on_press(msg) + .style(|_t: &Theme, status: ButtonStatus| { + let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed); + button::Style { + background: Some(Background::Color(if hovered { + Color { + a: 0.12, + ..palette::accent() + } + } else { + Color { + a: 0.04, + ..palette::accent() + } + })), + text_color: palette::text(), + border: Border { + color: palette::border(), + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +/// chip button variant carrying an inline SVG glyph. +fn icon_chip_button<'a>( + glyph: &'static [u8], + msg: Message, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let handle = svg::Handle::from_memory(glyph); + let icon = svg::Svg::new(handle) + .width(Length::Fixed(16.0)) + .height(Length::Fixed(16.0)) + .style(|_t: &Theme, _status| svg::Style { + color: Some(palette::text()), + }); + + iced_widget::button(icon) + .padding(Padding::from([4, 10])) + .on_press(msg) + .style(|_t: &Theme, status: ButtonStatus| { + let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed); + button::Style { + background: Some(Background::Color(if hovered { + Color { + a: 0.12, + ..palette::accent() + } + } else { + Color { + a: 0.04, + ..palette::accent() + } + })), + text_color: palette::text(), + border: Border { + color: palette::border(), + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +/// large icon button used by the transport bar, dimmed and disabled without a track. +fn transport_button<'a>( + glyph: &'static [u8], + msg: Message, + enabled: bool, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let handle = svg::Handle::from_memory(glyph); + let tint = if enabled { palette::text() } else { palette::text_dim() }; + let icon = svg::Svg::new(handle) + .width(Length::Fixed(22.0)) + .height(Length::Fixed(22.0)) + .style(move |_t: &Theme, _status| svg::Style { color: Some(tint) }); + + let mut btn = iced_widget::button(icon).padding(Padding::from([6, 12])); + if enabled { + btn = btn.on_press(msg); + } + btn.style(|_t: &Theme, status: ButtonStatus| { + let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed); + button::Style { + background: if hovered { + Some(Background::Color(Color { + a: 0.10, + ..palette::accent() + })) + } else { + None + }, + text_color: palette::text(), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +/// flat sidebar-tinted background with a single hairline border. +fn panel_style(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(palette::sidebar())), + border: Border { + color: palette::border(), + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + } +} + +/// track-list sidebar background and border styling. +fn sidebar_style(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(palette::sidebar())), + border: Border { + color: palette::border(), + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + } +} + +/// faded square shown in place of cover art before the art worker resolves a track. +fn art_placeholder_style(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(Color { + a: 0.20, + ..palette::text_dim() + })), + border: Border { + color: palette::border(), + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + } +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..6777594 --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,25 @@ +use iced_wgpu::core::Color; + +/// dark-theme color tokens shared by every panel and widget. +pub mod palette { + use super::Color; + + const BG_C: Color = Color::from_rgb(0.06, 0.06, 0.08); + const SIDEBAR_C: Color = Color::from_rgb(0.09, 0.09, 0.12); + const BORDER_C: Color = Color::from_rgb(0.18, 0.18, 0.22); + const TEXT_C: Color = Color::from_rgb(0.92, 0.92, 0.95); + const TEXT_DIM_C: Color = Color::from_rgb(0.55, 0.55, 0.62); + const ACCENT_C: Color = Color::from_rgb(0.78, 0.62, 0.95); + + pub fn bg() -> Color { BG_C } + pub fn sidebar() -> Color { SIDEBAR_C } + pub fn border() -> Color { BORDER_C } + pub fn text() -> Color { TEXT_C } + pub fn text_dim() -> Color { TEXT_DIM_C } + pub fn accent() -> Color { ACCENT_C } +} + +/// supplies the clear color handed to the wgpu compositor each frame. +pub fn compositor_clear() -> Color { + palette::bg() +} diff --git a/src/viewport.rs b/src/viewport.rs new file mode 100644 index 0000000..cc22169 --- /dev/null +++ b/src/viewport.rs @@ -0,0 +1,513 @@ + + +use iced_graphics::{Shell as GShell, Viewport}; +use iced_runtime::user_interface::{self, UserInterface}; +use iced_wgpu::core::renderer::Style; +use iced_wgpu::core::time::Instant; +use iced_wgpu::core::{ + clipboard, keyboard, mouse, window, Color, Event, Font, Pixels, Point, Size, SmolStr, + Theme, +}; +use iced_wgpu::Engine; +use raw_window_handle::{RawDisplayHandle, RawWindowHandle}; + +use crate::ui::{theme, App, Message}; + +/// per-window bundle of the wgpu surface, iced renderer, App state, and pending input event queue. +pub struct ViewportHandle { + surface: wgpu::Surface<'static>, + device: wgpu::Device, + #[allow(dead_code)] + queue: wgpu::Queue, + format: wgpu::TextureFormat, + width: u32, + height: u32, + #[allow(dead_code)] + scale: f32, + renderer: iced_wgpu::Renderer, + viewport: Viewport, + cache: user_interface::Cache, + events: Vec, + cursor: mouse::Cursor, + needs_redraw: bool, + + /// most recent touch coordinate, source of scroll deltas across move events. + last_touch: Option<(f32, f32)>, + + /// touch-down anchor compared against the release point in the tap-vs-drag check. + touch_start: Option<(f32, f32)>, + + /// accumulated euclidean travel from touch-down, gating the tap-vs-scroll decision. + touch_drift: f32, + + /// marks an active touch originating over the sidebar, routing vertical drags to wheel scrolls. + touch_in_sidebar: bool, + pub state: App, +} + +/// stub clipboard handed to iced, returning empty on reads and dropping writes. +struct NullClipboard; +impl clipboard::Clipboard for NullClipboard { + fn read(&self, _kind: clipboard::Kind) -> Option { + None + } + fn write(&mut self, _kind: clipboard::Kind, _contents: String) {} +} + +impl ViewportHandle { + + /// builds a viewport surface from raw display+window handles supplied by the host shell. + pub fn new_from_raw( + raw_window: RawWindowHandle, + raw_display: RawDisplayHandle, + width: f32, + height: f32, + scale: f32, + ) -> Option { + let (instance, surface) = create_instance_and_surface(|instance| { + let target = wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: raw_display, + raw_window_handle: raw_window, + }; + unsafe { instance.create_surface_unsafe(target).ok() } + })?; + finalise(instance, surface, width, height, scale) + } + + /// drives one frame of state ticking, iced layout, and wgpu submission. + pub fn render_frame(&mut self) { + render(self); + } + + /// reconfigures the wgpu surface and iced viewport against the supplied size and scale factor. + pub fn resize_px(&mut self, width: f32, height: f32, scale: f32) { + let phys_w = (width * scale) as u32; + let phys_h = (height * scale) as u32; + if phys_w == 0 || phys_h == 0 { + return; + } + self.width = phys_w; + self.height = phys_h; + self.scale = scale; + self.viewport = Viewport::with_physical_size(Size::new(phys_w, phys_h), scale); + self.surface.configure( + &self.device, + &wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: self.format, + width: phys_w, + height: phys_h, + present_mode: wgpu::PresentMode::AutoVsync, + alpha_mode: wgpu::CompositeAlphaMode::Auto, + view_formats: vec![], + desired_maximum_frame_latency: 2, + }, + ); + self.needs_redraw = true; + } + + /// queues a cursor-moved event in logical coords and flags the viewport for redraw. + pub fn push_mouse_move(&mut self, x: f32, y: f32) { + let p = Point::new(x, y); + self.cursor = mouse::Cursor::Available(p); + self.events + .push(Event::Mouse(mouse::Event::CursorMoved { position: p })); + self.needs_redraw = true; + } + + /// queues a cursor-left event after the pointer exits the window. + pub fn push_mouse_left(&mut self) { + self.cursor = mouse::Cursor::Unavailable; + self.events.push(Event::Mouse(mouse::Event::CursorLeft)); + self.needs_redraw = true; + } + + /// queues a cursor move plus mouse button press or release at the supplied logical point. + pub fn push_mouse_button(&mut self, x: f32, y: f32, button: u32, pressed: bool) { + let p = Point::new(x, y); + self.cursor = mouse::Cursor::Available(p); + self.events + .push(Event::Mouse(mouse::Event::CursorMoved { position: p })); + let b = match button { + 0 => mouse::Button::Left, + 1 => mouse::Button::Right, + 2 => mouse::Button::Middle, + _ => return, + }; + let ev = if pressed { + mouse::Event::ButtonPressed(b) + } else { + mouse::Event::ButtonReleased(b) + }; + self.events.push(Event::Mouse(ev)); + self.needs_redraw = true; + } + + /// queues a wheel-scrolled event with pixel-delta semantics. + pub fn push_mouse_scroll(&mut self, x: f32, y: f32, dx: f32, dy: f32) { + let p = Point::new(x, y); + self.cursor = mouse::Cursor::Available(p); + self.events.push(Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: dx, y: dy }, + })); + self.needs_redraw = true; + } + + /// folds an iOS touch into mouse-style events, treating sidebar drags as wheel scrolls. + pub fn push_touch(&mut self, x: f32, y: f32, pressed: bool, moved: bool) { + const TOUCH_TAP_SLOP: f32 = 10.0; + if !pressed && !moved { + + if self.touch_in_sidebar { + if let Some((sx, sy)) = self.touch_start.take() { + if self.touch_drift <= TOUCH_TAP_SLOP { + self.push_mouse_button(sx, sy, 0, true); + self.push_mouse_button(sx, sy, 0, false); + } + } + } else { + self.push_mouse_button(x, y, 0, false); + } + self.last_touch = None; + self.touch_drift = 0.0; + self.touch_in_sidebar = false; + return; + } + if pressed && !moved { + + let h = self.viewport.logical_size().height; + self.touch_in_sidebar = self.state.point_in_sidebar(x, y, h); + self.touch_start = Some((x, y)); + self.touch_drift = 0.0; + self.last_touch = Some((x, y)); + if self.touch_in_sidebar { + self.push_mouse_move(x, y); + } else { + self.push_mouse_button(x, y, 0, true); + } + return; + } + + self.push_mouse_move(x, y); + if let Some((px, py)) = self.last_touch { + let dx = x - px; + let dy = y - py; + self.touch_drift += (dx * dx + dy * dy).sqrt(); + if self.touch_in_sidebar && dy.abs() > 0.0 { + let p = Point::new(x, y); + self.events.push(Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0.0, y: dy }, + })); + self.cursor = mouse::Cursor::Available(p); + } + } + self.last_touch = Some((x, y)); + } + + /// translates a host-encoded key triple into iced keyboard events with modifiers. + pub fn push_key_event( + &mut self, + named: u32, + utf8: Option, + mods: u32, + pressed: bool, + ) { + let text = utf8.map(SmolStr::from); + let key = match named { + 1 => keyboard::Key::Named(keyboard::key::Named::Enter), + 2 => keyboard::Key::Named(keyboard::key::Named::Escape), + 3 => keyboard::Key::Named(keyboard::key::Named::Backspace), + 4 => keyboard::Key::Named(keyboard::key::Named::Tab), + 5 => keyboard::Key::Named(keyboard::key::Named::ArrowLeft), + 6 => keyboard::Key::Named(keyboard::key::Named::ArrowRight), + 7 => keyboard::Key::Named(keyboard::key::Named::ArrowUp), + 8 => keyboard::Key::Named(keyboard::key::Named::ArrowDown), + 9 => keyboard::Key::Named(keyboard::key::Named::Delete), + 10 => keyboard::Key::Named(keyboard::key::Named::Home), + 11 => keyboard::Key::Named(keyboard::key::Named::End), + _ => match &text { + Some(s) => keyboard::Key::Character(s.clone()), + None => keyboard::Key::Unidentified, + }, + }; + let mut m = keyboard::Modifiers::empty(); + if mods & 1 != 0 { + m |= keyboard::Modifiers::SHIFT; + } + if mods & 2 != 0 { + m |= keyboard::Modifiers::CTRL; + } + if mods & 4 != 0 { + m |= keyboard::Modifiers::ALT; + } + if mods & 8 != 0 { + m |= keyboard::Modifiers::LOGO; + } + let physical = + keyboard::key::Physical::Unidentified(keyboard::key::NativeCode::Unidentified); + let event = if pressed { + keyboard::Event::KeyPressed { + key: key.clone(), + modified_key: key, + physical_key: physical, + location: keyboard::Location::Standard, + modifiers: m, + text, + repeat: false, + } + } else { + keyboard::Event::KeyReleased { + key: key.clone(), + modified_key: key, + physical_key: physical, + location: keyboard::Location::Standard, + modifiers: m, + } + }; + self.events.push(Event::Keyboard(event)); + self.needs_redraw = true; + } + + /// hands a folder picked by the host into the App's library loading flow. + pub fn apply_picked_folder(&mut self, path: std::path::PathBuf) { + self.state.update(Message::PickedFolder(path)); + self.needs_redraw = true; + } + + /// hands a single file picked by the host into the App's track loading flow. + pub fn apply_picked_file(&mut self, path: std::path::PathBuf) { + self.state.update(Message::PickedFile(path)); + self.needs_redraw = true; + } + + /// hands a multi-file pick from the host into the ad-hoc library loading flow. + pub fn apply_picked_files(&mut self, paths: Vec) { + self.state.update(Message::PickedFiles(paths)); + self.needs_redraw = true; + } + + /// updates the library import progress strip, hiding on a total of zero. + pub fn set_library_progress(&mut self, current: u32, total: u32) { + self.state.library_progress = if total == 0 { None } else { Some((current, total)) }; + self.needs_redraw = true; + } + + /// pushes placeholder track rows from the iOS host ahead of export completion. + pub fn set_pending_titles(&mut self, entries: Vec<(String, Option)>) { + self.state.set_pending_titles(entries); + self.needs_redraw = true; + } + + /// resolves a placeholder row to a real path once the host finishes the export. + pub fn set_track_path(&mut self, idx: usize, path: std::path::PathBuf) { + self.state.set_track_path(idx, path); + self.needs_redraw = true; + } + + /// forwards raw artwork bytes from the host to the art decode worker. + pub fn set_track_art_bytes(&mut self, idx: usize, bytes: Vec) { + self.state.set_track_art_bytes(idx, bytes); + self.needs_redraw = true; + } + + /// drains the App's picker request flag. + pub fn take_pending_pick(&mut self) -> u8 { + self.state.take_pending_pick() + } +} + +/// builds a wgpu instance restricted to the platform's preferred backend and obtains a surface from the caller. +fn create_instance_and_surface( + build_surface: F, +) -> Option<(wgpu::Instance, wgpu::Surface<'static>)> +where + F: FnOnce(&wgpu::Instance) -> Option>, +{ + #[cfg(any(target_os = "macos", target_os = "ios"))] + let backends = wgpu::Backends::METAL; + #[cfg(target_os = "windows")] + let backends = wgpu::Backends::DX12; + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))] + let backends = wgpu::Backends::all(); + + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends, + ..Default::default() + }); + let surface = build_surface(&instance)?; + Some((instance, surface)) +} + +/// completes wgpu adapter selection, configures the surface, loads the bundled font, and assembles the App. +fn finalise( + instance: wgpu::Instance, + surface: wgpu::Surface<'static>, + width: f32, + height: f32, + scale: f32, +) -> Option { + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + })) + .ok()?; + + let (device, queue) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?; + + let phys_w = (width * scale) as u32; + let phys_h = (height * scale) as u32; + + let caps = surface.get_capabilities(&adapter); + let format = caps.formats.first().copied()?; + let alpha_mode = caps + .alpha_modes + .first() + .copied() + .unwrap_or(wgpu::CompositeAlphaMode::Auto); + + surface.configure( + &device, + &wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: phys_w.max(1), + height: phys_h.max(1), + present_mode: wgpu::PresentMode::AutoVsync, + alpha_mode, + view_formats: vec![], + desired_maximum_frame_latency: 2, + }, + ); + + let engine = Engine::new( + &adapter, + device.clone(), + queue.clone(), + format, + None, + GShell::headless(), + ); + + static FONT_LOADED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + FONT_LOADED.get_or_init(|| { + let bytes: &'static [u8] = include_bytes!("../fonts/Inter-Regular.ttf"); + if let Ok(mut fs) = iced_graphics::text::font_system().write() { + fs.load_font(std::borrow::Cow::Borrowed(bytes)); + } + }); + let renderer = iced_wgpu::Renderer::new(engine, Font::with_name("Inter 18pt"), Pixels(14.0)); + let viewport = Viewport::with_physical_size(Size::new(phys_w.max(1), phys_h.max(1)), scale); + let state = App::new(device.clone(), queue.clone()); + + Some(ViewportHandle { + surface, + device, + queue, + format, + width: phys_w, + height: phys_h, + scale, + renderer, + viewport, + cache: user_interface::Cache::new(), + events: Vec::new(), + cursor: mouse::Cursor::Available(Point::new(width / 2.0, height / 2.0)), + needs_redraw: true, + last_touch: None, + touch_start: None, + touch_drift: 0.0, + touch_in_sidebar: false, + state, + }) +} + +/// runs one tick, rebuilds the UI, dispatches messages, and presents the next surface frame. +fn render(handle: &mut ViewportHandle) { + handle.state.tick(); + let pending = !handle.events.is_empty(); + let playing = handle + .state + .engine + .as_ref() + .map(|e| e.is_playing()) + .unwrap_or(false); + + let animating = playing + || handle.state.track_loading + || handle.state.library_progress.is_some(); + if !animating && !handle.needs_redraw && !pending { + return; + } + + let frame = match handle.surface.get_current_texture() { + Ok(f) => f, + Err(_e) => { + #[cfg(debug_assertions)] + { + use std::sync::atomic::{AtomicBool, Ordering}; + static LOGGED: AtomicBool = AtomicBool::new(false); + if !LOGGED.swap(true, Ordering::Relaxed) { + eprintln!("yr_crystals: surface acquire failed: {_e:?}"); + } + } + return; + } + }; + let view = frame.texture.create_view(&Default::default()); + let logical_size = handle.viewport.logical_size(); + + handle + .events + .push(Event::Window(window::Event::RedrawRequested(Instant::now()))); + + let cache = std::mem::take(&mut handle.cache); + let mut ui = UserInterface::build( + handle.state.view(), + Size::new(logical_size.width, logical_size.height), + cache, + &mut handle.renderer, + ); + + let mut clipboard = NullClipboard; + let mut messages: Vec = Vec::new(); + let drained: Vec = handle.events.drain(..).collect(); + let _ = ui.update( + &drained, + handle.cursor, + &mut handle.renderer, + &mut clipboard, + &mut messages, + ); + + let theme = Theme::Dark; + let style = Style { + text_color: Color::WHITE, + }; + + if messages.is_empty() { + ui.draw(&mut handle.renderer, &theme, &style, handle.cursor); + handle.cache = ui.into_cache(); + } else { + let cache = ui.into_cache(); + for msg in messages.drain(..) { + handle.state.update(msg); + } + let mut ui = UserInterface::build( + handle.state.view(), + Size::new(logical_size.width, logical_size.height), + cache, + &mut handle.renderer, + ); + ui.draw(&mut handle.renderer, &theme, &style, handle.cursor); + handle.cache = ui.into_cache(); + } + + let background = theme::compositor_clear(); + handle + .renderer + .present(Some(background), handle.format, &view, &handle.viewport); + frame.present(); + handle.needs_redraw = false; +} diff --git a/src/visualizer/build.rs b/src/visualizer/build.rs new file mode 100644 index 0000000..11049e9 --- /dev/null +++ b/src/visualizer/build.rs @@ -0,0 +1,84 @@ + + +use crate::visualizer::pipeline::CepVertex; +use crate::visualizer::state::VisState; + +/// emits a vertical cepstrum line plot centered in the viewport and fades the top and bottom edges. +pub fn build_cepstrum(out: &mut Vec, state: &VisState, w: f32, h: f32) { + out.clear(); + if state.smoothed_cepstrum.is_empty() { + return; + } + let q_start = 12_usize; + let q_end = 600_usize.min(state.smoothed_cepstrum.len()); + if q_end <= q_start { + return; + } + + let mut peak = 0.0_f32; + for i in q_start..q_end { + peak = peak.max(state.smoothed_cepstrum[i].abs()); + } + if peak < 1e-7 { + return; + } + let inv_peak = 1.0 / peak; + let max_disp = w * 0.06; + let cx = w * 0.5; + + let uc = state.unified_color; + let (cr, cg, cb_) = hsv_to_rgb(uc[0], (uc[1] * 0.7).clamp(0.0, 1.0), uc[2]); + let ca = 0.45_f32; + let fade_margin = 0.08_f32; + + let edge_fade = |t: f32| -> f32 { + if t < fade_margin { + t / fade_margin + } else if t > 1.0 - fade_margin { + (1.0 - t) / fade_margin + } else { + 1.0 + } + }; + + let mut prev_x = cx + state.smoothed_cepstrum[q_start] * inv_peak * max_disp; + let mut prev_y = 0.0_f32; + let mut prev_t = 0.0_f32; + + for i in q_start + 1..q_end { + let t = (i - q_start) as f32 / (q_end - q_start) as f32; + let y = t * h; + let x = cx + state.smoothed_cepstrum[i] * inv_peak * max_disp; + let a0 = ca * edge_fade(prev_t); + let a1 = ca * edge_fade(t); + out.push(CepVertex { + position: [prev_x, prev_y], + color: [cr, cg, cb_, a0], + }); + out.push(CepVertex { + position: [x, y], + color: [cr, cg, cb_, a1], + }); + prev_x = x; + prev_y = y; + prev_t = t; + } +} + +/// converts an hsv triple in 0..1 ranges into a linear rgb triple. +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { + let h = (h.fract() + 1.0).fract() * 6.0; + let i = h.floor(); + let f = h - i; + let p = v * (1.0 - s); + let q = v * (1.0 - s * f); + let t = v * (1.0 - s * (1.0 - f)); + match i as i32 % 6 { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + } +} diff --git a/src/visualizer/mod.rs b/src/visualizer/mod.rs new file mode 100644 index 0000000..44f89ab --- /dev/null +++ b/src/visualizer/mod.rs @@ -0,0 +1,81 @@ + + +pub mod build; +pub mod pipeline; +pub mod primitive; +pub mod state; + +use std::sync::Arc; + +use iced_wgpu::core::{mouse, Rectangle}; +use iced_widget::shader::Program; + +use crate::analyzer::FrameData; +use crate::visualizer::primitive::VisPrimitive; + +/// snapshot of every visualizer toggle and slider value. +#[derive(Debug, Clone, Copy)] +pub struct VizParams { + pub glass: bool, + pub entropy_on: bool, + pub entropy_strength: f32, + pub album_colors: bool, + pub mirrored: bool, + pub inverted: bool, + pub hue: f32, + pub contrast: f32, + pub brightness: f32, +} + +impl Default for VizParams { + fn default() -> Self { + Self { + glass: true, + entropy_on: false, + entropy_strength: 0.0, + album_colors: false, + mirrored: false, + inverted: false, + hue: 0.9, + contrast: 1.0, + brightness: 1.0, + } + } +} + +/// iced shader-widget bundle holding the latest analyzer frames, render params, and album palette. +#[derive(Debug, Clone)] +pub struct VisualizerProgram { + pub frames: Arc>, + pub params: VizParams, + pub palette: Option>>, +} + +impl VisualizerProgram { + pub fn new( + frames: Arc>, + params: VizParams, + palette: Option>>, + ) -> Self { + Self { frames, params, palette } + } +} + +impl Program for VisualizerProgram { + type State = (); + type Primitive = VisPrimitive; + + /// hands the frames, params, and palette across to the wgpu primitive layer. + fn draw( + &self, + _state: &Self::State, + _cursor: mouse::Cursor, + _bounds: Rectangle, + ) -> Self::Primitive { + VisPrimitive { + frames: self.frames.clone(), + params: self.params, + palette: self.palette.clone(), + } + } +} diff --git a/src/visualizer/pipeline.rs b/src/visualizer/pipeline.rs new file mode 100644 index 0000000..ec2e4c0 --- /dev/null +++ b/src/visualizer/pipeline.rs @@ -0,0 +1,445 @@ + + +use bytemuck::{Pod, Zeroable}; +use iced_wgpu::primitive::Pipeline; + +use crate::visualizer::state::VisState; + +/// gpu-side per-bin record consumed by the visualizer storage buffer. +#[repr(C)] +#[derive(Debug, Clone, Copy, Pod, Zeroable, Default)] +pub struct BinGpu { + pub log_x: f32, + pub visual_norm: f32, + pub primary_norm: f32, + pub bright_mod: f32, + pub alpha_mod: f32, + pub hue: f32, + pub sat: f32, + pub val: f32, +} + +/// uniform block holding viewport size, layout counts, render flags, and the unified glass color. +#[repr(C)] +#[derive(Debug, Clone, Copy, Pod, Zeroable)] +pub struct GlobalsGpu { + pub bounds: [f32; 2], + pub base: [f32; 2], + pub num_bins: u32, + pub num_channels: u32, + pub flags: u32, + pub fade_bins: u32, + pub hue_param: f32, + pub contrast: f32, + pub brightness: f32, + pub _pad0: f32, + pub unified_hue: f32, + pub unified_sat: f32, + pub unified_val: f32, + pub _pad1: f32, +} + +/// vertex for the cepstrum line plot, carrying pixel position and rgba color. +#[repr(C)] +#[derive(Debug, Clone, Copy, Pod, Zeroable)] +pub struct CepVertex { + pub position: [f32; 2], + pub color: [f32; 4], +} + +/// bitfield values packed into GlobalsGpu::flags. +pub const FLAG_GLASS: u32 = 1; +pub const FLAG_MIRRORED: u32 = 2; +pub const FLAG_INVERTED: u32 = 4; +pub const FLAG_STEREO: u32 = 8; + +/// owns the wgpu render pipelines, gpu buffers, and cpu-side smoothing state. +pub struct VisPipeline { + fill_pipeline: wgpu::RenderPipeline, + line_pipeline: wgpu::RenderPipeline, + cep_pipeline: wgpu::RenderPipeline, + + bind_group: wgpu::BindGroup, + globals_buf: wgpu::Buffer, + bins_buf: wgpu::Buffer, + bins_capacity: u64, + + cep_buf: wgpu::Buffer, + cep_capacity: u64, + pub cep_count: u32, + + pub state: VisState, + + pub fill_verts: u32, + pub line_verts: u32, + pub instances: u32, + + pub scratch_bins: Vec, + pub scratch_cep: Vec, +} + +const INITIAL_BINS_CAPACITY: u64 = 256 * 2; +const INITIAL_CEP_CAPACITY: u64 = 1024; + +impl Pipeline for VisPipeline { + /// builds the three render pipelines, allocates the uniform/storage/vertex buffers, and seeds the bind group. + fn new(device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("yr_crystals.visualizer.shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("../../shaders/visualizer.wgsl").into(), + ), + }); + + let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("yr_crystals.visualizer.bind_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("yr_crystals.visualizer.pipeline_layout"), + bind_group_layouts: &[&bind_layout], + push_constant_ranges: &[], + }); + + let blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::SrcAlpha, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + }; + + let target = wgpu::ColorTargetState { + format, + blend: Some(blend), + write_mask: wgpu::ColorWrites::ALL, + }; + + let fill_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("yr_crystals.visualizer.fill"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_fill"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(target.clone())], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + let line_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("yr_crystals.visualizer.line"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_line"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(target.clone())], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::LineList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + let cep_attrs = [ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: 8, + shader_location: 1, + format: wgpu::VertexFormat::Float32x4, + }, + ]; + + let cep_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("yr_crystals.visualizer.cep"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_cep"), + buffers: &[wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &cep_attrs, + }], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(target)], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::LineList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + let globals_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.visualizer.globals"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bins_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.visualizer.bins"), + size: INITIAL_BINS_CAPACITY * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("yr_crystals.visualizer.bind_group"), + layout: &bind_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: globals_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: bins_buf.as_entire_binding(), + }, + ], + }); + + let cep_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.visualizer.cep"), + size: INITIAL_CEP_CAPACITY * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + Self { + fill_pipeline, + line_pipeline, + cep_pipeline, + bind_group, + globals_buf, + bins_buf, + bins_capacity: INITIAL_BINS_CAPACITY, + cep_buf, + cep_capacity: INITIAL_CEP_CAPACITY, + cep_count: 0, + state: VisState::default(), + fill_verts: 0, + line_verts: 0, + instances: 0, + scratch_bins: Vec::with_capacity((INITIAL_BINS_CAPACITY) as usize), + scratch_cep: Vec::with_capacity(INITIAL_CEP_CAPACITY as usize), + } + } +} + +impl VisPipeline { + + /// pushes globals, scratch bins, and cepstrum vertices to the gpu, growing buffers if outgrown. + pub fn upload( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + globals: &GlobalsGpu, + num_channels: u32, + num_bins: u32, + instances: u32, + ) { + queue.write_buffer(&self.globals_buf, 0, bytemuck::bytes_of(globals)); + + if !self.scratch_bins.is_empty() { + let needed = self.scratch_bins.len() as u64; + if needed > self.bins_capacity { + let mut new_cap = self.bins_capacity.max(1); + while new_cap < needed { + new_cap *= 2; + } + self.bins_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.visualizer.bins"), + size: new_cap * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.bins_capacity = new_cap; + + self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("yr_crystals.visualizer.bind_group"), + layout: &device.create_bind_group_layout(&bind_layout_descriptor()), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.globals_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: self.bins_buf.as_entire_binding(), + }, + ], + }); + } + queue.write_buffer(&self.bins_buf, 0, bytemuck::cast_slice(&self.scratch_bins)); + } + + self.cep_count = self.scratch_cep.len() as u32; + if !self.scratch_cep.is_empty() { + let needed = self.scratch_cep.len() as u64; + if needed > self.cep_capacity { + let mut new_cap = self.cep_capacity.max(1); + while new_cap < needed { + new_cap *= 2; + } + self.cep_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("yr_crystals.visualizer.cep"), + size: new_cap * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.cep_capacity = new_cap; + } + queue.write_buffer(&self.cep_buf, 0, bytemuck::cast_slice(&self.scratch_cep)); + } + + let segs = num_bins.saturating_sub(1); + self.fill_verts = num_channels * segs * 6; + self.line_verts = num_channels * num_bins * 2; + self.instances = instances.max(1); + } + + /// records a single render pass over the fills, the bin outline, and any cepstrum overlay into the clip rect. + pub fn render_into( + &self, + encoder: &mut wgpu::CommandEncoder, + target: &wgpu::TextureView, + clip: &iced_wgpu::core::Rectangle, + ) { + if self.fill_verts == 0 && self.line_verts == 0 && self.cep_count == 0 { + return; + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("yr_crystals.visualizer.pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_viewport( + clip.x as f32, + clip.y as f32, + clip.width as f32, + clip.height as f32, + 0.0, + 1.0, + ); + pass.set_scissor_rect(clip.x, clip.y, clip.width, clip.height); + pass.set_bind_group(0, &self.bind_group, &[]); + + if self.fill_verts > 0 { + pass.set_pipeline(&self.fill_pipeline); + pass.draw(0..self.fill_verts, 0..self.instances); + } + if self.line_verts > 0 { + pass.set_pipeline(&self.line_pipeline); + pass.draw(0..self.line_verts, 0..self.instances); + } + if self.cep_count > 0 { + pass.set_pipeline(&self.cep_pipeline); + pass.set_vertex_buffer(0, self.cep_buf.slice(..)); + pass.draw(0..self.cep_count, 0..1); + } + } +} + +/// returns the bind-group layout that pairs the globals uniform with the bins storage buffer. +fn bind_layout_descriptor<'a>() -> wgpu::BindGroupLayoutDescriptor<'a> { + wgpu::BindGroupLayoutDescriptor { + label: Some("yr_crystals.visualizer.bind_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + } +} diff --git a/src/visualizer/primitive.rs b/src/visualizer/primitive.rs new file mode 100644 index 0000000..ffe801a --- /dev/null +++ b/src/visualizer/primitive.rs @@ -0,0 +1,120 @@ + + +use std::sync::Arc; + +use iced_wgpu::core::Rectangle; +use iced_wgpu::graphics::Viewport; +use iced_wgpu::primitive::Primitive; + +use crate::analyzer::FrameData; +use crate::visualizer::build; +use crate::visualizer::pipeline::{ + GlobalsGpu, VisPipeline, FLAG_GLASS, FLAG_INVERTED, FLAG_MIRRORED, FLAG_STEREO, +}; +use crate::visualizer::VizParams; + +/// frame snapshot carried from the iced widget into the wgpu primitive layer each draw. +#[derive(Debug, Clone)] +pub struct VisPrimitive { + pub frames: Arc>, + pub params: VizParams, + pub palette: Option>>, +} + +impl Primitive for VisPrimitive { + type Pipeline = VisPipeline; + + /// folds the frame into cpu state and uploads packed bins, cepstrum vertices, and globals to the gpu. + fn prepare( + &self, + pipeline: &mut Self::Pipeline, + device: &wgpu::Device, + queue: &wgpu::Queue, + bounds: &Rectangle, + viewport: &Viewport, + ) { + let palette = self.palette.as_deref().map(|v| v.as_slice()); + if !self.frames.is_empty() { + let frames_id = Arc::as_ptr(&self.frames) as usize; + pipeline + .state + .ingest(&self.frames, frames_id, &self.params, palette); + } + + let scale = viewport.scale_factor(); + let w_px = (bounds.width * scale).max(1.0); + let h_px = (bounds.height * scale).max(1.0); + + let stereo = pipeline.state.channels.len() > 1; + let num_channels = pipeline.state.channels.len() as u32; + let num_bins = pipeline + .state + .channels + .first() + .map(|c| c.bins.len() as u32) + .unwrap_or(0); + + let (base_w, base_h, instances) = if self.params.mirrored { + (w_px * 0.55, h_px * 0.5, 4u32) + } else { + (w_px, h_px, 1u32) + }; + + let mut flags = 0u32; + if self.params.glass { + flags |= FLAG_GLASS; + } + if self.params.mirrored { + flags |= FLAG_MIRRORED; + } + if self.params.inverted { + flags |= FLAG_INVERTED; + } + if stereo { + flags |= FLAG_STEREO; + } + + let uc = pipeline.state.unified_color; + let globals = GlobalsGpu { + bounds: [w_px, h_px], + base: [base_w, base_h], + num_bins, + num_channels, + flags, + fade_bins: if self.params.mirrored { 4 } else { 0 }, + hue_param: self.params.hue, + contrast: self.params.contrast, + brightness: self.params.brightness, + _pad0: 0.0, + unified_hue: uc[0], + unified_sat: uc[1], + unified_val: uc[2], + _pad1: 0.0, + }; + + let mut scratch_bins = std::mem::take(&mut pipeline.scratch_bins); + let mut scratch_cep = std::mem::take(&mut pipeline.scratch_cep); + pipeline + .state + .pack_bins(&self.frames, stereo, &mut scratch_bins); + scratch_cep.clear(); + if self.params.mirrored { + build::build_cepstrum(&mut scratch_cep, &pipeline.state, w_px, h_px); + } + pipeline.scratch_bins = scratch_bins; + pipeline.scratch_cep = scratch_cep; + + pipeline.upload(device, queue, &globals, num_channels, num_bins, instances); + } + + /// dispatches the prepared pipeline into the encoder against the iced-supplied clip rectangle. + fn render( + &self, + pipeline: &Self::Pipeline, + encoder: &mut wgpu::CommandEncoder, + target: &wgpu::TextureView, + clip_bounds: &iced_wgpu::core::Rectangle, + ) { + pipeline.render_into(encoder, target, clip_bounds); + } +} diff --git a/src/visualizer/state.rs b/src/visualizer/state.rs new file mode 100644 index 0000000..a863309 --- /dev/null +++ b/src/visualizer/state.rs @@ -0,0 +1,437 @@ + + +use std::collections::VecDeque; + +use crate::analyzer::FrameData; +use crate::palette; +use crate::visualizer::pipeline::BinGpu; +use crate::visualizer::VizParams; + +const HUE_HISTORY_LEN: usize = 40; +const HISTORY_LEN: usize = 30; + +/// per-bin smoothed magnitude, modulation offsets, palette color, and recent visual history. +#[derive(Debug, Clone, Default)] +pub struct BinState { + pub visual_db: f32, + pub primary_visual_db: f32, + pub last_raw_db: f32, + pub bright_mod: f32, + pub alpha_mod: f32, + pub cached_color: [f32; 3], + pub history: VecDeque, +} + +/// row of bins for one audio channel. +#[derive(Debug, Default, Clone)] +pub struct ChannelState { + pub bins: Vec, +} + +/// cpu-side smoothing state for the visualizer. +#[derive(Debug, Default)] +pub struct VisState { + pub channels: Vec, + pub hue_history: VecDeque<(f32, f32)>, + pub hue_sum_cos: f32, + pub hue_sum_sin: f32, + pub unified_color: [f32; 3], + pub smoothed_cepstrum: Vec, + + pub last_frames_id: usize, +} + +impl VisState { + + /// folds a fresh analyzer frame into the smoothed bin, hue, and cepstrum tracks. + pub fn ingest( + &mut self, + frames: &[FrameData], + frames_id: usize, + params: &VizParams, + palette: Option<&[[f32; 3]]>, + ) { + self.last_frames_id = frames_id; + + if self.channels.len() != frames.len() { + self.channels.resize(frames.len(), ChannelState::default()); + } + + if params.glass { + if let Some(f0) = frames.first() { + self.unified_color = self.update_glass_color(f0, params); + } + } else { + self.unified_color = [0.0, 0.0, 1.0]; + } + + for (ch_idx, frame) in frames.iter().enumerate() { + ingest_channel(&mut self.channels[ch_idx], frame, params, palette); + } + + if params.mirrored { + if let Some(f0) = frames.first() { + let raw = &f0.cepstrum; + if self.smoothed_cepstrum.len() != raw.len() { + self.smoothed_cepstrum = vec![0.0; raw.len()]; + } + for (i, r) in raw.iter().enumerate() { + self.smoothed_cepstrum[i] = 0.15 * r + 0.85 * self.smoothed_cepstrum[i]; + } + } + } + } + + /// flattens every channel's bins into the gpu-bound vector and applies a small x-shift to the right channel. + pub fn pack_bins(&self, frames: &[FrameData], stereo: bool, out: &mut Vec) { + out.clear(); + let n_bins = self.channels.first().map(|c| c.bins.len()).unwrap_or(0); + if n_bins == 0 { + return; + } + for (ch_idx, channel) in self.channels.iter().enumerate() { + let freqs = frames + .get(ch_idx) + .map(|f| f.freqs.as_slice()) + .unwrap_or(&[]); + let x_offset = if ch_idx == 1 && stereo { 1.005 } else { 1.0 }; + for (i, b) in channel.bins.iter().enumerate() { + let freq = freqs.get(i).copied().unwrap_or(0.0); + let visual_norm = ((b.visual_db + 80.0) / 80.0).clamp(0.0, 1.0); + let primary_norm = ((b.primary_visual_db + 80.0) / 80.0).clamp(0.0, 1.0); + out.push(BinGpu { + log_x: log_x(freq * x_offset), + visual_norm, + primary_norm, + bright_mod: b.bright_mod, + alpha_mod: b.alpha_mod, + hue: b.cached_color[0], + sat: b.cached_color[1], + val: b.cached_color[2], + }); + } + } + } + + /// derives a hue from spectral midpoint and mean amplitude, smoothed by a circular running mean. + fn update_glass_color(&mut self, f0: &FrameData, params: &VizParams) -> [f32; 3] { + let mid_freq = f0 + .freqs + .get(f0.freqs.len() / 2) + .copied() + .unwrap_or(1000.0); + let mean_db = if f0.db.is_empty() { + -80.0 + } else { + f0.db.iter().sum::() / f0.db.len() as f32 + }; + + let log_min = 20.0_f32.log10(); + let log_max = 20_000.0_f32.log10(); + let freq_norm = + (mid_freq.max(1e-9).log10() - log_min) / (log_max - log_min); + + let amp_norm = ((mean_db + 80.0) / 80.0).clamp(0.0, 1.0); + let amp_weight = (1.0 / (freq_norm + 1e-4).powf(5.0) * 2.0).clamp(0.5, 6.0); + + let mut hue = (freq_norm + amp_norm * amp_weight * params.hue).rem_euclid(1.0); + if params.mirrored { + hue = 1.0 - hue; + } + if hue < 0.0 { + hue += 1.0; + } + + let angle = hue * std::f32::consts::TAU; + let cos_v = angle.cos(); + let sin_v = angle.sin(); + + self.hue_history.push_back((cos_v, sin_v)); + self.hue_sum_cos += cos_v; + self.hue_sum_sin += sin_v; + if self.hue_history.len() > HUE_HISTORY_LEN { + if let Some((c, s)) = self.hue_history.pop_front() { + self.hue_sum_cos -= c; + self.hue_sum_sin -= s; + } + } + + let smoothed_angle = self.hue_sum_sin.atan2(self.hue_sum_cos); + let mut smoothed_hue = smoothed_angle / std::f32::consts::TAU; + if smoothed_hue < 0.0 { + smoothed_hue += 1.0; + } + [smoothed_hue, 1.0, 1.0] + } +} + +/// maps a frequency in hertz to a 0..1 horizontal position on the 20 hz to 20 khz log axis. +fn log_x(freq: f32) -> f32 { + let log_min = 20.0_f32.log10(); + let log_max = 20_000.0_f32.log10(); + if freq <= 0.0 { + return 0.0; + } + (freq.max(1e-9).log10() - log_min) / (log_max - log_min) +} + +/// updates one channel's bin smoothing, peak modulations, treble compensation, and palette colors. +fn ingest_channel( + channel: &mut ChannelState, + frame: &FrameData, + params: &VizParams, + palette: Option<&[[f32; 3]]>, +) { + let n = frame.db.len(); + if channel.bins.len() != n { + channel.bins.resize(n, BinState::default()); + } + if n == 0 { + return; + } + + let use_entropy = params.entropy_on; + + let mut bin_entropy = vec![0.0_f32; n]; + if use_entropy { + for (i, b) in channel.bins.iter().enumerate() { + bin_entropy[i] = calculate_entropy(&b.history); + } + } + let median_entropy = if use_entropy { + median_of(&bin_entropy) + } else { + 0.0 + }; + + for (i, b) in channel.bins.iter_mut().enumerate() { + let raw = frame.db[i]; + let primary = frame.primary_db.get(i).copied().unwrap_or(raw); + + let change = raw - b.visual_db; + if use_entropy { + let relative = median_entropy - bin_entropy[i]; + let base = 1.5_f32; + let reward_gain = base + params.entropy_strength; + let penalty_gain = base - params.entropy_strength; + let gain = if relative >= 0.0 { reward_gain } else { penalty_gain }; + let multiplier = (1.0 + relative * gain * 2.0).clamp(0.05, 4.0); + b.visual_db += change * multiplier; + b.history.push_back(b.visual_db); + while b.history.len() > HISTORY_LEN { + b.history.pop_front(); + } + } else { + let resp = 0.2_f32; + b.visual_db = b.visual_db * (1.0 - resp) + raw * resp; + + b.history.push_back(b.visual_db); + while b.history.len() > HISTORY_LEN { + b.history.pop_front(); + } + } + + let pattern_resp = 0.1_f32; + b.primary_visual_db = b.primary_visual_db * (1.0 - pattern_resp) + primary * pattern_resp; + b.last_raw_db = raw; + } + + let mut vertex_energy: Vec = channel + .bins + .iter() + .map(|b| ((b.primary_visual_db + 80.0) / 80.0).clamp(0.0, 1.0)) + .collect(); + + let split = n / 2; + let mut max_low = 0.01_f32; + let mut max_high = 0.01_f32; + for v in vertex_energy.iter().take(split) { + max_low = max_low.max(*v); + } + for v in vertex_energy.iter().skip(split) { + max_high = max_high.max(*v); + } + let treble_boost = (max_low / max_high).clamp(1.0, 40.0); + + let mut global_max = 0.001_f32; + for (j, v) in vertex_energy.iter_mut().enumerate() { + if j >= split { + let t = (j - split) as f32 / (n - split) as f32; + *v *= 1.0 + (treble_boost - 1.0) * t; + } + let compressed = v.tanh(); + *v = compressed; + if compressed > global_max { + global_max = compressed; + } + } + for v in vertex_energy.iter_mut() { + *v = (*v / global_max).clamp(0.0, 1.0); + } + + for b in channel.bins.iter_mut() { + b.bright_mod = 0.0; + b.alpha_mod = 0.0; + } + + let entropy_factor = if use_entropy { + params.entropy_strength.abs().max(0.1) + } else { + 1.0 + }; + + if n >= 3 { + for i in 1..n - 1 { + let curr = vertex_energy[i]; + let prev = vertex_energy[i - 1]; + let next = vertex_energy[i + 1]; + if curr > prev && curr > next { + let left_dominant = prev > next; + let sharpness = (curr - prev).min(curr - next); + let peak_intensity = + (sharpness * 10.0 * entropy_factor).powf(0.3).clamp(0.0, 1.0); + let decay_base = 0.65 - (sharpness * 3.0).clamp(0.0, 0.35); + + for d in 1..=12_i32 { + apply_pattern(&mut channel.bins, i, d, left_dominant, -1, peak_intensity, decay_base); + apply_pattern(&mut channel.bins, i, d, !left_dominant, 1, peak_intensity, decay_base); + } + } + } + } + + let use_palette = params.album_colors + && palette + .map(|p| !p.is_empty()) + .unwrap_or(false); + let denom = (n as f32 - 1.0).max(1.0); + for (i, b) in channel.bins.iter_mut().enumerate() { + if use_palette { + let pal = palette.unwrap(); + let plen = pal.len(); + let raw_idx = if plen >= n { + i * (plen - 1) / (n - 1).max(1) + } else { + i * plen / n.max(1) + }; + let pal_idx = if params.mirrored { + plen.saturating_sub(1).saturating_sub(raw_idx) + } else { + raw_idx + } + .min(plen - 1); + let rgb = pal[pal_idx]; + let (h, s, v) = palette::rgb_to_hsv(rgb[0], rgb[1], rgb[2]); + b.cached_color = [h, s, v]; + } else { + let mut hue = i as f32 / denom; + if params.mirrored { + hue = 1.0 - hue; + } + b.cached_color = [hue, 1.0, 1.0]; + } + } +} + +/// stamps a three-step bright/alpha modulation pattern outward from a peak bin, decaying with distance. +fn apply_pattern( + bins: &mut [BinState], + centre: usize, + dist: i32, + is_bright_side: bool, + direction: i32, + peak_intensity: f32, + decay_base: f32, +) { + let target = if direction == -1 { + centre as isize - dist as isize + } else { + centre as isize + dist as isize - 1 + }; + if target < 0 || target as usize >= bins.len() { + return; + } + let cycle = (dist - 1) / 3; + let step = (dist - 1) % 3; + let decay = decay_base.powi(cycle); + let intensity = peak_intensity * decay; + if intensity < 0.01 { + return; + } + let mut ty = step; + if is_bright_side { + ty = (ty + 2) % 3; + } + let bin = &mut bins[target as usize]; + match ty { + 0 => { + bin.bright_mod += 0.8 * intensity; + bin.alpha_mod -= 0.8 * intensity; + } + 1 => { + bin.bright_mod -= 0.8 * intensity; + bin.alpha_mod += 0.2 * intensity; + } + _ => { + bin.bright_mod += 0.8 * intensity; + bin.alpha_mod += 0.2 * intensity; + } + } +} + +/// scores deviation of a bin's recent history from a low-frequency reconstruction as the entropy proxy. +fn calculate_entropy(history: &VecDeque) -> f32 { + let buf: Vec = history.iter().copied().collect(); + let n = buf.len(); + if n < 4 { + return 0.0; + } + let nf = n as f64; + + let mut x_re = vec![0.0_f64; n]; + let mut x_im = vec![0.0_f64; n]; + for k in 0..n { + let mut re = 0.0; + let mut im = 0.0; + for (idx, &h) in buf.iter().enumerate() { + let angle = -2.0 * std::f64::consts::PI * k as f64 * idx as f64 / nf; + re += h as f64 * angle.cos(); + im += h as f64 * angle.sin(); + } + x_re[k] = re; + x_im[k] = im; + } + + for k in (n / 2 + 1)..n { + x_re[k] = 0.0; + x_im[k] = 0.0; + } + for k in 1..n.div_ceil(2) { + x_re[k] *= 2.0; + x_im[k] *= 2.0; + } + + let mut sq_sum = 0.0_f64; + for idx in 0..n { + let mut im = 0.0; + for k in 0..n { + let angle = 2.0 * std::f64::consts::PI * k as f64 * idx as f64 / nf; + im += x_re[k] * angle.sin() + x_im[k] * angle.cos(); + } + im /= nf; + sq_sum += im * im; + } + + ((sq_sum / nf).sqrt() as f32 / 10.0).clamp(0.0, 1.0) +} + +/// returns the median element via a partial selection sort over a copy. +fn median_of(values: &[f32]) -> f32 { + if values.is_empty() { + return 0.0; + } + let mut v = values.to_vec(); + let mid = v.len() / 2; + v.select_nth_unstable_by(mid, |a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + v[mid] +} diff --git a/src/weave.rs b/src/weave.rs new file mode 100644 index 0000000..1b370e4 --- /dev/null +++ b/src/weave.rs @@ -0,0 +1,212 @@ +#![allow(clippy::needless_range_loop)] + +use std::f64::consts::PI; + +/// smooths a curve by re-weaving log-spaced bins from local extrema with a cosine-bell window. +pub fn idealize_curve( + curve: &[f64], + granularity: i32, + detail: i32, + caps: Option, +) -> Vec { + let n = curve.len(); + if n == 0 { + return Vec::new(); + } + let mut current = curve.to_vec(); + + let mut num_bins = 4 + ((granularity as f64) / 100.0 * 60.0) as i32; + let mut iterations = 1 + ((detail as f64) / 100.0 * 4.0) as i32; + if let Some(c) = caps { + if let Some(b) = c.max_bins { + num_bins = num_bins.min(b); + } + if let Some(i) = c.max_iters { + iterations = iterations.min(i); + } + } + if num_bins < 1 { + num_bins = 1; + } + if iterations < 1 { + iterations = 1; + } + + for _ in 0..iterations { + let mut next = vec![0.0_f64; n]; + let mut is_set = vec![false; n]; + + let log_n = (n as f64).ln(); + let mut boundaries: Vec = Vec::with_capacity(num_bins as usize + 1); + for i in 0..=num_bins { + let ratio = (i as f64) / (num_bins as f64); + let b = (ratio * log_n).exp() as usize; + boundaries.push(b.clamp(1, n - 1)); + } + boundaries[0] = 0; + + for i in 0..num_bins as usize { + let bin_start = boundaries[i]; + let bin_end = boundaries[i + 1]; + if bin_start + 1 >= bin_end { + continue; + } + + let mut extrema: Vec<(usize, f64)> = Vec::new(); + for j in (bin_start + 1)..bin_end { + let prev = current[j - 1]; + let cur = current[j]; + let next_v = current[j + 1]; + if (cur > prev && cur > next_v) || (cur < prev && cur < next_v) { + extrema.push((j, cur)); + } + } + if extrema.is_empty() { + continue; + } + + let lobe_width = (bin_end - bin_start) as f64 / 2.0; + for j in bin_start..=bin_end { + let mut total = 0.0_f64; + let mut sum = 0.0_f64; + for &(idx, val) in &extrema { + let dist = j as f64 - idx as f64; + if dist.abs() < lobe_width { + let w = (((dist / lobe_width) * PI).cos() + 1.0) * 0.5; + sum += val * w; + total += w; + } + } + if total > 0.0 { + next[j] = sum / total; + is_set[j] = true; + } + } + } + + let mut last_set = 0usize; + if !is_set[0] { + for i in 0..n { + if is_set[i] { + last_set = i; + break; + } + } + } + for i in 1..n { + if is_set[i] { + if i > last_set + 1 { + let start_val = next[last_set]; + let end_val = next[i]; + let span = (i - last_set) as f64; + for j in (last_set + 1)..i { + let progress = (j - last_set) as f64 / span; + next[j] = start_val + (end_val - start_val) * progress; + } + } + last_set = i; + } + } + current = next; + } + + current +} + +/// reconstructs a curve by interpolating supplied values across the source's own extrema. +pub fn weave_anchors(source: &[f64], anchor_vals: &[f64]) -> Vec { + let n = source.len(); + if n == 0 { + return Vec::new(); + } + + let mut anchors: Vec = Vec::new(); + if n > 2 { + for i in 1..n - 1 { + let prev = source[i - 1]; + let cur = source[i]; + let next_v = source[i + 1]; + if (cur > prev && cur > next_v) || (cur < prev && cur < next_v) { + anchors.push(i); + } + } + } + if anchors.is_empty() { + return source.to_vec(); + } + debug_assert_eq!(anchors.len(), anchor_vals.len()); + + let mut out = vec![0.0_f64; n]; + for i in 0..n { + let mut total = 0.0_f64; + let mut sum = 0.0_f64; + for (k, &idx) in anchors.iter().enumerate() { + let prev_idx = if k > 0 { anchors[k - 1] } else { 0 }; + let next_idx = if k + 1 < anchors.len() { anchors[k + 1] } else { n - 1 }; + let mut lobe_width = (next_idx - prev_idx) as f64 / 2.0; + if lobe_width == 0.0 { + lobe_width = n as f64 / (2.0 * anchors.len() as f64); + } + let dist = i as f64 - idx as f64; + if dist.abs() < lobe_width { + let w = (((dist / lobe_width) * PI).cos() + 1.0) * 0.5; + sum += anchor_vals[k] * w; + total += w; + } + } + if total > 0.0 { + out[i] = sum / total; + } else { + + let pos = anchors.partition_point(|&a| a < i); + if pos == 0 { + out[i] = anchor_vals[0]; + } else if pos == anchors.len() { + out[i] = anchor_vals[anchors.len() - 1]; + } else { + let prev_idx = anchors[pos - 1]; + let next_idx = anchors[pos]; + let span = (next_idx - prev_idx) as f64; + let progress = (i - prev_idx) as f64 / span; + out[i] = anchor_vals[pos - 1] + + (anchor_vals[pos] - anchor_vals[pos - 1]) * progress; + } + } + } + out +} + +/// returns the indices of strict local maxima and minima in a curve. +pub fn local_extrema(curve: &[f64]) -> Vec { + let n = curve.len(); + if n < 3 { + return Vec::new(); + } + let mut out = Vec::new(); + for i in 1..n - 1 { + let prev = curve[i - 1]; + let cur = curve[i]; + let next_v = curve[i + 1]; + if (cur > prev && cur > next_v) || (cur < prev && cur < next_v) { + out.push(i); + } + } + out +} + +/// upper bounds on bin count and iteration depth. +#[derive(Debug, Clone, Copy, Default)] +pub struct Caps { + pub max_bins: Option, + pub max_iters: Option, +} + +impl Caps { + /// mobile preset capping bins to 30 and iterations to 2. + pub fn mobile() -> Self { + Self { + max_bins: Some(30), + max_iters: Some(2), + } + } +} diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..f706b5b --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xtask" +version = "0.0.0" +edition = "2021" +publish = false + +[[bin]] +name = "xtask" +path = "src/main.rs" diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..ef75f67 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,133 @@ + + +use std::env; +use std::path::PathBuf; +use std::process::{Command, ExitCode}; + +const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux", "ios", "android"]; + +/// dispatches a cargo xtask sub-command to the matching platform script under scripts/. +fn main() -> ExitCode { + let args: Vec = env::args().skip(1).collect(); + let cmd = args.first().map(String::as_str).unwrap_or(""); + + if cmd.is_empty() || cmd == "help" || cmd == "--help" || cmd == "-h" { + print_help(); + return ExitCode::from(2); + } + + let extra_args: Vec<&String> = args.iter().skip(1).collect(); + let (action, platform) = parse(cmd); + + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask manifest must have a parent") + .to_path_buf(); + + let (script, runner) = match platform.as_str() { + "windows" => ( + repo_root.join(format!("scripts/windows/{action}.ps1")), + vec![ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + ], + ), + "linux" | "macos" | "ios" | "android" => ( + repo_root.join(format!("scripts/{platform}/{action}.sh")), + vec!["bash"], + ), + other => { + eprintln!("unknown platform: {other}"); + return ExitCode::from(2); + } + }; + + if !script.exists() { + eprintln!("script not found: {}", script.display()); + return ExitCode::from(1); + } + + let extra_display = if extra_args.is_empty() { + String::new() + } else { + format!( + " {}", + extra_args.iter().map(|s| s.as_str()).collect::>().join(" "), + ) + }; + eprintln!("→ {} {}{}", runner.join(" "), script.display(), extra_display); + + let mut command = Command::new(runner[0]); + for arg in &runner[1..] { + command.arg(arg); + } + command.arg(&script); + for a in &extra_args { + command.arg(a.as_str()); + } + command.current_dir(&repo_root); + + match command.status() { + Ok(status) if status.success() => ExitCode::SUCCESS, + Ok(status) => ExitCode::from(status.code().unwrap_or(1) as u8), + Err(e) => { + eprintln!("failed to run {}: {e}", script.display()); + ExitCode::from(1) + } + } +} + +/// splits an action-platform command into (action, platform), defaulting to the host OS. +fn parse(cmd: &str) -> (String, String) { + if let Some(idx) = cmd.rfind('-') { + let suffix = &cmd[idx + 1..]; + if KNOWN_PLATFORMS.contains(&suffix) { + return (cmd[..idx].to_string(), suffix.to_string()); + } + } + (cmd.to_string(), current_platform().to_string()) +} + +/// resolves the host OS to a script directory name, exiting if the OS lacks scripts. +fn current_platform() -> &'static str { + match env::consts::OS { + "linux" => "linux", + "macos" => "macos", + "windows" => "windows", + other => { + eprintln!("unsupported OS: {other}"); + std::process::exit(2); + } + } +} + +/// prints command list, platform suffix syntax, and iOS provisioning hints. +fn print_help() { + eprintln!("usage: cargo xtask "); + eprintln!(); + eprintln!("commands:"); + eprintln!(" build release build for the current platform"); + eprintln!(" install release build + install (macOS: /Applications)"); + eprintln!(" debug debug build + foreground launch (live console on iOS)"); + eprintln!(" select-ios pick a physical device or simulator interactively"); + eprintln!(" select-android pick an attached Android device by adb serial"); + eprintln!(" bootstrap-android install android sdk packages from .android-sdk-packages"); + eprintln!(" generate-icons-android rasterize assets/Icon.svg into android mipmap buckets"); + eprintln!(" xcodeproj-ios generate ios/YrXtals.xcodeproj via xcodegen"); + eprintln!(" release-ios build an App Store-signed .ipa for Transporter"); + eprintln!(); + eprintln!("append -macos / -windows / -linux / -ios / -android to force a platform."); + eprintln!("trailing args pass through to the script — iOS scripts take [sim|device]."); + eprintln!(); + eprintln!("examples:"); + eprintln!(" cargo xtask select-ios # menu → saved to .yrxtls-ios-target"); + eprintln!(" cargo xtask debug-ios # honours saved target, no arg needed"); + eprintln!(" cargo xtask install-ios device # one-off override"); + eprintln!(" cargo xtask build-macos"); + eprintln!(); + eprintln!("iOS device builds default to ~/Downloads/All.mobileprovision. Override with"); + eprintln!("YRXTALS_IOS_PROFILE; YRXTALS_IOS_DEVICE / YRXTALS_IOS_SIM override the saved UDID."); +}