diff --git a/.gitignore b/.gitignore index b2a9db7..88bb380 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ build/ +dist/ build_android/ build_ios/ build_macos/ @@ -34,3 +35,5 @@ android/app/src/main/res/mipmap-anydpi-v26/ android/app/src/main/jniLibs/ *.xcuserstate xcuserdata/ + +web/ diff --git a/Cargo.toml b/Cargo.toml index 812980e..cfe1674 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "xtask"] +members = [".", "xtask", "web"] default-members = ["."] resolver = "2" diff --git a/Cross.toml b/Cross.toml new file mode 100644 index 0000000..c2d767a --- /dev/null +++ b/Cross.toml @@ -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", +] diff --git a/assets/Mute.svg b/assets/Mute.svg new file mode 100644 index 0000000..16cd563 --- /dev/null +++ b/assets/Mute.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/Unmute.svg b/assets/Unmute.svg new file mode 100644 index 0000000..11a8982 --- /dev/null +++ b/assets/Unmute.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..80a0588 --- /dev/null +++ b/build.rs @@ -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 = 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 { + 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) +} diff --git a/include/yr_xtals.h b/include/yr_xtals.h index dbacf0b..59637ae 100644 --- a/include/yr_xtals.h +++ b/include/yr_xtals.h @@ -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. 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. char *viewport_get_settings_json(struct ViewportHandle *handle); diff --git a/ios/Info.plist b/ios/Info.plist index ac67dfd..63c8139 100644 --- a/ios/Info.plist +++ b/ios/Info.plist @@ -32,14 +32,14 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.3 + 1.0.5 CFBundleSupportedPlatforms iPhoneOS iPhoneSimulator CFBundleVersion - 1.0.3 + 1.0.5 ITSAppUsesNonExemptEncryption LSSupportsOpeningDocumentsInPlace @@ -58,7 +58,6 @@ UIBackgroundModes audio - picture-in-picture UIDeviceFamily diff --git a/ios/src/CaptureController.swift b/ios/src/CaptureController.swift index 08da458..a76b986 100644 --- a/ios/src/CaptureController.swift +++ b/ios/src/CaptureController.swift @@ -10,7 +10,7 @@ class CaptureController { /// true while AVAudioEngine is running through CaptureSession. 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 { if #available(iOS 15.0, *), let p = pipController as? PipController { return p.isPictureInPictureActive @@ -68,7 +68,7 @@ class CaptureController { 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() { guard #available(iOS 15.0, *) else { return } guard let v = view else { return } diff --git a/ios/src/CaptureSession.swift b/ios/src/CaptureSession.swift index c6935bb..ecde4ec 100644 --- a/ios/src/CaptureSession.swift +++ b/ios/src/CaptureSession.swift @@ -2,17 +2,16 @@ import AVFoundation import UIKit /// 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 { private var engine: AVAudioEngine? private(set) var isCapturing = false 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?)? - /// 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)? /// 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") } - /// applies the low-latency mic AVAudioSession configuration. + /// applies the low-latency mic AVAudioSession configuration with .videoRecording mode. private func configureSession() throws { let session = AVAudioSession.sharedInstance() try session.setCategory( .playAndRecord, - mode: .measurement, - options: [.defaultToSpeaker, .mixWithOthers], + mode: .videoRecording, + options: [.defaultToSpeaker, .mixWithOthers, .allowBluetoothA2DP, .allowAirPlay], ) try session.setPreferredSampleRate(48_000) try session.setPreferredIOBufferDuration(0.005) 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. @@ -62,6 +77,7 @@ class CaptureSession { try engine.start() self.engine = engine + pinOutputToSpeakerIfReceiver() } /// 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 self?.handleMediaServicesReset() }) + observers.append(center.addObserver( + forName: AVAudioSession.routeChangeNotification, + object: nil, + queue: .main, + ) { [weak self] _ in + self?.pinOutputToSpeakerIfReceiver() + }) } private func unregisterObservers() { @@ -123,7 +146,7 @@ class CaptureSession { 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() { if pipActiveProvider?() == true { print("[YrXtals] resignActive but PiP active, keeping engine alive") @@ -133,7 +156,7 @@ class CaptureSession { 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() { guard isCapturing else { return } 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() { teardownEngineAndSession() guard isCapturing else { return } diff --git a/ios/src/LibraryController.swift b/ios/src/LibraryController.swift index fa3ed70..eab6c54 100644 --- a/ios/src/LibraryController.swift +++ b/ios/src/LibraryController.swift @@ -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. """ - /// 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]) { let kind = pendingKind 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) { guard session == coordSession else { return } activeCoordinator?.cancel() diff --git a/ios/src/PipController.swift b/ios/src/PipController.swift index f1c17ba..3ecb6bf 100644 --- a/ios/src/PipController.swift +++ b/ios/src/PipController.swift @@ -4,17 +4,18 @@ import UIKit import CoreMedia 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, *) class PipController: NSObject { weak var view: IcedViewportView? private let displayLayer = AVSampleBufferDisplayLayer() + private let containerView = UIView() private var pipController: AVPictureInPictureController? 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 init(view: IcedViewportView) { @@ -22,6 +23,7 @@ class PipController: NSObject { self.view = view attachDisplayLayer() setupPipController() + setupPixelBufferPool() } /// returns true when AVPictureInPictureController reports an active PiP session. @@ -29,7 +31,7 @@ class PipController: NSObject { 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() { if displayLink != nil { return } let link = CADisplayLink(target: self, selector: #selector(tick)) @@ -45,27 +47,38 @@ class PipController: NSObject { 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() { - guard let pipController = pipController else { return } - if pipController.isPictureInPictureActive { return } + guard let pipController = pipController else { + print("[YrXtals] PiP requested but controller is nil") + return + } + if pipController.isPictureInPictureActive { + pipController.stopPictureInPicture() + return + } 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 } 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() { + containerView.frame = CGRect(x: 0, y: 0, width: 1, height: 1) + containerView.isUserInteractionEnabled = false + containerView.backgroundColor = .clear + view?.addSubview(containerView) + displayLayer.videoGravity = .resizeAspect - displayLayer.frame = CGRect(origin: .zero, size: renderSize) - displayLayer.bounds = CGRect(origin: .zero, size: renderSize) - displayLayer.opacity = 0 - view?.layer.addSublayer(displayLayer) + displayLayer.frame = containerView.bounds + displayLayer.bounds = containerView.bounds + displayLayer.opacity = 0.01 + 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() { guard AVPictureInPictureController.isPictureInPictureSupported() else { print("[YrXtals] PiP not supported on this device") @@ -77,46 +90,63 @@ class PipController: NSObject { ) let controller = AVPictureInPictureController(contentSource: source) controller.delegate = self + if #available(iOS 14.2, *) { + controller.canStartPictureInPictureAutomaticallyFromInline = false + } pipController = controller } - /// pulls bin magnitudes from rust, renders a simplified bar visualization into a CMSampleBuffer, enqueues it. - @objc private func tick() { - guard let view = view, let handle = view.viewportHandle else { return } - let n = binsBuffer.withUnsafeMutableBufferPointer { buf in - viewport_get_pip_snapshot(handle, buf.baseAddress, buf.count) + /// 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)") } - if n == 0 { - // no frames yet, but iOS expects samples — enqueue a blank one so PiP eligibility flips on - enqueueRenderedFrame(bins: []) - return - } - let bins = Array(binsBuffer.prefix(n)) - enqueueRenderedFrame(bins: bins) } - /// renders bins as vertical bars onto a CVPixelBuffer, wraps in CMSampleBuffer, enqueues to the display layer. - private func enqueueRenderedFrame(bins: [Float]) { - guard let pixelBuffer = makePixelBuffer() else { return } + /// renders one PiP frame from a pooled CVPixelBuffer through the Rust visualizer FFI into the display layer. + @objc private func tick() { + guard let view = view, let handle = view.viewportHandle else { return } + guard let pool = pixelBufferPool else { return } - CVPixelBufferLockBaseAddress(pixelBuffer, []) - defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) } + var pixelBuffer: CVPixelBuffer? + let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pixelBuffer) + guard status == kCVReturnSuccess, let pb = pixelBuffer else { + return + } - 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 } + 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, []) - drawBars(into: ctx, bins: bins, width: width, height: height) + enqueue(pixelBuffer: pb) + } + /// wraps the CVPixelBuffer in a CMSampleBuffer with host-clock timing and enqueues onto the display layer. + private func enqueue(pixelBuffer: CVPixelBuffer) { var formatDescription: CMFormatDescription? CMVideoFormatDescriptionCreateForImageBuffer( allocator: kCFAllocatorDefault, @@ -154,46 +184,6 @@ class PipController: NSObject { } 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, *) @@ -203,7 +193,6 @@ extension PipController: AVPictureInPictureSampleBufferPlaybackDelegate { _ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool, ) { - // the visualizer "plays" continuously while in PiP; the toggle is ignored. } func pictureInPictureControllerTimeRangeForPlayback( @@ -222,7 +211,6 @@ extension PipController: AVPictureInPictureSampleBufferPlaybackDelegate { _ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions, ) { - // ignored; we render at a fixed internal size and let AVKit scale. } func pictureInPictureController( @@ -267,4 +255,12 @@ extension PipController: AVPictureInPictureControllerDelegate { ) { 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) + } } diff --git a/ios/src/YrXtalsApp.swift b/ios/src/YrXtalsApp.swift index 07a8fd4..680f771 100644 --- a/ios/src/YrXtalsApp.swift +++ b/ios/src/YrXtalsApp.swift @@ -33,7 +33,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { 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() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { let warm = UIDocumentPickerViewController(forOpeningContentTypes: [.folder, .audio], asCopy: false) diff --git a/scripts/macos/package.sh b/scripts/macos/package.sh new file mode 100755 index 0000000..464d812 --- /dev/null +++ b/scripts/macos/package.sh @@ -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-.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 < [--target ...] + +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" </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" diff --git a/src/analyzer.rs b/src/analyzer.rs index ed3dc21..5bfedfc 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -46,10 +46,10 @@ pub struct Analyzer { /// soft cap on the live buffer length. 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, - /// dB threshold below which incoming live PCM chunks are replaced with silence. + /// dB cutoff for the live-mode noise gate. noise_gate_db: f32, } @@ -136,8 +136,7 @@ impl Analyzer { self.hilbert_needs_reset = true; } - /// retunes the three bands' transform lengths around a base frame size and routes the hilbert hop to match. - /// 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. + /// retunes the three bands' transform lengths and the hilbert hop, force-clearing every per-config buffer. pub fn set_dsp_params(&mut self, frame_size: usize, hop_size: usize) { let trans_size = (frame_size / 4).max(64); let deep_size = if frame_size < 2048 { frame_size * 4 } else { frame_size * 2 }; @@ -269,7 +268,6 @@ impl Analyzer { 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 MIN_RMS: f64 = 0.001; const MAX_GAIN: f64 = 50.0; @@ -286,7 +284,6 @@ impl Analyzer { let ch = channels as usize; 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 }; for _ in 0..frames { self.live_buffer.push_back(0.0); @@ -336,11 +333,10 @@ impl Analyzer { 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(); } - /// 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) { let hop = self.hilbert_hop_size; let fft = self.hilbert_fft_size; @@ -353,7 +349,6 @@ impl Analyzer { } else { hop * 2 }; - // fixed ~30ms slack across all configs — keeps lag bounded regardless of hop size. let slack_ms = 30usize; let slack_entries = if self.live_sample_rate == 0 { hop * 4 @@ -378,7 +373,6 @@ impl Analyzer { 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(); if self.hilbert_needs_reset { diff --git a/src/analyzer_worker.rs b/src/analyzer_worker.rs index f102354..5883691 100644 --- a/src/analyzer_worker.rs +++ b/src/analyzer_worker.rs @@ -215,7 +215,6 @@ fn run( } } 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 mut latest_owned: Option> = None; for _ in 0..200 { diff --git a/src/desktop_capture.rs b/src/desktop_capture.rs index facda53..25fbab0 100644 --- a/src/desktop_capture.rs +++ b/src/desktop_capture.rs @@ -4,7 +4,7 @@ use cpal::{SampleFormat, Stream}; use crate::analyzer_worker::PcmSender; 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 { _stream: Stream, } @@ -15,7 +15,7 @@ impl DesktopCapture { 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 { let device = devices::input_device(name) .ok_or_else(|| "no input device available".to_string())?; diff --git a/src/gpu_dsp.rs b/src/gpu_dsp.rs index 307bb87..61a734c 100644 --- a/src/gpu_dsp.rs +++ b/src/gpu_dsp.rs @@ -18,10 +18,10 @@ struct Args { const ARGS_BYTES: u64 = std::mem::size_of::() 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; -/// 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 { data_buf: wgpu::Buffer, scratch_buf: wgpu::Buffer, @@ -31,7 +31,7 @@ struct GpuSlot { 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 { device: wgpu::Device, queue: wgpu::Queue, @@ -50,7 +50,7 @@ pub struct 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 { assert!(n.is_power_of_two() && n >= 2, "fft size must be a power of two ≥ 2"); let log2_n = n.trailing_zeros(); @@ -197,7 +197,6 @@ impl GpuFft1D { 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]; this.submit_forward(&zero); @@ -220,14 +219,13 @@ impl GpuFft1D { 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 { debug_assert_eq!(out.len(), self.n as usize); let Some((slot_idx, sub_idx)) = self.pending.pop_front() else { 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 { submission_index: Some(sub_idx), timeout: None, @@ -239,7 +237,6 @@ impl GpuFft1D { slice.map_async(wgpu::MapMode::Read, move |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()); rx.recv().expect("map_async result").expect("map ok"); slot.staging_mapped = true; @@ -256,7 +253,7 @@ impl GpuFft1D { 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) { while let Some((slot_idx, sub_idx)) = self.pending.pop_front() { let _ = self.device.poll(wgpu::PollType::Wait { @@ -301,7 +298,7 @@ impl GpuFft1D { 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) { debug_assert_eq!(input.len(), self.n as usize); self.ensure_all_args(inverse); diff --git a/src/ios.rs b/src/ios.rs index f527ca5..8f5760c 100644 --- a/src/ios.rs +++ b/src/ios.rs @@ -406,6 +406,19 @@ pub extern "C" fn viewport_take_pending_persist_settings(handle: *mut ViewportHa 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. #[unsafe(no_mangle)] pub extern "C" fn viewport_free_string(s: *mut c_char) { diff --git a/src/main.rs b/src/main.rs index 34c1e74..45cfda2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] + /// hands off to the winit-driven shell. #[cfg(all(not(target_os = "ios"), not(target_os = "android")))] fn main() { diff --git a/src/processor.rs b/src/processor.rs index e9d0ea3..c41706a 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -37,8 +37,9 @@ pub struct Processor { window: Vec, buffer: Vec, - custom_bins: Vec, + sample_freqs: Vec, freqs_const: Vec, + num_bins: usize, history: VecDeque>, smoothing_length: usize, @@ -70,8 +71,9 @@ impl Processor { gpu_blend: 0.0, window: Vec::new(), buffer: Vec::new(), - custom_bins: Vec::new(), + sample_freqs: Vec::new(), freqs_const: Vec::new(), + num_bins: 26, history: VecDeque::new(), smoothing_length: 3, expand_ratio: 1.0, @@ -123,27 +125,44 @@ impl Processor { 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) { - 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.history.clear(); + let n = self.num_bins.max(1); let min_freq = 40.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 = Vec::with_capacity(n + 1); + let mut display_edges: Vec = Vec::with_capacity(n + 1); for i in 0..=n { - let f = min_freq * (max_freq / min_freq).powf(i as f64 / n as f64); - self.custom_bins.push(f); + let t = i as f64 / n as f64; + 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); - for i in 0..self.custom_bins.len() - 1 { - self.freqs_const - .push((self.custom_bins[i] + self.custom_bins[i + 1]) / 2.0); + for i in 0..n { + self.sample_freqs.push((sample_edges[i] + sample_edges[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) { for c in self.buffer.iter_mut() { *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) { if self.frame_size == size { return; @@ -165,25 +184,13 @@ impl Processor { self.cpu_cep_inv = Some(self.cpu_planner.plan_fft_inverse(size)); self.gpu_fft = Some(GpuFft1D::new(self.device.clone(), self.queue.clone(), size as u32)); - self.window = (0..size) - .map(|i| { - let a0 = 0.35875; - let a1 = 0.48829; - let a2 = 0.14128; - let a3 = 0.01168; - let denom = (size - 1) as f64; - a0 - a1 * (2.0 * PI * i as f64 / denom).cos() - + a2 * (4.0 * PI * i as f64 / denom).cos() - - a3 * (6.0 * PI * i as f64 / denom).cos() - }) - .collect(); - + self.window = build_window(size); self.buffer = vec![Complex64::new(0.0, 0.0); size]; self.history.clear(); + self.rebuild_bins(); } - /// shifts an incoming chunk into the tail of the analytic-signal buffer, evicting the head. - /// when the chunk is larger than the frame, only the latest frame_size samples are retained. + /// fills the analytic-signal buffer with the latest frame_size samples of an incoming chunk, evicting the head. pub fn push_data(&mut self, data: &[Complex64]) { let n = self.frame_size; if n == 0 { @@ -224,7 +231,6 @@ impl Processor { 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 = self.gpu_fft.as_mut().expect("gpu fft plan"); 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()]; - 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 mut val = 20.0 * mag.max(1e-12).log10(); 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 { + 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. fn lerp_at(freqs: &[f64], values: &[f64], target: f64) -> f64 { if freqs.is_empty() { diff --git a/src/ui/app.rs b/src/ui/app.rs index 4f27a3a..086db01 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -43,7 +43,7 @@ pub struct CaptureState { /// flips true once any media session has reported metadata. drives the transport-enable state. 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, /// running total of audio frames received over the FFI capture channel since launch. @@ -74,7 +74,7 @@ pub struct App { pub current_palette: Option>>, 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 show_settings: bool, @@ -127,16 +127,16 @@ pub struct App { /// show_settings copy bridging the middle-tap collapse cycle. 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, - /// 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, - /// cached list of output device names refreshed on demand. + /// cached list of output device names. pub output_devices: Vec, - /// cached list of input device names refreshed on demand. + /// cached list of input device names. pub input_devices: Vec, /// 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. 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")] 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 { -60.0 } @@ -254,7 +254,7 @@ pub enum Message { PickedFiles(Vec), } -/// 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")] fn capture_dsp_defaults() -> (usize, usize, usize) { (4096, 1024, 15) @@ -386,7 +386,7 @@ impl App { 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) { self.settings = settings_for_mode(self.playback_mode); self.apply_settings_to_worker(); @@ -399,7 +399,7 @@ impl App { 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 { let (local, capture) = match self.playback_mode { PlaybackMode::Local => (self.settings, self.settings_inactive), @@ -409,7 +409,7 @@ impl App { 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) { let Ok(parsed) = serde_json::from_str::(json) else { return }; match self.playback_mode { @@ -425,7 +425,7 @@ impl App { 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 { let v = self.pending_capture_action; 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 { let p = self.pending_pick; self.pending_pick = 0; diff --git a/src/ui/player.rs b/src/ui/player.rs index 837d988..14fc042 100644 --- a/src/ui/player.rs +++ b/src/ui/player.rs @@ -108,36 +108,46 @@ fn library_progress_strip(app: &App) -> Element<'_, Message, Theme, iced_wgpu::R } } -/// builds the title row plus folder, file, and settings chip buttons. -fn top_bar(_app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { +/// 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> { let title = mouse_area(text("Yr Xtals").size(16).color(palette::text())) .on_press(Message::ReturnToMainMenu); - let folder_btn = chip_button("Folder", Message::OpenFolder); - #[cfg(target_os = "ios")] - let file_btn = chip_button("Library", Message::OpenFile); - #[cfg(not(target_os = "ios"))] - let file_btn = chip_button("File", Message::OpenFile); let settings_btn = icon_chip_button(SETTINGS_SVG, Message::ToggleSettings); - let bar = row![ - title, - Space::new().width(Length::Fixed(20.0)), - folder_btn, - Space::new().width(Length::Fixed(8.0)), - file_btn, - Space::new().width(Length::Fill), - settings_btn, - ] - .padding(Padding::from([0, 16])) - .spacing(0) - .align_y(iced_wgpu::core::Alignment::Center) - .height(Length::Fill); + let bar = match app.playback_mode { + PlaybackMode::Local => { + let folder_btn = chip_button("Folder", Message::OpenFolder); + #[cfg(target_os = "ios")] + let file_btn = chip_button("Library", Message::OpenFile); + #[cfg(not(target_os = "ios"))] + let file_btn = chip_button("File", Message::OpenFile); + row![ + title, + Space::new().width(Length::Fixed(20.0)), + folder_btn, + Space::new().width(Length::Fixed(8.0)), + file_btn, + Space::new().width(Length::Fill), + settings_btn, + ] + } + PlaybackMode::Capture => row![ + title, + Space::new().width(Length::Fill), + settings_btn, + ], + }; - container(bar) - .width(Length::Fill) - .height(Length::Fixed(TOP_BAR_H)) - .style(panel_style) - .into() + container( + bar.padding(Padding::from([0, 16])) + .spacing(0) + .align_y(iced_wgpu::core::Alignment::Center) + .height(Length::Fill), + ) + .width(Length::Fill) + .height(Length::Fixed(TOP_BAR_H)) + .style(panel_style) + .into() } /// hash key feeding iced's lazy cache, redrawing only after a meaningful sidebar change. @@ -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")))] fn device_row<'a, F>( label: &'a str, @@ -674,7 +684,7 @@ where .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")))] #[derive(Clone, PartialEq)] enum DeviceChoice { diff --git a/src/viewport.rs b/src/viewport.rs index 8f7756b..714e53b 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -48,6 +48,10 @@ pub struct ViewportHandle { /// classification of an active touch over the settings panel: pending until SLOP, then tap/drag/scroll. settings_touch: SettingsTouch, pub state: App, + + /// lazy off-screen visualizer renderer feeding the iOS PiP display layer. + #[cfg(target_os = "ios")] + pip_renderer: Option, } /// 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() } + /// 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. pub fn take_pending_rebuild_capture(&mut self) -> bool { self.state.take_pending_rebuild_capture() @@ -601,9 +639,27 @@ fn finalise( touch_in_sidebar: false, settings_touch: SettingsTouch::None, 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. fn render(handle: &mut ViewportHandle) { handle.state.tick(); diff --git a/src/visualizer/mod.rs b/src/visualizer/mod.rs index 44f89ab..4375017 100644 --- a/src/visualizer/mod.rs +++ b/src/visualizer/mod.rs @@ -2,6 +2,8 @@ pub mod build; pub mod pipeline; +#[cfg(target_os = "ios")] +pub mod pip; pub mod primitive; pub mod state; diff --git a/src/visualizer/pip.rs b/src/visualizer/pip.rs new file mode 100644 index 0000000..3d6fde3 --- /dev/null +++ b/src/visualizer/pip.rs @@ -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>, + 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:: { + 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) +} diff --git a/src/visualizer/pipeline.rs b/src/visualizer/pipeline.rs index ec2e4c0..b9caa5b 100644 --- a/src/visualizer/pipeline.rs +++ b/src/visualizer/pipeline.rs @@ -82,8 +82,14 @@ const INITIAL_BINS_CAPACITY: u64 = 256 * 2; const INITIAL_CEP_CAPACITY: u64 = 1024; impl Pipeline for VisPipeline { - /// builds the three render pipelines, allocates the uniform/storage/vertex buffers, and seeds the bind group. - fn new(device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self { + 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 { label: Some("yr_crystals.visualizer.shader"), source: wgpu::ShaderSource::Wgsl( diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0363fcb..eaabc2f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -4,7 +4,7 @@ use std::env; use std::path::PathBuf; 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/. fn main() -> ExitCode { @@ -19,6 +19,8 @@ fn main() -> ExitCode { let extra_args: Vec<&String> = args.iter().skip(1).collect(); let (action, platform) = if cmd == "release-playstore" { ("release-playstore".to_string(), "android".to_string()) + } else if cmd == "web" { + ("build".to_string(), "web".to_string()) } else { parse(cmd) }; @@ -39,7 +41,7 @@ fn main() -> ExitCode { "-File", ], ), - "linux" | "macos" | "ios" | "android" => ( + "linux" | "macos" | "ios" | "android" | "web" => ( repo_root.join(format!("scripts/{platform}/{action}.sh")), vec!["bash"], ), @@ -116,6 +118,10 @@ fn print_help() { eprintln!(" build release build for the current platform"); eprintln!(" install release build + install (macOS: /Applications)"); eprintln!(" debug debug build + foreground launch (live console on iOS)"); + eprintln!(" package cross-compile + zip distributables (macOS host only)"); + eprintln!(" --all all six desktop targets"); + eprintln!(" --target 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-android pick an attached Android device by adb serial"); eprintln!(" bootstrap-android install android sdk packages from .android-sdk-packages");