use std::ptr::NonNull;
use std::time::Duration;
use crate::frame::{AudioBufferPool, AudioFrame, Platform, StreamId};
use crate::capture::{
initialize_monotonic_timestamp_domain, monotonic_timestamp_ns, CaptureError as LoopbackError,
CaptureMode, CaptureObservationCounters, CaptureObservationHandle, CaptureObservations,
CaptureSource, SourceKind, SourceState, StableSourceId,
};
use crate::timing::TimelineMapping;
#[repr(C)]
struct RawSourceInfo {
audio_object_id: u32,
process_id: i32,
bundle_id: [u8; 256],
name: [u8; 256],
source_kind_code: u8,
source_state_code: u8,
sample_rate_hz: u32,
channel_count: u16,
process_start_time_ns: u64,
}
#[derive(Debug)]
struct AuditedCaptureSource {
source: CaptureSource,
process_start_time_ns: u64,
}
extern "C" {
fn pks_process_tap_available() -> i32;
fn pks_discover_sources(out: *mut RawSourceInfo, max_count: i32) -> i32;
fn pks_create_process_tap(
pids: *const i32,
process_count: i32,
out_status: *mut i32,
out_stage: *mut u8,
) -> *mut std::ffi::c_void;
fn pks_tap_start(tap: *mut std::ffi::c_void, out_status: *mut i32, out_stage: *mut u8) -> i32;
fn pks_destroy_process_tap(tap: *mut std::ffi::c_void);
fn pks_tap_read_frames_timed(
tap: *mut std::ffi::c_void,
out: *mut f32,
frame_count: u32,
out_source_frame_position_frames: *mut u64,
out_anchor_frame_position_frames: *mut u64,
out_anchor_host_time_ns: *mut u64,
) -> u32;
fn pks_tap_current_host_time_ns() -> u64;
fn pks_tap_drop_count(tap: *const std::ffi::c_void) -> u64;
fn pks_tap_sample_rate(tap: *const std::ffi::c_void) -> u32;
fn pks_tap_channels(tap: *const std::ffi::c_void) -> u32;
fn pks_tap_level(tap: *const std::ffi::c_void) -> f32;
}
pub fn tap_available() -> bool {
unsafe {
let _diagnostic_symbol = pks_tap_level;
pks_process_tap_available() != 0
}
}
pub fn discover_sources_native() -> Vec<CaptureSource> {
discover_sources_native_with_audit()
.into_iter()
.map(|audited| audited.source)
.collect()
}
fn discover_sources_native_with_audit() -> Vec<AuditedCaptureSource> {
const MAX: usize = 128;
let raw: Vec<RawSourceInfo> = unsafe {
let mut v: Vec<RawSourceInfo> = Vec::with_capacity(MAX);
std::ptr::write_bytes(v.as_mut_ptr(), 0, MAX);
v.set_len(MAX);
let n = pks_discover_sources(v.as_mut_ptr(), MAX as i32);
v.truncate(n.max(0) as usize);
v
};
raw.iter()
.map(|r| {
let process_id = if r.process_id > 0 {
Some(r.process_id as u32)
} else {
None
};
let source_kind = match r.source_kind_code {
1 => SourceKind::InputDevice,
2 => SourceKind::OutputDevice,
3 => SourceKind::SystemMix,
_ => SourceKind::Application,
};
let native_identity = cstr_to_opt(&r.bundle_id);
let stable_key = native_identity
.as_deref()
.map(|id| id.to_owned())
.unwrap_or_else(|| {
if matches!(
source_kind,
SourceKind::InputDevice | SourceKind::OutputDevice
) {
format!("coreaudio-object:{}", r.audio_object_id)
} else {
format!("pid:{}", r.process_id)
}
});
let app_id = (source_kind == SourceKind::Application)
.then(|| native_identity.clone())
.flatten();
let device_uid = matches!(
source_kind,
SourceKind::InputDevice | SourceKind::OutputDevice
)
.then_some(native_identity)
.flatten();
AuditedCaptureSource {
source: CaptureSource {
stable_id: StableSourceId::new(Platform::Macos, source_kind, stable_key),
name: cstr_to_string(&r.name)
.unwrap_or_else(|| format!("pid:{}", r.process_id)),
process_id,
app_id,
device_uid,
state: match r.source_state_code {
1 => SourceState::Playing,
2 => SourceState::Silent,
3 => SourceState::Unavailable,
_ => SourceState::Available,
},
sample_rate_hz: r.sample_rate_hz,
channels: r.channel_count,
},
process_start_time_ns: r.process_start_time_ns,
}
})
.collect()
}
fn cstr_to_string(buf: &[u8]) -> Option<String> {
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
if end == 0 {
return None;
}
Some(String::from_utf8_lossy(&buf[..end]).into_owned())
}
fn cstr_to_opt(buf: &[u8]) -> Option<String> {
cstr_to_string(buf)
}
struct ProcessTap(NonNull<std::ffi::c_void>);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct ProcessTapReadBatch {
frame_count: u32,
source_frame_position_frames: u64,
anchor_frame_position_frames: u64,
anchor_host_time_ns: u64,
}
fn source_host_timestamp_ns(batch: ProcessTapReadBatch, sample_rate_hz: u32) -> Option<u64> {
if batch.frame_count == 0 || batch.anchor_host_time_ns == 0 || sample_rate_hz == 0 {
return None;
}
let frame_delta = i128::from(batch.source_frame_position_frames)
.checked_sub(i128::from(batch.anchor_frame_position_frames))?;
let timestamp_delta_ns = frame_delta
.checked_mul(1_000_000_000)?
.checked_div(i128::from(sample_rate_hz))?;
let timestamp_ns = i128::from(batch.anchor_host_time_ns).checked_add(timestamp_delta_ns)?;
u64::try_from(timestamp_ns).ok().filter(|value| *value != 0)
}
fn process_timestamp_ns(
batch: ProcessTapReadBatch,
sample_rate_hz: u32,
host_to_process: TimelineMapping,
) -> Option<u64> {
host_to_process.normalize_timestamp_ns(source_host_timestamp_ns(batch, sample_rate_hz)?)
}
const CORE_AUDIO_PERMISSION_DENIED_STATUS: i32 = i32::from_be_bytes(*b"!hog");
fn tap_operation(stage_code: u8) -> &'static str {
match stage_code {
1 => "resolving the selected process",
2 => "creating the CoreAudio process tap",
3 => "reading the CoreAudio process tap identifier",
4 => "creating the CoreAudio aggregate device",
5 => "allocating the CoreAudio process tap handle",
6 => "creating the CoreAudio device callback",
7 => "starting the CoreAudio aggregate device",
8 => "checking CoreAudio process tap platform support",
_ => "opening the CoreAudio process tap",
}
}
fn tap_error(status_code: i32, stage_code: u8) -> LoopbackError {
let operation = tap_operation(stage_code);
if status_code == CORE_AUDIO_PERMISSION_DENIED_STATUS {
LoopbackError::PermissionDenied { operation }
} else {
LoopbackError::BackendStatus {
operation,
status_code,
}
}
}
fn stable_source_id(mode: &CaptureMode) -> Result<StableSourceId, LoopbackError> {
match mode {
CaptureMode::SystemMix => Ok(StableSourceId::new(
Platform::Macos,
SourceKind::SystemMix,
"system:mix",
)),
CaptureMode::Process(pid) => Ok(StableSourceId::new(
Platform::Macos,
SourceKind::Application,
format!("pid:{pid}"),
)),
CaptureMode::ExactApplication { stable_id, .. }
| CaptureMode::ExactApplicationStable { stable_id } => Ok(stable_id.clone()),
CaptureMode::Application(bundle_id) => Ok(StableSourceId::new(
Platform::Macos,
SourceKind::Application,
bundle_id.clone(),
)),
CaptureMode::InputDevice(_) => Err(LoopbackError::ModeUnsupported(mode.clone())),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ExactApplicationOpenAudit {
process_id: u32,
stable_id: StableSourceId,
process_start_time_ns: u64,
}
fn exact_application_open_audit(
sources: &[AuditedCaptureSource],
process_id: u32,
stable_id: &StableSourceId,
) -> Option<ExactApplicationOpenAudit> {
sources
.iter()
.find(|audited| {
audited.process_start_time_ns != 0
&& audited.source.process_id == Some(process_id)
&& audited.source.stable_id == *stable_id
&& audited.source.stable_id.kind == SourceKind::Application
})
.map(|audited| ExactApplicationOpenAudit {
process_id,
stable_id: stable_id.clone(),
process_start_time_ns: audited.process_start_time_ns,
})
}
fn capture_exact_application_open_audit(
process_id: u32,
stable_id: &StableSourceId,
) -> Result<ExactApplicationOpenAudit, LoopbackError> {
exact_application_open_audit(&discover_sources_native_with_audit(), process_id, stable_id)
.ok_or_else(|| LoopbackError::SourceUnavailable {
stable_key: stable_id.stable_key.clone(),
})
}
fn verify_exact_application_open_audit(
expected: &ExactApplicationOpenAudit,
) -> Result<(), LoopbackError> {
let observed = exact_application_open_audit(
&discover_sources_native_with_audit(),
expected.process_id,
&expected.stable_id,
);
if observed.as_ref() == Some(expected) {
Ok(())
} else {
Err(LoopbackError::SourceUnavailable {
stable_key: expected.stable_id.stable_key.clone(),
})
}
}
unsafe impl Send for ProcessTap {}
impl ProcessTap {
fn global() -> Result<Self, LoopbackError> {
Self::create(std::ptr::null(), 0)
}
fn for_pids(pids: &[i32]) -> Result<Self, LoopbackError> {
Self::create(pids.as_ptr(), pids.len() as i32)
}
fn create(pids: *const i32, process_count: i32) -> Result<Self, LoopbackError> {
let mut status_code = 0;
let mut stage_code = 0;
let handle = unsafe {
pks_create_process_tap(pids, process_count, &mut status_code, &mut stage_code)
};
NonNull::new(handle)
.map(Self)
.ok_or_else(|| tap_error(status_code, stage_code))
}
fn start(&mut self) -> Result<(), LoopbackError> {
let mut status_code = 0;
let mut stage_code = 0;
if unsafe { pks_tap_start(self.0.as_ptr(), &mut status_code, &mut stage_code) } == 0 {
Ok(())
} else {
Err(tap_error(status_code, stage_code))
}
}
fn sample_rate_hz(&self) -> u32 {
unsafe { pks_tap_sample_rate(self.0.as_ptr()) }
}
fn channel_count(&self) -> u32 {
unsafe { pks_tap_channels(self.0.as_ptr()) }
}
fn read_frames(&mut self, out: &mut [f32], frame_count: u32) -> ProcessTapReadBatch {
let required_samples = frame_count as usize * self.channel_count() as usize;
if out.len() < required_samples {
return ProcessTapReadBatch {
frame_count: 0,
source_frame_position_frames: 0,
anchor_frame_position_frames: 0,
anchor_host_time_ns: 0,
};
}
let mut source_frame_position_frames = 0;
let mut anchor_frame_position_frames = 0;
let mut anchor_host_time_ns = 0;
let read_frame_count = unsafe {
pks_tap_read_frames_timed(
self.0.as_ptr(),
out.as_mut_ptr(),
frame_count,
&mut source_frame_position_frames,
&mut anchor_frame_position_frames,
&mut anchor_host_time_ns,
)
};
ProcessTapReadBatch {
frame_count: read_frame_count,
source_frame_position_frames,
anchor_frame_position_frames,
anchor_host_time_ns,
}
}
fn current_host_time_ns() -> u64 {
unsafe { pks_tap_current_host_time_ns() }
}
fn drop_count(&self) -> u64 {
unsafe { pks_tap_drop_count(self.0.as_ptr()) }
}
}
impl Drop for ProcessTap {
fn drop(&mut self) {
unsafe {
pks_destroy_process_tap(self.0.as_ptr());
}
}
}
const POOL_CAPACITY_FRAMES: usize = 32;
pub struct TapLoopbackSource {
reader_thread: Option<std::thread::JoinHandle<()>>,
pub(crate) stop_tx: std::sync::mpsc::SyncSender<()>,
counters: CaptureObservationCounters,
source_id: crate::frame::SourceId,
}
impl TapLoopbackSource {
pub(crate) fn capture_mode_with_runtime_event_sender<F>(
mode: CaptureMode,
mut callback: F,
runtime_event_sender: Option<crate::capture::SourceRuntimeEventSender>,
) -> Result<Self, LoopbackError>
where
F: FnMut(AudioFrame) + Send + 'static,
{
if !tap_available() {
return Err(LoopbackError::BackendInit(
"CoreAudio process tap requires macOS 14.4 or later".into(),
));
}
let exact_application_open_audit = if let CaptureMode::ExactApplication {
process_id,
stable_id,
} = &mode
{
Some(capture_exact_application_open_audit(
*process_id,
stable_id,
)?)
} else {
None
};
let mut tap = match &mode {
CaptureMode::SystemMix => ProcessTap::global()?,
CaptureMode::Process(pid) => ProcessTap::for_pids(&[*pid as i32])?,
CaptureMode::ExactApplication { process_id, .. } => {
ProcessTap::for_pids(&[*process_id as i32])?
}
CaptureMode::ExactApplicationStable { .. } => {
return Err(LoopbackError::ModeUnsupported(mode.clone()));
}
CaptureMode::Application(bundle_id) => {
let sources = discover_sources_native();
let pids: Vec<i32> = sources
.iter()
.filter(|s| s.app_id.as_deref() == Some(bundle_id.as_str()))
.filter_map(|s| s.process_id.map(|p| p as i32))
.collect();
if std::env::var_os("PKS_TAP_DIAG").is_some() {
eprintln!(
"tap_diag: app_source_lookup bundle_id={} sources={} pids={:?}",
bundle_id,
sources.len(),
pids
);
}
if pids.is_empty() {
return Err(LoopbackError::BackendInit(format!(
"no running audio process found for bundle ID: {bundle_id}"
)));
}
ProcessTap::for_pids(&pids)?
}
CaptureMode::InputDevice(_) => {
return Err(LoopbackError::ModeUnsupported(mode));
}
};
tap.start()?;
if let Some(expected) = exact_application_open_audit.as_ref() {
verify_exact_application_open_audit(expected)?;
}
let sample_rate_hz = tap.sample_rate_hz();
if sample_rate_hz == 0 {
return Err(LoopbackError::BackendInit(
"tap reported a zero sample rate".to_owned(),
));
}
let channel_count = tap.channel_count() as u8;
initialize_monotonic_timestamp_domain();
let host_time_before_ns = ProcessTap::current_host_time_ns();
let process_time_ns = monotonic_timestamp_ns();
let host_time_after_ns = ProcessTap::current_host_time_ns();
if host_time_before_ns == 0 || host_time_after_ns < host_time_before_ns {
return Err(LoopbackError::BackendInit(
"CoreAudio host-time mapping is unavailable".to_owned(),
));
}
let host_time_midpoint_ns =
host_time_before_ns.saturating_add((host_time_after_ns - host_time_before_ns) / 2);
let host_to_process = TimelineMapping::new(host_time_midpoint_ns, process_time_ns);
let callback_frame_count: u32 = sample_rate_hz / 50; let buffer_capacity_samples = callback_frame_count as usize * channel_count as usize;
let pool = AudioBufferPool::new(POOL_CAPACITY_FRAMES, buffer_capacity_samples);
let (stop_tx, stop_rx) = std::sync::mpsc::sync_channel::<()>(1);
let counters = CaptureObservationCounters::default();
let capture_counters = counters.clone();
let stable_id = stable_source_id(&mode)?;
let source_id = stable_id.source_id();
let failure_counters = counters.clone();
let thread = std::thread::Builder::new()
.name("pks-tap-reader".into())
.spawn(move || {
let worker = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut sequence_num: u64 = 0;
let mut buffer = vec![0.0f32; buffer_capacity_samples];
let mut observed_drop_count = tap.drop_count();
loop {
if stop_rx.try_recv().is_ok() {
break;
}
let batch = tap.read_frames(&mut buffer, callback_frame_count);
let frame_count = batch.frame_count;
let drop_count = tap.drop_count();
capture_counters.observe_dispatch_queue_full_frames(
drop_count.saturating_sub(observed_drop_count),
);
observed_drop_count = drop_count;
if frame_count == 0 {
std::thread::sleep(Duration::from_millis(1));
continue;
}
capture_counters.observe_callback_buffer();
let Some(timestamp_ns) =
process_timestamp_ns(batch, sample_rate_hz, host_to_process)
else {
capture_counters.observe_stream_error();
if let Some(sender) = runtime_event_sender.as_ref() {
let _ = crate::capture::publish_backend_failure(
sender,
stable_id.clone(),
crate::capture::SourceGeneration::INITIAL,
"macOS tap reader",
crate::capture::CaptureRuntimeFailureClass::BackendClass {
class: "native-host-timeline-unavailable".to_owned(),
},
);
}
break;
};
let frame_sequence_number = sequence_num;
sequence_num = sequence_num.saturating_add(1);
let mut handle = match pool.acquire() {
Some(h) => h,
None => {
capture_counters.observe_pool_exhaustion();
continue;
}
};
let dst = handle.as_mut_slice();
let sample_count = frame_count as usize * channel_count as usize;
if sample_count > dst.len() {
capture_counters.observe_oversized_buffer();
continue;
}
dst[..sample_count].copy_from_slice(&buffer[..sample_count]);
if handle.try_set_len(sample_count).is_err() {
capture_counters.observe_oversized_buffer();
continue;
}
let mut frame = AudioFrame::new(
StreamId(0),
source_id,
frame_sequence_number,
timestamp_ns,
channel_count,
handle,
);
frame.sample_rate_hz = sample_rate_hz;
capture_counters.observe_enqueued_frame();
callback(frame);
}
}));
if let Err(payload) = worker {
failure_counters.observe_stream_error();
if let Some(sender) = runtime_event_sender.as_ref() {
let _ = crate::capture::publish_backend_failure(
sender,
stable_id,
crate::capture::SourceGeneration::INITIAL,
"macOS tap reader",
crate::capture::CaptureRuntimeFailureClass::BackendClass {
class: "reader-panicked".to_owned(),
},
);
}
std::panic::resume_unwind(payload);
}
})
.map_err(|e| LoopbackError::BackendInit(format!("thread spawn: {e}")))?;
Ok(Self {
reader_thread: Some(thread),
stop_tx,
counters,
source_id,
})
}
pub fn source_id(&self) -> crate::frame::SourceId {
self.source_id
}
pub fn observations(&self) -> CaptureObservations {
self.counters.snapshot()
}
pub fn observation_handle(&self) -> CaptureObservationHandle {
self.counters.observation_handle()
}
pub(crate) fn stop_and_join(&mut self) -> Result<CaptureObservations, LoopbackError> {
let counters = self.counters.clone();
self.stop_reader()?;
Ok(counters.snapshot())
}
fn stop_reader(&mut self) -> Result<(), LoopbackError> {
let _ = self.stop_tx.try_send(());
self.reader_thread.take().map_or(Ok(()), |thread| {
crate::capture::join_capture_worker(thread, "macOS tap reader")
})
}
}
impl Drop for TapLoopbackSource {
fn drop(&mut self) {
let _ = self.stop_reader();
}
}
#[cfg(test)]
mod tests {
use super::{
exact_application_open_audit, process_timestamp_ns, source_host_timestamp_ns,
stable_source_id, tap_error, AuditedCaptureSource, ExactApplicationOpenAudit,
ProcessTapReadBatch, CORE_AUDIO_PERMISSION_DENIED_STATUS,
};
use crate::capture::{
CaptureError, CaptureMode, CaptureSource, SourceKind, SourceState, StableSourceId,
};
use crate::frame::Platform;
use crate::timing::TimelineMapping;
fn audited_application(
stable_id: StableSourceId,
process_id: u32,
process_start_time_ns: u64,
) -> AuditedCaptureSource {
let app_id = stable_id.stable_key.clone();
AuditedCaptureSource {
source: CaptureSource {
stable_id,
name: "Application".to_owned(),
process_id: Some(process_id),
app_id: Some(app_id),
device_uid: None,
state: SourceState::Playing,
sample_rate_hz: 48_000,
channels: 2,
},
process_start_time_ns,
}
}
#[test]
fn given_core_audio_permission_status_when_mapped_then_denial_remains_typed() {
assert_eq!(
tap_error(CORE_AUDIO_PERMISSION_DENIED_STATUS, 2),
CaptureError::PermissionDenied {
operation: "creating the CoreAudio process tap"
}
);
}
#[test]
fn given_reader_position_before_native_anchor_when_mapped_then_sample_delta_is_preserved() {
let batch = ProcessTapReadBatch {
frame_count: 480,
source_frame_position_frames: 48_000,
anchor_frame_position_frames: 48_480,
anchor_host_time_ns: 2_000_000_000,
};
assert_eq!(source_host_timestamp_ns(batch, 48_000), Some(1_990_000_000));
}
#[test]
fn given_native_host_time_when_normalized_then_process_clock_boundary_is_comparable() {
let batch = ProcessTapReadBatch {
frame_count: 960,
source_frame_position_frames: 96_000,
anchor_frame_position_frames: 96_000,
anchor_host_time_ns: 9_000_000_000,
};
let mapping = TimelineMapping::new(8_500_000_000, 500_000_000);
assert_eq!(
process_timestamp_ns(batch, 48_000, mapping),
Some(1_000_000_000)
);
}
#[test]
fn given_other_core_audio_status_when_mapped_then_raw_status_is_preserved() {
assert_eq!(
tap_error(-50, 7),
CaptureError::BackendStatus {
operation: "starting the CoreAudio aggregate device",
status_code: -50
}
);
}
#[test]
fn given_exact_application_target_when_framed_then_stable_identity_is_preserved() {
let stable_id =
StableSourceId::new(Platform::Macos, SourceKind::Application, "com.acme.meeting");
let expected = stable_id.source_id();
let observed = stable_source_id(&CaptureMode::ExactApplication {
process_id: 42,
stable_id,
})
.unwrap()
.source_id();
assert_eq!(observed, expected);
}
#[test]
fn given_reused_pid_with_different_application_when_verified_then_target_is_rejected() {
let selected =
StableSourceId::new(Platform::Macos, SourceKind::Application, "com.acme.meeting");
let replacement =
StableSourceId::new(Platform::Macos, SourceKind::Application, "com.other.player");
let sources = vec![audited_application(replacement, 42, 200)];
assert_eq!(exact_application_open_audit(&sources, 42, &selected), None);
}
#[test]
fn given_same_pid_and_application_when_verified_then_target_is_retained() {
let selected =
StableSourceId::new(Platform::Macos, SourceKind::Application, "com.acme.meeting");
let sources = vec![audited_application(selected.clone(), 42, 100)];
assert_eq!(
exact_application_open_audit(&sources, 42, &selected),
Some(ExactApplicationOpenAudit {
process_id: 42,
stable_id: selected,
process_start_time_ns: 100,
})
);
}
#[test]
fn given_same_pid_and_application_with_new_creation_when_audited_then_reuse_is_detected() {
let selected =
StableSourceId::new(Platform::Macos, SourceKind::Application, "com.acme.meeting");
let before = ExactApplicationOpenAudit {
process_id: 42,
stable_id: selected.clone(),
process_start_time_ns: 100,
};
let replacement = vec![audited_application(selected.clone(), 42, 200)];
assert_ne!(
exact_application_open_audit(&replacement, 42, &selected),
Some(before)
);
}
#[test]
fn given_missing_creation_time_when_audited_then_exact_open_fails_closed() {
let selected =
StableSourceId::new(Platform::Macos, SourceKind::Application, "com.acme.meeting");
let sources = vec![audited_application(selected.clone(), 42, 0)];
assert_eq!(exact_application_open_audit(&sources, 42, &selected), None);
}
}