Skip to content

Commit 39c9733

Browse files
committed
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
1 parent 36c7483 commit 39c9733

6 files changed

Lines changed: 354 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ cargo-clippy = []
4040
# Async support (executor-agnostic, works with any async runtime)
4141
async = []
4242

43+
# cpal audio integration for zero-copy audio playback
44+
cpal = ["dep:cpal"]
45+
4346
# macOS version feature flags
4447
# Enable features for specific macOS versions
4548
macos_13_0 = []
@@ -51,6 +54,7 @@ macos_15_2 = ["macos_15_0"]
5154
macos_26_0 = ["macos_15_2"]
5255

5356
[dependencies]
57+
cpal = { version = "0.15", optional = true }
5458

5559
[dev-dependencies]
5660
png = "0.17"

src/cm/ffi.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,23 @@ extern "C" {
156156
) -> *mut std::ffi::c_void;
157157
pub fn cm_format_description_release(format_description: *mut std::ffi::c_void);
158158

159+
// CMFormatDescription Audio APIs
160+
pub fn cm_format_description_get_audio_sample_rate(
161+
format_description: *mut std::ffi::c_void,
162+
) -> f64;
163+
pub fn cm_format_description_get_audio_channel_count(
164+
format_description: *mut std::ffi::c_void,
165+
) -> u32;
166+
pub fn cm_format_description_get_audio_bits_per_channel(
167+
format_description: *mut std::ffi::c_void,
168+
) -> u32;
169+
pub fn cm_format_description_get_audio_bytes_per_frame(
170+
format_description: *mut std::ffi::c_void,
171+
) -> u32;
172+
pub fn cm_format_description_get_audio_format_flags(
173+
format_description: *mut std::ffi::c_void,
174+
) -> u32;
175+
159176
// Hash functions
160177
pub fn cm_sample_buffer_hash(sample_buffer: *mut std::ffi::c_void) -> usize;
161178
pub fn cv_pixel_buffer_hash(pixel_buffer: *mut std::ffi::c_void) -> usize;

src/cm/format_description.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,90 @@ impl CMFormatDescription {
202202
pub fn is_alac(&self) -> bool {
203203
self.media_subtype() == codec_types::ALAC
204204
}
205+
206+
// Audio format description methods
207+
208+
/// Get the audio sample rate in Hz
209+
///
210+
/// Returns `None` if this is not an audio format description.
211+
pub fn audio_sample_rate(&self) -> Option<f64> {
212+
if !self.is_audio() {
213+
return None;
214+
}
215+
let rate = unsafe { ffi::cm_format_description_get_audio_sample_rate(self.0) };
216+
if rate > 0.0 {
217+
Some(rate)
218+
} else {
219+
None
220+
}
221+
}
222+
223+
/// Get the number of audio channels
224+
///
225+
/// Returns `None` if this is not an audio format description.
226+
pub fn audio_channel_count(&self) -> Option<u32> {
227+
if !self.is_audio() {
228+
return None;
229+
}
230+
let count = unsafe { ffi::cm_format_description_get_audio_channel_count(self.0) };
231+
if count > 0 {
232+
Some(count)
233+
} else {
234+
None
235+
}
236+
}
237+
238+
/// Get the bits per audio channel
239+
///
240+
/// Returns `None` if this is not an audio format description.
241+
pub fn audio_bits_per_channel(&self) -> Option<u32> {
242+
if !self.is_audio() {
243+
return None;
244+
}
245+
let bits = unsafe { ffi::cm_format_description_get_audio_bits_per_channel(self.0) };
246+
if bits > 0 {
247+
Some(bits)
248+
} else {
249+
None
250+
}
251+
}
252+
253+
/// Get the bytes per audio frame
254+
///
255+
/// Returns `None` if this is not an audio format description.
256+
pub fn audio_bytes_per_frame(&self) -> Option<u32> {
257+
if !self.is_audio() {
258+
return None;
259+
}
260+
let bytes = unsafe { ffi::cm_format_description_get_audio_bytes_per_frame(self.0) };
261+
if bytes > 0 {
262+
Some(bytes)
263+
} else {
264+
None
265+
}
266+
}
267+
268+
/// Get the audio format flags
269+
///
270+
/// Returns `None` if this is not an audio format description.
271+
pub fn audio_format_flags(&self) -> Option<u32> {
272+
if !self.is_audio() {
273+
return None;
274+
}
275+
Some(unsafe { ffi::cm_format_description_get_audio_format_flags(self.0) })
276+
}
277+
278+
/// Check if audio is float format (based on format flags)
279+
pub fn audio_is_float(&self) -> bool {
280+
// kAudioFormatFlagIsFloat = 1
281+
self.audio_format_flags().is_some_and(|f| f & 1 != 0)
282+
}
283+
284+
/// Check if audio is big-endian (based on format flags)
285+
pub fn audio_is_big_endian(&self) -> bool {
286+
// kAudioFormatFlagIsBigEndian = 2
287+
self.audio_format_flags().is_some_and(|f| f & 2 != 0)
288+
}
205289
}
206290

