1.0.4 < this commit < 1.0.5
- pip on ios - gpu backend - whatever i said in the iOS app notes when i released 1.0.4 that i've forgotten already - oh yeah, and webgpu + a light canvas implementation for my website
This commit is contained in:
parent
05ba2789ba
commit
7a9126f626
|
|
@ -1,4 +1,5 @@
|
||||||
build/
|
build/
|
||||||
|
dist/
|
||||||
build_android/
|
build_android/
|
||||||
build_ios/
|
build_ios/
|
||||||
build_macos/
|
build_macos/
|
||||||
|
|
@ -34,3 +35,5 @@ android/app/src/main/res/mipmap-anydpi-v26/
|
||||||
android/app/src/main/jniLibs/
|
android/app/src/main/jniLibs/
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
|
||||||
|
web/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [".", "xtask"]
|
members = [".", "xtask", "web"]
|
||||||
default-members = ["."]
|
default-members = ["."]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# pre-build steps installing libasound2-dev for the target architecture.
|
||||||
|
|
||||||
|
[target.x86_64-unknown-linux-gnu]
|
||||||
|
pre-build = [
|
||||||
|
"dpkg --add-architecture $CROSS_DEB_ARCH",
|
||||||
|
"apt-get update && apt-get --assume-yes install libasound2-dev:$CROSS_DEB_ARCH",
|
||||||
|
]
|
||||||
|
|
||||||
|
[target.aarch64-unknown-linux-gnu]
|
||||||
|
pre-build = [
|
||||||
|
"dpkg --add-architecture $CROSS_DEB_ARCH",
|
||||||
|
"apt-get update && apt-get --assume-yes install libasound2-dev:$CROSS_DEB_ARCH",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128px" height="128px">
|
||||||
|
<path d="M 104.059 38.078 C 111.987 35.523 118.439 74.492 114.356 76.505 C 112.783 82.431 110.279 87.73 107.071 92.34 L 79.027 66.024 C 79.284 64.55 79.284 63.042 79.027 61.568 L 104.059 38.078 Z M 107.071 92.34 L 94.789 104.59 C 78.285 116.044 55.379 117.109 37.679 104.646 L 25.432 92.357 C 27.078 94.721 28.941 96.96 31.014 99.042 C 33.129 101.147 35.359 103.013 37.679 104.646 L 46.606 95.133 L 62.617 106.115 L 62.617 78.071 L 64.032 76.563 C 65.506 76.82 67.014 76.82 68.488 76.563 L 94.789 104.59 C 99.492 101.326 103.676 97.219 107.071 92.34 Z M 62.617 78.071 L 62.575 78.116 L 62.575 105.589 L 46.891 94.83 L 46.89 94.831 L 46.889 94.831 L 46.606 95.133 L 33.007 85.805 L 32.414 85.805 L 32.971 85.283 L 32.965 85.279 L 21.323 85.279 C 21.407 85.455 21.491 85.63 21.577 85.805 L 12.423 85.805 L 12.423 85.279 L 12.381 85.279 L 12.381 41.251 L 21.882 41.251 C 22.944 39.139 24.138 37.138 25.45 35.252 L 31.842 41.251 L 32.981 41.251 L 46.29 32.122 L 37.732 23.002 C 54.236 11.548 78.317 11.292 96.017 23.754 L 68.489 51.029 C 67.014 50.772 65.507 50.772 64.032 51.029 L 62.617 49.521 L 62.617 21.478 L 62.575 21.507 L 62.575 49.476 L 62.617 49.521 L 62.617 78.071 Z M 46.89 94.83 L 46.891 94.83 L 62.575 78.116 L 62.575 49.476 L 46.607 32.46 L 33.023 41.777 L 32.403 41.777 L 53.493 61.568 C 53.236 63.042 53.236 64.55 53.494 66.024 L 32.971 85.283 L 46.889 94.831 L 46.89 94.83 Z M 25.432 92.357 C 23.974 90.264 22.688 88.073 21.577 85.805 L 32.414 85.805 L 25.432 92.357 Z M 21.622 41.777 L 12.423 41.777 L 12.423 85.279 L 21.323 85.279 C 16.33 74.773 15.089 62.673 18.165 51.087 C 19.041 47.785 20.207 44.679 21.622 41.777 Z M 21.622 41.777 L 32.403 41.777 L 31.842 41.251 L 21.882 41.251 C 21.794 41.426 21.707 41.601 21.622 41.777 Z M 25.45 35.252 L 37.166 22.398 C 37.166 22.398 28.845 30.374 25.45 35.252 Z M 46.607 32.46 L 62.575 21.507 L 62.575 20.952 L 46.29 32.122 L 46.607 32.46 Z" style="stroke: rgb(0, 0, 0); fill: rgba(216, 137, 171, 0.424);"/>
|
||||||
|
<path d="M 62.153 19.448 L 62.153 108.552 L 32.989 86.966 L 12.41 86.966 L 12.41 40.917 L 33.147 40.917 L 62.153 19.448 Z" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128px" height="128px">
|
||||||
|
<path d="M 126.467 64.643 C 126.467 88.926 106.782 108.611 82.5 108.611 C 75.312 108.611 68.526 106.886 62.535 103.827 L 62.535 107.62 L 61.931 107.62 L 61.931 108.552 L 32.767 86.966 L 12.188 86.966 L 12.188 40.917 L 32.925 40.917 L 61.931 19.448 L 61.931 20.565 L 62.535 20.565 L 62.535 25.459 C 68.526 22.4 75.312 20.675 82.5 20.675 C 106.782 20.675 126.467 40.36 126.467 64.643 Z M 82.5 105.803 C 105.231 105.803 123.659 87.375 123.659 64.644 C 123.659 41.913 105.231 23.485 82.5 23.485 C 75.256 23.485 68.448 25.357 62.535 28.643 L 62.535 100.645 C 68.448 103.931 75.256 105.803 82.5 105.803 Z M 91.956 31.086 C 105.794 31.086 117.011 46.586 117.011 65.708 C 117.011 84.829 105.794 100.33 91.956 100.33 C 90.811 100.33 89.682 100.223 88.577 100.018 C 100.814 97.736 110.249 83.245 110.249 65.708 C 110.249 48.171 100.815 33.678 88.577 31.399 C 89.682 31.192 90.811 31.086 91.956 31.086 Z M 80.024 34.389 C 92.54 34.389 102.686 48.411 102.686 65.708 C 102.686 83.005 92.54 97.026 80.024 97.026 C 78.986 97.026 77.966 96.93 76.966 96.743 C 88.035 94.68 96.571 81.57 96.571 65.708 C 96.571 49.845 88.036 36.735 76.966 34.674 C 77.965 34.488 78.986 34.389 80.024 34.389 Z M 68.699 36.74 C 80.277 36.74 89.662 49.709 89.662 65.708 C 89.662 81.708 80.277 94.676 68.699 94.676 C 67.74 94.676 66.796 94.588 65.871 94.413 C 76.111 92.506 84.005 80.381 84.005 65.708 C 84.005 51.034 76.111 38.91 65.871 37.002 C 66.796 36.829 67.74 36.74 68.699 36.74 Z" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -0,0 +1,100 @@
|
||||||
|
// embeds assets/Icon.svg into the windows .exe via rsvg-convert, magick, llvm-windres, and rustc-link-arg-bins.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||||
|
if target_os != "windows" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let svg = "assets/Icon.svg";
|
||||||
|
let out_dir = std::env::var("OUT_DIR").expect("cargo OUT_DIR");
|
||||||
|
let tmp = format!("{out_dir}/icon_tmp");
|
||||||
|
let ico = format!("{out_dir}/icon.ico");
|
||||||
|
let rc = format!("{out_dir}/icon.rc");
|
||||||
|
let res = format!("{out_dir}/icon.res");
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-changed={svg}");
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
|
||||||
|
if !std::path::Path::new(svg).exists() {
|
||||||
|
println!("cargo:warning=no {svg} — skipping windows icon embed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::create_dir_all(&tmp);
|
||||||
|
let sizes = [16, 24, 32, 48, 64, 128, 256];
|
||||||
|
let mut pngs: Vec<String> = Vec::new();
|
||||||
|
for size in sizes {
|
||||||
|
let out = format!("{tmp}/icon_{size}.png");
|
||||||
|
let s = size.to_string();
|
||||||
|
if !run(&["rsvg-convert", "--width", &s, "--height", &s, svg, "-o", &out]) {
|
||||||
|
println!("cargo:warning=rsvg-convert failed — windows icon not embedded (brew install librsvg)");
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pngs.push(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut magick_args: Vec<&str> = pngs.iter().map(|s| s.as_str()).collect();
|
||||||
|
magick_args.push(&ico);
|
||||||
|
if !run_vec("magick", &magick_args) {
|
||||||
|
println!("cargo:warning=magick failed — windows icon not embedded (brew install imagemagick)");
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
|
|
||||||
|
if std::fs::write(&rc, "1 ICON \"icon.ico\"\r\n").is_err() {
|
||||||
|
println!("cargo:warning=could not write {rc} — windows icon not embedded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(windres) = locate_llvm_windres() else {
|
||||||
|
println!("cargo:warning=llvm-windres not found — windows icon not embedded (brew install llvm)");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !run(&[&windres, "-I", &out_dir, &rc, "-o", &res]) {
|
||||||
|
println!("cargo:warning=llvm-windres failed — windows icon not embedded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("cargo:rustc-link-arg-bins={res}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// resolves llvm-windres from $LLVM_WINDRES, Homebrew's keg-only llvm prefix on Apple Silicon and Intel, or $PATH.
|
||||||
|
fn locate_llvm_windres() -> Option<String> {
|
||||||
|
if let Ok(p) = std::env::var("LLVM_WINDRES") {
|
||||||
|
if std::path::Path::new(&p).exists() {
|
||||||
|
return Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let candidates = [
|
||||||
|
"/opt/homebrew/opt/llvm/bin/llvm-windres",
|
||||||
|
"/usr/local/opt/llvm/bin/llvm-windres",
|
||||||
|
];
|
||||||
|
for c in candidates {
|
||||||
|
if std::path::Path::new(c).exists() {
|
||||||
|
return Some(c.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if run(&["sh", "-c", "command -v llvm-windres"]) {
|
||||||
|
return Some("llvm-windres".to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(args: &[&str]) -> bool {
|
||||||
|
std::process::Command::new(args[0])
|
||||||
|
.args(&args[1..])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_vec(cmd: &str, args: &[&str]) -> bool {
|
||||||
|
std::process::Command::new(cmd)
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
@ -92,6 +92,13 @@ bool viewport_take_pending_pip_request(struct ViewportHandle *handle);
|
||||||
/// writes the latest left-channel bin dB values into a caller-provided float buffer. returns the number of values actually written.
|
/// writes the latest left-channel bin dB values into a caller-provided float buffer. returns the number of values actually written.
|
||||||
size_t viewport_get_pip_snapshot(struct ViewportHandle *handle, float *out, size_t max_len);
|
size_t viewport_get_pip_snapshot(struct ViewportHandle *handle, float *out, size_t max_len);
|
||||||
|
|
||||||
|
/// off-screen-renders one PiP frame of the visualizer into a caller-provided BGRA buffer.
|
||||||
|
void viewport_render_pip_to_bgra(struct ViewportHandle *handle,
|
||||||
|
uint8_t *dst,
|
||||||
|
uint32_t dst_stride,
|
||||||
|
uint32_t width,
|
||||||
|
uint32_t height);
|
||||||
|
|
||||||
/// serializes both settings slots as a JSON string. caller frees via viewport_free_string.
|
/// serializes both settings slots as a JSON string. caller frees via viewport_free_string.
|
||||||
char *viewport_get_settings_json(struct ViewportHandle *handle);
|
char *viewport_get_settings_json(struct ViewportHandle *handle);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,14 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.3</string>
|
<string>1.0.5</string>
|
||||||
<key>CFBundleSupportedPlatforms</key>
|
<key>CFBundleSupportedPlatforms</key>
|
||||||
<array>
|
<array>
|
||||||
<string>iPhoneOS</string>
|
<string>iPhoneOS</string>
|
||||||
<string>iPhoneSimulator</string>
|
<string>iPhoneSimulator</string>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0.3</string>
|
<string>1.0.5</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
|
@ -58,7 +58,6 @@
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
<string>picture-in-picture</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>UIDeviceFamily</key>
|
<key>UIDeviceFamily</key>
|
||||||
<array>
|
<array>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ class CaptureController {
|
||||||
/// true while AVAudioEngine is running through CaptureSession.
|
/// true while AVAudioEngine is running through CaptureSession.
|
||||||
var isCapturing: Bool { session.isCapturing }
|
var isCapturing: Bool { session.isCapturing }
|
||||||
|
|
||||||
/// true when the PipController reports an active PiP session. used by CaptureSession to skip teardown on resignActive.
|
/// true when the PipController reports an active PiP session.
|
||||||
var isPictureInPictureActive: Bool {
|
var isPictureInPictureActive: Bool {
|
||||||
if #available(iOS 15.0, *), let p = pipController as? PipController {
|
if #available(iOS 15.0, *), let p = pipController as? PipController {
|
||||||
return p.isPictureInPictureActive
|
return p.isPictureInPictureActive
|
||||||
|
|
@ -68,7 +68,7 @@ class CaptureController {
|
||||||
startPipFeedIfPossible()
|
startPipFeedIfPossible()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// lazily creates the PipController (iOS 15+) and starts feeding it visualizer snapshots so PiP entry is eligible the moment the user taps the chip.
|
/// lazily creates the iOS-15 PipController and starts its visualizer feed.
|
||||||
private func startPipFeedIfPossible() {
|
private func startPipFeedIfPossible() {
|
||||||
guard #available(iOS 15.0, *) else { return }
|
guard #available(iOS 15.0, *) else { return }
|
||||||
guard let v = view else { return }
|
guard let v = view else { return }
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,16 @@ import AVFoundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// drives an AVAudioEngine input tap on the device mic and pushes mono float PCM into the rust capture pipeline.
|
/// drives an AVAudioEngine input tap on the device mic and pushes mono float PCM into the rust capture pipeline.
|
||||||
/// stash-and-restart lifecycle: isCapturing remembers user intent; engine is fully torn down on backgrounding and rebuilt from scratch on every return to active state.
|
|
||||||
class CaptureSession {
|
class CaptureSession {
|
||||||
|
|
||||||
private var engine: AVAudioEngine?
|
private var engine: AVAudioEngine?
|
||||||
private(set) var isCapturing = false
|
private(set) var isCapturing = false
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
/// supplies the live viewport handle each tap. captured weakly to avoid extending the view's lifetime.
|
/// weak-captured live viewport handle source called once per tap.
|
||||||
var viewportHandleProvider: (() -> OpaquePointer?)?
|
var viewportHandleProvider: (() -> OpaquePointer?)?
|
||||||
|
|
||||||
/// reports whether Picture-in-Picture is currently active. when true, we skip teardown on resignActive so the engine keeps running in the PiP window.
|
/// reports whether Picture-in-Picture is currently active.
|
||||||
var pipActiveProvider: (() -> Bool)?
|
var pipActiveProvider: (() -> Bool)?
|
||||||
|
|
||||||
/// stashes the capture-on intent, runs the full configure + start sequence, and registers lifecycle observers.
|
/// stashes the capture-on intent, runs the full configure + start sequence, and registers lifecycle observers.
|
||||||
|
|
@ -33,17 +32,33 @@ class CaptureSession {
|
||||||
print("[YrXtals] CaptureSession stopped")
|
print("[YrXtals] CaptureSession stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// applies the low-latency mic AVAudioSession configuration.
|
/// applies the low-latency mic AVAudioSession configuration with .videoRecording mode.
|
||||||
private func configureSession() throws {
|
private func configureSession() throws {
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
try session.setCategory(
|
try session.setCategory(
|
||||||
.playAndRecord,
|
.playAndRecord,
|
||||||
mode: .measurement,
|
mode: .videoRecording,
|
||||||
options: [.defaultToSpeaker, .mixWithOthers],
|
options: [.defaultToSpeaker, .mixWithOthers, .allowBluetoothA2DP, .allowAirPlay],
|
||||||
)
|
)
|
||||||
try session.setPreferredSampleRate(48_000)
|
try session.setPreferredSampleRate(48_000)
|
||||||
try session.setPreferredIOBufferDuration(0.005)
|
try session.setPreferredIOBufferDuration(0.005)
|
||||||
try session.setActive(true, options: [])
|
try session.setActive(true, options: [])
|
||||||
|
pinOutputToSpeakerIfReceiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// forces the main speaker only when the current route includes the built-in receiver.
|
||||||
|
private func pinOutputToSpeakerIfReceiver() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
let outputs = session.currentRoute.outputs.map { $0.portType }
|
||||||
|
print("[YrXtals] audio route outputs: \(outputs.map { $0.rawValue })")
|
||||||
|
if outputs.contains(.builtInReceiver) {
|
||||||
|
do {
|
||||||
|
try session.overrideOutputAudioPort(.speaker)
|
||||||
|
print("[YrXtals] overrode output to .speaker (was on receiver)")
|
||||||
|
} catch {
|
||||||
|
print("[YrXtals] override to speaker failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// allocates a fresh AVAudioEngine, taps the input bus, and starts the engine.
|
/// allocates a fresh AVAudioEngine, taps the input bus, and starts the engine.
|
||||||
|
|
@ -62,6 +77,7 @@ class CaptureSession {
|
||||||
|
|
||||||
try engine.start()
|
try engine.start()
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
|
pinOutputToSpeakerIfReceiver()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// fully tears down anything that might be alive, then runs the configure + tap + start sequence from scratch.
|
/// fully tears down anything that might be alive, then runs the configure + tap + start sequence from scratch.
|
||||||
|
|
@ -115,6 +131,13 @@ class CaptureSession {
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
self?.handleMediaServicesReset()
|
self?.handleMediaServicesReset()
|
||||||
})
|
})
|
||||||
|
observers.append(center.addObserver(
|
||||||
|
forName: AVAudioSession.routeChangeNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main,
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.pinOutputToSpeakerIfReceiver()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func unregisterObservers() {
|
private func unregisterObservers() {
|
||||||
|
|
@ -123,7 +146,7 @@ class CaptureSession {
|
||||||
observers.removeAll()
|
observers.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// fully tears down on transition out of active state — unless PiP is active, in which case iOS keeps us alive in the floating window and we must keep the engine running.
|
/// tears down the engine + session on resignActive, except while a PiP session is active.
|
||||||
private func handleResignActive() {
|
private func handleResignActive() {
|
||||||
if pipActiveProvider?() == true {
|
if pipActiveProvider?() == true {
|
||||||
print("[YrXtals] resignActive but PiP active, keeping engine alive")
|
print("[YrXtals] resignActive but PiP active, keeping engine alive")
|
||||||
|
|
@ -133,7 +156,7 @@ class CaptureSession {
|
||||||
print("[YrXtals] CaptureSession torn down on resignActive")
|
print("[YrXtals] CaptureSession torn down on resignActive")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// runs the full start sequence on return to active state, but only when isCapturing was previously set.
|
/// runs the full start sequence on return to active state, only when isCapturing.
|
||||||
private func handleBecomeActive() {
|
private func handleBecomeActive() {
|
||||||
guard isCapturing else { return }
|
guard isCapturing else { return }
|
||||||
do {
|
do {
|
||||||
|
|
@ -166,7 +189,7 @@ class CaptureSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// handles iOS resetting the audio HAL: full teardown and full restart when we should be capturing.
|
/// full teardown and full restart triggered by the audio-HAL reset notification.
|
||||||
private func handleMediaServicesReset() {
|
private func handleMediaServicesReset() {
|
||||||
teardownEngineAndSession()
|
teardownEngineAndSession()
|
||||||
guard isCapturing else { return }
|
guard isCapturing else { return }
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ final class LibraryController: NSObject,
|
||||||
The File Coordinator might need to Cache. If you can, try files that are not downloaded from iCloud. Even with Keep Downloaded, the Cache of the URLs happens at varied intervals. Close the app, close files, open the app again, try again. It will work if you wait until the URLs have had time to Cache. There is nothing I can do to change this; it's not my bug.
|
The File Coordinator might need to Cache. If you can, try files that are not downloaded from iCloud. Even with Keep Downloaded, the Cache of the URLs happens at varied intervals. Close the app, close files, open the app again, try again. It will work if you wait until the URLs have had time to Cache. There is nothing I can do to change this; it's not my bug.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
/// claims the security-scoped folder URL and kicks off the coordinator-with-retry pipeline.
|
/// claims the security-scoped folder URL and starts the coordinator-with-retry pipeline.
|
||||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||||
let kind = pendingKind
|
let kind = pendingKind
|
||||||
pendingKind = 0
|
pendingKind = 0
|
||||||
|
|
@ -245,7 +245,7 @@ final class LibraryController: NSObject,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// fires when the watchdog elapses without a coordinator success; cancels the in-flight call and starts the retry countdown.
|
/// cancels the pending coordinator call and starts the retry countdown after the watchdog elapses.
|
||||||
private func handleAttemptTimeout(session: Int) {
|
private func handleAttemptTimeout(session: Int) {
|
||||||
guard session == coordSession else { return }
|
guard session == coordSession else { return }
|
||||||
activeCoordinator?.cancel()
|
activeCoordinator?.cancel()
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,18 @@ import UIKit
|
||||||
import CoreMedia
|
import CoreMedia
|
||||||
import CoreVideo
|
import CoreVideo
|
||||||
|
|
||||||
/// drives an AVPictureInPictureController backed by an AVSampleBufferDisplayLayer that we feed with a simplified bar visualization sampled from the rust analyzer.
|
/// drives an AVPictureInPictureController over an AVSampleBufferDisplayLayer fed by the Rust visualizer through an IOSurface-backed CVPixelBuffer.
|
||||||
@available(iOS 15.0, *)
|
@available(iOS 15.0, *)
|
||||||
class PipController: NSObject {
|
class PipController: NSObject {
|
||||||
|
|
||||||
weak var view: IcedViewportView?
|
weak var view: IcedViewportView?
|
||||||
private let displayLayer = AVSampleBufferDisplayLayer()
|
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||||
|
private let containerView = UIView()
|
||||||
private var pipController: AVPictureInPictureController?
|
private var pipController: AVPictureInPictureController?
|
||||||
private var displayLink: CADisplayLink?
|
private var displayLink: CADisplayLink?
|
||||||
private var binsBuffer: [Float] = Array(repeating: 0, count: 64)
|
private var pixelBufferPool: CVPixelBufferPool?
|
||||||
|
|
||||||
private let renderSize = CGSize(width: 320, height: 180)
|
private let renderSize = CGSize(width: 640, height: 360)
|
||||||
private static let displayTimescale: Int32 = 600
|
private static let displayTimescale: Int32 = 600
|
||||||
|
|
||||||
init(view: IcedViewportView) {
|
init(view: IcedViewportView) {
|
||||||
|
|
@ -22,6 +23,7 @@ class PipController: NSObject {
|
||||||
self.view = view
|
self.view = view
|
||||||
attachDisplayLayer()
|
attachDisplayLayer()
|
||||||
setupPipController()
|
setupPipController()
|
||||||
|
setupPixelBufferPool()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// returns true when AVPictureInPictureController reports an active PiP session.
|
/// returns true when AVPictureInPictureController reports an active PiP session.
|
||||||
|
|
@ -29,7 +31,7 @@ class PipController: NSObject {
|
||||||
pipController?.isPictureInPictureActive ?? false
|
pipController?.isPictureInPictureActive ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// begins the CADisplayLink that pulls bin magnitudes and enqueues sample buffers at ~30 fps.
|
/// starts the ~30fps CADisplayLink rendering one PiP frame per tick.
|
||||||
func startFeeding() {
|
func startFeeding() {
|
||||||
if displayLink != nil { return }
|
if displayLink != nil { return }
|
||||||
let link = CADisplayLink(target: self, selector: #selector(tick))
|
let link = CADisplayLink(target: self, selector: #selector(tick))
|
||||||
|
|
@ -45,27 +47,38 @@ class PipController: NSObject {
|
||||||
displayLayer.flushAndRemoveImage()
|
displayLayer.flushAndRemoveImage()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// requests AVPictureInPictureController to enter PiP. no-op when already active or unsupported.
|
/// toggles PiP, stopping when active and starting when not.
|
||||||
func enterPip() {
|
func enterPip() {
|
||||||
guard let pipController = pipController else { return }
|
guard let pipController = pipController else {
|
||||||
if pipController.isPictureInPictureActive { return }
|
print("[YrXtals] PiP requested but controller is nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pipController.isPictureInPictureActive {
|
||||||
|
pipController.stopPictureInPicture()
|
||||||
|
return
|
||||||
|
}
|
||||||
if !pipController.isPictureInPicturePossible {
|
if !pipController.isPictureInPicturePossible {
|
||||||
print("[YrXtals] PiP not yet possible (no samples enqueued?)")
|
print("[YrXtals] PiP rejected: isPictureInPicturePossible=false. supported=\(AVPictureInPictureController.isPictureInPictureSupported()) layerStatus=\(displayLayer.status.rawValue) ready=\(displayLayer.isReadyForMoreMediaData)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pipController.startPictureInPicture()
|
pipController.startPictureInPicture()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// places the AVSampleBufferDisplayLayer in the view hierarchy off-screen so it's a valid PiP source without obscuring the metal layer.
|
/// nests the AVSampleBufferDisplayLayer inside a 1pt UIView with a plain CALayer parent.
|
||||||
private func attachDisplayLayer() {
|
private func attachDisplayLayer() {
|
||||||
|
containerView.frame = CGRect(x: 0, y: 0, width: 1, height: 1)
|
||||||
|
containerView.isUserInteractionEnabled = false
|
||||||
|
containerView.backgroundColor = .clear
|
||||||
|
view?.addSubview(containerView)
|
||||||
|
|
||||||
displayLayer.videoGravity = .resizeAspect
|
displayLayer.videoGravity = .resizeAspect
|
||||||
displayLayer.frame = CGRect(origin: .zero, size: renderSize)
|
displayLayer.frame = containerView.bounds
|
||||||
displayLayer.bounds = CGRect(origin: .zero, size: renderSize)
|
displayLayer.bounds = containerView.bounds
|
||||||
displayLayer.opacity = 0
|
displayLayer.opacity = 0.01
|
||||||
view?.layer.addSublayer(displayLayer)
|
containerView.layer.addSublayer(displayLayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// wires up AVPictureInPictureController with the sample buffer display layer content source.
|
/// configures AVPictureInPictureController against the sample buffer display layer.
|
||||||
private func setupPipController() {
|
private func setupPipController() {
|
||||||
guard AVPictureInPictureController.isPictureInPictureSupported() else {
|
guard AVPictureInPictureController.isPictureInPictureSupported() else {
|
||||||
print("[YrXtals] PiP not supported on this device")
|
print("[YrXtals] PiP not supported on this device")
|
||||||
|
|
@ -77,46 +90,63 @@ class PipController: NSObject {
|
||||||
)
|
)
|
||||||
let controller = AVPictureInPictureController(contentSource: source)
|
let controller = AVPictureInPictureController(contentSource: source)
|
||||||
controller.delegate = self
|
controller.delegate = self
|
||||||
|
if #available(iOS 14.2, *) {
|
||||||
|
controller.canStartPictureInPictureAutomaticallyFromInline = false
|
||||||
|
}
|
||||||
pipController = controller
|
pipController = controller
|
||||||
}
|
}
|
||||||
|
|
||||||
/// pulls bin magnitudes from rust, renders a simplified bar visualization into a CMSampleBuffer, enqueues it.
|
/// builds the IOSurface-backed BGRA CVPixelBufferPool sized for the PiP frame.
|
||||||
|
private func setupPixelBufferPool() {
|
||||||
|
let attrs: [CFString: Any] = [
|
||||||
|
kCVPixelBufferPixelFormatTypeKey: Int(kCVPixelFormatType_32BGRA),
|
||||||
|
kCVPixelBufferWidthKey: Int(renderSize.width),
|
||||||
|
kCVPixelBufferHeightKey: Int(renderSize.height),
|
||||||
|
kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
|
||||||
|
kCVPixelBufferMetalCompatibilityKey: true,
|
||||||
|
kCVPixelBufferCGImageCompatibilityKey: true,
|
||||||
|
kCVPixelBufferCGBitmapContextCompatibilityKey: true,
|
||||||
|
]
|
||||||
|
var pool: CVPixelBufferPool?
|
||||||
|
let status = CVPixelBufferPoolCreate(
|
||||||
|
kCFAllocatorDefault,
|
||||||
|
nil,
|
||||||
|
attrs as CFDictionary,
|
||||||
|
&pool,
|
||||||
|
)
|
||||||
|
if status == kCVReturnSuccess {
|
||||||
|
pixelBufferPool = pool
|
||||||
|
} else {
|
||||||
|
print("[YrXtals] CVPixelBufferPoolCreate failed: \(status)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// renders one PiP frame from a pooled CVPixelBuffer through the Rust visualizer FFI into the display layer.
|
||||||
@objc private func tick() {
|
@objc private func tick() {
|
||||||
guard let view = view, let handle = view.viewportHandle else { return }
|
guard let view = view, let handle = view.viewportHandle else { return }
|
||||||
let n = binsBuffer.withUnsafeMutableBufferPointer { buf in
|
guard let pool = pixelBufferPool else { return }
|
||||||
viewport_get_pip_snapshot(handle, buf.baseAddress, buf.count)
|
|
||||||
}
|
var pixelBuffer: CVPixelBuffer?
|
||||||
if n == 0 {
|
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pixelBuffer)
|
||||||
// no frames yet, but iOS expects samples — enqueue a blank one so PiP eligibility flips on
|
guard status == kCVReturnSuccess, let pb = pixelBuffer else {
|
||||||
enqueueRenderedFrame(bins: [])
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let bins = Array(binsBuffer.prefix(n))
|
|
||||||
enqueueRenderedFrame(bins: bins)
|
CVPixelBufferLockBaseAddress(pb, [])
|
||||||
|
let base = CVPixelBufferGetBaseAddress(pb)
|
||||||
|
let stride = UInt32(CVPixelBufferGetBytesPerRow(pb))
|
||||||
|
let w = UInt32(CVPixelBufferGetWidth(pb))
|
||||||
|
let h = UInt32(CVPixelBufferGetHeight(pb))
|
||||||
|
if let dst = base?.assumingMemoryBound(to: UInt8.self) {
|
||||||
|
viewport_render_pip_to_bgra(handle, dst, stride, w, h)
|
||||||
|
}
|
||||||
|
CVPixelBufferUnlockBaseAddress(pb, [])
|
||||||
|
|
||||||
|
enqueue(pixelBuffer: pb)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// renders bins as vertical bars onto a CVPixelBuffer, wraps in CMSampleBuffer, enqueues to the display layer.
|
/// wraps the CVPixelBuffer in a CMSampleBuffer with host-clock timing and enqueues onto the display layer.
|
||||||
private func enqueueRenderedFrame(bins: [Float]) {
|
private func enqueue(pixelBuffer: CVPixelBuffer) {
|
||||||
guard let pixelBuffer = makePixelBuffer() else { return }
|
|
||||||
|
|
||||||
CVPixelBufferLockBaseAddress(pixelBuffer, [])
|
|
||||||
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
|
|
||||||
|
|
||||||
let width = CVPixelBufferGetWidth(pixelBuffer)
|
|
||||||
let height = CVPixelBufferGetHeight(pixelBuffer)
|
|
||||||
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
|
|
||||||
guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return }
|
|
||||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
||||||
let bitmapInfo: UInt32 = CGBitmapInfo.byteOrder32Little.rawValue
|
|
||||||
| CGImageAlphaInfo.premultipliedFirst.rawValue
|
|
||||||
guard let ctx = CGContext(
|
|
||||||
data: base, width: width, height: height,
|
|
||||||
bitsPerComponent: 8, bytesPerRow: bytesPerRow,
|
|
||||||
space: colorSpace, bitmapInfo: bitmapInfo,
|
|
||||||
) else { return }
|
|
||||||
|
|
||||||
drawBars(into: ctx, bins: bins, width: width, height: height)
|
|
||||||
|
|
||||||
var formatDescription: CMFormatDescription?
|
var formatDescription: CMFormatDescription?
|
||||||
CMVideoFormatDescriptionCreateForImageBuffer(
|
CMVideoFormatDescriptionCreateForImageBuffer(
|
||||||
allocator: kCFAllocatorDefault,
|
allocator: kCFAllocatorDefault,
|
||||||
|
|
@ -154,46 +184,6 @@ class PipController: NSObject {
|
||||||
}
|
}
|
||||||
displayLayer.enqueue(sb)
|
displayLayer.enqueue(sb)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// paints the bars onto the CG context. dB values normalized into 0..1 by the standard -60dB..0dB window.
|
|
||||||
private func drawBars(into ctx: CGContext, bins: [Float], width: Int, height: Int) {
|
|
||||||
ctx.setFillColor(CGColor(red: 0, green: 0, blue: 0, alpha: 1))
|
|
||||||
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
|
||||||
|
|
||||||
let count = max(bins.count, 1)
|
|
||||||
let barWidth = CGFloat(width) / CGFloat(count)
|
|
||||||
let h = CGFloat(height)
|
|
||||||
|
|
||||||
for (i, db) in bins.enumerated() {
|
|
||||||
let normalized = max(0, min(1, (CGFloat(db) + 60) / 60))
|
|
||||||
let barHeight = normalized * h * 0.9
|
|
||||||
let x = CGFloat(i) * barWidth
|
|
||||||
let y = (h - barHeight) / 2
|
|
||||||
let hue = CGFloat(i) / CGFloat(count)
|
|
||||||
let color = UIColor(hue: hue, saturation: 0.85, brightness: 1.0, alpha: 1.0).cgColor
|
|
||||||
ctx.setFillColor(color)
|
|
||||||
ctx.fill(CGRect(x: x + 1, y: y, width: max(barWidth - 2, 1), height: barHeight))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// allocates a BGRA CVPixelBuffer at the render size, IOSurface-backed for AVSampleBufferDisplayLayer compatibility.
|
|
||||||
private func makePixelBuffer() -> CVPixelBuffer? {
|
|
||||||
let attrs: [CFString: Any] = [
|
|
||||||
kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
|
|
||||||
kCVPixelBufferCGImageCompatibilityKey: true,
|
|
||||||
kCVPixelBufferCGBitmapContextCompatibilityKey: true,
|
|
||||||
]
|
|
||||||
var pb: CVPixelBuffer?
|
|
||||||
let status = CVPixelBufferCreate(
|
|
||||||
kCFAllocatorDefault,
|
|
||||||
Int(renderSize.width),
|
|
||||||
Int(renderSize.height),
|
|
||||||
kCVPixelFormatType_32BGRA,
|
|
||||||
attrs as CFDictionary,
|
|
||||||
&pb,
|
|
||||||
)
|
|
||||||
return status == kCVReturnSuccess ? pb : nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 15.0, *)
|
@available(iOS 15.0, *)
|
||||||
|
|
@ -203,7 +193,6 @@ extension PipController: AVPictureInPictureSampleBufferPlaybackDelegate {
|
||||||
_ pictureInPictureController: AVPictureInPictureController,
|
_ pictureInPictureController: AVPictureInPictureController,
|
||||||
setPlaying playing: Bool,
|
setPlaying playing: Bool,
|
||||||
) {
|
) {
|
||||||
// the visualizer "plays" continuously while in PiP; the toggle is ignored.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pictureInPictureControllerTimeRangeForPlayback(
|
func pictureInPictureControllerTimeRangeForPlayback(
|
||||||
|
|
@ -222,7 +211,6 @@ extension PipController: AVPictureInPictureSampleBufferPlaybackDelegate {
|
||||||
_ pictureInPictureController: AVPictureInPictureController,
|
_ pictureInPictureController: AVPictureInPictureController,
|
||||||
didTransitionToRenderSize newRenderSize: CMVideoDimensions,
|
didTransitionToRenderSize newRenderSize: CMVideoDimensions,
|
||||||
) {
|
) {
|
||||||
// ignored; we render at a fixed internal size and let AVKit scale.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pictureInPictureController(
|
func pictureInPictureController(
|
||||||
|
|
@ -267,4 +255,12 @@ extension PipController: AVPictureInPictureControllerDelegate {
|
||||||
) {
|
) {
|
||||||
print("[YrXtals] PiP failed to start: \(error)")
|
print("[YrXtals] PiP failed to start: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// completes AVKit's restore-UI handshake immediately.
|
||||||
|
func pictureInPictureController(
|
||||||
|
_ pictureInPictureController: AVPictureInPictureController,
|
||||||
|
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void,
|
||||||
|
) {
|
||||||
|
completionHandler(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// nudges the FileProvider XPC chain awake at launch so the first user-triggered picker presentation actually delivers events.
|
/// pre-warms the FileProvider XPC chain at launch.
|
||||||
private static func warmDocumentPicker() {
|
private static func warmDocumentPicker() {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
let warm = UIDocumentPickerViewController(forOpeningContentTypes: [.folder, .audio], asCopy: false)
|
let warm = UIDocumentPickerViewController(forOpeningContentTypes: [.folder, .audio], asCopy: false)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Cross-compile + zip distributables from a single macOS host.
|
||||||
|
#
|
||||||
|
# Targets: macos-aarch64, macos-x86_64, windows-aarch64, windows-x86_64, linux-aarch64, linux-x86_64.
|
||||||
|
# Output: dist/YrXtals-<target>.zip per target.
|
||||||
|
#
|
||||||
|
# Toolchain prerequisites:
|
||||||
|
# rustup, zip, codesign
|
||||||
|
# librsvg, iconutil
|
||||||
|
# imagemagick, llvm (for the windows .ico embed via build.rs)
|
||||||
|
# zig, cargo-zigbuild (windows cross-compile)
|
||||||
|
# docker, cross (linux cross-compile, since cpal pulls alsa-sys)
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) ;;
|
||||||
|
*) echo "package.sh: macOS host only (needs codesign + iconutil for the .app bundle)" >&2; exit 1;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# mirror .cargo/config.toml's /tmp target dir.
|
||||||
|
CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-/tmp/yr_crystals-target}"
|
||||||
|
export CARGO_TARGET_DIR
|
||||||
|
|
||||||
|
# raises the per-process file descriptor limit for the linker
|
||||||
|
ulimit -n 65536 2>/dev/null || ulimit -n 8192 2>/dev/null || true
|
||||||
|
|
||||||
|
ALL_TARGETS=(
|
||||||
|
macos-aarch64
|
||||||
|
macos-x86_64
|
||||||
|
windows-aarch64
|
||||||
|
windows-x86_64
|
||||||
|
linux-aarch64
|
||||||
|
linux-x86_64
|
||||||
|
)
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<EOF
|
||||||
|
usage: cargo xtask package --all
|
||||||
|
cargo xtask package --target <name> [--target <name> ...]
|
||||||
|
|
||||||
|
targets: ${ALL_TARGETS[*]}
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
TARGETS=()
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--all) TARGETS=("${ALL_TARGETS[@]}"); shift ;;
|
||||||
|
--target) [ $# -ge 2 ] || usage; TARGETS+=("$2"); shift 2 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) echo "unknown arg: $1" >&2; usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ ${#TARGETS[@]} -eq 0 ] && usage
|
||||||
|
|
||||||
|
need() { command -v "$1" >/dev/null 2>&1 || { echo "ERROR: $1 not found. $2" >&2; exit 1; }; }
|
||||||
|
|
||||||
|
need rustup "install rustup from https://rustup.rs"
|
||||||
|
need zip "comes with macOS"
|
||||||
|
|
||||||
|
NEEDS_ZIG=0
|
||||||
|
NEEDS_MAC_ICON=0
|
||||||
|
NEEDS_WIN_ICON=0
|
||||||
|
NEEDS_CROSS=0
|
||||||
|
for t in "${TARGETS[@]}"; do
|
||||||
|
case "$t" in
|
||||||
|
windows-*) NEEDS_ZIG=1; NEEDS_WIN_ICON=1 ;;
|
||||||
|
linux-*) NEEDS_CROSS=1 ;;
|
||||||
|
macos-*) NEEDS_MAC_ICON=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ $NEEDS_ZIG -eq 1 ]; then
|
||||||
|
need zig "brew install zig"
|
||||||
|
need cargo-zigbuild "cargo install cargo-zigbuild"
|
||||||
|
fi
|
||||||
|
if [ $NEEDS_MAC_ICON -eq 1 ]; then
|
||||||
|
need iconutil "comes with Xcode Command Line Tools (xcode-select --install)"
|
||||||
|
fi
|
||||||
|
if [ $NEEDS_WIN_ICON -eq 1 ]; then
|
||||||
|
need rsvg-convert "brew install librsvg"
|
||||||
|
need magick "brew install imagemagick"
|
||||||
|
# mirrors build.rs's llvm-windres resolution order.
|
||||||
|
if ! command -v llvm-windres >/dev/null 2>&1 \
|
||||||
|
&& [ ! -x /opt/homebrew/opt/llvm/bin/llvm-windres ] \
|
||||||
|
&& [ ! -x /usr/local/opt/llvm/bin/llvm-windres ] \
|
||||||
|
&& [ ! -x "${LLVM_WINDRES:-}" ]; then
|
||||||
|
echo "ERROR: llvm-windres not found. brew install llvm (or set LLVM_WINDRES to its absolute path)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ $NEEDS_CROSS -eq 1 ]; then
|
||||||
|
need cross "cargo install cross"
|
||||||
|
need docker "brew install --cask docker (or rancher, orbstack, etc) and start the daemon"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PKG="$ROOT/build/package"
|
||||||
|
DIST="$ROOT/dist"
|
||||||
|
mkdir -p "$PKG" "$DIST"
|
||||||
|
|
||||||
|
# shared 256px PNG for the windows + linux zips.
|
||||||
|
ICON_PNG="$ROOT/build/icon.png"
|
||||||
|
if [ ! -f "$ICON_PNG" ] || [ "$ROOT/assets/Icon.svg" -nt "$ICON_PNG" ]; then
|
||||||
|
if command -v rsvg-convert >/dev/null 2>&1 && [ -f "$ROOT/assets/Icon.svg" ]; then
|
||||||
|
rsvg-convert --width 256 --height 256 "$ROOT/assets/Icon.svg" -o "$ICON_PNG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
ensure_icns() {
|
||||||
|
local icns="$ROOT/build/AppIcon.icns"
|
||||||
|
if [ -f "$icns" ] && [ "$ROOT/assets/Icon.svg" -ot "$icns" ]; then return; fi
|
||||||
|
[ -f "$ROOT/assets/Icon.svg" ] || return 0
|
||||||
|
command -v rsvg-convert >/dev/null 2>&1 || return 0
|
||||||
|
|
||||||
|
local iconset="$ROOT/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" \
|
||||||
|
"$ROOT/assets/Icon.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 "$icns"
|
||||||
|
rm -rf "$iconset"
|
||||||
|
}
|
||||||
|
|
||||||
|
zip_target() {
|
||||||
|
local target="$1" path="$2"
|
||||||
|
local out="$DIST/YrXtals-${target}.zip"
|
||||||
|
rm -f "$out"
|
||||||
|
(cd "$(dirname "$path")" && zip -r -q "$out" "$(basename "$path")")
|
||||||
|
echo " → $out ($(du -h "$out" | cut -f1))"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_macos() {
|
||||||
|
local arch="$1" rust_target
|
||||||
|
case "$arch" in
|
||||||
|
aarch64) rust_target=aarch64-apple-darwin ;;
|
||||||
|
x86_64) rust_target=x86_64-apple-darwin ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
rustup target add "$rust_target" >/dev/null 2>&1 || true
|
||||||
|
ensure_icns
|
||||||
|
|
||||||
|
echo "==> macOS $arch ($rust_target)"
|
||||||
|
|
||||||
|
export MACOSX_DEPLOYMENT_TARGET=14.0
|
||||||
|
cargo build --release --bin yr_crystals --target "$rust_target"
|
||||||
|
|
||||||
|
local bin="$CARGO_TARGET_DIR/$rust_target/release/yr_crystals"
|
||||||
|
[ -f "$bin" ] || { echo "ERROR: yr_crystals missing for $rust_target" >&2; exit 1; }
|
||||||
|
|
||||||
|
local stage="$PKG/macos-${arch}"
|
||||||
|
local app="$stage/Yr Xtals.app"
|
||||||
|
rm -rf "$stage"
|
||||||
|
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
|
||||||
|
cp "$ROOT/macos/Info.plist" "$app/Contents/Info.plist"
|
||||||
|
cp "$bin" "$app/Contents/MacOS/yr_crystals"
|
||||||
|
[ -f "$ROOT/build/AppIcon.icns" ] && cp "$ROOT/build/AppIcon.icns" "$app/Contents/Resources/AppIcon.icns"
|
||||||
|
|
||||||
|
codesign --force --sign - "$app"
|
||||||
|
zip_target "macos-${arch}" "$app"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_windows() {
|
||||||
|
local arch="$1" rust_target
|
||||||
|
case "$arch" in
|
||||||
|
aarch64) rust_target=aarch64-pc-windows-gnullvm ;;
|
||||||
|
x86_64) rust_target=x86_64-pc-windows-gnu ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
rustup target add "$rust_target" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
echo "==> Windows $arch ($rust_target via cargo-zigbuild)"
|
||||||
|
|
||||||
|
cargo zigbuild --release --bin yr_crystals --target "$rust_target"
|
||||||
|
|
||||||
|
local stage="$PKG/windows-${arch}/YrXtals"
|
||||||
|
rm -rf "$stage"
|
||||||
|
mkdir -p "$stage"
|
||||||
|
|
||||||
|
cp "$CARGO_TARGET_DIR/$rust_target/release/yr_crystals.exe" "$stage/YrXtals.exe"
|
||||||
|
[ -f "$ICON_PNG" ] && cp "$ICON_PNG" "$stage/icon.png"
|
||||||
|
[ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE"
|
||||||
|
[ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$stage/README.md"
|
||||||
|
|
||||||
|
zip_target "windows-${arch}" "$stage"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_linux() {
|
||||||
|
local arch="$1" rust_target
|
||||||
|
case "$arch" in
|
||||||
|
aarch64) rust_target=aarch64-unknown-linux-gnu ;;
|
||||||
|
x86_64) rust_target=x86_64-unknown-linux-gnu ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# preinstalls the linux-x86_64 toolchain cross mounts into its amd64-only docker image.
|
||||||
|
rustup toolchain install stable-x86_64-unknown-linux-gnu --profile minimal --force-non-host >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
echo "==> Linux $arch ($rust_target via cross)"
|
||||||
|
|
||||||
|
# selects the amd64 variant of cross's image, the only platform manifest cross 0.2.5 publishes on ghcr.io.
|
||||||
|
# --target-dir target points the build at cross's mounted project volume, overriding the /tmp path from .cargo/config.toml.
|
||||||
|
(cd "$ROOT" && DOCKER_DEFAULT_PLATFORM=linux/amd64 cross build --release --bin yr_crystals --target "$rust_target" --target-dir target)
|
||||||
|
|
||||||
|
local bin="$ROOT/target/$rust_target/release/yr_crystals"
|
||||||
|
[ -f "$bin" ] || { echo "ERROR: yr_crystals missing for $rust_target" >&2; exit 1; }
|
||||||
|
|
||||||
|
local stage="$PKG/linux-${arch}/yrxtals"
|
||||||
|
rm -rf "$stage"
|
||||||
|
mkdir -p "$stage"
|
||||||
|
|
||||||
|
cp "$bin" "$stage/YrXtals"
|
||||||
|
chmod +x "$stage/YrXtals"
|
||||||
|
[ -f "$ICON_PNG" ] && cp "$ICON_PNG" "$stage/icon.png"
|
||||||
|
[ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE"
|
||||||
|
[ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$stage/README.md"
|
||||||
|
|
||||||
|
# self-contained installer bundled into the linux zip.
|
||||||
|
cat > "$stage/install.sh" <<'INSTALLER_EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
|
||||||
|
APP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||||||
|
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps"
|
||||||
|
mkdir -p "$BIN_DIR" "$APP_DIR" "$ICON_DIR"
|
||||||
|
|
||||||
|
pkill -x YrXtals 2>/dev/null || true
|
||||||
|
sleep 0.2
|
||||||
|
|
||||||
|
install -m 755 "$HERE/YrXtals" "$BIN_DIR/YrXtals"
|
||||||
|
[ -f "$HERE/icon.png" ] && install -m 644 "$HERE/icon.png" "$ICON_DIR/yrxtals.png"
|
||||||
|
|
||||||
|
cat > "$APP_DIR/yrxtals.desktop" <<DESKTOP
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Yr Xtals
|
||||||
|
Comment=Audio visualizer
|
||||||
|
Exec=$BIN_DIR/YrXtals
|
||||||
|
Icon=yrxtals
|
||||||
|
Terminal=false
|
||||||
|
Categories=AudioVideo;Audio;
|
||||||
|
DESKTOP
|
||||||
|
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
update-desktop-database "$APP_DIR" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installed:"
|
||||||
|
echo " binary → $BIN_DIR/YrXtals"
|
||||||
|
echo " icon → $ICON_DIR/yrxtals.png"
|
||||||
|
echo " desktop → $APP_DIR/yrxtals.desktop"
|
||||||
|
|
||||||
|
case ":$PATH:" in
|
||||||
|
*":$BIN_DIR:"*) ;;
|
||||||
|
*) echo "note: $BIN_DIR is not on your PATH" >&2 ;;
|
||||||
|
esac
|
||||||
|
INSTALLER_EOF
|
||||||
|
chmod +x "$stage/install.sh"
|
||||||
|
|
||||||
|
zip_target "linux-${arch}" "$stage"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "packaging: ${TARGETS[*]}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
for t in "${TARGETS[@]}"; do
|
||||||
|
case "$t" in
|
||||||
|
macos-aarch64) build_macos aarch64 ;;
|
||||||
|
macos-x86_64) build_macos x86_64 ;;
|
||||||
|
windows-aarch64) build_windows aarch64 ;;
|
||||||
|
windows-x86_64) build_windows x86_64 ;;
|
||||||
|
linux-aarch64) build_linux aarch64 ;;
|
||||||
|
linux-x86_64) build_linux x86_64 ;;
|
||||||
|
*) echo "unknown target: $t (valid: ${ALL_TARGETS[*]})" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "done. dist:"
|
||||||
|
ls -lh "$DIST"
|
||||||
|
|
@ -46,10 +46,10 @@ pub struct Analyzer {
|
||||||
/// soft cap on the live buffer length.
|
/// soft cap on the live buffer length.
|
||||||
live_buffer_max: usize,
|
live_buffer_max: usize,
|
||||||
|
|
||||||
/// smoothed AGC gain applied to incoming live PCM before it enters the analyzer buffer.
|
/// smoothed AGC gain applied to incoming live PCM.
|
||||||
live_gain: f64,
|
live_gain: f64,
|
||||||
|
|
||||||
/// dB threshold below which incoming live PCM chunks are replaced with silence.
|
/// dB cutoff for the live-mode noise gate.
|
||||||
noise_gate_db: f32,
|
noise_gate_db: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,8 +136,7 @@ impl Analyzer {
|
||||||
self.hilbert_needs_reset = true;
|
self.hilbert_needs_reset = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// retunes the three bands' transform lengths around a base frame size and routes the hilbert hop to match.
|
/// retunes the three bands' transform lengths and the hilbert hop, force-clearing every per-config buffer.
|
||||||
/// always force-clears processor buffers, smoothing history, hilbert state, and the live buffer so the result of a given (fft, hop) pair doesn't depend on the path taken to get there.
|
|
||||||
pub fn set_dsp_params(&mut self, frame_size: usize, hop_size: usize) {
|
pub fn set_dsp_params(&mut self, frame_size: usize, hop_size: usize) {
|
||||||
let trans_size = (frame_size / 4).max(64);
|
let trans_size = (frame_size / 4).max(64);
|
||||||
let deep_size = if frame_size < 2048 { frame_size * 4 } else { frame_size * 2 };
|
let deep_size = if frame_size < 2048 { frame_size * 4 } else { frame_size * 2 };
|
||||||
|
|
@ -269,7 +268,6 @@ impl Analyzer {
|
||||||
self.hilbert_needs_reset = true;
|
self.hilbert_needs_reset = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// chunk-RMS AGC: keep mic levels near a fixed target so a quiet phone-mic still drives the visualizer.
|
|
||||||
const TARGET_RMS: f64 = 0.15;
|
const TARGET_RMS: f64 = 0.15;
|
||||||
const MIN_RMS: f64 = 0.001;
|
const MIN_RMS: f64 = 0.001;
|
||||||
const MAX_GAIN: f64 = 50.0;
|
const MAX_GAIN: f64 = 50.0;
|
||||||
|
|
@ -286,7 +284,6 @@ impl Analyzer {
|
||||||
|
|
||||||
let ch = channels as usize;
|
let ch = channels as usize;
|
||||||
if rms_db < self.noise_gate_db {
|
if rms_db < self.noise_gate_db {
|
||||||
// chunk is below the gate — write silence at the same frame count to keep the hop clock alive.
|
|
||||||
let frames = if ch > 0 { samples.len() / ch } else { 0 };
|
let frames = if ch > 0 { samples.len() / ch } else { 0 };
|
||||||
for _ in 0..frames {
|
for _ in 0..frames {
|
||||||
self.live_buffer.push_back(0.0);
|
self.live_buffer.push_back(0.0);
|
||||||
|
|
@ -336,11 +333,10 @@ impl Analyzer {
|
||||||
self.live_buffer.pop_front();
|
self.live_buffer.pop_front();
|
||||||
}
|
}
|
||||||
|
|
||||||
// real-time anchor every PCM push, not only when step_live happens to fire. without this, audio piles up between step_lives whenever the worker is busy and the visualizer slides further behind reality until the buffer hits its 2-second cap.
|
|
||||||
self.trim_live_buffer();
|
self.trim_live_buffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// drops oldest live-buffer entries down to what the current (fft, hop) needs plus a small fixed real-time slack, so the visualizer always works against recent audio.
|
/// drops oldest live-buffer entries down to what the current (fft, hop) needs plus a fixed real-time slack.
|
||||||
fn trim_live_buffer(&mut self) {
|
fn trim_live_buffer(&mut self) {
|
||||||
let hop = self.hilbert_hop_size;
|
let hop = self.hilbert_hop_size;
|
||||||
let fft = self.hilbert_fft_size;
|
let fft = self.hilbert_fft_size;
|
||||||
|
|
@ -353,7 +349,6 @@ impl Analyzer {
|
||||||
} else {
|
} else {
|
||||||
hop * 2
|
hop * 2
|
||||||
};
|
};
|
||||||
// fixed ~30ms slack across all configs — keeps lag bounded regardless of hop size.
|
|
||||||
let slack_ms = 30usize;
|
let slack_ms = 30usize;
|
||||||
let slack_entries = if self.live_sample_rate == 0 {
|
let slack_entries = if self.live_sample_rate == 0 {
|
||||||
hop * 4
|
hop * 4
|
||||||
|
|
@ -378,7 +373,6 @@ impl Analyzer {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// defense-in-depth trim in case PCM stopped arriving briefly (no push_live_pcm calls but old buffered audio still around).
|
|
||||||
self.trim_live_buffer();
|
self.trim_live_buffer();
|
||||||
|
|
||||||
if self.hilbert_needs_reset {
|
if self.hilbert_needs_reset {
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,6 @@ fn run(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AnalyzerMode::Live => {
|
AnalyzerMode::Live => {
|
||||||
// drains queued hops with a wall-clock budget so heavy fft+small-hop configs don't burn a full tick on a single drain pass and starve the pcm channel.
|
|
||||||
let drain_deadline = Instant::now() + Duration::from_millis(8);
|
let drain_deadline = Instant::now() + Duration::from_millis(8);
|
||||||
let mut latest_owned: Option<Vec<FrameData>> = None;
|
let mut latest_owned: Option<Vec<FrameData>> = None;
|
||||||
for _ in 0..200 {
|
for _ in 0..200 {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use cpal::{SampleFormat, Stream};
|
||||||
use crate::analyzer_worker::PcmSender;
|
use crate::analyzer_worker::PcmSender;
|
||||||
use crate::devices;
|
use crate::devices;
|
||||||
|
|
||||||
/// owns the cpal input stream that pushes captured PCM into the analyzer worker.
|
/// owns the cpal input stream feeding the analyzer worker.
|
||||||
pub struct DesktopCapture {
|
pub struct DesktopCapture {
|
||||||
_stream: Stream,
|
_stream: Stream,
|
||||||
}
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ impl DesktopCapture {
|
||||||
Self::start_with_device(pusher, None)
|
Self::start_with_device(pusher, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// opens an input device by name (system default when name is None) and starts the capture stream.
|
/// opens an input device by name, falling back to the system default when name is None.
|
||||||
pub fn start_with_device(pusher: PcmSender, name: Option<&str>) -> Result<Self, String> {
|
pub fn start_with_device(pusher: PcmSender, name: Option<&str>) -> Result<Self, String> {
|
||||||
let device = devices::input_device(name)
|
let device = devices::input_device(name)
|
||||||
.ok_or_else(|| "no input device available".to_string())?;
|
.ok_or_else(|| "no input device available".to_string())?;
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,10 @@ struct Args {
|
||||||
|
|
||||||
const ARGS_BYTES: u64 = std::mem::size_of::<Args>() as u64;
|
const ARGS_BYTES: u64 = std::mem::size_of::<Args>() as u64;
|
||||||
|
|
||||||
/// number of pipelined slots. 2 is enough for a one-frame lag (one in-flight + one available for new submit) — each slot's scratch buffer is free by the time we cycle back to it.
|
/// pipelined slot count. two slots sustain the one-frame lag pattern without barrier-serialization on a shared scratch buffer.
|
||||||
const NUM_SLOTS: usize = 2;
|
const NUM_SLOTS: usize = 2;
|
||||||
|
|
||||||
/// per-slot gpu resources: each in-flight submission gets exclusive use of its slot, so the next submission can be queued onto a different slot without barriers serializing them on the shared scratch buffer.
|
/// per-slot gpu resources owned exclusively by one pending submission.
|
||||||
struct GpuSlot {
|
struct GpuSlot {
|
||||||
data_buf: wgpu::Buffer,
|
data_buf: wgpu::Buffer,
|
||||||
scratch_buf: wgpu::Buffer,
|
scratch_buf: wgpu::Buffer,
|
||||||
|
|
@ -31,7 +31,7 @@ struct GpuSlot {
|
||||||
staging_mapped: bool,
|
staging_mapped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// fixed-size 1D radix-2 FFT with N pipelined slots. submit_forward queues work onto the next slot; try_collect_into drains the oldest pending submission specifically by its SubmissionIndex (no device-wide wait).
|
/// fixed-size 1D radix-2 FFT with N pipelined slots, polled by SubmissionIndex.
|
||||||
pub struct GpuFft1D {
|
pub struct GpuFft1D {
|
||||||
device: wgpu::Device,
|
device: wgpu::Device,
|
||||||
queue: wgpu::Queue,
|
queue: wgpu::Queue,
|
||||||
|
|
@ -50,7 +50,7 @@ pub struct GpuFft1D {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GpuFft1D {
|
impl GpuFft1D {
|
||||||
/// allocates pipelines, per-slot buffers and bind groups for an N-point FFT (N a power of two), then primes the first slot with a zero submission so the first try_collect_into never returns false.
|
/// allocates pipelines, per-slot buffers and bind groups for an N-point FFT, and primes the first slot with a zero submission.
|
||||||
pub fn new(device: wgpu::Device, queue: wgpu::Queue, n: u32) -> Self {
|
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");
|
assert!(n.is_power_of_two() && n >= 2, "fft size must be a power of two ≥ 2");
|
||||||
let log2_n = n.trailing_zeros();
|
let log2_n = n.trailing_zeros();
|
||||||
|
|
@ -197,7 +197,6 @@ impl GpuFft1D {
|
||||||
pending: VecDeque::with_capacity(NUM_SLOTS),
|
pending: VecDeque::with_capacity(NUM_SLOTS),
|
||||||
};
|
};
|
||||||
|
|
||||||
// prime: queue one zero submission so the first real call's collect always returns something (the zero result) instead of None. only one prime is needed for the lag pattern; the other slot stays available for the first real submit.
|
|
||||||
let zero = vec![Complex64::new(0.0, 0.0); n as usize];
|
let zero = vec![Complex64::new(0.0, 0.0); n as usize];
|
||||||
this.submit_forward(&zero);
|
this.submit_forward(&zero);
|
||||||
|
|
||||||
|
|
@ -220,14 +219,13 @@ impl GpuFft1D {
|
||||||
self.submit(input, true);
|
self.submit(input, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// drains the oldest pending submission into the output slice. returns false only when nothing is pending (the prime guarantees this never happens in steady state).
|
/// drains the oldest pending submission into the output slice, returning false only when nothing is pending.
|
||||||
pub fn try_collect_into(&mut self, out: &mut [Complex64]) -> bool {
|
pub fn try_collect_into(&mut self, out: &mut [Complex64]) -> bool {
|
||||||
debug_assert_eq!(out.len(), self.n as usize);
|
debug_assert_eq!(out.len(), self.n as usize);
|
||||||
let Some((slot_idx, sub_idx)) = self.pending.pop_front() else {
|
let Some((slot_idx, sub_idx)) = self.pending.pop_front() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// wait specifically for OUR submission, not the device's most recent globally. this keeps the wait deterministic when other rayon threads and the iced renderer are also submitting to the same device.
|
|
||||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||||
submission_index: Some(sub_idx),
|
submission_index: Some(sub_idx),
|
||||||
timeout: None,
|
timeout: None,
|
||||||
|
|
@ -239,7 +237,6 @@ impl GpuFft1D {
|
||||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||||
let _ = tx.send(r);
|
let _ = tx.send(r);
|
||||||
});
|
});
|
||||||
// drive the map callback. submission is already complete, so this returns quickly.
|
|
||||||
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
|
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
|
||||||
rx.recv().expect("map_async result").expect("map ok");
|
rx.recv().expect("map_async result").expect("map ok");
|
||||||
slot.staging_mapped = true;
|
slot.staging_mapped = true;
|
||||||
|
|
@ -256,7 +253,7 @@ impl GpuFft1D {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// drains every pending submission without reading its staging buffer, then re-primes with a single zero submission. used when processor state is cleared so the next get_spectrum sees a fresh pipeline instead of a stale frame from before the reset.
|
/// drains every pending submission without reading its staging buffer and re-primes with a single zero submission.
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
while let Some((slot_idx, sub_idx)) = self.pending.pop_front() {
|
while let Some((slot_idx, sub_idx)) = self.pending.pop_front() {
|
||||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||||
|
|
@ -301,7 +298,7 @@ impl GpuFft1D {
|
||||||
self.cached_inverse.set(Some(inverse));
|
self.cached_inverse.set(Some(inverse));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// uploads input to the next slot, dispatches bit-reversal + log2(N) butterfly passes against that slot's exclusive resources, queues the readback copy, and records the submission index for later collection.
|
/// uploads input to the next slot, dispatches bit-reversal and log2(N) butterfly passes, queues the readback copy, and records the submission index in the pending FIFO.
|
||||||
fn submit(&mut self, input: &[Complex64], inverse: bool) {
|
fn submit(&mut self, input: &[Complex64], inverse: bool) {
|
||||||
debug_assert_eq!(input.len(), self.n as usize);
|
debug_assert_eq!(input.len(), self.n as usize);
|
||||||
self.ensure_all_args(inverse);
|
self.ensure_all_args(inverse);
|
||||||
|
|
|
||||||
13
src/ios.rs
13
src/ios.rs
|
|
@ -406,6 +406,19 @@ pub extern "C" fn viewport_take_pending_persist_settings(handle: *mut ViewportHa
|
||||||
h.take_pending_persist_settings()
|
h.take_pending_persist_settings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// off-screen-renders one PiP frame of the visualizer into the provided BGRA buffer at the given row stride.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn viewport_render_pip_to_bgra(
|
||||||
|
handle: *mut ViewportHandle,
|
||||||
|
dst: *mut u8,
|
||||||
|
dst_stride: u32,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) {
|
||||||
|
let Some(h) = (unsafe { handle.as_mut() }) else { return };
|
||||||
|
h.render_pip_to_bgra(dst, dst_stride, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
/// releases a CString previously handed to Swift over the FFI boundary.
|
/// releases a CString previously handed to Swift over the FFI boundary.
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub extern "C" fn viewport_free_string(s: *mut c_char) {
|
pub extern "C" fn viewport_free_string(s: *mut c_char) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||||
|
|
||||||
/// hands off to the winit-driven shell.
|
/// hands off to the winit-driven shell.
|
||||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,9 @@ pub struct Processor {
|
||||||
window: Vec<f64>,
|
window: Vec<f64>,
|
||||||
buffer: Vec<Complex64>,
|
buffer: Vec<Complex64>,
|
||||||
|
|
||||||
custom_bins: Vec<f64>,
|
sample_freqs: Vec<f64>,
|
||||||
freqs_const: Vec<f64>,
|
freqs_const: Vec<f64>,
|
||||||
|
num_bins: usize,
|
||||||
|
|
||||||
history: VecDeque<Vec<f64>>,
|
history: VecDeque<Vec<f64>>,
|
||||||
smoothing_length: usize,
|
smoothing_length: usize,
|
||||||
|
|
@ -70,8 +71,9 @@ impl Processor {
|
||||||
gpu_blend: 0.0,
|
gpu_blend: 0.0,
|
||||||
window: Vec::new(),
|
window: Vec::new(),
|
||||||
buffer: Vec::new(),
|
buffer: Vec::new(),
|
||||||
custom_bins: Vec::new(),
|
sample_freqs: Vec::new(),
|
||||||
freqs_const: Vec::new(),
|
freqs_const: Vec::new(),
|
||||||
|
num_bins: 26,
|
||||||
history: VecDeque::new(),
|
history: VecDeque::new(),
|
||||||
smoothing_length: 3,
|
smoothing_length: 3,
|
||||||
expand_ratio: 1.0,
|
expand_ratio: 1.0,
|
||||||
|
|
@ -123,27 +125,44 @@ impl Processor {
|
||||||
self.gpu_blend = blend.clamp(0.0, 1.0);
|
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.
|
/// rebuilds the 40 hz to 11 khz bin edges and matching center frequencies for the n+1 output columns.
|
||||||
pub fn set_num_bins(&mut self, n: usize) {
|
pub fn set_num_bins(&mut self, n: usize) {
|
||||||
self.custom_bins.clear();
|
self.num_bins = n.max(1);
|
||||||
|
self.rebuild_bins();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rebuilds the FFT sampling targets (linear at small FFT for low-end coverage, log otherwise) and the always-log display centers.
|
||||||
|
fn rebuild_bins(&mut self) {
|
||||||
|
self.sample_freqs.clear();
|
||||||
self.freqs_const.clear();
|
self.freqs_const.clear();
|
||||||
self.history.clear();
|
self.history.clear();
|
||||||
|
|
||||||
|
let n = self.num_bins.max(1);
|
||||||
let min_freq = 40.0_f64;
|
let min_freq = 40.0_f64;
|
||||||
let max_freq = 11_000.0_f64;
|
let max_freq = 11_000.0_f64;
|
||||||
|
let linear = self.frame_size > 0 && self.frame_size <= SMALL_FFT_THRESHOLD;
|
||||||
|
|
||||||
|
let mut sample_edges: Vec<f64> = Vec::with_capacity(n + 1);
|
||||||
|
let mut display_edges: Vec<f64> = Vec::with_capacity(n + 1);
|
||||||
for i in 0..=n {
|
for i in 0..=n {
|
||||||
let f = min_freq * (max_freq / min_freq).powf(i as f64 / n as f64);
|
let t = i as f64 / n as f64;
|
||||||
self.custom_bins.push(f);
|
sample_edges.push(if linear {
|
||||||
|
min_freq + (max_freq - min_freq) * t
|
||||||
|
} else {
|
||||||
|
min_freq * (max_freq / min_freq).powf(t)
|
||||||
|
});
|
||||||
|
display_edges.push(min_freq * (max_freq / min_freq).powf(t));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.sample_freqs.push(10.0);
|
||||||
self.freqs_const.push(10.0);
|
self.freqs_const.push(10.0);
|
||||||
for i in 0..self.custom_bins.len() - 1 {
|
for i in 0..n {
|
||||||
self.freqs_const
|
self.sample_freqs.push((sample_edges[i] + sample_edges[i + 1]) / 2.0);
|
||||||
.push((self.custom_bins[i] + self.custom_bins[i + 1]) / 2.0);
|
self.freqs_const.push((display_edges[i] + display_edges[i + 1]) / 2.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// zeros the analytic-signal buffer, drops the rolling history, and resets the gpu pipeline. cheap reset that doesn't touch fft plans.
|
/// zeros the analytic-signal buffer, drops the rolling history, and resets the gpu pipeline.
|
||||||
pub fn clear_state(&mut self) {
|
pub fn clear_state(&mut self) {
|
||||||
for c in self.buffer.iter_mut() {
|
for c in self.buffer.iter_mut() {
|
||||||
*c = Complex64::new(0.0, 0.0);
|
*c = Complex64::new(0.0, 0.0);
|
||||||
|
|
@ -154,7 +173,7 @@ impl Processor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// rebuilds fft plans, the blackman-harris window, and the working buffer at the requested transform length.
|
/// rebuilds fft plans, the analysis window, the working buffer, and the bin layout at the requested transform length.
|
||||||
pub fn set_frame_size(&mut self, size: usize) {
|
pub fn set_frame_size(&mut self, size: usize) {
|
||||||
if self.frame_size == size {
|
if self.frame_size == size {
|
||||||
return;
|
return;
|
||||||
|
|
@ -165,25 +184,13 @@ impl Processor {
|
||||||
self.cpu_cep_inv = Some(self.cpu_planner.plan_fft_inverse(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.gpu_fft = Some(GpuFft1D::new(self.device.clone(), self.queue.clone(), size as u32));
|
||||||
|
|
||||||
self.window = (0..size)
|
self.window = build_window(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.buffer = vec![Complex64::new(0.0, 0.0); size];
|
||||||
self.history.clear();
|
self.history.clear();
|
||||||
|
self.rebuild_bins();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// shifts an incoming chunk into the tail of the analytic-signal buffer, evicting the head.
|
/// fills the analytic-signal buffer with the latest frame_size samples of an incoming chunk, evicting the head.
|
||||||
/// when the chunk is larger than the frame, only the latest frame_size samples are retained.
|
|
||||||
pub fn push_data(&mut self, data: &[Complex64]) {
|
pub fn push_data(&mut self, data: &[Complex64]) {
|
||||||
let n = self.frame_size;
|
let n = self.frame_size;
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
|
|
@ -224,7 +231,6 @@ impl Processor {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// lag-pattern gpu read: collect the previously-queued submission's result, then queue this frame's work. cpu and gpu overlap across the rayon scope so high call rates (small hop) don't serialize on per-call wait.
|
|
||||||
let gpu_work = if blend > 0.0 {
|
let gpu_work = if blend > 0.0 {
|
||||||
let gpu = self.gpu_fft.as_mut().expect("gpu fft plan");
|
let gpu = self.gpu_fft.as_mut().expect("gpu fft plan");
|
||||||
let mut prev = vec![Complex64::new(0.0, 0.0); n];
|
let mut prev = vec![Complex64::new(0.0, 0.0); n];
|
||||||
|
|
@ -306,7 +312,7 @@ impl Processor {
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut current_db = vec![0.0_f64; self.freqs_const.len()];
|
let mut current_db = vec![0.0_f64; self.freqs_const.len()];
|
||||||
for (i, &target) in self.freqs_const.iter().enumerate() {
|
for (i, &target) in self.sample_freqs.iter().enumerate() {
|
||||||
let mag = lerp_at(&freqs_full, &mag_full, target);
|
let mag = lerp_at(&freqs_full, &mag_full, target);
|
||||||
let mut val = 20.0 * mag.max(1e-12).log10();
|
let mut val = 20.0 * mag.max(1e-12).log10();
|
||||||
if (self.expand_ratio - 1.0).abs() > f32::EPSILON {
|
if (self.expand_ratio - 1.0).abs() > f32::EPSILON {
|
||||||
|
|
@ -353,6 +359,34 @@ impl Processor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// fft-size cutoff below which the processor switches to linear binning and a Hamming window.
|
||||||
|
const SMALL_FFT_THRESHOLD: usize = 4096;
|
||||||
|
|
||||||
|
/// builds the analysis window: Hamming below the small-fft threshold for a narrower main lobe, Blackman-Harris above for cleaner sidelobes.
|
||||||
|
fn build_window(size: usize) -> Vec<f64> {
|
||||||
|
if size == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let denom = (size - 1).max(1) as f64;
|
||||||
|
if size <= SMALL_FFT_THRESHOLD {
|
||||||
|
(0..size)
|
||||||
|
.map(|i| 0.54 - 0.46 * (2.0 * PI * i as f64 / denom).cos())
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
(0..size)
|
||||||
|
.map(|i| {
|
||||||
|
let a0 = 0.35875;
|
||||||
|
let a1 = 0.48829;
|
||||||
|
let a2 = 0.14128;
|
||||||
|
let a3 = 0.01168;
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// linearly interpolates a value from the freqs/values table at the given target frequency with endpoint clamping.
|
/// 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 {
|
fn lerp_at(freqs: &[f64], values: &[f64], target: f64) -> f64 {
|
||||||
if freqs.is_empty() {
|
if freqs.is_empty() {
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ pub struct CaptureState {
|
||||||
/// flips true once any media session has reported metadata. drives the transport-enable state.
|
/// flips true once any media session has reported metadata. drives the transport-enable state.
|
||||||
pub has_session: bool,
|
pub has_session: bool,
|
||||||
|
|
||||||
/// host-pushed flag: true when the user has granted notification listener access for now-playing info.
|
/// host-pushed flag tracking notification-listener access for now-playing metadata.
|
||||||
pub notification_access: bool,
|
pub notification_access: bool,
|
||||||
|
|
||||||
/// running total of audio frames received over the FFI capture channel since launch.
|
/// running total of audio frames received over the FFI capture channel since launch.
|
||||||
|
|
@ -74,7 +74,7 @@ pub struct App {
|
||||||
pub current_palette: Option<Arc<Vec<[f32; 3]>>>,
|
pub current_palette: Option<Arc<Vec<[f32; 3]>>>,
|
||||||
pub settings: Settings,
|
pub settings: Settings,
|
||||||
|
|
||||||
/// stash of the other mode's settings. swapped with `settings` on every mode transition.
|
/// stash of the other mode's settings, swapped with the active slot on every mode transition.
|
||||||
pub settings_inactive: Settings,
|
pub settings_inactive: Settings,
|
||||||
|
|
||||||
pub show_settings: bool,
|
pub show_settings: bool,
|
||||||
|
|
@ -127,16 +127,16 @@ pub struct App {
|
||||||
/// show_settings copy bridging the middle-tap collapse cycle.
|
/// show_settings copy bridging the middle-tap collapse cycle.
|
||||||
pub saved_show_settings: bool,
|
pub saved_show_settings: bool,
|
||||||
|
|
||||||
/// selected output device name on desktop (None = system default).
|
/// selected output device name on desktop, or None for the system default.
|
||||||
pub output_device: Option<String>,
|
pub output_device: Option<String>,
|
||||||
|
|
||||||
/// selected input device name on desktop (None = system default).
|
/// selected input device name on desktop, or None for the system default.
|
||||||
pub input_device: Option<String>,
|
pub input_device: Option<String>,
|
||||||
|
|
||||||
/// cached list of output device names refreshed on demand.
|
/// cached list of output device names.
|
||||||
pub output_devices: Vec<String>,
|
pub output_devices: Vec<String>,
|
||||||
|
|
||||||
/// cached list of input device names refreshed on demand.
|
/// cached list of input device names.
|
||||||
pub input_devices: Vec<String>,
|
pub input_devices: Vec<String>,
|
||||||
|
|
||||||
/// pending flag drained by the shell to rebuild the capture stream after an input-device change.
|
/// pending flag drained by the shell to rebuild the capture stream after an input-device change.
|
||||||
|
|
@ -165,12 +165,12 @@ pub struct Settings {
|
||||||
/// fraction of FFT work routed to the GPU pipeline, blended against the CPU result.
|
/// fraction of FFT work routed to the GPU pipeline, blended against the CPU result.
|
||||||
pub gpu_blend: f32,
|
pub gpu_blend: f32,
|
||||||
|
|
||||||
/// dB threshold gating live-mode PCM chunks. chunks below this RMS are replaced with silence.
|
/// dB cutoff for the live-mode noise gate.
|
||||||
#[serde(default = "default_noise_gate_db")]
|
#[serde(default = "default_noise_gate_db")]
|
||||||
pub noise_gate_db: f32,
|
pub noise_gate_db: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// default noise-gate threshold used when older settings JSON omits the field.
|
/// default noise-gate threshold.
|
||||||
fn default_noise_gate_db() -> f32 {
|
fn default_noise_gate_db() -> f32 {
|
||||||
-60.0
|
-60.0
|
||||||
}
|
}
|
||||||
|
|
@ -254,7 +254,7 @@ pub enum Message {
|
||||||
PickedFiles(Vec<PathBuf>),
|
PickedFiles(Vec<PathBuf>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// returns the platform-tuned (fft, hop, num_bins) triple for Playback capture mode. android/desktop keep the existing 26-bin default.
|
/// returns the platform-tuned (fft, hop, num_bins) triple for Playback capture mode.
|
||||||
#[cfg(target_os = "ios")]
|
#[cfg(target_os = "ios")]
|
||||||
fn capture_dsp_defaults() -> (usize, usize, usize) {
|
fn capture_dsp_defaults() -> (usize, usize, usize) {
|
||||||
(4096, 1024, 15)
|
(4096, 1024, 15)
|
||||||
|
|
@ -386,7 +386,7 @@ impl App {
|
||||||
self.worker.set_noise_gate(self.settings.noise_gate_db);
|
self.worker.set_noise_gate(self.settings.noise_gate_db);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// replaces the active settings slot with the platform defaults for the current mode and re-applies them.
|
/// resets the active settings slot to the current mode's platform defaults and re-applies to the worker.
|
||||||
pub fn reset_settings_to_defaults(&mut self) {
|
pub fn reset_settings_to_defaults(&mut self) {
|
||||||
self.settings = settings_for_mode(self.playback_mode);
|
self.settings = settings_for_mode(self.playback_mode);
|
||||||
self.apply_settings_to_worker();
|
self.apply_settings_to_worker();
|
||||||
|
|
@ -399,7 +399,7 @@ impl App {
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
/// serializes both settings slots as JSON, keyed by mode. used by the host shell to write to disk.
|
/// serializes both settings slots as JSON, keyed by mode.
|
||||||
pub fn settings_json(&self) -> String {
|
pub fn settings_json(&self) -> String {
|
||||||
let (local, capture) = match self.playback_mode {
|
let (local, capture) = match self.playback_mode {
|
||||||
PlaybackMode::Local => (self.settings, self.settings_inactive),
|
PlaybackMode::Local => (self.settings, self.settings_inactive),
|
||||||
|
|
@ -409,7 +409,7 @@ impl App {
|
||||||
serde_json::to_string(&snapshot).unwrap_or_default()
|
serde_json::to_string(&snapshot).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// applies a settings JSON blob (both mode slots) loaded by the host shell at startup. silent no-op on parse failure.
|
/// applies a host-supplied two-slot settings JSON blob, silently dropping malformed input.
|
||||||
pub fn apply_settings_json(&mut self, json: &str) {
|
pub fn apply_settings_json(&mut self, json: &str) {
|
||||||
let Ok(parsed) = serde_json::from_str::<PersistedSettings>(json) else { return };
|
let Ok(parsed) = serde_json::from_str::<PersistedSettings>(json) else { return };
|
||||||
match self.playback_mode {
|
match self.playback_mode {
|
||||||
|
|
@ -425,7 +425,7 @@ impl App {
|
||||||
self.apply_settings_to_worker();
|
self.apply_settings_to_worker();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// returns the capture-action flag and clears the slot in one step.
|
/// drains the capture-action flag.
|
||||||
pub fn take_pending_capture_action(&mut self) -> u8 {
|
pub fn take_pending_capture_action(&mut self) -> u8 {
|
||||||
let v = self.pending_capture_action;
|
let v = self.pending_capture_action;
|
||||||
self.pending_capture_action = 0;
|
self.pending_capture_action = 0;
|
||||||
|
|
@ -538,7 +538,7 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// returns the picker flag and clears the slot in one step.
|
/// drains the picker flag.
|
||||||
pub fn take_pending_pick(&mut self) -> u8 {
|
pub fn take_pending_pick(&mut self) -> u8 {
|
||||||
let p = self.pending_pick;
|
let p = self.pending_pick;
|
||||||
self.pending_pick = 0;
|
self.pending_pick = 0;
|
||||||
|
|
|
||||||
|
|
@ -108,18 +108,20 @@ fn library_progress_strip(app: &App) -> Element<'_, Message, Theme, iced_wgpu::R
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// builds the title row plus folder, file, and settings chip buttons.
|
/// builds the top-bar row of title, mode-dependent folder/file chips, and settings chip.
|
||||||
fn top_bar(_app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
|
fn top_bar(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
|
||||||
let title = mouse_area(text("Yr Xtals").size(16).color(palette::text()))
|
let title = mouse_area(text("Yr Xtals").size(16).color(palette::text()))
|
||||||
.on_press(Message::ReturnToMainMenu);
|
.on_press(Message::ReturnToMainMenu);
|
||||||
|
let settings_btn = icon_chip_button(SETTINGS_SVG, Message::ToggleSettings);
|
||||||
|
|
||||||
|
let bar = match app.playback_mode {
|
||||||
|
PlaybackMode::Local => {
|
||||||
let folder_btn = chip_button("Folder", Message::OpenFolder);
|
let folder_btn = chip_button("Folder", Message::OpenFolder);
|
||||||
#[cfg(target_os = "ios")]
|
#[cfg(target_os = "ios")]
|
||||||
let file_btn = chip_button("Library", Message::OpenFile);
|
let file_btn = chip_button("Library", Message::OpenFile);
|
||||||
#[cfg(not(target_os = "ios"))]
|
#[cfg(not(target_os = "ios"))]
|
||||||
let file_btn = chip_button("File", Message::OpenFile);
|
let file_btn = chip_button("File", Message::OpenFile);
|
||||||
let settings_btn = icon_chip_button(SETTINGS_SVG, Message::ToggleSettings);
|
row![
|
||||||
|
|
||||||
let bar = row![
|
|
||||||
title,
|
title,
|
||||||
Space::new().width(Length::Fixed(20.0)),
|
Space::new().width(Length::Fixed(20.0)),
|
||||||
folder_btn,
|
folder_btn,
|
||||||
|
|
@ -128,12 +130,20 @@ fn top_bar(_app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
|
||||||
Space::new().width(Length::Fill),
|
Space::new().width(Length::Fill),
|
||||||
settings_btn,
|
settings_btn,
|
||||||
]
|
]
|
||||||
.padding(Padding::from([0, 16]))
|
}
|
||||||
|
PlaybackMode::Capture => row![
|
||||||
|
title,
|
||||||
|
Space::new().width(Length::Fill),
|
||||||
|
settings_btn,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
container(
|
||||||
|
bar.padding(Padding::from([0, 16]))
|
||||||
.spacing(0)
|
.spacing(0)
|
||||||
.align_y(iced_wgpu::core::Alignment::Center)
|
.align_y(iced_wgpu::core::Alignment::Center)
|
||||||
.height(Length::Fill);
|
.height(Length::Fill),
|
||||||
|
)
|
||||||
container(bar)
|
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fixed(TOP_BAR_H))
|
.height(Length::Fixed(TOP_BAR_H))
|
||||||
.style(panel_style)
|
.style(panel_style)
|
||||||
|
|
@ -632,7 +642,7 @@ where
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// label + pick_list pair for selecting a sound device by name, with "System default" as the first option.
|
/// device-selection row pairing a label with a pick_list of available device names.
|
||||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||||
fn device_row<'a, F>(
|
fn device_row<'a, F>(
|
||||||
label: &'a str,
|
label: &'a str,
|
||||||
|
|
@ -674,7 +684,7 @@ where
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// device pick_list option: either the system default or a specific named device.
|
/// device pick_list option.
|
||||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
enum DeviceChoice {
|
enum DeviceChoice {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ pub struct ViewportHandle {
|
||||||
/// classification of an active touch over the settings panel: pending until SLOP, then tap/drag/scroll.
|
/// classification of an active touch over the settings panel: pending until SLOP, then tap/drag/scroll.
|
||||||
settings_touch: SettingsTouch,
|
settings_touch: SettingsTouch,
|
||||||
pub state: App,
|
pub state: App,
|
||||||
|
|
||||||
|
/// lazy off-screen visualizer renderer feeding the iOS PiP display layer.
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
pip_renderer: Option<crate::visualizer::pip::PipRenderer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// classifies an in-flight settings-panel touch once its dominant axis is known.
|
/// classifies an in-flight settings-panel touch once its dominant axis is known.
|
||||||
|
|
@ -485,6 +489,40 @@ impl ViewportHandle {
|
||||||
self.state.take_pending_capture_seek()
|
self.state.take_pending_capture_seek()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// off-screen-renders one PiP frame of the visualizer into the caller-provided BGRA buffer.
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
pub fn render_pip_to_bgra(
|
||||||
|
&mut self,
|
||||||
|
dst: *mut u8,
|
||||||
|
dst_stride: u32,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) {
|
||||||
|
if width == 0 || height == 0 || dst.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let renderer = self.pip_renderer.get_or_insert_with(|| {
|
||||||
|
crate::visualizer::pip::PipRenderer::new(
|
||||||
|
self.device.clone(),
|
||||||
|
self.queue.clone(),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let frames = self.state.frame_data.clone();
|
||||||
|
let palette = self.state.current_palette.clone();
|
||||||
|
let params = pip_viz_params(&self.state.settings);
|
||||||
|
renderer.render_into_bgra(
|
||||||
|
&frames,
|
||||||
|
¶ms,
|
||||||
|
palette.as_deref().map(|v| v.as_slice()),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
dst,
|
||||||
|
dst_stride,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// drains the pending capture-rebuild flag set after an input-device change.
|
/// drains the pending capture-rebuild flag set after an input-device change.
|
||||||
pub fn take_pending_rebuild_capture(&mut self) -> bool {
|
pub fn take_pending_rebuild_capture(&mut self) -> bool {
|
||||||
self.state.take_pending_rebuild_capture()
|
self.state.take_pending_rebuild_capture()
|
||||||
|
|
@ -601,9 +639,27 @@ fn finalise(
|
||||||
touch_in_sidebar: false,
|
touch_in_sidebar: false,
|
||||||
settings_touch: SettingsTouch::None,
|
settings_touch: SettingsTouch::None,
|
||||||
state,
|
state,
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
pip_renderer: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// projects App.Settings down to the visualizer parameter subset.
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
fn pip_viz_params(s: &crate::ui::app::Settings) -> crate::visualizer::VizParams {
|
||||||
|
crate::visualizer::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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// runs one tick, rebuilds the UI, dispatches messages, and presents the next surface frame.
|
/// runs one tick, rebuilds the UI, dispatches messages, and presents the next surface frame.
|
||||||
fn render(handle: &mut ViewportHandle) {
|
fn render(handle: &mut ViewportHandle) {
|
||||||
handle.state.tick();
|
handle.state.tick();
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
pub mod build;
|
pub mod build;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
pub mod pip;
|
||||||
pub mod primitive;
|
pub mod primitive;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::analyzer::FrameData;
|
||||||
|
use crate::visualizer::pipeline::{
|
||||||
|
GlobalsGpu, VisPipeline, FLAG_GLASS, FLAG_INVERTED, FLAG_MIRRORED, FLAG_STEREO,
|
||||||
|
};
|
||||||
|
use crate::visualizer::{build, VizParams};
|
||||||
|
|
||||||
|
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
|
||||||
|
const BYTES_PER_PIXEL: u32 = 4;
|
||||||
|
const COPY_BYTES_PER_ROW_ALIGNMENT: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||||
|
|
||||||
|
/// off-screen BGRA visualizer renderer.
|
||||||
|
pub struct PipRenderer {
|
||||||
|
device: wgpu::Device,
|
||||||
|
queue: wgpu::Queue,
|
||||||
|
pipeline: VisPipeline,
|
||||||
|
texture: wgpu::Texture,
|
||||||
|
view: wgpu::TextureView,
|
||||||
|
staging: wgpu::Buffer,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
padded_bytes_per_row: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PipRenderer {
|
||||||
|
/// allocates the off-screen texture, staging readback buffer, and a fresh BGRA-format visualizer pipeline.
|
||||||
|
pub fn new(device: wgpu::Device, queue: wgpu::Queue, width: u32, height: u32) -> Self {
|
||||||
|
let pipeline = VisPipeline::for_format(&device, &queue, FORMAT);
|
||||||
|
let (texture, view, staging, padded_bytes_per_row) =
|
||||||
|
allocate_targets(&device, width, height);
|
||||||
|
Self {
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
pipeline,
|
||||||
|
texture,
|
||||||
|
view,
|
||||||
|
staging,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
padded_bytes_per_row,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// reallocates the texture and staging buffer when the requested size changes.
|
||||||
|
fn ensure_size(&mut self, width: u32, height: u32) {
|
||||||
|
if self.width == width && self.height == height {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (texture, view, staging, padded_bytes_per_row) =
|
||||||
|
allocate_targets(&self.device, width, height);
|
||||||
|
self.texture = texture;
|
||||||
|
self.view = view;
|
||||||
|
self.staging = staging;
|
||||||
|
self.width = width;
|
||||||
|
self.height = height;
|
||||||
|
self.padded_bytes_per_row = padded_bytes_per_row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// renders one frame of the visualizer at the requested resolution and writes the BGRA pixels into dst, packed at dst_stride bytes per row.
|
||||||
|
pub fn render_into_bgra(
|
||||||
|
&mut self,
|
||||||
|
frames: &Arc<Vec<FrameData>>,
|
||||||
|
params: &VizParams,
|
||||||
|
palette: Option<&[[f32; 3]]>,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
dst: *mut u8,
|
||||||
|
dst_stride: u32,
|
||||||
|
) {
|
||||||
|
if width == 0 || height == 0 || dst.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.ensure_size(width, height);
|
||||||
|
|
||||||
|
if !frames.is_empty() {
|
||||||
|
let frames_id = Arc::as_ptr(frames) as usize;
|
||||||
|
self.pipeline.state.ingest(frames, frames_id, params, palette);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stereo = self.pipeline.state.channels.len() > 1;
|
||||||
|
let num_channels = self.pipeline.state.channels.len() as u32;
|
||||||
|
let num_bins = self
|
||||||
|
.pipeline
|
||||||
|
.state
|
||||||
|
.channels
|
||||||
|
.first()
|
||||||
|
.map(|c| c.bins.len() as u32)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let w_px = width as f32;
|
||||||
|
let h_px = height as f32;
|
||||||
|
let (base_w, base_h, instances) = if params.mirrored {
|
||||||
|
(w_px * 0.55, h_px * 0.5, 4u32)
|
||||||
|
} else {
|
||||||
|
(w_px, h_px, 1u32)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut flags = 0u32;
|
||||||
|
if params.glass {
|
||||||
|
flags |= FLAG_GLASS;
|
||||||
|
}
|
||||||
|
if params.mirrored {
|
||||||
|
flags |= FLAG_MIRRORED;
|
||||||
|
}
|
||||||
|
if params.inverted {
|
||||||
|
flags |= FLAG_INVERTED;
|
||||||
|
}
|
||||||
|
if stereo {
|
||||||
|
flags |= FLAG_STEREO;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uc = self.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 params.mirrored { 4 } else { 0 },
|
||||||
|
hue_param: params.hue,
|
||||||
|
contrast: params.contrast,
|
||||||
|
brightness: 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 self.pipeline.scratch_bins);
|
||||||
|
let mut scratch_cep = std::mem::take(&mut self.pipeline.scratch_cep);
|
||||||
|
self.pipeline.state.pack_bins(frames, stereo, &mut scratch_bins);
|
||||||
|
scratch_cep.clear();
|
||||||
|
if params.mirrored {
|
||||||
|
build::build_cepstrum(&mut scratch_cep, &self.pipeline.state, w_px, h_px);
|
||||||
|
}
|
||||||
|
self.pipeline.scratch_bins = scratch_bins;
|
||||||
|
self.pipeline.scratch_cep = scratch_cep;
|
||||||
|
|
||||||
|
self.pipeline
|
||||||
|
.upload(&self.device, &self.queue, &globals, num_channels, num_bins, instances);
|
||||||
|
|
||||||
|
let mut encoder = self
|
||||||
|
.device
|
||||||
|
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("yr_crystals.pip.encoder"),
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("yr_crystals.pip.clear"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: &self.view,
|
||||||
|
depth_slice: None,
|
||||||
|
resolve_target: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
timestamp_writes: None,
|
||||||
|
occlusion_query_set: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let clip = iced_wgpu::core::Rectangle::<u32> {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
};
|
||||||
|
self.pipeline.render_into(&mut encoder, &self.view, &clip);
|
||||||
|
|
||||||
|
encoder.copy_texture_to_buffer(
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
texture: &self.texture,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
wgpu::TexelCopyBufferInfo {
|
||||||
|
buffer: &self.staging,
|
||||||
|
layout: wgpu::TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(self.padded_bytes_per_row),
|
||||||
|
rows_per_image: Some(self.height),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
self.queue.submit(std::iter::once(encoder.finish()));
|
||||||
|
|
||||||
|
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());
|
||||||
|
if rx.recv().ok().and_then(|r| r.ok()).is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let view = slice.get_mapped_range();
|
||||||
|
let row_bytes = (self.width * BYTES_PER_PIXEL) as usize;
|
||||||
|
let padded = self.padded_bytes_per_row as usize;
|
||||||
|
let dst_step = dst_stride as usize;
|
||||||
|
for y in 0..self.height as usize {
|
||||||
|
let src_row = &view[y * padded..y * padded + row_bytes];
|
||||||
|
unsafe {
|
||||||
|
let dst_row = dst.add(y * dst_step);
|
||||||
|
std::ptr::copy_nonoverlapping(src_row.as_ptr(), dst_row, row_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.staging.unmap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// allocates the BGRA render texture, a matching view, and the row-aligned staging readback buffer.
|
||||||
|
fn allocate_targets(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> (wgpu::Texture, wgpu::TextureView, wgpu::Buffer, u32) {
|
||||||
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("yr_crystals.pip.texture"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width: width.max(1),
|
||||||
|
height: height.max(1),
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: FORMAT,
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
|
|
||||||
|
let unpadded = width * BYTES_PER_PIXEL;
|
||||||
|
let padded_bytes_per_row =
|
||||||
|
unpadded.div_ceil(COPY_BYTES_PER_ROW_ALIGNMENT) * COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||||
|
let staging = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("yr_crystals.pip.staging"),
|
||||||
|
size: (padded_bytes_per_row * height.max(1)) as u64,
|
||||||
|
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
(texture, view, staging, padded_bytes_per_row)
|
||||||
|
}
|
||||||
|
|
@ -82,8 +82,14 @@ const INITIAL_BINS_CAPACITY: u64 = 256 * 2;
|
||||||
const INITIAL_CEP_CAPACITY: u64 = 1024;
|
const INITIAL_CEP_CAPACITY: u64 = 1024;
|
||||||
|
|
||||||
impl Pipeline for VisPipeline {
|
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 {
|
||||||
fn new(device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
|
Self::for_format(device, queue, format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VisPipeline {
|
||||||
|
/// builds the three render pipelines, allocates the uniform/storage/vertex buffers, and seeds the bind group for an arbitrary color format.
|
||||||
|
pub fn for_format(device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
|
||||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
label: Some("yr_crystals.visualizer.shader"),
|
label: Some("yr_crystals.visualizer.shader"),
|
||||||
source: wgpu::ShaderSource::Wgsl(
|
source: wgpu::ShaderSource::Wgsl(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use std::env;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Command, ExitCode};
|
use std::process::{Command, ExitCode};
|
||||||
|
|
||||||
const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux", "ios", "android"];
|
const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux", "ios", "android", "web"];
|
||||||
|
|
||||||
/// dispatches a cargo xtask sub-command to the matching platform script under scripts/.
|
/// dispatches a cargo xtask sub-command to the matching platform script under scripts/.
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
|
|
@ -19,6 +19,8 @@ fn main() -> ExitCode {
|
||||||
let extra_args: Vec<&String> = args.iter().skip(1).collect();
|
let extra_args: Vec<&String> = args.iter().skip(1).collect();
|
||||||
let (action, platform) = if cmd == "release-playstore" {
|
let (action, platform) = if cmd == "release-playstore" {
|
||||||
("release-playstore".to_string(), "android".to_string())
|
("release-playstore".to_string(), "android".to_string())
|
||||||
|
} else if cmd == "web" {
|
||||||
|
("build".to_string(), "web".to_string())
|
||||||
} else {
|
} else {
|
||||||
parse(cmd)
|
parse(cmd)
|
||||||
};
|
};
|
||||||
|
|
@ -39,7 +41,7 @@ fn main() -> ExitCode {
|
||||||
"-File",
|
"-File",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
"linux" | "macos" | "ios" | "android" => (
|
"linux" | "macos" | "ios" | "android" | "web" => (
|
||||||
repo_root.join(format!("scripts/{platform}/{action}.sh")),
|
repo_root.join(format!("scripts/{platform}/{action}.sh")),
|
||||||
vec!["bash"],
|
vec!["bash"],
|
||||||
),
|
),
|
||||||
|
|
@ -116,6 +118,10 @@ fn print_help() {
|
||||||
eprintln!(" build release build for the current platform");
|
eprintln!(" build release build for the current platform");
|
||||||
eprintln!(" install release build + install (macOS: /Applications)");
|
eprintln!(" install release build + install (macOS: /Applications)");
|
||||||
eprintln!(" debug debug build + foreground launch (live console on iOS)");
|
eprintln!(" debug debug build + foreground launch (live console on iOS)");
|
||||||
|
eprintln!(" package cross-compile + zip distributables (macOS host only)");
|
||||||
|
eprintln!(" --all all six desktop targets");
|
||||||
|
eprintln!(" --target <name> e.g. macos-aarch64, windows-x86_64, linux-aarch64");
|
||||||
|
eprintln!(" web build the WASM bundle and yrxtls-serve binary into web/dist/");
|
||||||
eprintln!(" select-ios pick a physical device or simulator interactively");
|
eprintln!(" select-ios pick a physical device or simulator interactively");
|
||||||
eprintln!(" select-android pick an attached Android device by adb serial");
|
eprintln!(" select-android pick an attached Android device by adb serial");
|
||||||
eprintln!(" bootstrap-android install android sdk packages from .android-sdk-packages");
|
eprintln!(" bootstrap-android install android sdk packages from .android-sdk-packages");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue