From 39c973376bf29f98c4e85995bb6bce95201739b6 Mon Sep 17 00:00:00 2001 From: Per Johansson Date: Sun, 30 Nov 2025 23:51:45 +0100 Subject: [PATCH 1/7] feat(cpal): add optional cpal audio adapter - Add 'cpal' feature flag with optional cpal dependency - Add cpal_adapter module with AudioSamples and CpalAudioExt trait - Add audio format description APIs to CMFormatDescription: - audio_sample_rate() - audio_channel_count() - audio_bits_per_channel() - audio_bytes_per_frame() - audio_format_flags() - audio_is_float() - audio_is_big_endian() - Add Swift FFI functions for audio format description - Add AudioFormat struct for cpal StreamConfig conversion --- Cargo.toml | 4 + src/cm/ffi.rs | 17 ++ src/cm/format_description.rs | 84 ++++++++ src/cpal_adapter.rs | 200 ++++++++++++++++++ src/lib.rs | 4 + .../Sources/CoreMedia/CoreMedia.swift | 45 ++++ 6 files changed, 354 insertions(+) create mode 100644 src/cpal_adapter.rs diff --git a/Cargo.toml b/Cargo.toml index ffa3078..094bada 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,9 @@ cargo-clippy = [] # Async support (executor-agnostic, works with any async runtime) async = [] +# cpal audio integration for zero-copy audio playback +cpal = ["dep:cpal"] + # macOS version feature flags # Enable features for specific macOS versions macos_13_0 = [] @@ -51,6 +54,7 @@ macos_15_2 = ["macos_15_0"] macos_26_0 = ["macos_15_2"] [dependencies] +cpal = { version = "0.15", optional = true } [dev-dependencies] png = "0.17" diff --git a/src/cm/ffi.rs b/src/cm/ffi.rs index 41b5be2..7b5d8c9 100644 --- a/src/cm/ffi.rs +++ b/src/cm/ffi.rs @@ -156,6 +156,23 @@ extern "C" { ) -> *mut std::ffi::c_void; pub fn cm_format_description_release(format_description: *mut std::ffi::c_void); + // CMFormatDescription Audio APIs + pub fn cm_format_description_get_audio_sample_rate( + format_description: *mut std::ffi::c_void, + ) -> f64; + pub fn cm_format_description_get_audio_channel_count( + format_description: *mut std::ffi::c_void, + ) -> u32; + pub fn cm_format_description_get_audio_bits_per_channel( + format_description: *mut std::ffi::c_void, + ) -> u32; + pub fn cm_format_description_get_audio_bytes_per_frame( + format_description: *mut std::ffi::c_void, + ) -> u32; + pub fn cm_format_description_get_audio_format_flags( + format_description: *mut std::ffi::c_void, + ) -> u32; + // Hash functions pub fn cm_sample_buffer_hash(sample_buffer: *mut std::ffi::c_void) -> usize; pub fn cv_pixel_buffer_hash(pixel_buffer: *mut std::ffi::c_void) -> usize; diff --git a/src/cm/format_description.rs b/src/cm/format_description.rs index 879ca02..9f6fa45 100644 --- a/src/cm/format_description.rs +++ b/src/cm/format_description.rs @@ -202,6 +202,90 @@ impl CMFormatDescription { pub fn is_alac(&self) -> bool { self.media_subtype() == codec_types::ALAC } + + // Audio format description methods + + /// Get the audio sample rate in Hz + /// + /// Returns `None` if this is not an audio format description. + pub fn audio_sample_rate(&self) -> Option { + if !self.is_audio() { + return None; + } + let rate = unsafe { ffi::cm_format_description_get_audio_sample_rate(self.0) }; + if rate > 0.0 { + Some(rate) + } else { + None + } + } + + /// Get the number of audio channels + /// + /// Returns `None` if this is not an audio format description. + pub fn audio_channel_count(&self) -> Option { + if !self.is_audio() { + return None; + } + let count = unsafe { ffi::cm_format_description_get_audio_channel_count(self.0) }; + if count > 0 { + Some(count) + } else { + None + } + } + + /// Get the bits per audio channel + /// + /// Returns `None` if this is not an audio format description. + pub fn audio_bits_per_channel(&self) -> Option { + if !self.is_audio() { + return None; + } + let bits = unsafe { ffi::cm_format_description_get_audio_bits_per_channel(self.0) }; + if bits > 0 { + Some(bits) + } else { + None + } + } + + /// Get the bytes per audio frame + /// + /// Returns `None` if this is not an audio format description. + pub fn audio_bytes_per_frame(&self) -> Option { + if !self.is_audio() { + return None; + } + let bytes = unsafe { ffi::cm_format_description_get_audio_bytes_per_frame(self.0) }; + if bytes > 0 { + Some(bytes) + } else { + None + } + } + + /// Get the audio format flags + /// + /// Returns `None` if this is not an audio format description. + pub fn audio_format_flags(&self) -> Option { + if !self.is_audio() { + return None; + } + Some(unsafe { ffi::cm_format_description_get_audio_format_flags(self.0) }) + } + + /// Check if audio is float format (based on format flags) + pub fn audio_is_float(&self) -> bool { + // kAudioFormatFlagIsFloat = 1 + self.audio_format_flags().is_some_and(|f| f & 1 != 0) + } + + /// Check if audio is big-endian (based on format flags) + pub fn audio_is_big_endian(&self) -> bool { + // kAudioFormatFlagIsBigEndian = 2 + self.audio_format_flags().is_some_and(|f| f & 2 != 0) + } } impl Clone for CMFormatDescription { diff --git a/src/cpal_adapter.rs b/src/cpal_adapter.rs new file mode 100644 index 0000000..7ac7d56 --- /dev/null +++ b/src/cpal_adapter.rs @@ -0,0 +1,200 @@ +//! cpal integration for zero-copy audio playback +//! +//! This module provides adapters to use captured audio with the cpal audio library. +//! +//! # Example +//! +//! ```ignore +//! use screencapturekit::cpal_adapter::{AudioSamples, CpalAudioExt}; +//! use screencapturekit::cm::CMSampleBuffer; +//! +//! fn process_audio(sample: &CMSampleBuffer) { +//! // Get f32 samples from the captured audio +//! if let Some(samples) = sample.audio_f32_samples() { +//! for sample in samples.iter() { +//! // Process or send to cpal output stream +//! } +//! } +//! } +//! ``` + +use crate::cm::{AudioBufferList, CMSampleBuffer}; + +/// Audio samples extracted from a `CMSampleBuffer` +/// +/// Owns the underlying `AudioBufferList` and provides access to samples +/// in various formats compatible with cpal. +pub struct AudioSamples { + buffer_list: AudioBufferList, +} + +impl AudioSamples { + /// Create from a `CMSampleBuffer` + /// + /// Returns `None` if the sample buffer doesn't contain audio data. + pub fn new(sample: &CMSampleBuffer) -> Option { + let buffer_list = sample.audio_buffer_list()?; + Some(Self { buffer_list }) + } + + /// Get the number of audio channels + pub fn channels(&self) -> usize { + self.buffer_list + .get(0) + .map(|b| b.number_channels as usize) + .unwrap_or(0) + } + + /// Get raw bytes of audio data + pub fn as_bytes(&self) -> &[u8] { + self.buffer_list.get(0).map(|b| b.data()).unwrap_or(&[]) + } + + /// Get audio samples as f32 slice (zero-copy if data is already f32) + /// + /// # Safety + /// Assumes the audio data is in native-endian f32 format. + #[allow(clippy::cast_ptr_alignment)] + pub fn as_f32_slice(&self) -> &[f32] { + let bytes = self.as_bytes(); + if bytes.len() < 4 { + return &[]; + } + // Safety: macOS audio buffers are properly aligned for the sample type + unsafe { std::slice::from_raw_parts(bytes.as_ptr().cast::(), bytes.len() / 4) } + } + + /// Get audio samples as i16 slice (zero-copy if data is already i16) + /// + /// # Safety + /// Assumes the audio data is in native-endian i16 format. + #[allow(clippy::cast_ptr_alignment)] + pub fn as_i16_slice(&self) -> &[i16] { + let bytes = self.as_bytes(); + if bytes.len() < 2 { + return &[]; + } + // Safety: macOS audio buffers are properly aligned for the sample type + unsafe { std::slice::from_raw_parts(bytes.as_ptr().cast::(), bytes.len() / 2) } + } + + /// Iterator over f32 samples + pub fn iter_f32(&self) -> impl Iterator + '_ { + self.as_f32_slice().iter().copied() + } + + /// Iterator over i16 samples + pub fn iter_i16(&self) -> impl Iterator + '_ { + self.as_i16_slice().iter().copied() + } + + /// Get the number of f32 samples + pub fn len_f32(&self) -> usize { + self.as_bytes().len() / 4 + } + + /// Get the number of i16 samples + pub fn len_i16(&self) -> usize { + self.as_bytes().len() / 2 + } + + /// Check if empty + pub fn is_empty(&self) -> bool { + self.as_bytes().is_empty() + } +} + +/// Extension trait for `CMSampleBuffer` to provide cpal-compatible audio access +pub trait CpalAudioExt { + /// Get audio samples as f32 (for cpal output) + fn audio_f32_samples(&self) -> Option; + + /// Copy f32 audio samples into a cpal-compatible buffer + /// + /// Returns the number of samples copied. + fn copy_f32_to_buffer(&self, buffer: &mut [f32]) -> usize; + + /// Copy i16 audio samples into a cpal-compatible buffer + /// + /// Returns the number of samples copied. + fn copy_i16_to_buffer(&self, buffer: &mut [i16]) -> usize; +} + +impl CpalAudioExt for CMSampleBuffer { + fn audio_f32_samples(&self) -> Option { + AudioSamples::new(self) + } + + fn copy_f32_to_buffer(&self, buffer: &mut [f32]) -> usize { + let Some(samples) = AudioSamples::new(self) else { + return 0; + }; + let src = samples.as_f32_slice(); + let len = buffer.len().min(src.len()); + buffer[..len].copy_from_slice(&src[..len]); + len + } + + fn copy_i16_to_buffer(&self, buffer: &mut [i16]) -> usize { + let Some(samples) = AudioSamples::new(self) else { + return 0; + }; + let src = samples.as_i16_slice(); + let len = buffer.len().min(src.len()); + buffer[..len].copy_from_slice(&src[..len]); + len + } +} + +/// Audio format information for cpal stream configuration +#[derive(Debug, Clone, Copy)] +pub struct AudioFormat { + /// Sample rate in Hz + pub sample_rate: u32, + /// Number of channels + pub channels: u16, + /// Bits per sample + pub bits_per_sample: u16, + /// Whether samples are float format + pub is_float: bool, +} + +impl AudioFormat { + /// Extract audio format from a `CMSampleBuffer` + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + pub fn from_sample_buffer(sample: &CMSampleBuffer) -> Option { + let format_desc = sample.format_description()?; + if !format_desc.is_audio() { + return None; + } + + Some(Self { + sample_rate: format_desc.audio_sample_rate()? as u32, + channels: format_desc.audio_channel_count()? as u16, + bits_per_sample: format_desc.audio_bits_per_channel()? as u16, + is_float: format_desc.audio_is_float(), + }) + } + + /// Convert to cpal `StreamConfig` + pub fn to_stream_config(&self) -> cpal::StreamConfig { + cpal::StreamConfig { + channels: self.channels, + sample_rate: cpal::SampleRate(self.sample_rate), + buffer_size: cpal::BufferSize::Default, + } + } + + /// Get the cpal `SampleFormat` based on the audio format + pub fn sample_format(&self) -> cpal::SampleFormat { + if self.is_float { + cpal::SampleFormat::F32 + } else if self.bits_per_sample == 8 { + cpal::SampleFormat::I8 + } else if self.bits_per_sample == 32 { + cpal::SampleFormat::I32 + } else { + cpal::SampleFormat::I16 + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 07e070e..1ba1e96 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -346,6 +346,10 @@ pub mod utils; #[cfg(feature = "async")] pub mod async_api; +#[cfg(feature = "cpal")] +#[cfg_attr(docsrs, doc(cfg(feature = "cpal")))] +pub mod cpal_adapter; + // Re-export commonly used types pub use cm::{ codec_types, media_types, AudioBuffer, AudioBufferList, CMFormatDescription, CMSampleBuffer, diff --git a/swift-bridge/Sources/CoreMedia/CoreMedia.swift b/swift-bridge/Sources/CoreMedia/CoreMedia.swift index 5058191..a0092de 100644 --- a/swift-bridge/Sources/CoreMedia/CoreMedia.swift +++ b/swift-bridge/Sources/CoreMedia/CoreMedia.swift @@ -700,6 +700,51 @@ public func cm_format_description_release(_ formatDescription: UnsafeMutableRawP Unmanaged.fromOpaque(formatDescription).release() } +@_cdecl("cm_format_description_get_audio_sample_rate") +public func cm_format_description_get_audio_sample_rate(_ formatDescription: UnsafeMutableRawPointer) -> Double { + let desc = Unmanaged.fromOpaque(formatDescription).takeUnretainedValue() + guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else { + return 0.0 + } + return asbd.pointee.mSampleRate +} + +@_cdecl("cm_format_description_get_audio_channel_count") +public func cm_format_description_get_audio_channel_count(_ formatDescription: UnsafeMutableRawPointer) -> UInt32 { + let desc = Unmanaged.fromOpaque(formatDescription).takeUnretainedValue() + guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else { + return 0 + } + return asbd.pointee.mChannelsPerFrame +} + +@_cdecl("cm_format_description_get_audio_bits_per_channel") +public func cm_format_description_get_audio_bits_per_channel(_ formatDescription: UnsafeMutableRawPointer) -> UInt32 { + let desc = Unmanaged.fromOpaque(formatDescription).takeUnretainedValue() + guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else { + return 0 + } + return asbd.pointee.mBitsPerChannel +} + +@_cdecl("cm_format_description_get_audio_bytes_per_frame") +public func cm_format_description_get_audio_bytes_per_frame(_ formatDescription: UnsafeMutableRawPointer) -> UInt32 { + let desc = Unmanaged.fromOpaque(formatDescription).takeUnretainedValue() + guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else { + return 0 + } + return asbd.pointee.mBytesPerFrame +} + +@_cdecl("cm_format_description_get_audio_format_flags") +public func cm_format_description_get_audio_format_flags(_ formatDescription: UnsafeMutableRawPointer) -> UInt32 { + let desc = Unmanaged.fromOpaque(formatDescription).takeUnretainedValue() + guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else { + return 0 + } + return asbd.pointee.mFormatFlags +} + // MARK: - CMSampleBuffer Creation @_cdecl("cm_sample_buffer_create_for_image_buffer") From c0eab6d1fb16b428c3a78c804f6cb20e2385efd6 Mon Sep 17 00:00:00 2001 From: Per Johansson Date: Sun, 30 Nov 2025 23:53:58 +0100 Subject: [PATCH 2/7] test(cpal): add cpal adapter tests and example - Add tests/cpal_adapter_tests.rs with AudioFormat tests - Add examples/17_cpal_audio.rs demonstrating audio capture playback - Update examples/README.md with new example --- examples/17_cpal_audio.rs | 175 ++++++++++++++++++++++++++++++++++++ examples/README.md | 4 + tests/cpal_adapter_tests.rs | 68 ++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 examples/17_cpal_audio.rs create mode 100644 tests/cpal_adapter_tests.rs diff --git a/examples/17_cpal_audio.rs b/examples/17_cpal_audio.rs new file mode 100644 index 0000000..d13e957 --- /dev/null +++ b/examples/17_cpal_audio.rs @@ -0,0 +1,175 @@ +//! Example: cpal audio playback integration +//! +//! Demonstrates capturing system audio and playing it back through cpal. +//! +//! Run with: cargo run --example 17_cpal_audio --features cpal +//! +//! Note: Requires screen recording permission and audio output device. + +use screencapturekit::cpal_adapter::{AudioFormat, CpalAudioExt}; +use screencapturekit::prelude::*; +use std::sync::{Arc, Mutex}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + +/// Ring buffer for audio samples +struct AudioRingBuffer { + buffer: Vec, + write_pos: usize, + read_pos: usize, + capacity: usize, +} + +impl AudioRingBuffer { + fn new(capacity: usize) -> Self { + Self { + buffer: vec![0.0; capacity], + write_pos: 0, + read_pos: 0, + capacity, + } + } + + fn write(&mut self, samples: &[f32]) { + for &sample in samples { + self.buffer[self.write_pos] = sample; + self.write_pos = (self.write_pos + 1) % self.capacity; + } + } + + fn read(&mut self, output: &mut [f32]) { + for sample in output.iter_mut() { + *sample = self.buffer[self.read_pos]; + self.read_pos = (self.read_pos + 1) % self.capacity; + } + } +} + +struct AudioHandler { + ring_buffer: Arc>, + format_detected: Arc>>, +} + +impl SCStreamOutputTrait for AudioHandler { + fn did_output_sample_buffer(&self, sample: CMSampleBuffer, of_type: SCStreamOutputType) { + if of_type != SCStreamOutputType::Audio { + return; + } + + // Detect audio format on first sample + { + let mut format = self.format_detected.lock().unwrap(); + if format.is_none() { + if let Some(f) = AudioFormat::from_sample_buffer(&sample) { + println!( + "🔊 Audio format detected: {}Hz, {} channels, {} bits, float={}", + f.sample_rate, f.channels, f.bits_per_sample, f.is_float + ); + *format = Some(f); + } + } + } + + // Copy audio samples to ring buffer + if let Some(samples) = sample.audio_f32_samples() { + let slice = samples.as_f32_slice(); + if !slice.is_empty() { + let mut rb = self.ring_buffer.lock().unwrap(); + rb.write(slice); + } + } + } +} + +fn main() -> Result<(), Box> { + println!("🎵 cpal Audio Capture Example"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + + // Get display to capture + let content = SCShareableContent::get()?; + let displays = content.displays(); + let display = displays.first().ok_or("No display found")?; + + println!( + "📺 Capturing display: {}x{}", + display.width(), + display.height() + ); + + // Create content filter + let filter = SCContentFilter::builder() + .display(display) + .exclude_windows(&[]) + .build(); + + // Configure stream with audio capture + let config = SCStreamConfiguration::new() + .with_width(1920) + .with_height(1080) + .with_captures_audio(true) + .with_sample_rate(48000) + .with_channel_count(2); + + // Setup ring buffer for audio transfer + let ring_buffer = Arc::new(Mutex::new(AudioRingBuffer::new(48000 * 2 * 2))); // 2 seconds buffer + let format_detected = Arc::new(Mutex::new(None)); + + let handler = AudioHandler { + ring_buffer: Arc::clone(&ring_buffer), + format_detected: Arc::clone(&format_detected), + }; + + // Start capture + let mut stream = SCStream::new(&filter, &config); + stream.add_output_handler(handler, SCStreamOutputType::Audio); + stream.start_capture()?; + + println!("🎬 Capture started, waiting for audio format detection..."); + + // Wait for audio format detection + let audio_format = loop { + std::thread::sleep(std::time::Duration::from_millis(100)); + if let Some(format) = format_detected.lock().unwrap().clone() { + break format; + } + }; + + // Setup cpal output + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or("No output device found")?; + + println!("🔈 Output device: {}", device.name()?); + + let stream_config = audio_format.to_stream_config(); + let rb_clone = Arc::clone(&ring_buffer); + + let output_stream = device.build_output_stream( + &stream_config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + let mut rb = rb_clone.lock().unwrap(); + rb.read(data); + }, + |err| eprintln!("Audio output error: {}", err), + None, + )?; + + output_stream.play()?; + println!("▶️ Audio playback started"); + println!(); + println!("Playing captured system audio for 10 seconds..."); + println!("(Make sure to play some audio on your Mac!)"); + + std::thread::sleep(std::time::Duration::from_secs(10)); + + // Cleanup + drop(output_stream); + stream.stop_capture()?; + + println!(); + println!("✅ Done!"); + + Ok(()) +} diff --git a/examples/README.md b/examples/README.md index d715504..95dc11c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,6 +28,7 @@ cargo run --example 01_basic_capture | 14 | `app_capture` | Application-based filtering | - | | 15 | `memory_leak_check` | Memory leak detection with `leaks` | - | | 16 | `full_metal_app` | Full Metal GUI application | `macos_14_0` | +| 17 | `cpal_audio` | cpal audio playback integration | `cpal` | ## Running with Features @@ -58,6 +59,9 @@ cargo run --example 05_screenshot --features macos_26_0 # Metal GUI example cargo run --example 16_full_metal_app --features macos_14_0 +# cpal audio playback +cargo run --example 17_cpal_audio --features cpal + # All features cargo run --example 08_async --all-features ``` diff --git a/tests/cpal_adapter_tests.rs b/tests/cpal_adapter_tests.rs new file mode 100644 index 0000000..4c82ef1 --- /dev/null +++ b/tests/cpal_adapter_tests.rs @@ -0,0 +1,68 @@ +//! Tests for cpal audio adapter + +#[cfg(feature = "cpal")] +mod cpal_tests { + use screencapturekit::cpal_adapter::AudioFormat; + + #[test] + fn test_audio_format_to_stream_config() { + let format = AudioFormat { + sample_rate: 48000, + channels: 2, + bits_per_sample: 32, + is_float: true, + }; + + let config = format.to_stream_config(); + assert_eq!(config.sample_rate.0, 48000); + assert_eq!(config.channels, 2); + } + + #[test] + fn test_audio_format_sample_format_float() { + let format = AudioFormat { + sample_rate: 44100, + channels: 2, + bits_per_sample: 32, + is_float: true, + }; + + assert_eq!(format.sample_format(), cpal::SampleFormat::F32); + } + + #[test] + fn test_audio_format_sample_format_i16() { + let format = AudioFormat { + sample_rate: 44100, + channels: 2, + bits_per_sample: 16, + is_float: false, + }; + + assert_eq!(format.sample_format(), cpal::SampleFormat::I16); + } + + #[test] + fn test_audio_format_sample_format_i8() { + let format = AudioFormat { + sample_rate: 22050, + channels: 1, + bits_per_sample: 8, + is_float: false, + }; + + assert_eq!(format.sample_format(), cpal::SampleFormat::I8); + } + + #[test] + fn test_audio_format_sample_format_i32() { + let format = AudioFormat { + sample_rate: 96000, + channels: 2, + bits_per_sample: 32, + is_float: false, + }; + + assert_eq!(format.sample_format(), cpal::SampleFormat::I32); + } +} From 0cdf83dfe2870372da0544bd430fef102dff05a7 Mon Sep 17 00:00:00 2001 From: Per Johansson Date: Mon, 1 Dec 2025 00:02:43 +0100 Subject: [PATCH 3/7] feat(cpal): add audio input stream and cpal integration - Add SckAudioInputStream for cpal-style callback-based audio capture - Add SckAudioConfig for configuring audio capture parameters - Add AudioRingBuffer for thread-safe audio transfer between SCK and cpal - Add create_output_callback helper for easy cpal output integration - Add SckAudioCallbackInfo for callback metadata - Update example to use new simplified API - Add ring buffer tests for write/read, wrap-around, and edge cases --- examples/17_cpal_audio.rs | 137 ++-------- src/cpal_adapter.rs | 530 +++++++++++++++++++++++++++++++++++- tests/cpal_adapter_tests.rs | 71 ++++- 3 files changed, 621 insertions(+), 117 deletions(-) diff --git a/examples/17_cpal_audio.rs b/examples/17_cpal_audio.rs index d13e957..2950bde 100644 --- a/examples/17_cpal_audio.rs +++ b/examples/17_cpal_audio.rs @@ -6,81 +6,11 @@ //! //! Note: Requires screen recording permission and audio output device. -use screencapturekit::cpal_adapter::{AudioFormat, CpalAudioExt}; +use screencapturekit::cpal_adapter::{create_output_callback, SckAudioInputStream}; use screencapturekit::prelude::*; -use std::sync::{Arc, Mutex}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; -/// Ring buffer for audio samples -struct AudioRingBuffer { - buffer: Vec, - write_pos: usize, - read_pos: usize, - capacity: usize, -} - -impl AudioRingBuffer { - fn new(capacity: usize) -> Self { - Self { - buffer: vec![0.0; capacity], - write_pos: 0, - read_pos: 0, - capacity, - } - } - - fn write(&mut self, samples: &[f32]) { - for &sample in samples { - self.buffer[self.write_pos] = sample; - self.write_pos = (self.write_pos + 1) % self.capacity; - } - } - - fn read(&mut self, output: &mut [f32]) { - for sample in output.iter_mut() { - *sample = self.buffer[self.read_pos]; - self.read_pos = (self.read_pos + 1) % self.capacity; - } - } -} - -struct AudioHandler { - ring_buffer: Arc>, - format_detected: Arc>>, -} - -impl SCStreamOutputTrait for AudioHandler { - fn did_output_sample_buffer(&self, sample: CMSampleBuffer, of_type: SCStreamOutputType) { - if of_type != SCStreamOutputType::Audio { - return; - } - - // Detect audio format on first sample - { - let mut format = self.format_detected.lock().unwrap(); - if format.is_none() { - if let Some(f) = AudioFormat::from_sample_buffer(&sample) { - println!( - "🔊 Audio format detected: {}Hz, {} channels, {} bits, float={}", - f.sample_rate, f.channels, f.bits_per_sample, f.is_float - ); - *format = Some(f); - } - } - } - - // Copy audio samples to ring buffer - if let Some(samples) = sample.audio_f32_samples() { - let slice = samples.as_f32_slice(); - if !slice.is_empty() { - let mut rb = self.ring_buffer.lock().unwrap(); - rb.write(slice); - } - } - } -} - fn main() -> Result<(), Box> { println!("🎵 cpal Audio Capture Example"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); @@ -103,37 +33,31 @@ fn main() -> Result<(), Box> { .exclude_windows(&[]) .build(); - // Configure stream with audio capture - let config = SCStreamConfiguration::new() - .with_width(1920) - .with_height(1080) - .with_captures_audio(true) - .with_sample_rate(48000) - .with_channel_count(2); - - // Setup ring buffer for audio transfer - let ring_buffer = Arc::new(Mutex::new(AudioRingBuffer::new(48000 * 2 * 2))); // 2 seconds buffer - let format_detected = Arc::new(Mutex::new(None)); - - let handler = AudioHandler { - ring_buffer: Arc::clone(&ring_buffer), - format_detected: Arc::clone(&format_detected), - }; - - // Start capture - let mut stream = SCStream::new(&filter, &config); - stream.add_output_handler(handler, SCStreamOutputType::Audio); - stream.start_capture()?; - - println!("🎬 Capture started, waiting for audio format detection..."); - - // Wait for audio format detection - let audio_format = loop { - std::thread::sleep(std::time::Duration::from_millis(100)); - if let Some(format) = format_detected.lock().unwrap().clone() { - break format; + // Create SCK audio input stream + let mut input = SckAudioInputStream::new(&filter)?; + let buffer = input.ring_buffer().clone(); + + println!( + "🔊 Audio config: {}Hz, {} channels", + input.sample_rate(), + input.channels() + ); + + // Start capture - the callback receives samples (we also log) + input.start(|samples, info| { + static mut SAMPLE_COUNT: usize = 0; + unsafe { + SAMPLE_COUNT += samples.len(); + if SAMPLE_COUNT % (info.sample_rate as usize * 2) < samples.len() { + println!( + "📥 Captured {} samples total", + SAMPLE_COUNT / info.channels as usize + ); + } } - }; + })?; + + println!("🎬 Capture started"); // Setup cpal output let host = cpal::default_host(); @@ -143,15 +67,10 @@ fn main() -> Result<(), Box> { println!("🔈 Output device: {}", device.name()?); - let stream_config = audio_format.to_stream_config(); - let rb_clone = Arc::clone(&ring_buffer); - + let stream_config = input.cpal_config(); let output_stream = device.build_output_stream( &stream_config, - move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { - let mut rb = rb_clone.lock().unwrap(); - rb.read(data); - }, + create_output_callback(buffer), |err| eprintln!("Audio output error: {}", err), None, )?; @@ -166,7 +85,7 @@ fn main() -> Result<(), Box> { // Cleanup drop(output_stream); - stream.stop_capture()?; + input.stop()?; println!(); println!("✅ Done!"); diff --git a/src/cpal_adapter.rs b/src/cpal_adapter.rs index 7ac7d56..7e92fc6 100644 --- a/src/cpal_adapter.rs +++ b/src/cpal_adapter.rs @@ -1,24 +1,83 @@ -//! cpal integration for zero-copy audio playback +//! cpal integration for zero-copy audio capture //! -//! This module provides adapters to use captured audio with the cpal audio library. +//! This module provides multiple ways to integrate `ScreenCaptureKit` audio with cpal: //! -//! # Example +//! 1. **Low-level adapters** - `AudioSamples`, `CpalAudioExt` for manual integration +//! 2. **Audio input stream** - `SckAudioInputStream` for easy cpal-style callbacks +//! 3. **Ring buffer** - `AudioRingBuffer` for producer/consumer patterns +//! +//! # Example: Audio Input Stream (Recommended) +//! +//! ```ignore +//! use screencapturekit::cpal_adapter::SckAudioInputStream; +//! use screencapturekit::prelude::*; +//! +//! let content = SCShareableContent::get()?; +//! let display = &content.displays()[0]; +//! let filter = SCContentFilter::builder().display(display).exclude_windows(&[]).build(); +//! +//! let mut stream = SckAudioInputStream::new(&filter)?; +//! stream.start(|samples, info| { +//! println!("Got {} samples at {}Hz", samples.len(), info.sample_rate); +//! })?; +//! +//! // Later... +//! stream.stop()?; +//! ``` +//! +//! # Example: With cpal output +//! +//! ```ignore +//! use screencapturekit::cpal_adapter::{SckAudioInputStream, AudioRingBuffer}; +//! use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +//! use std::sync::{Arc, Mutex}; +//! +//! // Shared ring buffer +//! let buffer = Arc::new(Mutex::new(AudioRingBuffer::new(48000 * 2))); +//! let buffer_clone = Arc::clone(&buffer); +//! +//! // SCK input -> ring buffer +//! let mut input = SckAudioInputStream::new(&filter)?; +//! input.start(move |samples, _| { +//! buffer_clone.lock().unwrap().write(samples); +//! })?; +//! +//! // Ring buffer -> cpal output +//! let host = cpal::default_host(); +//! let device = host.default_output_device().unwrap(); +//! let config = cpal::StreamConfig { channels: 2, sample_rate: cpal::SampleRate(48000), .. }; +//! let output = device.build_output_stream(&config, move |data: &mut [f32], _| { +//! buffer.lock().unwrap().read(data); +//! }, |_| {}, None)?; +//! output.play()?; +//! ``` +//! +//! # Example: Low-level adapter //! //! ```ignore //! use screencapturekit::cpal_adapter::{AudioSamples, CpalAudioExt}; -//! use screencapturekit::cm::CMSampleBuffer; //! //! fn process_audio(sample: &CMSampleBuffer) { -//! // Get f32 samples from the captured audio //! if let Some(samples) = sample.audio_f32_samples() { -//! for sample in samples.iter() { -//! // Process or send to cpal output stream +//! for s in samples.iter_f32() { +//! // Process sample //! } //! } //! } //! ``` use crate::cm::{AudioBufferList, CMSampleBuffer}; +use crate::stream::configuration::SCStreamConfiguration; +use crate::stream::content_filter::SCContentFilter; +use crate::stream::output_trait::SCStreamOutputTrait; +use crate::stream::output_type::SCStreamOutputType; +use crate::stream::sc_stream::SCStream; + +use std::sync::{Arc, Condvar, Mutex}; + +// ============================================================================ +// Low-level adapters +// ============================================================================ /// Audio samples extracted from a `CMSampleBuffer` /// @@ -198,3 +257,460 @@ impl AudioFormat { } } } + +// ============================================================================ +// Ring Buffer for audio transfer +// ============================================================================ + +/// Thread-safe ring buffer for transferring audio between SCK and cpal +pub struct AudioRingBuffer { + buffer: Vec, + capacity: usize, + write_pos: usize, + read_pos: usize, + available: usize, +} + +impl AudioRingBuffer { + /// Create a new ring buffer with the given capacity in samples + pub fn new(capacity: usize) -> Self { + Self { + buffer: vec![0.0; capacity], + capacity, + write_pos: 0, + read_pos: 0, + available: 0, + } + } + + /// Write samples to the buffer, returning number written + pub fn write(&mut self, samples: &[f32]) -> usize { + let to_write = samples.len().min(self.capacity - self.available); + for &sample in &samples[..to_write] { + self.buffer[self.write_pos] = sample; + self.write_pos = (self.write_pos + 1) % self.capacity; + } + self.available += to_write; + to_write + } + + /// Read samples from the buffer, returning number read + pub fn read(&mut self, output: &mut [f32]) -> usize { + let to_read = output.len().min(self.available); + for sample in &mut output[..to_read] { + *sample = self.buffer[self.read_pos]; + self.read_pos = (self.read_pos + 1) % self.capacity; + } + // Fill rest with silence + for sample in &mut output[to_read..] { + *sample = 0.0; + } + self.available -= to_read; + to_read + } + + /// Get available samples count + pub fn available(&self) -> usize { + self.available + } + + /// Check if buffer is empty + pub fn is_empty(&self) -> bool { + self.available == 0 + } + + /// Clear the buffer + pub fn clear(&mut self) { + self.write_pos = 0; + self.read_pos = 0; + self.available = 0; + } +} + +// ============================================================================ +// Shared state for SCK audio capture +// ============================================================================ + +/// Internal state for audio capture +pub struct SckAudioState { + ring_buffer: AudioRingBuffer, + format: Option, + running: bool, +} + +impl SckAudioState { + /// Read samples from the internal ring buffer + pub fn read(&mut self, output: &mut [f32]) -> usize { + self.ring_buffer.read(output) + } + + /// Check if audio is available + pub fn available(&self) -> usize { + self.ring_buffer.available() + } +} + +/// Shared audio state wrapped in synchronization primitives +pub type SharedAudioState = Arc<(Mutex, Condvar)>; + +/// Internal handler that receives audio from `SCStream` +struct SckAudioHandler { + state: SharedAudioState, +} + +impl SCStreamOutputTrait for SckAudioHandler { + fn did_output_sample_buffer(&self, sample: CMSampleBuffer, of_type: SCStreamOutputType) { + if of_type != SCStreamOutputType::Audio { + return; + } + + let (lock, cvar) = &*self.state; + let mut state = lock.lock().unwrap(); + + // Detect format on first sample + if state.format.is_none() { + state.format = AudioFormat::from_sample_buffer(&sample); + } + + // Copy audio to ring buffer + if let Some(samples) = sample.audio_f32_samples() { + let slice = samples.as_f32_slice(); + state.ring_buffer.write(slice); + cvar.notify_all(); + } + } +} + +// ============================================================================ +// SCK Audio Input Stream +// ============================================================================ + +/// Configuration for SCK audio input +#[derive(Clone)] +pub struct SckAudioConfig { + filter: SCContentFilter, + /// Sample rate in Hz + pub sample_rate: u32, + /// Number of channels + pub channels: u16, + /// Buffer size in samples + pub buffer_size: usize, +} + +impl SckAudioConfig { + /// Create a new config with the given content filter + pub fn new(filter: &SCContentFilter) -> Self { + Self { + filter: filter.clone(), + sample_rate: 48000, + channels: 2, + buffer_size: 48000 * 2, // 1 second buffer + } + } + + /// Set the sample rate + pub fn with_sample_rate(mut self, rate: u32) -> Self { + self.sample_rate = rate; + self + } + + /// Set the number of channels + pub fn with_channels(mut self, channels: u16) -> Self { + self.channels = channels; + self + } + + /// Set the buffer size in samples + pub fn with_buffer_size(mut self, size: usize) -> Self { + self.buffer_size = size; + self + } + + /// Convert to cpal `StreamConfig` + pub fn to_cpal_config(&self) -> cpal::StreamConfig { + cpal::StreamConfig { + channels: self.channels, + sample_rate: cpal::SampleRate(self.sample_rate), + buffer_size: cpal::BufferSize::Default, + } + } +} + +/// Information about audio callback +#[derive(Debug, Clone, Copy)] +pub struct SckAudioCallbackInfo { + /// Sample rate in Hz + pub sample_rate: u32, + /// Number of channels + pub channels: u16, +} + +/// Errors from SCK audio operations +#[derive(Debug, Clone)] +pub enum SckAudioError { + /// Stream creation failed + StreamCreationFailed(String), + /// Stream start failed + StreamStartFailed(String), + /// Stream already running + AlreadyRunning, + /// Stream not running + NotRunning, +} + +impl std::fmt::Display for SckAudioError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::StreamCreationFailed(e) => write!(f, "Stream creation failed: {e}"), + Self::StreamStartFailed(e) => write!(f, "Stream start failed: {e}"), + Self::AlreadyRunning => write!(f, "Stream already running"), + Self::NotRunning => write!(f, "Stream not running"), + } + } +} + +impl std::error::Error for SckAudioError {} + +/// SCK audio input stream with callback support +/// +/// This provides a cpal-style callback interface for SCK audio capture. +/// +/// # Example +/// +/// ```ignore +/// use screencapturekit::cpal_adapter::SckAudioInputStream; +/// use screencapturekit::prelude::*; +/// +/// let content = SCShareableContent::get()?; +/// let display = &content.displays()[0]; +/// let filter = SCContentFilter::builder().display(display).exclude_windows(&[]).build(); +/// +/// let mut stream = SckAudioInputStream::new(&filter)?; +/// stream.start(|samples, info| { +/// println!("Got {} samples at {}Hz", samples.len(), info.sample_rate); +/// })?; +/// ``` +pub struct SckAudioInputStream { + config: SckAudioConfig, + state: SharedAudioState, + stream: Option, + callback_thread: Option>, +} + +impl SckAudioInputStream { + /// Create a new audio input stream with default settings + pub fn new(filter: &SCContentFilter) -> Result { + Self::with_config(SckAudioConfig::new(filter)) + } + + /// Create with custom configuration + pub fn with_config(config: SckAudioConfig) -> Result { + let state: SharedAudioState = Arc::new(( + Mutex::new(SckAudioState { + ring_buffer: AudioRingBuffer::new(config.buffer_size), + format: None, + running: false, + }), + Condvar::new(), + )); + + Ok(Self { + config, + state, + stream: None, + callback_thread: None, + }) + } + + /// Get the sample rate + pub fn sample_rate(&self) -> u32 { + self.config.sample_rate + } + + /// Get the number of channels + pub fn channels(&self) -> u16 { + self.config.channels + } + + /// Check if the stream is running + pub fn is_running(&self) -> bool { + let (lock, _) = &*self.state; + lock.lock().unwrap().running + } + + /// Get a cpal-compatible stream config + pub fn cpal_config(&self) -> cpal::StreamConfig { + self.config.to_cpal_config() + } + + /// Start capturing with a callback + /// + /// The callback receives audio samples as f32 slices. + pub fn start(&mut self, mut callback: F) -> Result<(), SckAudioError> + where + F: FnMut(&[f32], SckAudioCallbackInfo) + Send + 'static, + { + if self.is_running() { + return Err(SckAudioError::AlreadyRunning); + } + + // Create SCStream configuration + let stream_config = SCStreamConfiguration::new() + .with_width(1) + .with_height(1) + .with_captures_audio(true) + .with_sample_rate(self.config.sample_rate as i32) + .with_channel_count(self.config.channels as i32); + + // Create handler + let handler = SckAudioHandler { + state: Arc::clone(&self.state), + }; + + // Create and start stream + let mut stream = SCStream::new(&self.config.filter, &stream_config); + stream.add_output_handler(handler, SCStreamOutputType::Audio); + + stream + .start_capture() + .map_err(|e| SckAudioError::StreamStartFailed(e.to_string()))?; + + // Mark as running + { + let (lock, _) = &*self.state; + lock.lock().unwrap().running = true; + } + + // Spawn callback thread + let state_clone = Arc::clone(&self.state); + let channels = self.config.channels; + let sample_rate = self.config.sample_rate; + let buffer_size = 1024 * channels as usize; + + let handle = std::thread::spawn(move || { + let mut buffer = vec![0.0f32; buffer_size]; + let info = SckAudioCallbackInfo { + sample_rate, + channels, + }; + + loop { + { + let (lock, cvar) = &*state_clone; + let mut state = lock.lock().unwrap(); + + if !state.running { + break; + } + + // Wait for data + while state.ring_buffer.is_empty() && state.running { + state = cvar.wait(state).unwrap(); + } + + if !state.running { + break; + } + + // Read available data + let read = state.ring_buffer.read(&mut buffer); + if read > 0 { + drop(state); // Release lock before callback + callback(&buffer[..read], info); + } + } + } + }); + + self.stream = Some(stream); + self.callback_thread = Some(handle); + + Ok(()) + } + + /// Stop capturing + pub fn stop(&mut self) -> Result<(), SckAudioError> { + if !self.is_running() { + return Err(SckAudioError::NotRunning); + } + + // Signal stop + { + let (lock, cvar) = &*self.state; + let mut state = lock.lock().unwrap(); + state.running = false; + cvar.notify_all(); + } + + // Wait for callback thread + if let Some(handle) = self.callback_thread.take() { + let _ = handle.join(); + } + + // Stop SCStream + if let Some(ref mut stream) = self.stream { + let _ = stream.stop_capture(); + } + self.stream = None; + + Ok(()) + } + + /// Get access to the ring buffer for manual reading + /// + /// This is useful for integration with cpal output streams. + pub fn ring_buffer(&self) -> &SharedAudioState { + &self.state + } +} + +impl Drop for SckAudioInputStream { + fn drop(&mut self) { + let _ = self.stop(); + } +} + +// ============================================================================ +// Helper for cpal output integration +// ============================================================================ + +/// Create a cpal output callback that reads from an `SckAudioInputStream` +/// +/// # Example +/// +/// ```ignore +/// use screencapturekit::cpal_adapter::{SckAudioInputStream, create_output_callback}; +/// use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +/// +/// let mut input = SckAudioInputStream::new(&filter)?; +/// let buffer = input.ring_buffer().clone(); +/// +/// input.start(|_, _| {})?; // Start capture (callback not used when bridging) +/// +/// let host = cpal::default_host(); +/// let device = host.default_output_device().unwrap(); +/// let config = input.cpal_config(); +/// +/// let output = device.build_output_stream( +/// &config, +/// create_output_callback(buffer), +/// |err| eprintln!("Error: {}", err), +/// None, +/// )?; +/// output.play()?; +/// ``` +pub fn create_output_callback( + state: SharedAudioState, +) -> impl FnMut(&mut [f32], &cpal::OutputCallbackInfo) + Send + 'static { + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + let (lock, _) = &*state; + if let Ok(mut state) = lock.lock() { + state.ring_buffer.read(data); + } else { + // Fill with silence on lock failure + for sample in data.iter_mut() { + *sample = 0.0; + } + } + } +} diff --git a/tests/cpal_adapter_tests.rs b/tests/cpal_adapter_tests.rs index 4c82ef1..96e4831 100644 --- a/tests/cpal_adapter_tests.rs +++ b/tests/cpal_adapter_tests.rs @@ -2,7 +2,7 @@ #[cfg(feature = "cpal")] mod cpal_tests { - use screencapturekit::cpal_adapter::AudioFormat; + use screencapturekit::cpal_adapter::{AudioFormat, AudioRingBuffer}; #[test] fn test_audio_format_to_stream_config() { @@ -65,4 +65,73 @@ mod cpal_tests { assert_eq!(format.sample_format(), cpal::SampleFormat::I32); } + + #[test] + fn test_ring_buffer_write_read() { + let mut buffer = AudioRingBuffer::new(100); + + // Write some samples + let input = [1.0f32, 2.0, 3.0, 4.0, 5.0]; + let written = buffer.write(&input); + assert_eq!(written, 5); + assert_eq!(buffer.available(), 5); + + // Read them back + let mut output = [0.0f32; 5]; + let read = buffer.read(&mut output); + assert_eq!(read, 5); + assert_eq!(output, input); + assert_eq!(buffer.available(), 0); + } + + #[test] + fn test_ring_buffer_wrap_around() { + let mut buffer = AudioRingBuffer::new(10); + + // Fill with samples + let input1 = [1.0f32; 8]; + buffer.write(&input1); + + // Read some + let mut output = [0.0f32; 6]; + buffer.read(&mut output); + + // Write more (should wrap) + let input2 = [2.0f32; 6]; + let written = buffer.write(&input2); + assert_eq!(written, 6); + + // Read all remaining + let mut output2 = [0.0f32; 8]; + let read = buffer.read(&mut output2); + assert_eq!(read, 8); + } + + #[test] + fn test_ring_buffer_overflow_protection() { + let mut buffer = AudioRingBuffer::new(5); + + // Try to write more than capacity + let input = [1.0f32; 10]; + let written = buffer.write(&input); + assert_eq!(written, 5); // Only writes up to capacity + } + + #[test] + fn test_ring_buffer_underflow_fills_silence() { + let mut buffer = AudioRingBuffer::new(10); + + // Write 3 samples + buffer.write(&[1.0, 2.0, 3.0]); + + // Read 5 samples (2 should be silence) + let mut output = [9.9f32; 5]; + let read = buffer.read(&mut output); + assert_eq!(read, 3); + assert_eq!(output[0], 1.0); + assert_eq!(output[1], 2.0); + assert_eq!(output[2], 3.0); + assert_eq!(output[3], 0.0); // Silence + assert_eq!(output[4], 0.0); // Silence + } } From b3863dd784fd5b4b6bda5214735ac35d07780531 Mon Sep 17 00:00:00 2001 From: Per Johansson Date: Mon, 1 Dec 2025 00:13:22 +0100 Subject: [PATCH 4/7] feat(cpal): add zero-copy audio stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ZeroCopyAudioStream that calls callback directly from SCStream thread - Callback receives direct pointer to CMSampleBuffer data - no copies - Update module docs with performance comparison table - Add example 18_zero_copy_audio demonstrating zero-copy capture - Update examples README Zero-copy path: SCStream → CMSampleBuffer → callback (direct pointer access) Buffered path (for cpal output): SCStream → CMSampleBuffer → RingBuffer → cpal output callback --- examples/18_zero_copy_audio.rs | 96 ++++++++++++++++ examples/README.md | 8 +- src/cpal_adapter.rs | 198 ++++++++++++++++++++++++++------- src/stream/sc_stream.rs | 70 ++++++++++-- 4 files changed, 321 insertions(+), 51 deletions(-) create mode 100644 examples/18_zero_copy_audio.rs diff --git a/examples/18_zero_copy_audio.rs b/examples/18_zero_copy_audio.rs new file mode 100644 index 0000000..ce52acd --- /dev/null +++ b/examples/18_zero_copy_audio.rs @@ -0,0 +1,96 @@ +//! Example: Zero-copy audio capture +//! +//! Demonstrates zero-copy audio capture using ZeroCopyAudioStream. +//! The callback receives a direct pointer to CMSampleBuffer data. +//! +//! Run with: cargo run --example 18_zero_copy_audio --features cpal + +use screencapturekit::cpal_adapter::ZeroCopyAudioStream; +use screencapturekit::prelude::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +fn main() -> Result<(), Box> { + println!("🎵 Zero-Copy Audio Capture Example"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + + // Get display to capture + let content = SCShareableContent::get()?; + let displays = content.displays(); + let display = displays.first().ok_or("No display found")?; + + println!( + "📺 Capturing display: {}x{}", + display.width(), + display.height() + ); + + // Create content filter + let filter = SCContentFilter::builder() + .display(display) + .exclude_windows(&[]) + .build(); + + // Counters for statistics + let sample_count = Arc::new(AtomicUsize::new(0)); + let callback_count = Arc::new(AtomicUsize::new(0)); + let sample_count_clone = Arc::clone(&sample_count); + let callback_count_clone = Arc::clone(&callback_count); + + println!("🚀 Starting zero-copy capture..."); + println!(); + + // Start zero-copy capture + // The callback runs on SCStream's thread with direct access to CMSampleBuffer data + let stream = ZeroCopyAudioStream::start(&filter, move |samples, info| { + // This is ZERO-COPY: `samples` points directly into CMSampleBuffer memory + // No intermediate buffers, no copies! + + let count = callback_count_clone.fetch_add(1, Ordering::Relaxed); + sample_count_clone.fetch_add(samples.len(), Ordering::Relaxed); + + // Example: compute RMS (root mean square) for volume level + if count % 100 == 0 { + let rms: f32 = (samples.iter().map(|s| s * s).sum::() / samples.len() as f32).sqrt(); + let db = 20.0 * rms.max(1e-10).log10(); + println!( + "📊 Callback #{}: {} samples, {:.1} dB ({}Hz, {} ch)", + count, + samples.len(), + db, + info.sample_rate, + info.channels + ); + } + })?; + + println!("✅ Capture running for 5 seconds..."); + println!(" (Play some audio on your Mac to see levels)"); + println!(); + + std::thread::sleep(std::time::Duration::from_secs(5)); + + // Stop and show stats + drop(stream); + + let total_samples = sample_count.load(Ordering::Relaxed); + let total_callbacks = callback_count.load(Ordering::Relaxed); + + println!(); + println!("📈 Statistics:"); + println!(" Total callbacks: {}", total_callbacks); + println!(" Total samples: {}", total_samples); + println!( + " Avg samples/callback: {}", + if total_callbacks > 0 { + total_samples / total_callbacks + } else { + 0 + } + ); + println!(); + println!("✅ Done!"); + + Ok(()) +} diff --git a/examples/README.md b/examples/README.md index 95dc11c..a0c5e61 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,7 +28,8 @@ cargo run --example 01_basic_capture | 14 | `app_capture` | Application-based filtering | - | | 15 | `memory_leak_check` | Memory leak detection with `leaks` | - | | 16 | `full_metal_app` | Full Metal GUI application | `macos_14_0` | -| 17 | `cpal_audio` | cpal audio playback integration | `cpal` | +| 17 | `cpal_audio` | cpal audio playback (buffered) | `cpal` | +| 18 | `zero_copy_audio` | Zero-copy audio capture | `cpal` | ## Running with Features @@ -59,9 +60,12 @@ cargo run --example 05_screenshot --features macos_26_0 # Metal GUI example cargo run --example 16_full_metal_app --features macos_14_0 -# cpal audio playback +# cpal audio (buffered, for playback) cargo run --example 17_cpal_audio --features cpal +# Zero-copy audio (for processing) +cargo run --example 18_zero_copy_audio --features cpal + # All features cargo run --example 08_async --all-features ``` diff --git a/src/cpal_adapter.rs b/src/cpal_adapter.rs index 7e92fc6..ec5857e 100644 --- a/src/cpal_adapter.rs +++ b/src/cpal_adapter.rs @@ -2,69 +2,57 @@ //! //! This module provides multiple ways to integrate `ScreenCaptureKit` audio with cpal: //! -//! 1. **Low-level adapters** - `AudioSamples`, `CpalAudioExt` for manual integration -//! 2. **Audio input stream** - `SckAudioInputStream` for easy cpal-style callbacks -//! 3. **Ring buffer** - `AudioRingBuffer` for producer/consumer patterns +//! 1. **Zero-copy stream** - `ZeroCopyAudioStream` for lowest latency (recommended) +//! 2. **Buffered stream** - `SckAudioInputStream` with ring buffer for cpal output +//! 3. **Low-level adapters** - `AudioSamples`, `CpalAudioExt` for manual integration //! -//! # Example: Audio Input Stream (Recommended) +//! # Example: Zero-Copy (Recommended for processing) //! //! ```ignore -//! use screencapturekit::cpal_adapter::SckAudioInputStream; +//! use screencapturekit::cpal_adapter::ZeroCopyAudioStream; //! use screencapturekit::prelude::*; //! -//! let content = SCShareableContent::get()?; -//! let display = &content.displays()[0]; //! let filter = SCContentFilter::builder().display(display).exclude_windows(&[]).build(); //! -//! let mut stream = SckAudioInputStream::new(&filter)?; -//! stream.start(|samples, info| { -//! println!("Got {} samples at {}Hz", samples.len(), info.sample_rate); +//! // Callback receives direct pointer to CMSampleBuffer data - no copies! +//! let stream = ZeroCopyAudioStream::start(&filter, |samples, info| { +//! // WARNING: This runs on SCStream's thread - don't block! +//! analyze_audio(samples); //! })?; //! -//! // Later... -//! stream.stop()?; +//! std::thread::sleep(std::time::Duration::from_secs(10)); +//! drop(stream); // Stops capture //! ``` //! -//! # Example: With cpal output +//! # Example: Buffered (for cpal output playback) //! //! ```ignore -//! use screencapturekit::cpal_adapter::{SckAudioInputStream, AudioRingBuffer}; +//! use screencapturekit::cpal_adapter::{SckAudioInputStream, create_output_callback}; //! use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; -//! use std::sync::{Arc, Mutex}; //! -//! // Shared ring buffer -//! let buffer = Arc::new(Mutex::new(AudioRingBuffer::new(48000 * 2))); -//! let buffer_clone = Arc::clone(&buffer); -//! -//! // SCK input -> ring buffer //! let mut input = SckAudioInputStream::new(&filter)?; -//! input.start(move |samples, _| { -//! buffer_clone.lock().unwrap().write(samples); -//! })?; +//! let buffer = input.ring_buffer().clone(); +//! input.start(|_, _| {})?; //! -//! // Ring buffer -> cpal output +//! // Play captured audio through speakers //! let host = cpal::default_host(); //! let device = host.default_output_device().unwrap(); -//! let config = cpal::StreamConfig { channels: 2, sample_rate: cpal::SampleRate(48000), .. }; -//! let output = device.build_output_stream(&config, move |data: &mut [f32], _| { -//! buffer.lock().unwrap().read(data); -//! }, |_| {}, None)?; +//! let output = device.build_output_stream( +//! &input.cpal_config(), +//! create_output_callback(buffer), +//! |_| {}, +//! None, +//! )?; //! output.play()?; //! ``` //! -//! # Example: Low-level adapter +//! # Performance Comparison //! -//! ```ignore -//! use screencapturekit::cpal_adapter::{AudioSamples, CpalAudioExt}; -//! -//! fn process_audio(sample: &CMSampleBuffer) { -//! if let Some(samples) = sample.audio_f32_samples() { -//! for s in samples.iter_f32() { -//! // Process sample -//! } -//! } -//! } -//! ``` +//! | API | Copies | Latency | Use Case | +//! |-----|--------|---------|----------| +//! | `ZeroCopyAudioStream` | 0 | Lowest | Audio analysis, effects | +//! | `SckAudioInputStream` | 2 | Low | cpal output, buffering needed | +//! | `AudioSamples` (manual) | 0 | Lowest | Custom integration | use crate::cm::{AudioBufferList, CMSampleBuffer}; use crate::stream::configuration::SCStreamConfiguration; @@ -714,3 +702,133 @@ pub fn create_output_callback( } } } + +// ============================================================================ +// Zero-Copy Audio Stream +// ============================================================================ + +/// Zero-copy audio handler that calls callback directly from SCStream thread +struct ZeroCopyAudioHandler +where + F: FnMut(&[f32], SckAudioCallbackInfo) + Send, +{ + callback: std::sync::Mutex, + sample_rate: u32, + channels: u16, +} + +impl SCStreamOutputTrait for ZeroCopyAudioHandler +where + F: FnMut(&[f32], SckAudioCallbackInfo) + Send, +{ + fn did_output_sample_buffer(&self, sample: CMSampleBuffer, of_type: SCStreamOutputType) { + if of_type != SCStreamOutputType::Audio { + return; + } + + // Zero-copy: get slice directly from CMSampleBuffer + if let Some(audio_samples) = sample.audio_f32_samples() { + let slice = audio_samples.as_f32_slice(); + if !slice.is_empty() { + let info = SckAudioCallbackInfo { + sample_rate: self.sample_rate, + channels: self.channels, + }; + if let Ok(mut callback) = self.callback.lock() { + callback(slice, info); + } + } + } + } +} + +/// Zero-copy audio input stream +/// +/// Unlike `SckAudioInputStream`, this calls your callback directly from the +/// SCStream capture thread with a reference to the audio data - no copies. +/// +/// **Trade-offs:** +/// - ✅ Zero-copy: data is read directly from `CMSampleBuffer` +/// - ✅ Lower latency: no intermediate buffering +/// - ⚠️ Callback runs on SCStream's thread (don't block!) +/// - ⚠️ Cannot be used with `create_output_callback` (no ring buffer) +/// +/// # Example +/// +/// ```ignore +/// use screencapturekit::cpal_adapter::ZeroCopyAudioStream; +/// use screencapturekit::prelude::*; +/// +/// let filter = SCContentFilter::builder().display(display).exclude_windows(&[]).build(); +/// +/// let stream = ZeroCopyAudioStream::start(&filter, |samples, info| { +/// // This runs on SCStream's thread - don't block! +/// // `samples` points directly into CMSampleBuffer memory +/// process_audio(samples); +/// })?; +/// +/// // Stream runs until dropped +/// std::thread::sleep(std::time::Duration::from_secs(10)); +/// drop(stream); // Stops capture +/// ``` +pub struct ZeroCopyAudioStream { + stream: SCStream, +} + +impl ZeroCopyAudioStream { + /// Start zero-copy audio capture + /// + /// The callback receives a direct reference to audio data in the `CMSampleBuffer`. + /// **Important:** The callback runs on SCStream's internal thread - avoid blocking! + pub fn start(filter: &SCContentFilter, callback: F) -> Result + where + F: FnMut(&[f32], SckAudioCallbackInfo) + Send + 'static, + { + Self::start_with_config(filter, 48000, 2, callback) + } + + /// Start with custom sample rate and channels + pub fn start_with_config( + filter: &SCContentFilter, + sample_rate: u32, + channels: u16, + callback: F, + ) -> Result + where + F: FnMut(&[f32], SckAudioCallbackInfo) + Send + 'static, + { + let stream_config = SCStreamConfiguration::new() + .with_width(1) + .with_height(1) + .with_captures_audio(true) + .with_sample_rate(sample_rate as i32) + .with_channel_count(channels as i32); + + let handler = ZeroCopyAudioHandler { + callback: std::sync::Mutex::new(callback), + sample_rate, + channels, + }; + + let mut stream = SCStream::new(filter, &stream_config); + stream.add_output_handler(handler, SCStreamOutputType::Audio); + + stream + .start_capture() + .map_err(|e| SckAudioError::StreamStartFailed(e.to_string()))?; + + Ok(Self { stream }) + } + + /// Stop capture explicitly (also happens on drop) + pub fn stop(self) -> Result<(), SckAudioError> { + // Drop will handle cleanup + Ok(()) + } +} + +impl Drop for ZeroCopyAudioStream { + fn drop(&mut self) { + let _ = self.stream.stop_capture(); + } +} diff --git a/src/stream/sc_stream.rs b/src/stream/sc_stream.rs index f2c0ef6..fc1213f 100644 --- a/src/stream/sc_stream.rs +++ b/src/stream/sc_stream.rs @@ -110,6 +110,8 @@ extern "C" fn sample_handler( /// ``` pub struct SCStream { ptr: *const c_void, + /// Handler IDs registered by this stream instance, keyed by output type + handler_ids: Vec<(usize, SCStreamOutputType)>, } unsafe impl Send for SCStream {} @@ -166,7 +168,10 @@ impl SCStream { ffi::sc_stream_create(filter.as_ptr(), configuration.as_ptr(), error_callback) }; assert!(!ptr.is_null(), "Swift bridge returned null stream"); - Self { ptr } + Self { + ptr, + handler_ids: Vec::new(), + } } pub fn new_with_delegate( @@ -327,8 +332,15 @@ impl SCStream { }; if ok { + self.handler_ids.push((handler_id, of_type)); Some(handler_id) } else { + // Remove from registry since Swift rejected it + HANDLER_REGISTRY + .lock() + .unwrap() + .as_mut() + .map(|handlers| handlers.remove(&handler_id)); None } } @@ -347,13 +359,29 @@ impl SCStream { /// # Returns /// /// Returns `true` if the handler was found and removed, `false` otherwise. - pub fn remove_output_handler(&mut self, id: usize, _of_type: SCStreamOutputType) -> bool { - // Mutex poisoning is unrecoverable; unwrap is appropriate - let mut registry = HANDLER_REGISTRY.lock().unwrap(); - registry - .as_mut() - .and_then(|handlers| handlers.remove(&id)) - .is_some() + pub fn remove_output_handler(&mut self, id: usize, of_type: SCStreamOutputType) -> bool { + // Remove from our tracking + let pos = self.handler_ids.iter().position(|(hid, _)| *hid == id); + if pos.is_none() { + return false; + } + self.handler_ids.remove(pos.unwrap()); + + // Remove from global registry + { + let mut registry = HANDLER_REGISTRY.lock().unwrap(); + if let Some(handlers) = registry.as_mut() { + handlers.remove(&id); + } + } + + // Tell Swift to remove the output + let output_type_int = match of_type { + SCStreamOutputType::Screen => 0, + SCStreamOutputType::Audio => 1, + SCStreamOutputType::Microphone => 2, + }; + unsafe { ffi::sc_stream_remove_stream_output(self.ptr, output_type_int) } } /// Start capturing screen content @@ -502,6 +530,25 @@ impl SCStream { impl Drop for SCStream { fn drop(&mut self) { + // Clean up all registered handlers + for (id, of_type) in std::mem::take(&mut self.handler_ids) { + // Remove from global registry + { + let mut registry = HANDLER_REGISTRY.lock().unwrap(); + if let Some(handlers) = registry.as_mut() { + handlers.remove(&id); + } + } + + // Tell Swift to remove the output + let output_type_int = match of_type { + SCStreamOutputType::Screen => 0, + SCStreamOutputType::Audio => 1, + SCStreamOutputType::Microphone => 2, + }; + unsafe { ffi::sc_stream_remove_stream_output(self.ptr, output_type_int) }; + } + if !self.ptr.is_null() { unsafe { ffi::sc_stream_release(self.ptr) }; } @@ -513,6 +560,8 @@ impl Clone for SCStream { unsafe { Self { ptr: crate::ffi::sc_stream_retain(self.ptr), + // Cloned stream starts with no handlers - handlers are per-instance + handler_ids: Vec::new(), } } } @@ -520,7 +569,10 @@ impl Clone for SCStream { impl fmt::Debug for SCStream { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("SCStream").field("ptr", &self.ptr).finish() + f.debug_struct("SCStream") + .field("ptr", &self.ptr) + .field("handler_ids", &self.handler_ids) + .finish() } } From 66d8ba609206f963e6c773f6ab927d2f8337c0e9 Mon Sep 17 00:00:00 2001 From: Per Johansson Date: Thu, 4 Dec 2025 19:27:03 +0100 Subject: [PATCH 5/7] chore: remove cpal dependency and update dev dependencies - Remove cpal feature flag and optional dependency - Remove cpal_adapter module and related examples - Update dev dependencies to latest versions: - png: 0.17 -> 0.18 - metal: 0.29 -> 0.32 - cocoa: 0.25 -> 0.26 - core-graphics-types: 0.1 -> 0.2 --- Cargo.toml | 14 +- examples/17_cpal_audio.rs | 94 ---- examples/18_zero_copy_audio.rs | 96 ---- examples/README.md | 8 - src/cpal_adapter.rs | 834 --------------------------------- src/lib.rs | 4 - src/output/pixel_buffer.rs | 35 ++ tests/cpal_adapter_tests.rs | 137 ------ 8 files changed, 40 insertions(+), 1182 deletions(-) delete mode 100644 examples/17_cpal_audio.rs delete mode 100644 examples/18_zero_copy_audio.rs delete mode 100644 src/cpal_adapter.rs delete mode 100644 tests/cpal_adapter_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 094bada..c221c2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,9 +40,6 @@ cargo-clippy = [] # Async support (executor-agnostic, works with any async runtime) async = [] -# cpal audio integration for zero-copy audio playback -cpal = ["dep:cpal"] - # macOS version feature flags # Enable features for specific macOS versions macos_13_0 = [] @@ -54,14 +51,13 @@ macos_15_2 = ["macos_15_0"] macos_26_0 = ["macos_15_2"] [dependencies] -cpal = { version = "0.15", optional = true } [dev-dependencies] -png = "0.17" -tokio = { version = "1.40", features = ["sync", "rt", "rt-multi-thread", "macros", "test-util"] } -metal = "0.29" -cocoa = "0.25" -core-graphics-types = "0.1" +png = "0.18" +tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "macros", "test-util"] } +metal = "0.32" +cocoa = "0.26" +core-graphics-types = "0.2" objc = "0.2" winit = "0.28" raw-window-handle = "0.5" diff --git a/examples/17_cpal_audio.rs b/examples/17_cpal_audio.rs deleted file mode 100644 index 2950bde..0000000 --- a/examples/17_cpal_audio.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Example: cpal audio playback integration -//! -//! Demonstrates capturing system audio and playing it back through cpal. -//! -//! Run with: cargo run --example 17_cpal_audio --features cpal -//! -//! Note: Requires screen recording permission and audio output device. - -use screencapturekit::cpal_adapter::{create_output_callback, SckAudioInputStream}; -use screencapturekit::prelude::*; - -use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; - -fn main() -> Result<(), Box> { - println!("🎵 cpal Audio Capture Example"); - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!(); - - // Get display to capture - let content = SCShareableContent::get()?; - let displays = content.displays(); - let display = displays.first().ok_or("No display found")?; - - println!( - "📺 Capturing display: {}x{}", - display.width(), - display.height() - ); - - // Create content filter - let filter = SCContentFilter::builder() - .display(display) - .exclude_windows(&[]) - .build(); - - // Create SCK audio input stream - let mut input = SckAudioInputStream::new(&filter)?; - let buffer = input.ring_buffer().clone(); - - println!( - "🔊 Audio config: {}Hz, {} channels", - input.sample_rate(), - input.channels() - ); - - // Start capture - the callback receives samples (we also log) - input.start(|samples, info| { - static mut SAMPLE_COUNT: usize = 0; - unsafe { - SAMPLE_COUNT += samples.len(); - if SAMPLE_COUNT % (info.sample_rate as usize * 2) < samples.len() { - println!( - "📥 Captured {} samples total", - SAMPLE_COUNT / info.channels as usize - ); - } - } - })?; - - println!("🎬 Capture started"); - - // Setup cpal output - let host = cpal::default_host(); - let device = host - .default_output_device() - .ok_or("No output device found")?; - - println!("🔈 Output device: {}", device.name()?); - - let stream_config = input.cpal_config(); - let output_stream = device.build_output_stream( - &stream_config, - create_output_callback(buffer), - |err| eprintln!("Audio output error: {}", err), - None, - )?; - - output_stream.play()?; - println!("▶️ Audio playback started"); - println!(); - println!("Playing captured system audio for 10 seconds..."); - println!("(Make sure to play some audio on your Mac!)"); - - std::thread::sleep(std::time::Duration::from_secs(10)); - - // Cleanup - drop(output_stream); - input.stop()?; - - println!(); - println!("✅ Done!"); - - Ok(()) -} diff --git a/examples/18_zero_copy_audio.rs b/examples/18_zero_copy_audio.rs deleted file mode 100644 index ce52acd..0000000 --- a/examples/18_zero_copy_audio.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! Example: Zero-copy audio capture -//! -//! Demonstrates zero-copy audio capture using ZeroCopyAudioStream. -//! The callback receives a direct pointer to CMSampleBuffer data. -//! -//! Run with: cargo run --example 18_zero_copy_audio --features cpal - -use screencapturekit::cpal_adapter::ZeroCopyAudioStream; -use screencapturekit::prelude::*; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; - -fn main() -> Result<(), Box> { - println!("🎵 Zero-Copy Audio Capture Example"); - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!(); - - // Get display to capture - let content = SCShareableContent::get()?; - let displays = content.displays(); - let display = displays.first().ok_or("No display found")?; - - println!( - "📺 Capturing display: {}x{}", - display.width(), - display.height() - ); - - // Create content filter - let filter = SCContentFilter::builder() - .display(display) - .exclude_windows(&[]) - .build(); - - // Counters for statistics - let sample_count = Arc::new(AtomicUsize::new(0)); - let callback_count = Arc::new(AtomicUsize::new(0)); - let sample_count_clone = Arc::clone(&sample_count); - let callback_count_clone = Arc::clone(&callback_count); - - println!("🚀 Starting zero-copy capture..."); - println!(); - - // Start zero-copy capture - // The callback runs on SCStream's thread with direct access to CMSampleBuffer data - let stream = ZeroCopyAudioStream::start(&filter, move |samples, info| { - // This is ZERO-COPY: `samples` points directly into CMSampleBuffer memory - // No intermediate buffers, no copies! - - let count = callback_count_clone.fetch_add(1, Ordering::Relaxed); - sample_count_clone.fetch_add(samples.len(), Ordering::Relaxed); - - // Example: compute RMS (root mean square) for volume level - if count % 100 == 0 { - let rms: f32 = (samples.iter().map(|s| s * s).sum::() / samples.len() as f32).sqrt(); - let db = 20.0 * rms.max(1e-10).log10(); - println!( - "📊 Callback #{}: {} samples, {:.1} dB ({}Hz, {} ch)", - count, - samples.len(), - db, - info.sample_rate, - info.channels - ); - } - })?; - - println!("✅ Capture running for 5 seconds..."); - println!(" (Play some audio on your Mac to see levels)"); - println!(); - - std::thread::sleep(std::time::Duration::from_secs(5)); - - // Stop and show stats - drop(stream); - - let total_samples = sample_count.load(Ordering::Relaxed); - let total_callbacks = callback_count.load(Ordering::Relaxed); - - println!(); - println!("📈 Statistics:"); - println!(" Total callbacks: {}", total_callbacks); - println!(" Total samples: {}", total_samples); - println!( - " Avg samples/callback: {}", - if total_callbacks > 0 { - total_samples / total_callbacks - } else { - 0 - } - ); - println!(); - println!("✅ Done!"); - - Ok(()) -} diff --git a/examples/README.md b/examples/README.md index a0c5e61..d715504 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,8 +28,6 @@ cargo run --example 01_basic_capture | 14 | `app_capture` | Application-based filtering | - | | 15 | `memory_leak_check` | Memory leak detection with `leaks` | - | | 16 | `full_metal_app` | Full Metal GUI application | `macos_14_0` | -| 17 | `cpal_audio` | cpal audio playback (buffered) | `cpal` | -| 18 | `zero_copy_audio` | Zero-copy audio capture | `cpal` | ## Running with Features @@ -60,12 +58,6 @@ cargo run --example 05_screenshot --features macos_26_0 # Metal GUI example cargo run --example 16_full_metal_app --features macos_14_0 -# cpal audio (buffered, for playback) -cargo run --example 17_cpal_audio --features cpal - -# Zero-copy audio (for processing) -cargo run --example 18_zero_copy_audio --features cpal - # All features cargo run --example 08_async --all-features ``` diff --git a/src/cpal_adapter.rs b/src/cpal_adapter.rs deleted file mode 100644 index ec5857e..0000000 --- a/src/cpal_adapter.rs +++ /dev/null @@ -1,834 +0,0 @@ -//! cpal integration for zero-copy audio capture -//! -//! This module provides multiple ways to integrate `ScreenCaptureKit` audio with cpal: -//! -//! 1. **Zero-copy stream** - `ZeroCopyAudioStream` for lowest latency (recommended) -//! 2. **Buffered stream** - `SckAudioInputStream` with ring buffer for cpal output -//! 3. **Low-level adapters** - `AudioSamples`, `CpalAudioExt` for manual integration -//! -//! # Example: Zero-Copy (Recommended for processing) -//! -//! ```ignore -//! use screencapturekit::cpal_adapter::ZeroCopyAudioStream; -//! use screencapturekit::prelude::*; -//! -//! let filter = SCContentFilter::builder().display(display).exclude_windows(&[]).build(); -//! -//! // Callback receives direct pointer to CMSampleBuffer data - no copies! -//! let stream = ZeroCopyAudioStream::start(&filter, |samples, info| { -//! // WARNING: This runs on SCStream's thread - don't block! -//! analyze_audio(samples); -//! })?; -//! -//! std::thread::sleep(std::time::Duration::from_secs(10)); -//! drop(stream); // Stops capture -//! ``` -//! -//! # Example: Buffered (for cpal output playback) -//! -//! ```ignore -//! use screencapturekit::cpal_adapter::{SckAudioInputStream, create_output_callback}; -//! use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; -//! -//! let mut input = SckAudioInputStream::new(&filter)?; -//! let buffer = input.ring_buffer().clone(); -//! input.start(|_, _| {})?; -//! -//! // Play captured audio through speakers -//! let host = cpal::default_host(); -//! let device = host.default_output_device().unwrap(); -//! let output = device.build_output_stream( -//! &input.cpal_config(), -//! create_output_callback(buffer), -//! |_| {}, -//! None, -//! )?; -//! output.play()?; -//! ``` -//! -//! # Performance Comparison -//! -//! | API | Copies | Latency | Use Case | -//! |-----|--------|---------|----------| -//! | `ZeroCopyAudioStream` | 0 | Lowest | Audio analysis, effects | -//! | `SckAudioInputStream` | 2 | Low | cpal output, buffering needed | -//! | `AudioSamples` (manual) | 0 | Lowest | Custom integration | - -use crate::cm::{AudioBufferList, CMSampleBuffer}; -use crate::stream::configuration::SCStreamConfiguration; -use crate::stream::content_filter::SCContentFilter; -use crate::stream::output_trait::SCStreamOutputTrait; -use crate::stream::output_type::SCStreamOutputType; -use crate::stream::sc_stream::SCStream; - -use std::sync::{Arc, Condvar, Mutex}; - -// ============================================================================ -// Low-level adapters -// ============================================================================ - -/// Audio samples extracted from a `CMSampleBuffer` -/// -/// Owns the underlying `AudioBufferList` and provides access to samples -/// in various formats compatible with cpal. -pub struct AudioSamples { - buffer_list: AudioBufferList, -} - -impl AudioSamples { - /// Create from a `CMSampleBuffer` - /// - /// Returns `None` if the sample buffer doesn't contain audio data. - pub fn new(sample: &CMSampleBuffer) -> Option { - let buffer_list = sample.audio_buffer_list()?; - Some(Self { buffer_list }) - } - - /// Get the number of audio channels - pub fn channels(&self) -> usize { - self.buffer_list - .get(0) - .map(|b| b.number_channels as usize) - .unwrap_or(0) - } - - /// Get raw bytes of audio data - pub fn as_bytes(&self) -> &[u8] { - self.buffer_list.get(0).map(|b| b.data()).unwrap_or(&[]) - } - - /// Get audio samples as f32 slice (zero-copy if data is already f32) - /// - /// # Safety - /// Assumes the audio data is in native-endian f32 format. - #[allow(clippy::cast_ptr_alignment)] - pub fn as_f32_slice(&self) -> &[f32] { - let bytes = self.as_bytes(); - if bytes.len() < 4 { - return &[]; - } - // Safety: macOS audio buffers are properly aligned for the sample type - unsafe { std::slice::from_raw_parts(bytes.as_ptr().cast::(), bytes.len() / 4) } - } - - /// Get audio samples as i16 slice (zero-copy if data is already i16) - /// - /// # Safety - /// Assumes the audio data is in native-endian i16 format. - #[allow(clippy::cast_ptr_alignment)] - pub fn as_i16_slice(&self) -> &[i16] { - let bytes = self.as_bytes(); - if bytes.len() < 2 { - return &[]; - } - // Safety: macOS audio buffers are properly aligned for the sample type - unsafe { std::slice::from_raw_parts(bytes.as_ptr().cast::(), bytes.len() / 2) } - } - - /// Iterator over f32 samples - pub fn iter_f32(&self) -> impl Iterator + '_ { - self.as_f32_slice().iter().copied() - } - - /// Iterator over i16 samples - pub fn iter_i16(&self) -> impl Iterator + '_ { - self.as_i16_slice().iter().copied() - } - - /// Get the number of f32 samples - pub fn len_f32(&self) -> usize { - self.as_bytes().len() / 4 - } - - /// Get the number of i16 samples - pub fn len_i16(&self) -> usize { - self.as_bytes().len() / 2 - } - - /// Check if empty - pub fn is_empty(&self) -> bool { - self.as_bytes().is_empty() - } -} - -/// Extension trait for `CMSampleBuffer` to provide cpal-compatible audio access -pub trait CpalAudioExt { - /// Get audio samples as f32 (for cpal output) - fn audio_f32_samples(&self) -> Option; - - /// Copy f32 audio samples into a cpal-compatible buffer - /// - /// Returns the number of samples copied. - fn copy_f32_to_buffer(&self, buffer: &mut [f32]) -> usize; - - /// Copy i16 audio samples into a cpal-compatible buffer - /// - /// Returns the number of samples copied. - fn copy_i16_to_buffer(&self, buffer: &mut [i16]) -> usize; -} - -impl CpalAudioExt for CMSampleBuffer { - fn audio_f32_samples(&self) -> Option { - AudioSamples::new(self) - } - - fn copy_f32_to_buffer(&self, buffer: &mut [f32]) -> usize { - let Some(samples) = AudioSamples::new(self) else { - return 0; - }; - let src = samples.as_f32_slice(); - let len = buffer.len().min(src.len()); - buffer[..len].copy_from_slice(&src[..len]); - len - } - - fn copy_i16_to_buffer(&self, buffer: &mut [i16]) -> usize { - let Some(samples) = AudioSamples::new(self) else { - return 0; - }; - let src = samples.as_i16_slice(); - let len = buffer.len().min(src.len()); - buffer[..len].copy_from_slice(&src[..len]); - len - } -} - -/// Audio format information for cpal stream configuration -#[derive(Debug, Clone, Copy)] -pub struct AudioFormat { - /// Sample rate in Hz - pub sample_rate: u32, - /// Number of channels - pub channels: u16, - /// Bits per sample - pub bits_per_sample: u16, - /// Whether samples are float format - pub is_float: bool, -} - -impl AudioFormat { - /// Extract audio format from a `CMSampleBuffer` - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - pub fn from_sample_buffer(sample: &CMSampleBuffer) -> Option { - let format_desc = sample.format_description()?; - if !format_desc.is_audio() { - return None; - } - - Some(Self { - sample_rate: format_desc.audio_sample_rate()? as u32, - channels: format_desc.audio_channel_count()? as u16, - bits_per_sample: format_desc.audio_bits_per_channel()? as u16, - is_float: format_desc.audio_is_float(), - }) - } - - /// Convert to cpal `StreamConfig` - pub fn to_stream_config(&self) -> cpal::StreamConfig { - cpal::StreamConfig { - channels: self.channels, - sample_rate: cpal::SampleRate(self.sample_rate), - buffer_size: cpal::BufferSize::Default, - } - } - - /// Get the cpal `SampleFormat` based on the audio format - pub fn sample_format(&self) -> cpal::SampleFormat { - if self.is_float { - cpal::SampleFormat::F32 - } else if self.bits_per_sample == 8 { - cpal::SampleFormat::I8 - } else if self.bits_per_sample == 32 { - cpal::SampleFormat::I32 - } else { - cpal::SampleFormat::I16 - } - } -} - -// ============================================================================ -// Ring Buffer for audio transfer -// ============================================================================ - -/// Thread-safe ring buffer for transferring audio between SCK and cpal -pub struct AudioRingBuffer { - buffer: Vec, - capacity: usize, - write_pos: usize, - read_pos: usize, - available: usize, -} - -impl AudioRingBuffer { - /// Create a new ring buffer with the given capacity in samples - pub fn new(capacity: usize) -> Self { - Self { - buffer: vec![0.0; capacity], - capacity, - write_pos: 0, - read_pos: 0, - available: 0, - } - } - - /// Write samples to the buffer, returning number written - pub fn write(&mut self, samples: &[f32]) -> usize { - let to_write = samples.len().min(self.capacity - self.available); - for &sample in &samples[..to_write] { - self.buffer[self.write_pos] = sample; - self.write_pos = (self.write_pos + 1) % self.capacity; - } - self.available += to_write; - to_write - } - - /// Read samples from the buffer, returning number read - pub fn read(&mut self, output: &mut [f32]) -> usize { - let to_read = output.len().min(self.available); - for sample in &mut output[..to_read] { - *sample = self.buffer[self.read_pos]; - self.read_pos = (self.read_pos + 1) % self.capacity; - } - // Fill rest with silence - for sample in &mut output[to_read..] { - *sample = 0.0; - } - self.available -= to_read; - to_read - } - - /// Get available samples count - pub fn available(&self) -> usize { - self.available - } - - /// Check if buffer is empty - pub fn is_empty(&self) -> bool { - self.available == 0 - } - - /// Clear the buffer - pub fn clear(&mut self) { - self.write_pos = 0; - self.read_pos = 0; - self.available = 0; - } -} - -// ============================================================================ -// Shared state for SCK audio capture -// ============================================================================ - -/// Internal state for audio capture -pub struct SckAudioState { - ring_buffer: AudioRingBuffer, - format: Option, - running: bool, -} - -impl SckAudioState { - /// Read samples from the internal ring buffer - pub fn read(&mut self, output: &mut [f32]) -> usize { - self.ring_buffer.read(output) - } - - /// Check if audio is available - pub fn available(&self) -> usize { - self.ring_buffer.available() - } -} - -/// Shared audio state wrapped in synchronization primitives -pub type SharedAudioState = Arc<(Mutex, Condvar)>; - -/// Internal handler that receives audio from `SCStream` -struct SckAudioHandler { - state: SharedAudioState, -} - -impl SCStreamOutputTrait for SckAudioHandler { - fn did_output_sample_buffer(&self, sample: CMSampleBuffer, of_type: SCStreamOutputType) { - if of_type != SCStreamOutputType::Audio { - return; - } - - let (lock, cvar) = &*self.state; - let mut state = lock.lock().unwrap(); - - // Detect format on first sample - if state.format.is_none() { - state.format = AudioFormat::from_sample_buffer(&sample); - } - - // Copy audio to ring buffer - if let Some(samples) = sample.audio_f32_samples() { - let slice = samples.as_f32_slice(); - state.ring_buffer.write(slice); - cvar.notify_all(); - } - } -} - -// ============================================================================ -// SCK Audio Input Stream -// ============================================================================ - -/// Configuration for SCK audio input -#[derive(Clone)] -pub struct SckAudioConfig { - filter: SCContentFilter, - /// Sample rate in Hz - pub sample_rate: u32, - /// Number of channels - pub channels: u16, - /// Buffer size in samples - pub buffer_size: usize, -} - -impl SckAudioConfig { - /// Create a new config with the given content filter - pub fn new(filter: &SCContentFilter) -> Self { - Self { - filter: filter.clone(), - sample_rate: 48000, - channels: 2, - buffer_size: 48000 * 2, // 1 second buffer - } - } - - /// Set the sample rate - pub fn with_sample_rate(mut self, rate: u32) -> Self { - self.sample_rate = rate; - self - } - - /// Set the number of channels - pub fn with_channels(mut self, channels: u16) -> Self { - self.channels = channels; - self - } - - /// Set the buffer size in samples - pub fn with_buffer_size(mut self, size: usize) -> Self { - self.buffer_size = size; - self - } - - /// Convert to cpal `StreamConfig` - pub fn to_cpal_config(&self) -> cpal::StreamConfig { - cpal::StreamConfig { - channels: self.channels, - sample_rate: cpal::SampleRate(self.sample_rate), - buffer_size: cpal::BufferSize::Default, - } - } -} - -/// Information about audio callback -#[derive(Debug, Clone, Copy)] -pub struct SckAudioCallbackInfo { - /// Sample rate in Hz - pub sample_rate: u32, - /// Number of channels - pub channels: u16, -} - -/// Errors from SCK audio operations -#[derive(Debug, Clone)] -pub enum SckAudioError { - /// Stream creation failed - StreamCreationFailed(String), - /// Stream start failed - StreamStartFailed(String), - /// Stream already running - AlreadyRunning, - /// Stream not running - NotRunning, -} - -impl std::fmt::Display for SckAudioError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::StreamCreationFailed(e) => write!(f, "Stream creation failed: {e}"), - Self::StreamStartFailed(e) => write!(f, "Stream start failed: {e}"), - Self::AlreadyRunning => write!(f, "Stream already running"), - Self::NotRunning => write!(f, "Stream not running"), - } - } -} - -impl std::error::Error for SckAudioError {} - -/// SCK audio input stream with callback support -/// -/// This provides a cpal-style callback interface for SCK audio capture. -/// -/// # Example -/// -/// ```ignore -/// use screencapturekit::cpal_adapter::SckAudioInputStream; -/// use screencapturekit::prelude::*; -/// -/// let content = SCShareableContent::get()?; -/// let display = &content.displays()[0]; -/// let filter = SCContentFilter::builder().display(display).exclude_windows(&[]).build(); -/// -/// let mut stream = SckAudioInputStream::new(&filter)?; -/// stream.start(|samples, info| { -/// println!("Got {} samples at {}Hz", samples.len(), info.sample_rate); -/// })?; -/// ``` -pub struct SckAudioInputStream { - config: SckAudioConfig, - state: SharedAudioState, - stream: Option, - callback_thread: Option>, -} - -impl SckAudioInputStream { - /// Create a new audio input stream with default settings - pub fn new(filter: &SCContentFilter) -> Result { - Self::with_config(SckAudioConfig::new(filter)) - } - - /// Create with custom configuration - pub fn with_config(config: SckAudioConfig) -> Result { - let state: SharedAudioState = Arc::new(( - Mutex::new(SckAudioState { - ring_buffer: AudioRingBuffer::new(config.buffer_size), - format: None, - running: false, - }), - Condvar::new(), - )); - - Ok(Self { - config, - state, - stream: None, - callback_thread: None, - }) - } - - /// Get the sample rate - pub fn sample_rate(&self) -> u32 { - self.config.sample_rate - } - - /// Get the number of channels - pub fn channels(&self) -> u16 { - self.config.channels - } - - /// Check if the stream is running - pub fn is_running(&self) -> bool { - let (lock, _) = &*self.state; - lock.lock().unwrap().running - } - - /// Get a cpal-compatible stream config - pub fn cpal_config(&self) -> cpal::StreamConfig { - self.config.to_cpal_config() - } - - /// Start capturing with a callback - /// - /// The callback receives audio samples as f32 slices. - pub fn start(&mut self, mut callback: F) -> Result<(), SckAudioError> - where - F: FnMut(&[f32], SckAudioCallbackInfo) + Send + 'static, - { - if self.is_running() { - return Err(SckAudioError::AlreadyRunning); - } - - // Create SCStream configuration - let stream_config = SCStreamConfiguration::new() - .with_width(1) - .with_height(1) - .with_captures_audio(true) - .with_sample_rate(self.config.sample_rate as i32) - .with_channel_count(self.config.channels as i32); - - // Create handler - let handler = SckAudioHandler { - state: Arc::clone(&self.state), - }; - - // Create and start stream - let mut stream = SCStream::new(&self.config.filter, &stream_config); - stream.add_output_handler(handler, SCStreamOutputType::Audio); - - stream - .start_capture() - .map_err(|e| SckAudioError::StreamStartFailed(e.to_string()))?; - - // Mark as running - { - let (lock, _) = &*self.state; - lock.lock().unwrap().running = true; - } - - // Spawn callback thread - let state_clone = Arc::clone(&self.state); - let channels = self.config.channels; - let sample_rate = self.config.sample_rate; - let buffer_size = 1024 * channels as usize; - - let handle = std::thread::spawn(move || { - let mut buffer = vec![0.0f32; buffer_size]; - let info = SckAudioCallbackInfo { - sample_rate, - channels, - }; - - loop { - { - let (lock, cvar) = &*state_clone; - let mut state = lock.lock().unwrap(); - - if !state.running { - break; - } - - // Wait for data - while state.ring_buffer.is_empty() && state.running { - state = cvar.wait(state).unwrap(); - } - - if !state.running { - break; - } - - // Read available data - let read = state.ring_buffer.read(&mut buffer); - if read > 0 { - drop(state); // Release lock before callback - callback(&buffer[..read], info); - } - } - } - }); - - self.stream = Some(stream); - self.callback_thread = Some(handle); - - Ok(()) - } - - /// Stop capturing - pub fn stop(&mut self) -> Result<(), SckAudioError> { - if !self.is_running() { - return Err(SckAudioError::NotRunning); - } - - // Signal stop - { - let (lock, cvar) = &*self.state; - let mut state = lock.lock().unwrap(); - state.running = false; - cvar.notify_all(); - } - - // Wait for callback thread - if let Some(handle) = self.callback_thread.take() { - let _ = handle.join(); - } - - // Stop SCStream - if let Some(ref mut stream) = self.stream { - let _ = stream.stop_capture(); - } - self.stream = None; - - Ok(()) - } - - /// Get access to the ring buffer for manual reading - /// - /// This is useful for integration with cpal output streams. - pub fn ring_buffer(&self) -> &SharedAudioState { - &self.state - } -} - -impl Drop for SckAudioInputStream { - fn drop(&mut self) { - let _ = self.stop(); - } -} - -// ============================================================================ -// Helper for cpal output integration -// ============================================================================ - -/// Create a cpal output callback that reads from an `SckAudioInputStream` -/// -/// # Example -/// -/// ```ignore -/// use screencapturekit::cpal_adapter::{SckAudioInputStream, create_output_callback}; -/// use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; -/// -/// let mut input = SckAudioInputStream::new(&filter)?; -/// let buffer = input.ring_buffer().clone(); -/// -/// input.start(|_, _| {})?; // Start capture (callback not used when bridging) -/// -/// let host = cpal::default_host(); -/// let device = host.default_output_device().unwrap(); -/// let config = input.cpal_config(); -/// -/// let output = device.build_output_stream( -/// &config, -/// create_output_callback(buffer), -/// |err| eprintln!("Error: {}", err), -/// None, -/// )?; -/// output.play()?; -/// ``` -pub fn create_output_callback( - state: SharedAudioState, -) -> impl FnMut(&mut [f32], &cpal::OutputCallbackInfo) + Send + 'static { - move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { - let (lock, _) = &*state; - if let Ok(mut state) = lock.lock() { - state.ring_buffer.read(data); - } else { - // Fill with silence on lock failure - for sample in data.iter_mut() { - *sample = 0.0; - } - } - } -} - -// ============================================================================ -// Zero-Copy Audio Stream -// ============================================================================ - -/// Zero-copy audio handler that calls callback directly from SCStream thread -struct ZeroCopyAudioHandler -where - F: FnMut(&[f32], SckAudioCallbackInfo) + Send, -{ - callback: std::sync::Mutex, - sample_rate: u32, - channels: u16, -} - -impl SCStreamOutputTrait for ZeroCopyAudioHandler -where - F: FnMut(&[f32], SckAudioCallbackInfo) + Send, -{ - fn did_output_sample_buffer(&self, sample: CMSampleBuffer, of_type: SCStreamOutputType) { - if of_type != SCStreamOutputType::Audio { - return; - } - - // Zero-copy: get slice directly from CMSampleBuffer - if let Some(audio_samples) = sample.audio_f32_samples() { - let slice = audio_samples.as_f32_slice(); - if !slice.is_empty() { - let info = SckAudioCallbackInfo { - sample_rate: self.sample_rate, - channels: self.channels, - }; - if let Ok(mut callback) = self.callback.lock() { - callback(slice, info); - } - } - } - } -} - -/// Zero-copy audio input stream -/// -/// Unlike `SckAudioInputStream`, this calls your callback directly from the -/// SCStream capture thread with a reference to the audio data - no copies. -/// -/// **Trade-offs:** -/// - ✅ Zero-copy: data is read directly from `CMSampleBuffer` -/// - ✅ Lower latency: no intermediate buffering -/// - ⚠️ Callback runs on SCStream's thread (don't block!) -/// - ⚠️ Cannot be used with `create_output_callback` (no ring buffer) -/// -/// # Example -/// -/// ```ignore -/// use screencapturekit::cpal_adapter::ZeroCopyAudioStream; -/// use screencapturekit::prelude::*; -/// -/// let filter = SCContentFilter::builder().display(display).exclude_windows(&[]).build(); -/// -/// let stream = ZeroCopyAudioStream::start(&filter, |samples, info| { -/// // This runs on SCStream's thread - don't block! -/// // `samples` points directly into CMSampleBuffer memory -/// process_audio(samples); -/// })?; -/// -/// // Stream runs until dropped -/// std::thread::sleep(std::time::Duration::from_secs(10)); -/// drop(stream); // Stops capture -/// ``` -pub struct ZeroCopyAudioStream { - stream: SCStream, -} - -impl ZeroCopyAudioStream { - /// Start zero-copy audio capture - /// - /// The callback receives a direct reference to audio data in the `CMSampleBuffer`. - /// **Important:** The callback runs on SCStream's internal thread - avoid blocking! - pub fn start(filter: &SCContentFilter, callback: F) -> Result - where - F: FnMut(&[f32], SckAudioCallbackInfo) + Send + 'static, - { - Self::start_with_config(filter, 48000, 2, callback) - } - - /// Start with custom sample rate and channels - pub fn start_with_config( - filter: &SCContentFilter, - sample_rate: u32, - channels: u16, - callback: F, - ) -> Result - where - F: FnMut(&[f32], SckAudioCallbackInfo) + Send + 'static, - { - let stream_config = SCStreamConfiguration::new() - .with_width(1) - .with_height(1) - .with_captures_audio(true) - .with_sample_rate(sample_rate as i32) - .with_channel_count(channels as i32); - - let handler = ZeroCopyAudioHandler { - callback: std::sync::Mutex::new(callback), - sample_rate, - channels, - }; - - let mut stream = SCStream::new(filter, &stream_config); - stream.add_output_handler(handler, SCStreamOutputType::Audio); - - stream - .start_capture() - .map_err(|e| SckAudioError::StreamStartFailed(e.to_string()))?; - - Ok(Self { stream }) - } - - /// Stop capture explicitly (also happens on drop) - pub fn stop(self) -> Result<(), SckAudioError> { - // Drop will handle cleanup - Ok(()) - } -} - -impl Drop for ZeroCopyAudioStream { - fn drop(&mut self) { - let _ = self.stream.stop_capture(); - } -} diff --git a/src/lib.rs b/src/lib.rs index 1ba1e96..07e070e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -346,10 +346,6 @@ pub mod utils; #[cfg(feature = "async")] pub mod async_api; -#[cfg(feature = "cpal")] -#[cfg_attr(docsrs, doc(cfg(feature = "cpal")))] -pub mod cpal_adapter; - // Re-export commonly used types pub use cm::{ codec_types, media_types, AudioBuffer, AudioBufferList, CMFormatDescription, CMSampleBuffer, diff --git a/src/output/pixel_buffer.rs b/src/output/pixel_buffer.rs index 36dc4c4..536564f 100644 --- a/src/output/pixel_buffer.rs +++ b/src/output/pixel_buffer.rs @@ -158,6 +158,41 @@ impl PixelBufferLockGuard<'_> { self.bytes_per_row } + /// Get the number of planes in the pixel buffer + /// + /// Returns 0 for non-planar formats (e.g., BGRA). + /// Returns 2 for bi-planar formats like NV12 (`YCbCr_420v`). + pub fn plane_count(&self) -> usize { + unsafe { crate::cm::ffi::cv_pixel_buffer_get_plane_count(self.buffer_ptr) } + } + + /// Get the width of a specific plane + pub fn width_of_plane(&self, plane_index: usize) -> usize { + unsafe { crate::cm::ffi::cv_pixel_buffer_get_width_of_plane(self.buffer_ptr, plane_index) } + } + + /// Get the height of a specific plane + pub fn height_of_plane(&self, plane_index: usize) -> usize { + unsafe { crate::cm::ffi::cv_pixel_buffer_get_height_of_plane(self.buffer_ptr, plane_index) } + } + + /// Get the base address of a specific plane + pub fn base_address_of_plane(&self, plane_index: usize) -> Option<*const u8> { + unsafe { + let ptr = crate::cm::ffi::cv_pixel_buffer_get_base_address_of_plane(self.buffer_ptr, plane_index); + if ptr.is_null() { + None + } else { + Some(ptr.cast::()) + } + } + } + + /// Get the bytes per row of a specific plane + pub fn bytes_per_row_of_plane(&self, plane_index: usize) -> usize { + unsafe { crate::cm::ffi::cv_pixel_buffer_get_bytes_per_row_of_plane(self.buffer_ptr, plane_index) } + } + /// Get raw pointer to buffer data pub fn as_ptr(&self) -> *const u8 { self.base_address.as_ptr() diff --git a/tests/cpal_adapter_tests.rs b/tests/cpal_adapter_tests.rs deleted file mode 100644 index 96e4831..0000000 --- a/tests/cpal_adapter_tests.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! Tests for cpal audio adapter - -#[cfg(feature = "cpal")] -mod cpal_tests { - use screencapturekit::cpal_adapter::{AudioFormat, AudioRingBuffer}; - - #[test] - fn test_audio_format_to_stream_config() { - let format = AudioFormat { - sample_rate: 48000, - channels: 2, - bits_per_sample: 32, - is_float: true, - }; - - let config = format.to_stream_config(); - assert_eq!(config.sample_rate.0, 48000); - assert_eq!(config.channels, 2); - } - - #[test] - fn test_audio_format_sample_format_float() { - let format = AudioFormat { - sample_rate: 44100, - channels: 2, - bits_per_sample: 32, - is_float: true, - }; - - assert_eq!(format.sample_format(), cpal::SampleFormat::F32); - } - - #[test] - fn test_audio_format_sample_format_i16() { - let format = AudioFormat { - sample_rate: 44100, - channels: 2, - bits_per_sample: 16, - is_float: false, - }; - - assert_eq!(format.sample_format(), cpal::SampleFormat::I16); - } - - #[test] - fn test_audio_format_sample_format_i8() { - let format = AudioFormat { - sample_rate: 22050, - channels: 1, - bits_per_sample: 8, - is_float: false, - }; - - assert_eq!(format.sample_format(), cpal::SampleFormat::I8); - } - - #[test] - fn test_audio_format_sample_format_i32() { - let format = AudioFormat { - sample_rate: 96000, - channels: 2, - bits_per_sample: 32, - is_float: false, - }; - - assert_eq!(format.sample_format(), cpal::SampleFormat::I32); - } - - #[test] - fn test_ring_buffer_write_read() { - let mut buffer = AudioRingBuffer::new(100); - - // Write some samples - let input = [1.0f32, 2.0, 3.0, 4.0, 5.0]; - let written = buffer.write(&input); - assert_eq!(written, 5); - assert_eq!(buffer.available(), 5); - - // Read them back - let mut output = [0.0f32; 5]; - let read = buffer.read(&mut output); - assert_eq!(read, 5); - assert_eq!(output, input); - assert_eq!(buffer.available(), 0); - } - - #[test] - fn test_ring_buffer_wrap_around() { - let mut buffer = AudioRingBuffer::new(10); - - // Fill with samples - let input1 = [1.0f32; 8]; - buffer.write(&input1); - - // Read some - let mut output = [0.0f32; 6]; - buffer.read(&mut output); - - // Write more (should wrap) - let input2 = [2.0f32; 6]; - let written = buffer.write(&input2); - assert_eq!(written, 6); - - // Read all remaining - let mut output2 = [0.0f32; 8]; - let read = buffer.read(&mut output2); - assert_eq!(read, 8); - } - - #[test] - fn test_ring_buffer_overflow_protection() { - let mut buffer = AudioRingBuffer::new(5); - - // Try to write more than capacity - let input = [1.0f32; 10]; - let written = buffer.write(&input); - assert_eq!(written, 5); // Only writes up to capacity - } - - #[test] - fn test_ring_buffer_underflow_fills_silence() { - let mut buffer = AudioRingBuffer::new(10); - - // Write 3 samples - buffer.write(&[1.0, 2.0, 3.0]); - - // Read 5 samples (2 should be silence) - let mut output = [9.9f32; 5]; - let read = buffer.read(&mut output); - assert_eq!(read, 3); - assert_eq!(output[0], 1.0); - assert_eq!(output[1], 2.0); - assert_eq!(output[2], 3.0); - assert_eq!(output[3], 0.0); // Silence - assert_eq!(output[4], 0.0); // Silence - } -} From 89a21049ae6533af7dd8a3203efebe5b72533927 Mon Sep 17 00:00:00 2001 From: Per Johansson Date: Thu, 4 Dec 2025 19:34:59 +0100 Subject: [PATCH 6/7] style: fix formatting in pixel_buffer.rs --- src/output/pixel_buffer.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/output/pixel_buffer.rs b/src/output/pixel_buffer.rs index 536564f..b19c47c 100644 --- a/src/output/pixel_buffer.rs +++ b/src/output/pixel_buffer.rs @@ -159,7 +159,7 @@ impl PixelBufferLockGuard<'_> { } /// Get the number of planes in the pixel buffer - /// + /// /// Returns 0 for non-planar formats (e.g., BGRA). /// Returns 2 for bi-planar formats like NV12 (`YCbCr_420v`). pub fn plane_count(&self) -> usize { @@ -179,7 +179,10 @@ impl PixelBufferLockGuard<'_> { /// Get the base address of a specific plane pub fn base_address_of_plane(&self, plane_index: usize) -> Option<*const u8> { unsafe { - let ptr = crate::cm::ffi::cv_pixel_buffer_get_base_address_of_plane(self.buffer_ptr, plane_index); + let ptr = crate::cm::ffi::cv_pixel_buffer_get_base_address_of_plane( + self.buffer_ptr, + plane_index, + ); if ptr.is_null() { None } else { @@ -190,7 +193,9 @@ impl PixelBufferLockGuard<'_> { /// Get the bytes per row of a specific plane pub fn bytes_per_row_of_plane(&self, plane_index: usize) -> usize { - unsafe { crate::cm::ffi::cv_pixel_buffer_get_bytes_per_row_of_plane(self.buffer_ptr, plane_index) } + unsafe { + crate::cm::ffi::cv_pixel_buffer_get_bytes_per_row_of_plane(self.buffer_ptr, plane_index) + } } /// Get raw pointer to buffer data From 7a8ac67542a0ff69b1e9056de13bbcf01c57cd89 Mon Sep 17 00:00:00 2001 From: Per Johansson Date: Thu, 4 Dec 2025 19:39:52 +0100 Subject: [PATCH 7/7] chore: release v1.4.0 --- CHANGELOG.md | 19 +++++++++++++++++++ Cargo.toml | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3628e5d..685460a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0](https://github.com/doom-fish/screencapturekit-rs/compare/v1.3.0...v1.4.0) - 2025-12-04 + +### Added + +- *(cpal)* add zero-copy audio stream +- *(cpal)* add audio input stream and cpal integration +- *(cpal)* add optional cpal audio adapter +- *(screenshot)* add multi-format image saving support +- *(cm)* add frame info accessors to CMSampleBuffer + +### Other + +- fix formatting in pixel_buffer.rs +- remove cpal dependency and update dev dependencies +- *(cpal)* add cpal adapter tests and example +- update README to use macos_26_0 as latest feature +- fix rustfmt formatting issues +- *(tests)* rename sync_completion_tests to completion_tests + ## [1.3.0](https://github.com/doom-fish/screencapturekit-rs/compare/v1.2.0...v1.3.0) - 2025-11-30 ### Added diff --git a/Cargo.toml b/Cargo.toml index c221c2b..883f2c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "screencapturekit" -version = "1.3.0" +version = "1.4.0" edition = "2021" license = "MIT OR Apache-2.0" homepage = "https://github.com/doom-fish/screencapturekit-rs"