207291
impl Clone for CMFormatDescription {

src/cpal_adapter.rs

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
//! cpal integration for zero-copy audio playback
2+
//!
3+
//! This module provides adapters to use captured audio with the cpal audio library.
4+
//!
5+
//! # Example
6+
//!
7+
//! ```ignore
8+
//! use screencapturekit::cpal_adapter::{AudioSamples, CpalAudioExt};
9+
//! use screencapturekit::cm::CMSampleBuffer;
10+
//!
11+
//! fn process_audio(sample: &CMSampleBuffer) {
12+
//! // Get f32 samples from the captured audio
13+
//! if let Some(samples) = sample.audio_f32_samples() {
14+
//! for sample in samples.iter() {
15+
//! // Process or send to cpal output stream
16+
//! }
17+
//! }
18+
//! }
19+
//! ```
20+
21+
use crate::cm::{AudioBufferList, CMSampleBuffer};
22+
23+
/// Audio samples extracted from a `CMSampleBuffer`
24+
///
25+
/// Owns the underlying `AudioBufferList` and provides access to samples
26+
/// in various formats compatible with cpal.
27+
pub struct AudioSamples {
28+
buffer_list: AudioBufferList,
29+
}
30+
31+
impl AudioSamples {
32+
/// Create from a `CMSampleBuffer`
33+
///
34+
/// Returns `None` if the sample buffer doesn't contain audio data.
35+
pub fn new(sample: &CMSampleBuffer) -> Option<Self> {
36+
let buffer_list = sample.audio_buffer_list()?;
37+
Some(Self { buffer_list })
38+
}
39+
40+
/// Get the number of audio channels
41+
pub fn channels(&self) -> usize {
42+
self.buffer_list
43+
.get(0)
44+
.map(|b| b.number_channels as usize)
45+
.unwrap_or(0)
46+
}
47+
48+
/// Get raw bytes of audio data
49+
pub fn as_bytes(&self) -> &[u8] {
50+
self.buffer_list.get(0).map(|b| b.data()).unwrap_or(&[])
51+
}
52+
53+
/// Get audio samples as f32 slice (zero-copy if data is already f32)
54+
///
55+
/// # Safety
56+
/// Assumes the audio data is in native-endian f32 format.
57+
#[allow(clippy::cast_ptr_alignment)]
58+
pub fn as_f32_slice(&self) -> &[f32] {
59+
let bytes = self.as_bytes();
60+
if bytes.len() < 4 {
61+
return &[];
62+
}
63+
// Safety: macOS audio buffers are properly aligned for the sample type
64+
unsafe { std::slice::from_raw_parts(bytes.as_ptr().cast::<f32>(), bytes.len() / 4) }
65+
}
66+
67+
/// Get audio samples as i16 slice (zero-copy if data is already i16)
68+
///
69+
/// # Safety
70+
/// Assumes the audio data is in native-endian i16 format.
71+
#[allow(clippy::cast_ptr_alignment)]
72+
pub fn as_i16_slice(&self) -> &[i16] {
73+
let bytes = self.as_bytes();
74+
if bytes.len() < 2 {
75+
return &[];
76+
}
77+
// Safety: macOS audio buffers are properly aligned for the sample type
78+
unsafe { std::slice::from_raw_parts(bytes.as_ptr().cast::<i16>(), bytes.len() / 2) }
79+
}
80+
81+
/// Iterator over f32 samples
82+
pub fn iter_f32(&self) -> impl Iterator<Item = f32> + '_ {
83+
self.as_f32_slice().iter().copied()
84+
}
85+
86+
/// Iterator over i16 samples
87+
pub fn iter_i16(&self) -> impl Iterator<Item = i16> + '_ {
88+
self.as_i16_slice().iter().copied()
89+
}
90+
91+
/// Get the number of f32 samples
92+
pub fn len_f32(&self) -> usize {
93+
self.as_bytes().len() / 4
94+
}
95+
96+
/// Get the number of i16 samples
97+
pub fn len_i16(&self) -> usize {
98+
self.as_bytes().len() / 2
99+
}
100+
101+
/// Check if empty
102+
pub fn is_empty(&self) -> bool {
103+
self.as_bytes().is_empty()
104+
}
105+
}
106+
107+
/// Extension trait for `CMSampleBuffer` to provide cpal-compatible audio access
108+
pub trait CpalAudioExt {
109+
/// Get audio samples as f32 (for cpal output)
110+
fn audio_f32_samples(&self) -> Option<AudioSamples>;
111+
112+
/// Copy f32 audio samples into a cpal-compatible buffer
113+
///
114+
/// Returns the number of samples copied.
115+
fn copy_f32_to_buffer(&self, buffer: &mut [f32]) -> usize;
116+
117+
/// Copy i16 audio samples into a cpal-compatible buffer
118+
///
119+
/// Returns the number of samples copied.
120+
fn copy_i16_to_buffer(&self, buffer: &mut [i16]) -> usize;
121+
}
122+
123+
impl CpalAudioExt for CMSampleBuffer {
124+
fn audio_f32_samples(&self) -> Option<AudioSamples> {
125+
AudioSamples::new(self)
126+
}
127+
128+
fn copy_f32_to_buffer(&self, buffer: &mut [f32]) -> usize {
129+
let Some(samples) = AudioSamples::new(self) else {
130+
return 0;
131+
};
132+
let src = samples.as_f32_slice();
133+
let len = buffer.len().min(src.len());
134+
buffer[..len].copy_from_slice(&src[..len]);
135+
len
136+
}
137+
138+
fn copy_i16_to_buffer(&self, buffer: &mut [i16]) -> usize {
139+
let Some(samples) = AudioSamples::new(self) else {
140+
return 0;
141+
};
142+
let src = samples.as_i16_slice();
143+
let len = buffer.len().min(src.len());
144+
buffer[..len].copy_from_slice(&src[..len]);
145+
len
146+
}
147+
}
148+
149+
/// Audio format information for cpal stream configuration
150+
#[derive(Debug, Clone, Copy)]
151+
pub struct AudioFormat {
152+
/// Sample rate in Hz
153+
pub sample_rate: u32,
154+
/// Number of channels
155+
pub channels: u16,
156+
/// Bits per sample
157+
pub bits_per_sample: u16,
158+
/// Whether samples are float format
159+
pub is_float: bool,
160+
}
161+
162+
impl AudioFormat {
163+
/// Extract audio format from a `CMSampleBuffer`
164+
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
165+
pub fn from_sample_buffer(sample: &CMSampleBuffer) -> Option<Self> {
166+
let format_desc = sample.format_description()?;
167+
if !format_desc.is_audio() {
168+
return None;
169+
}
170+
171+
Some(Self {
172+
sample_rate: format_desc.audio_sample_rate()? as u32,
173+
channels: format_desc.audio_channel_count()? as u16,
174+
bits_per_sample: format_desc.audio_bits_per_channel()? as u16,
175+
is_float: format_desc.audio_is_float(),
176+
})
177+
}
178+
179+
/// Convert to cpal `StreamConfig`
180+
pub fn to_stream_config(&self) -> cpal::StreamConfig {
181+
cpal::StreamConfig {
182+
channels: self.channels,
183+
sample_rate: cpal::SampleRate(self.sample_rate),
184+
buffer_size: cpal::BufferSize::Default,
185+
}
186+
}
187+
188+
/// Get the cpal `SampleFormat` based on the audio format
189+
pub fn sample_format(&self) -> cpal::SampleFormat {
190+
if self.is_float {
191+
cpal::SampleFormat::F32
192+
} else if self.bits_per_sample == 8 {
193+
cpal::SampleFormat::I8
194+
} else if self.bits_per_sample == 32 {
195+
cpal::SampleFormat::I32
196+
} else {
197+
cpal::SampleFormat::I16
198+
}
199+
}
200+
}

