diff --git a/include/yr_xtals.h b/include/yr_xtals.h
index 256eed5..dbacf0b 100644
--- a/include/yr_xtals.h
+++ b/include/yr_xtals.h
@@ -86,6 +86,12 @@ void viewport_set_capture_metadata(struct ViewportHandle *handle,
uint64_t position_ms,
uint64_t duration_ms);
+/// drains the pending Picture-in-Picture entry-request flag set by tapping the PiP chip.
+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);
+
/// 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 1374fe7..ac67dfd 100644
--- a/ios/Info.plist
+++ b/ios/Info.plist
@@ -58,6 +58,7 @@
UIBackgroundModes
audio
+ picture-in-picture
UIDeviceFamily
diff --git a/ios/src/CaptureController.swift b/ios/src/CaptureController.swift
index ea96940..08da458 100644
--- a/ios/src/CaptureController.swift
+++ b/ios/src/CaptureController.swift
@@ -3,12 +3,21 @@ import AVFoundation
/// gates mic capture on AVCaptureDevice audio permission and exposes start/stop to the iced viewport.
class CaptureController {
- private let session = CaptureSession()
+ let session = CaptureSession()
+ private var pipController: AnyObject?
weak var view: IcedViewportView?
/// 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.
+ var isPictureInPictureActive: Bool {
+ if #available(iOS 15.0, *), let p = pipController as? PipController {
+ return p.isPictureInPictureActive
+ }
+ return false
+ }
+
/// requests AVCaptureDevice audio permission if missing; starts the mic session on grant or when already granted.
func start() {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
@@ -28,19 +37,44 @@ class CaptureController {
}
}
- /// stops the mic session.
+ /// forwards a PiP-entry request from the rust drain to the PipController.
+ func enterPip() {
+ if #available(iOS 15.0, *) {
+ (pipController as? PipController)?.enterPip()
+ }
+ }
+
+ /// stops the mic session and tears down the PiP feed.
func stop() {
session.stop()
+ if #available(iOS 15.0, *), let p = pipController as? PipController {
+ p.stopFeeding()
+ }
+ pipController = nil
}
private func startSession() {
session.viewportHandleProvider = { [weak view = self.view] in
view?.viewportHandle
}
+ session.pipActiveProvider = { [weak self] in
+ self?.isPictureInPictureActive ?? false
+ }
do {
try session.start()
} catch {
print("[YrXtals] CaptureSession start failed: \(error)")
}
+ 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.
+ private func startPipFeedIfPossible() {
+ guard #available(iOS 15.0, *) else { return }
+ guard let v = view else { return }
+ if pipController == nil {
+ pipController = PipController(view: v)
+ }
+ (pipController as? PipController)?.startFeeding()
}
}
diff --git a/ios/src/CaptureSession.swift b/ios/src/CaptureSession.swift
index 7ef67f4..c6935bb 100644
--- a/ios/src/CaptureSession.swift
+++ b/ios/src/CaptureSession.swift
@@ -12,6 +12,9 @@ class CaptureSession {
/// supplies the live viewport handle each tap. captured weakly to avoid extending the view's lifetime.
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.
+ var pipActiveProvider: (() -> Bool)?
+
/// stashes the capture-on intent, runs the full configure + start sequence, and registers lifecycle observers.
func start() throws {
if isCapturing { return }
@@ -120,8 +123,12 @@ class CaptureSession {
observers.removeAll()
}
- /// fully tears down on transition out of active state. preserves isCapturing so the resume path knows to restart.
+ /// 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.
private func handleResignActive() {
+ if pipActiveProvider?() == true {
+ print("[YrXtals] resignActive but PiP active, keeping engine alive")
+ return
+ }
teardownEngineAndSession()
print("[YrXtals] CaptureSession torn down on resignActive")
}
diff --git a/ios/src/IcedViewportView.swift b/ios/src/IcedViewportView.swift
index fa5763a..6a389be 100644
--- a/ios/src/IcedViewportView.swift
+++ b/ios/src/IcedViewportView.swift
@@ -121,6 +121,10 @@ class IcedViewportView: UIView {
default: break
}
+ if viewport_take_pending_pip_request(handle) {
+ captureController?.enterPip()
+ }
+
if viewport_take_pending_persist_settings(handle) {
if let cstr = viewport_get_settings_json(handle) {
let json = String(cString: cstr)
diff --git a/ios/src/PipController.swift b/ios/src/PipController.swift
new file mode 100644
index 0000000..f1c17ba
--- /dev/null
+++ b/ios/src/PipController.swift
@@ -0,0 +1,270 @@
+import AVKit
+import AVFoundation
+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.
+@available(iOS 15.0, *)
+class PipController: NSObject {
+
+ weak var view: IcedViewportView?
+ private let displayLayer = AVSampleBufferDisplayLayer()
+ private var pipController: AVPictureInPictureController?
+ private var displayLink: CADisplayLink?
+ private var binsBuffer: [Float] = Array(repeating: 0, count: 64)
+
+ private let renderSize = CGSize(width: 320, height: 180)
+ private static let displayTimescale: Int32 = 600
+
+ init(view: IcedViewportView) {
+ super.init()
+ self.view = view
+ attachDisplayLayer()
+ setupPipController()
+ }
+
+ /// returns true when AVPictureInPictureController reports an active PiP session.
+ var isPictureInPictureActive: Bool {
+ pipController?.isPictureInPictureActive ?? false
+ }
+
+ /// begins the CADisplayLink that pulls bin magnitudes and enqueues sample buffers at ~30 fps.
+ func startFeeding() {
+ if displayLink != nil { return }
+ let link = CADisplayLink(target: self, selector: #selector(tick))
+ link.preferredFramesPerSecond = 30
+ link.add(to: .main, forMode: .common)
+ displayLink = link
+ }
+
+ /// stops the feed and flushes the display layer.
+ func stopFeeding() {
+ displayLink?.invalidate()
+ displayLink = nil
+ displayLayer.flushAndRemoveImage()
+ }
+
+ /// requests AVPictureInPictureController to enter PiP. no-op when already active or unsupported.
+ func enterPip() {
+ guard let pipController = pipController else { return }
+ if pipController.isPictureInPictureActive { return }
+ if !pipController.isPictureInPicturePossible {
+ print("[YrXtals] PiP not yet possible (no samples enqueued?)")
+ return
+ }
+ pipController.startPictureInPicture()
+ }
+
+ /// places the AVSampleBufferDisplayLayer in the view hierarchy off-screen so it's a valid PiP source without obscuring the metal layer.
+ private func attachDisplayLayer() {
+ displayLayer.videoGravity = .resizeAspect
+ displayLayer.frame = CGRect(origin: .zero, size: renderSize)
+ displayLayer.bounds = CGRect(origin: .zero, size: renderSize)
+ displayLayer.opacity = 0
+ view?.layer.addSublayer(displayLayer)
+ }
+
+ /// wires up AVPictureInPictureController with the sample buffer display layer content source.
+ private func setupPipController() {
+ guard AVPictureInPictureController.isPictureInPictureSupported() else {
+ print("[YrXtals] PiP not supported on this device")
+ return
+ }
+ let source = AVPictureInPictureController.ContentSource(
+ sampleBufferDisplayLayer: displayLayer,
+ playbackDelegate: self,
+ )
+ let controller = AVPictureInPictureController(contentSource: source)
+ controller.delegate = self
+ 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)
+ }
+ 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 }
+
+ 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?
+ CMVideoFormatDescriptionCreateForImageBuffer(
+ allocator: kCFAllocatorDefault,
+ imageBuffer: pixelBuffer,
+ formatDescriptionOut: &formatDescription,
+ )
+ guard let formatDesc = formatDescription else { return }
+
+ let timescale = Self.displayTimescale
+ let presentationTime = CMTime(
+ value: CMTimeValue(CACurrentMediaTime() * Double(timescale)),
+ timescale: timescale,
+ )
+ var timing = CMSampleTimingInfo(
+ duration: CMTime(value: CMTimeValue(timescale / 30), timescale: timescale),
+ presentationTimeStamp: presentationTime,
+ decodeTimeStamp: .invalid,
+ )
+
+ var sampleBuffer: CMSampleBuffer?
+ CMSampleBufferCreateForImageBuffer(
+ allocator: kCFAllocatorDefault,
+ imageBuffer: pixelBuffer,
+ dataReady: true,
+ makeDataReadyCallback: nil,
+ refcon: nil,
+ formatDescription: formatDesc,
+ sampleTiming: &timing,
+ sampleBufferOut: &sampleBuffer,
+ )
+ guard let sb = sampleBuffer else { return }
+
+ if displayLayer.status == .failed {
+ displayLayer.flush()
+ }
+ 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, *)
+extension PipController: AVPictureInPictureSampleBufferPlaybackDelegate {
+
+ func pictureInPictureController(
+ _ pictureInPictureController: AVPictureInPictureController,
+ setPlaying playing: Bool,
+ ) {
+ // the visualizer "plays" continuously while in PiP; the toggle is ignored.
+ }
+
+ func pictureInPictureControllerTimeRangeForPlayback(
+ _ pictureInPictureController: AVPictureInPictureController,
+ ) -> CMTimeRange {
+ return CMTimeRange(start: .negativeInfinity, end: .positiveInfinity)
+ }
+
+ func pictureInPictureControllerIsPlaybackPaused(
+ _ pictureInPictureController: AVPictureInPictureController,
+ ) -> Bool {
+ return false
+ }
+
+ func pictureInPictureController(
+ _ pictureInPictureController: AVPictureInPictureController,
+ didTransitionToRenderSize newRenderSize: CMVideoDimensions,
+ ) {
+ // ignored; we render at a fixed internal size and let AVKit scale.
+ }
+
+ func pictureInPictureController(
+ _ pictureInPictureController: AVPictureInPictureController,
+ skipByInterval skipInterval: CMTime,
+ completion completionHandler: @escaping () -> Void,
+ ) {
+ completionHandler()
+ }
+}
+
+@available(iOS 15.0, *)
+extension PipController: AVPictureInPictureControllerDelegate {
+
+ func pictureInPictureControllerWillStartPictureInPicture(
+ _ pictureInPictureController: AVPictureInPictureController,
+ ) {
+ print("[YrXtals] PiP will start")
+ }
+
+ func pictureInPictureControllerDidStartPictureInPicture(
+ _ pictureInPictureController: AVPictureInPictureController,
+ ) {
+ print("[YrXtals] PiP did start")
+ }
+
+ func pictureInPictureControllerWillStopPictureInPicture(
+ _ pictureInPictureController: AVPictureInPictureController,
+ ) {
+ print("[YrXtals] PiP will stop")
+ }
+
+ func pictureInPictureControllerDidStopPictureInPicture(
+ _ pictureInPictureController: AVPictureInPictureController,
+ ) {
+ print("[YrXtals] PiP did stop")
+ }
+
+ func pictureInPictureController(
+ _ pictureInPictureController: AVPictureInPictureController,
+ failedToStartPictureInPictureWithError error: Error,
+ ) {
+ print("[YrXtals] PiP failed to start: \(error)")
+ }
+}
diff --git a/macos/Info.plist b/macos/Info.plist
index 37b4a7e..b23ab93 100644
--- a/macos/Info.plist
+++ b/macos/Info.plist
@@ -35,6 +35,6 @@
ITSAppUsesNonExemptEncryption
NSMicrophoneUsageDescription
- Yr Xtals does not record audio. This entry is here only because the audio framework asks for it on some macOS versions.
+ Yr Xtals listens through the microphone in Playback capture mode so the visualizer reacts to whatever audio is playing around you.
diff --git a/src/analyzer.rs b/src/analyzer.rs
index 681647e..ed3dc21 100644
--- a/src/analyzer.rs
+++ b/src/analyzer.rs
@@ -48,6 +48,9 @@ pub struct Analyzer {
/// smoothed AGC gain applied to incoming live PCM before it enters the analyzer buffer.
live_gain: f64,
+
+ /// dB threshold below which incoming live PCM chunks are replaced with silence.
+ noise_gate_db: f32,
}
impl Analyzer {
@@ -104,9 +107,15 @@ impl Analyzer {
live_buffer: VecDeque::with_capacity(192_000),
live_buffer_max: 192_000,
live_gain: 1.0,
+ noise_gate_db: -100.0,
}
}
+ /// stores the noise-gate threshold in dB applied to live-mode PCM chunks.
+ pub fn set_noise_gate(&mut self, db: f32) {
+ self.noise_gate_db = db;
+ }
+
/// returns the loaded track's sample rate, falling back to 48 khz before any track loads.
pub fn sample_rate(&self) -> u32 {
self.track.as_ref().map(|t| t.sample_rate).unwrap_or(48_000)
@@ -128,26 +137,29 @@ impl Analyzer {
}
/// 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.
pub fn set_dsp_params(&mut self, frame_size: usize, hop_size: usize) {
let trans_size = (frame_size / 4).max(64);
let deep_size = if frame_size < 2048 { frame_size * 4 } else { frame_size * 2 };
- let hilbert_changed =
- frame_size != self.hilbert_fft_size || hop_size != self.hilbert_hop_size;
- if hilbert_changed {
- self.hilbert_fft_size = frame_size;
- self.hilbert_hop_size = hop_size;
- self.hilbert_needs_reset = true;
- }
+
+ self.hilbert_fft_size = frame_size;
+ self.hilbert_hop_size = hop_size;
+ self.hilbert_needs_reset = true;
+ self.live_buffer.clear();
self.frame_size = frame_size;
self.hop_size = hop_size;
+
for p in &mut self.main {
p.set_frame_size(frame_size);
+ p.clear_state();
}
for p in &mut self.transient {
p.set_frame_size(trans_size);
+ p.clear_state();
}
for p in &mut self.deep {
p.set_frame_size(deep_size);
+ p.clear_state();
}
}
@@ -270,6 +282,24 @@ impl Analyzer {
sum_sq += (s as f64) * (s as f64);
}
let rms = (sum_sq / samples.len() as f64).sqrt();
+ let rms_db = 20.0 * (rms.max(1e-12)).log10() as f32;
+
+ 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);
+ self.live_buffer.push_back(0.0);
+ }
+ while self.live_buffer.len() > self.live_buffer_max {
+ self.live_buffer.pop_front();
+ self.live_buffer.pop_front();
+ }
+ self.trim_live_buffer();
+ return;
+ }
+
let target_gain = if rms > MIN_RMS {
(TARGET_RMS / rms).clamp(MIN_GAIN, MAX_GAIN)
} else {
@@ -279,7 +309,6 @@ impl Analyzer {
self.live_gain += alpha * (target_gain - self.live_gain);
let gain = self.live_gain as f32;
- let ch = channels as usize;
match ch {
2 => {
for &s in samples {
@@ -306,6 +335,39 @@ 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();
+ }
+
+ /// 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.
+ fn trim_live_buffer(&mut self) {
+ let hop = self.hilbert_hop_size;
+ let fft = self.hilbert_fft_size;
+ if hop == 0 || fft == 0 {
+ return;
+ }
+ let warmup_blocks = fft / hop;
+ let needed = if self.hilbert_needs_reset {
+ warmup_blocks * hop * 2 + hop * 2
+ } 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
+ } else {
+ (self.live_sample_rate as usize * slack_ms / 1000) * 2
+ };
+ let max_keep = (needed + slack_entries).min(self.live_buffer_max);
+ if self.live_buffer.len() > max_keep {
+ let drop_pairs = (self.live_buffer.len() - max_keep) / 2;
+ for _ in 0..drop_pairs {
+ self.live_buffer.pop_front();
+ self.live_buffer.pop_front();
+ }
+ }
}
/// consumes one hop's worth of interleaved stereo from the live buffer and publishes a frame pair.
@@ -315,6 +377,10 @@ impl Analyzer {
if hop == 0 || fft == 0 {
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 {
let warmup_blocks = fft / hop;
let warmup_stereo = warmup_blocks * hop * 2;
diff --git a/src/analyzer_worker.rs b/src/analyzer_worker.rs
index be63ed7..f102354 100644
--- a/src/analyzer_worker.rs
+++ b/src/analyzer_worker.rs
@@ -25,6 +25,7 @@ enum Cmd {
SetNumBins(usize),
SetSmoothing { granularity: i32, detail: i32, strength: f32 },
SetGpuBlend(f32),
+ SetNoiseGate(f32),
SetMode(AnalyzerMode),
PushLivePcm { samples: Vec, sample_rate: u32, channels: u32 },
Shutdown,
@@ -109,6 +110,11 @@ impl AnalyzerWorker {
let _ = self.cmd_tx.send(Cmd::SetGpuBlend(blend));
}
+ /// queues a noise-gate threshold change in dB.
+ pub fn set_noise_gate(&self, db: f32) {
+ let _ = self.cmd_tx.send(Cmd::SetNoiseGate(db));
+ }
+
/// queues a switch between Track and Live analyzer modes.
pub fn set_mode(&self, mode: AnalyzerMode) {
let _ = self.cmd_tx.send(Cmd::SetMode(mode));
@@ -209,9 +215,13 @@ fn run(
}
}
AnalyzerMode::Live => {
- // drains all hops queued in the live buffer per tick so small hops (e.g. 64) don't fall behind the audio thread; publishes only the most recent frame pair.
+ // 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 {
+ if Instant::now() >= drain_deadline {
+ break;
+ }
match analyzer.step_live() {
Some(latest) => latest_owned = Some(latest.to_vec()),
None => break,
@@ -244,6 +254,7 @@ fn apply(analyzer: &mut Analyzer, cmd: Cmd) {
strength,
} => analyzer.set_smoothing_params(granularity, detail, strength),
Cmd::SetGpuBlend(b) => analyzer.set_gpu_blend(b),
+ Cmd::SetNoiseGate(db) => analyzer.set_noise_gate(db),
Cmd::SetMode(_) | Cmd::PushLivePcm { .. } => {}
Cmd::Shutdown => {}
}
diff --git a/src/desktop_capture.rs b/src/desktop_capture.rs
new file mode 100644
index 0000000..facda53
--- /dev/null
+++ b/src/desktop_capture.rs
@@ -0,0 +1,79 @@
+use cpal::traits::{DeviceTrait, StreamTrait};
+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.
+pub struct DesktopCapture {
+ _stream: Stream,
+}
+
+impl DesktopCapture {
+ /// opens the default input device and starts an f32/i16/u16 input stream feeding the analyzer.
+ pub fn start(pusher: PcmSender) -> Result {
+ Self::start_with_device(pusher, None)
+ }
+
+ /// opens an input device by name (system default when name is None) and starts the capture stream.
+ 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())?;
+ let config = device
+ .default_input_config()
+ .map_err(|e| format!("default input config: {e}"))?;
+
+ let sample_rate = config.sample_rate().0;
+ let channels = config.channels() as u32;
+ let sample_format = config.sample_format();
+ let stream_config: cpal::StreamConfig = config.into();
+
+ #[cfg(debug_assertions)]
+ eprintln!(
+ "yr_crystals[dbg] capture device opened: sr={sample_rate} ch={channels} fmt={sample_format:?}",
+ );
+
+ let err_fn = |e| eprintln!("yr_crystals: capture stream error: {e}");
+
+ let stream = match sample_format {
+ SampleFormat::F32 => device.build_input_stream(
+ &stream_config,
+ move |data: &[f32], _: &_| {
+ pusher.push(data.to_vec(), sample_rate, channels);
+ },
+ err_fn,
+ None,
+ ),
+ SampleFormat::I16 => device.build_input_stream(
+ &stream_config,
+ move |data: &[i16], _: &_| {
+ let samples: Vec =
+ data.iter().map(|&s| s as f32 / i16::MAX as f32).collect();
+ pusher.push(samples, sample_rate, channels);
+ },
+ err_fn,
+ None,
+ ),
+ SampleFormat::U16 => device.build_input_stream(
+ &stream_config,
+ move |data: &[u16], _: &_| {
+ let samples: Vec = data
+ .iter()
+ .map(|&s| (s as f32 - 32768.0) / 32768.0)
+ .collect();
+ pusher.push(samples, sample_rate, channels);
+ },
+ err_fn,
+ None,
+ ),
+ other => return Err(format!("unsupported capture sample format: {other:?}")),
+ }
+ .map_err(|e| format!("build input stream: {e}"))?;
+
+ stream
+ .play()
+ .map_err(|e| format!("start input stream: {e}"))?;
+
+ Ok(Self { _stream: stream })
+ }
+}
diff --git a/src/devices.rs b/src/devices.rs
new file mode 100644
index 0000000..e20a36d
--- /dev/null
+++ b/src/devices.rs
@@ -0,0 +1,43 @@
+use cpal::traits::{DeviceTrait, HostTrait};
+
+/// returns the names of every input device on the default host, with duplicates kept.
+pub fn list_inputs() -> Vec {
+ let host = cpal::default_host();
+ host.input_devices()
+ .map(|iter| iter.filter_map(|d| d.name().ok()).collect())
+ .unwrap_or_default()
+}
+
+/// returns the names of every output device on the default host, with duplicates kept.
+pub fn list_outputs() -> Vec {
+ let host = cpal::default_host();
+ host.output_devices()
+ .map(|iter| iter.filter_map(|d| d.name().ok()).collect())
+ .unwrap_or_default()
+}
+
+/// resolves an optional device name to a cpal input device, falling back to the system default.
+pub fn input_device(name: Option<&str>) -> Option {
+ let host = cpal::default_host();
+ if let Some(want) = name {
+ if let Ok(mut iter) = host.input_devices() {
+ if let Some(found) = iter.find(|d| d.name().ok().as_deref() == Some(want)) {
+ return Some(found);
+ }
+ }
+ }
+ host.default_input_device()
+}
+
+/// resolves an optional device name to a cpal output device, falling back to the system default.
+pub fn output_device(name: Option<&str>) -> Option {
+ let host = cpal::default_host();
+ if let Some(want) = name {
+ if let Ok(mut iter) = host.output_devices() {
+ if let Some(found) = iter.find(|d| d.name().ok().as_deref() == Some(want)) {
+ return Some(found);
+ }
+ }
+ }
+ host.default_output_device()
+}
diff --git a/src/engine.rs b/src/engine.rs
index 88799e8..c08f1bd 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -60,16 +60,28 @@ pub struct AudioEngine {
}
impl AudioEngine {
- /// opens the default output device, builds the f32 stream, and starts the audio thread paused.
+ /// opens the default output device.
#[cfg(not(target_os = "android"))]
pub fn new() -> Result {
+ Self::with_output_device(None)
+ }
+
+ /// opens an output device by name (system default when name is None), builds the f32 stream, and starts the audio thread paused.
+ #[cfg(not(target_os = "android"))]
+ pub fn with_output_device(name: Option<&str>) -> Result {
#[cfg(debug_assertions)]
let _t0 = std::time::Instant::now();
let host = cpal::default_host();
- let device = host
- .default_output_device()
- .ok_or_else(|| "no default output device".to_string())?;
+ let device = match name {
+ Some(want) => host
+ .output_devices()
+ .ok()
+ .and_then(|mut iter| iter.find(|d| d.name().ok().as_deref() == Some(want)))
+ .or_else(|| host.default_output_device()),
+ None => host.default_output_device(),
+ }
+ .ok_or_else(|| "no default output device".to_string())?;
let config = device
.default_output_config()
.map_err(|e| format!("default output config: {e}"))?;
diff --git a/src/gpu_dsp.rs b/src/gpu_dsp.rs
index bcc42c7..307bb87 100644
--- a/src/gpu_dsp.rs
+++ b/src/gpu_dsp.rs
@@ -2,6 +2,7 @@
use bytemuck::{Pod, Zeroable};
use num_complex::Complex64;
+use std::collections::VecDeque;
const FFT_WGSL: &str = include_str!("../shaders/fft.wgsl");
@@ -17,35 +18,39 @@ struct Args {
const ARGS_BYTES: u64 = std::mem::size_of::() as u64;
-/// fixed-size 1D radix-2 FFT running on a wgpu compute queue with a single staged readback path.
+/// 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.
+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.
+struct GpuSlot {
+ data_buf: wgpu::Buffer,
+ scratch_buf: wgpu::Buffer,
+ args_buf: wgpu::Buffer,
+ staging: wgpu::Buffer,
+ bind_group: wgpu::BindGroup,
+ 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).
pub struct GpuFft1D {
device: wgpu::Device,
queue: wgpu::Queue,
n: u32,
log2_n: u32,
- pending: Option,
-
- staging_mapped: bool,
-
bit_reverse: wgpu::ComputePipeline,
butterfly: wgpu::ComputePipeline,
- args_buf: wgpu::Buffer,
-
all_args_buf: wgpu::Buffer,
-
cached_inverse: std::cell::Cell