src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,10 @@ pub mod utils;
346346
#[cfg(feature = "async")]
347347
pub mod async_api;
348348

349+
#[cfg(feature = "cpal")]
350+
#[cfg_attr(docsrs, doc(cfg(feature = "cpal")))]
351+
pub mod cpal_adapter;
352+
349353
// Re-export commonly used types
350354
pub use cm::{
351355
codec_types, media_types, AudioBuffer, AudioBufferList, CMFormatDescription, CMSampleBuffer,

swift-bridge/Sources/CoreMedia/CoreMedia.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,51 @@ public func cm_format_description_release(_ formatDescription: UnsafeMutableRawP
700700
Unmanaged<CMFormatDescription>.fromOpaque(formatDescription).release()
701701
}
702702

703+
@_cdecl("cm_format_description_get_audio_sample_rate")
704+
public func cm_format_description_get_audio_sample_rate(_ formatDescription: UnsafeMutableRawPointer) -> Double {
705+
let desc = Unmanaged<CMFormatDescription>.fromOpaque(formatDescription).takeUnretainedValue()
706+
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else {
707+
return 0.0
708+
}
709+
return asbd.pointee.mSampleRate
710+
}
711+
712+
@_cdecl("cm_format_description_get_audio_channel_count")
713+
public func cm_format_description_get_audio_channel_count(_ formatDescription: UnsafeMutableRawPointer) -> UInt32 {
714+
let desc = Unmanaged<CMFormatDescription>.fromOpaque(formatDescription).takeUnretainedValue()
715+
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else {
716+
return 0
717+
}
718+
return asbd.pointee.mChannelsPerFrame
719+
}
720+
721+
@_cdecl("cm_format_description_get_audio_bits_per_channel")
722+
public func cm_format_description_get_audio_bits_per_channel(_ formatDescription: UnsafeMutableRawPointer) -> UInt32 {
723+
let desc = Unmanaged<CMFormatDescription>.fromOpaque(formatDescription).takeUnretainedValue()
724+
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else {
725+
return 0
726+
}
727+
return asbd.pointee.mBitsPerChannel
728+
}
729+
730+
@_cdecl("cm_format_description_get_audio_bytes_per_frame")
731+
public func cm_format_description_get_audio_bytes_per_frame(_ formatDescription: UnsafeMutableRawPointer) -> UInt32 {
732+
let desc = Unmanaged<CMFormatDescription>.fromOpaque(formatDescription).takeUnretainedValue()
733+
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else {
734+
return 0
735+
}
736+
return asbd.pointee.mBytesPerFrame
737+
}
738+
739+
@_cdecl("cm_format_description_get_audio_format_flags")
740+
public func cm_format_description_get_audio_format_flags(_ formatDescription: UnsafeMutableRawPointer) -> UInt32 {
741+
let desc = Unmanaged<CMFormatDescription>.fromOpaque(formatDescription).takeUnretainedValue()
742+
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else {
743+
return 0
744+
}
745+
return asbd.pointee.mFormatFlags
746+
}
747+
703748
// MARK: - CMSampleBuffer Creation
704749

705750
@_cdecl("cm_sample_buffer_create_for_image_buffer")

0 commit comments

Comments
 (0)