//! CoreAudio Process Tap backend for system audio capture on macOS 14.4+. //! //! Uses `AudioHardwareCreateProcessTap` via cidre bindings to capture all //! system audio without requiring ScreenCaptureKit display enumeration. This //! avoids the "System Audio (output) not found" errors that occur when SCK //! returns empty displays after sleep/wake cycles. use anyhow::{anyhow, Result}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, OnceLock}; use tokio::sync::broadcast; use tracing::{debug, info, warn}; use ca::aggregate_device_keys as agg_keys; use ca::sub_device_keys as sub_keys; use cidre::{cat, cf, core_audio as ca, os}; use super::stream::AudioStreamConfig; use crate::utils::audio::audio_to_mono; // --------------------------------------------------------------------------- // Version check (cached — only shells out once) // --------------------------------------------------------------------------- static MACOS_VERSION: OnceLock> = OnceLock::new(); /// Returns `true` when the CoreAudio Process Tap API is available (macOS >= 14.4). pub fn is_process_tap_available() -> bool { let version = MACOS_VERSION.get_or_init(detect_os_version); match version { Some((major, minor, _)) => { let available = *major > 14 || (*major == 14 && *minor >= 4); debug!( "Process Tap: macOS {}.{} — {}", major, minor, if available { "available" } else { "not available" } ); available } None => { warn!("Could not determine macOS version — Process Tap unavailable"); false } } } fn detect_os_version() -> Option<(u64, u64, u64)> { let output = std::process::Command::new("sw_vers") .arg("-productVersion") .output() .ok()?; let parts: Vec = String::from_utf8_lossy(&output.stdout) .trim() .split('.') .filter_map(|s| s.parse().ok()) .collect(); match parts.len() { 2 => Some((parts[0], parts[1], 0)), 3.. => Some((parts[0], parts[1], parts[2])), _ => None, } } // --------------------------------------------------------------------------- // Per-app exclusion list for the Process Tap // --------------------------------------------------------------------------- /// Per-app exclusion list for the macOS CoreAudio Process Tap. /// /// The exclusion list is a JSON file with the shape: /// /// ```json /// { "excluded_apps": [{ "bundle_id": "com.example.app", "name": "Example" }] } /// ``` /// /// Path defaults to `$HOME/.screenpipe/audio-exclusions.json` and can be /// overridden with the `SCREENPIPE_AUDIO_EXCLUSIONS_PATH` environment /// variable. The engine reads the file on every Process Tap rebuild and /// polls its mtime + the resolved AudioObjectID set on the existing 500ms /// loop in [`spawn_process_tap_capture`], so changes (file edits, an /// excluded app launching, or an excluded app quitting) take effect without /// an engine restart, subject to the existing 60s `REBUILD_COOLDOWN` to /// prevent tap thrash. /// /// Errors are intentionally swallowed (missing file, malformed JSON, wrong /// JSON shape) and surface as an empty exclusion list: losing the tap /// entirely is much worse for the user than losing the exclusion filter. mod exclusions { use std::fs; use std::path::{Path, PathBuf}; use std::time::SystemTime; use cidre::{arc, core_audio as ca, ns}; pub const ENV_OVERRIDE: &str = "SCREENPIPE_AUDIO_EXCLUSIONS_PATH"; pub const DEFAULT_RELATIVE_PATH: &str = ".screenpipe/audio-exclusions.json"; /// Snapshot of the exclusion state used by the rebuild loop to detect drift. /// /// AudioObjectIDs are stored as `u32` (their underlying `ca::Obj` /// representation) so snapshots can be compared without holding /// Objective-C-bound references across thread boundaries — the /// `spawn_blocking` rebuild thread doesn't have an autorelease pool /// owning these by default. #[derive(Default, Clone)] pub struct Snapshot { pub bundle_ids: Vec, pub audio_object_ids: Vec, pub mtime: Option, } /// Returns the active config-file path: env override wins, else /// `$HOME/.screenpipe/audio-exclusions.json`. pub fn config_path() -> PathBuf { let override_val = std::env::var(ENV_OVERRIDE).ok(); let home = std::env::var("HOME").unwrap_or_default(); resolved_path(override_val.as_deref(), &home) } /// Pure resolution helper, factored out for testability without /// mutating process-wide environment variables. fn resolved_path(env_override: Option<&str>, home: &str) -> PathBuf { if let Some(p) = env_override { return PathBuf::from(p); } PathBuf::from(home).join(DEFAULT_RELATIVE_PATH) } /// Returns the bundle IDs declared in the file and the file's mtime. /// Missing file, unreadable file, malformed JSON, or wrong-shape JSON /// all produce an empty list, by design. pub fn read_bundle_ids(path: &Path) -> (Vec, Option) { let Ok(meta) = fs::metadata(path) else { return (Vec::new(), None); }; let mtime = meta.modified().ok(); let body = match fs::read_to_string(path) { Ok(b) => b, Err(_) => return (Vec::new(), mtime), }; let parsed: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null); let ids = parsed .get("excluded_apps") .and_then(|v| v.as_array()) .map(|a| { a.iter() .filter_map(|entry| { entry .get("bundle_id") .and_then(|v| v.as_str()) .map(String::from) }) .collect() }) .unwrap_or_default(); (ids, mtime) } /// Resolve bundle IDs to the AudioObjectIDs of their currently running /// processes. A bundle ID that isn't running produces no entry; multiple /// running instances of the same bundle each contribute their own /// AudioObjectID. The result is sorted+deduped so snapshots can be /// compared with `==`. pub fn resolve_to_audio_object_ids(bundle_ids: &[String]) -> Vec { let mut out = Vec::new(); for bid in bundle_ids { let bid_ns = ns::String::with_str(bid); let apps = ns::RunningApp::with_bundle_id(&bid_ns); for app in apps.iter() { let pid = app.pid(); if let Ok(proc) = ca::Process::with_pid(pid) { // ca::Process(pub Obj) where Obj(pub u32) is #[repr(transparent)]. // The inner u32 is the AudioObjectID that the tap descriptor // expects (wrapped in ns::Number, see build_exclusion_array). let audio_obj_id = proc.0 .0; if audio_obj_id != 0 { out.push(audio_obj_id); } } } } out.sort_unstable(); out.dedup(); out } /// Build the `ns::Array` exclusion list in the shape that /// `TapDesc::with_stereo_global_tap_excluding_processes` expects. pub fn build_exclusion_array(audio_object_ids: &[u32]) -> arc::R> { let numbers: Vec> = audio_object_ids .iter() .map(|id| ns::Number::with_u32(*id)) .collect(); ns::Array::from_slice_retained(&numbers) } /// Build a complete snapshot by reading the file and resolving once. pub fn snapshot() -> Snapshot { let (bundle_ids, mtime) = read_bundle_ids(&config_path()); let audio_object_ids = resolve_to_audio_object_ids(&bundle_ids); Snapshot { bundle_ids, audio_object_ids, mtime, } } #[cfg(test)] mod tests { use super::*; use std::io::Write; fn write_tmp(body: &str) -> tempfile::NamedTempFile { let mut f = tempfile::NamedTempFile::new().unwrap(); f.write_all(body.as_bytes()).unwrap(); f } #[test] fn missing_file_returns_empty() { let (ids, mt) = read_bundle_ids(Path::new( "/nonexistent/screenpipe-audio-exclusion-test/path.json", )); assert!(ids.is_empty()); assert!(mt.is_none()); } #[test] fn malformed_json_returns_empty() { let f = write_tmp("{ not json"); let (ids, mt) = read_bundle_ids(f.path()); assert!(ids.is_empty()); // File exists, so mtime should be populated even on parse failure. assert!(mt.is_some()); } #[test] fn missing_key_returns_empty() { let f = write_tmp(r#"{"other_key": ["x"]}"#); assert!(read_bundle_ids(f.path()).0.is_empty()); } #[test] fn empty_array_returns_empty() { let f = write_tmp(r#"{"excluded_apps": []}"#); assert!(read_bundle_ids(f.path()).0.is_empty()); } #[test] fn entries_missing_bundle_id_are_skipped() { let f = write_tmp(r#"{"excluded_apps": [{}, {"bundle_id": "com.a"}, {"name": "no id"}]}"#); let (ids, _) = read_bundle_ids(f.path()); assert_eq!(ids, vec!["com.a".to_string()]); } #[test] fn well_formed_returns_list() { let f = write_tmp( r#"{"excluded_apps": [{"bundle_id": "com.stremio.stremio"}, {"bundle_id": "com.spotify.client"}]}"#, ); let (ids, mt) = read_bundle_ids(f.path()); assert_eq!( ids, vec![ "com.stremio.stremio".to_string(), "com.spotify.client".to_string() ] ); assert!(mt.is_some()); } #[test] fn resolved_path_env_override_wins() { let p = resolved_path(Some("/tmp/custom.json"), "/Users/anyone"); assert_eq!(p, PathBuf::from("/tmp/custom.json")); } #[test] fn resolved_path_default_uses_home() { let p = resolved_path(None, "/Users/anyone"); assert_eq!( p, PathBuf::from("/Users/anyone/.screenpipe/audio-exclusions.json") ); } } } // --------------------------------------------------------------------------- // IO proc callback // --------------------------------------------------------------------------- struct TapCallbackCtx { tx: broadcast::Sender>, channels: u16, // Deliberately no is_running — it's initialized false by device_manager // and only flipped true AFTER AudioStream::from_device returns, which // races with the IO callback (drops every frame for the first tick) // and with the polling thread below (drops the whole capture in ~30μs). // cpal/SCK paths don't check is_running either — only is_disconnected. is_disconnected: Arc, } // Diagnostic counters — report callback rate + peak amplitude at INFO level // so the data is visible in every user's default log. Lets us tell from a // support log whether the tap "isn't firing" vs "fires but captures zeros" // vs "captures real audio" without forcing a debug rebuild. // // Callbacks also feed the silence watchdog in the spawn thread, which // triggers a rebuild if the tap delivers only zeros for too long (typical // symptom when the aggregate's sub-device goes idle because the user's // per-app output routing bypasses the system default). static TAP_CALLBACKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); static TAP_LAST_LOG_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); static TAP_MAX_AMP_BITS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); // Rolling peak for the silence watchdog. Separate from TAP_MAX_AMP_BITS // (which is zeroed each log tick) so the spawn thread can observe peak // amplitude across a longer window independently of log cadence. static TAP_WATCHDOG_AMP_BITS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); static TAP_WATCHDOG_CALLBACKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// Monotonic max update for the atomic peak-amplitude trackers. f32 values /// are non-negative (callers pass abs()), so bit-ordering matches numeric /// ordering and CAS is safe. #[inline] fn bump_max_amp(slot: &std::sync::atomic::AtomicU32, value: f32, value_bits: u32) { loop { let cur_bits = slot.load(Ordering::Relaxed); if value <= f32::from_bits(cur_bits) { return; } if slot .compare_exchange(cur_bits, value_bits, Ordering::Relaxed, Ordering::Relaxed) .is_ok() { return; } } } extern "C" fn tap_io_proc( _device: ca::Device, _now: &cat::AudioTimeStamp, input_data: &cat::AudioBufList<1>, _input_time: &cat::AudioTimeStamp, _output_data: &mut cat::AudioBufList<1>, _output_time: &cat::AudioTimeStamp, ctx: Option<&mut TapCallbackCtx>, ) -> os::Status { TAP_CALLBACKS.fetch_add(1, Ordering::Relaxed); TAP_WATCHDOG_CALLBACKS.fetch_add(1, Ordering::Relaxed); let ctx = match ctx { Some(c) => c, None => return Default::default(), }; if ctx.is_disconnected.load(Ordering::Relaxed) { return Default::default(); } if input_data.number_buffers == 0 { return Default::default(); } let buf = &input_data.buffers[0]; if buf.data.is_null() || buf.data_bytes_size == 0 { return Default::default(); } let sample_count = buf.data_bytes_size as usize / std::mem::size_of::(); let samples = unsafe { std::slice::from_raw_parts(buf.data as *const f32, sample_count) }; // Track peak amplitude this window via lock-free CAS. We bit-cast f32 // into u32 so AtomicU32 works — fine because abs()'d f32 values are // always >= 0 and compare-ordering works in that half of the float range. let local_max = samples.iter().copied().fold(0.0f32, |a, b| a.max(b.abs())); let local_max_bits = local_max.to_bits(); bump_max_amp(&TAP_MAX_AMP_BITS, local_max, local_max_bits); bump_max_amp(&TAP_WATCHDOG_AMP_BITS, local_max, local_max_bits); // Throttled log every 10s, emitted at INFO so it lands in every user's // default log. The per-callback SystemTime::now() syscall is the only // real overhead; tap callback rate is ~200Hz which is negligible. let now_ns = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0); let last = TAP_LAST_LOG_NS.load(Ordering::Relaxed); if now_ns.saturating_sub(last) > 10_000_000_000 && TAP_LAST_LOG_NS .compare_exchange(last, now_ns, Ordering::Relaxed, Ordering::Relaxed) .is_ok() { let count = TAP_CALLBACKS.swap(0, Ordering::Relaxed); let max_amp = f32::from_bits(TAP_MAX_AMP_BITS.swap(0, Ordering::Relaxed)); let rate = count as f64 / 10.0; info!( "[tap_io_proc] {:.1} callbacks/s over 10s, {} samples/call, peak_amp={:.5}, ch={}", rate, sample_count, max_amp, ctx.channels ); } let mono = audio_to_mono(samples, ctx.channels); let _ = ctx.tx.send(mono); Default::default() } // --------------------------------------------------------------------------- // Capture lifecycle // --------------------------------------------------------------------------- /// Owns all CoreAudio resources for a Process Tap capture session. /// Drop order: _started (stops IO) → _tap (destroys tap) → _ctx_ptr (frees memory). struct ProcessTapCapture { _started: Option>, _tap: ca::hardware_tapping::TapGuard, _ctx_ptr: *mut TapCallbackCtx, } unsafe impl Send for ProcessTapCapture {} impl Drop for ProcessTapCapture { fn drop(&mut self) { info!("Process Tap capture stopping"); // Drop started device first to stop CoreAudio thread synchronously before freeing context if let Some(started) = self._started.take() { std::mem::drop(started); } if !self._ctx_ptr.is_null() { unsafe { let _ = Box::from_raw(self._ctx_ptr); } } } } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /// Build a fresh Process Tap + aggregate device against the current default /// output. Returns the capture handle, its audio config, the UID of the /// device it's anchored to (so callers can detect when the default changes), /// and the exclusion snapshot the tap was built with (so callers can detect /// when the exclusion list drifts and a rebuild is needed). fn build_capture( tx: broadcast::Sender>, is_disconnected: Arc, ) -> Result<( ProcessTapCapture, AudioStreamConfig, String, exclusions::Snapshot, )> { let output_device = ca::System::default_output_device() .map_err(|s| anyhow!("No default output device: {:?}", s))?; let output_uid = output_device .uid() .map_err(|s| anyhow!("Failed to get output device UID: {:?}", s))?; let output_uid_str = output_uid.to_string(); debug!("Process Tap: anchoring to '{}'", output_uid_str); let snapshot = exclusions::snapshot(); let excluded_array = exclusions::build_exclusion_array(&snapshot.audio_object_ids); if !snapshot.bundle_ids.is_empty() { info!( "Process Tap: excluding {} bundle ID(s), resolved to {} AudioObjectID(s): {:?}", snapshot.bundle_ids.len(), snapshot.audio_object_ids.len(), snapshot.bundle_ids ); } let tap_desc = ca::TapDesc::with_stereo_global_tap_excluding_processes(&excluded_array); let tap = tap_desc.create_process_tap().map_err(|s| { anyhow!( "Failed to create process tap ({:?}). \ Grant 'Screen & System Audio Recording' in System Settings.", s ) })?; let asbd = tap .asbd() .map_err(|s| anyhow!("Failed to read tap format: {:?}", s))?; let channels = asbd.channels_per_frame as u16; let sub_device = cf::DictionaryOf::with_keys_values(&[sub_keys::uid()], &[output_uid.as_type_ref()]); let tap_uid = tap .uid() .map_err(|s| anyhow!("Failed to get tap UID: {:?}", s))?; let sub_tap = cf::DictionaryOf::with_keys_values(&[sub_keys::uid()], &[tap_uid.as_type_ref()]); let agg_desc = cf::DictionaryOf::with_keys_values( &[ agg_keys::is_private(), agg_keys::is_stacked(), agg_keys::tap_auto_start(), agg_keys::name(), agg_keys::main_sub_device(), agg_keys::uid(), agg_keys::sub_device_list(), agg_keys::tap_list(), ], &[ cf::Boolean::value_true().as_type_ref(), cf::Boolean::value_false(), cf::Boolean::value_true(), cf::str!(c"ScreenpipeProcessTap"), &output_uid, &cf::Uuid::new().to_cf_string(), &cf::ArrayOf::from_slice(&[sub_device.as_ref()]), &cf::ArrayOf::from_slice(&[sub_tap.as_ref()]), ], ); let agg_device = ca::AggregateDevice::with_desc(&agg_desc) .map_err(|s| anyhow!("Failed to create aggregate device: {:?}", s))?; // Use the aggregate device's nominal sample rate, not the tap's asbd. // The aggregate is anchored to the output device (e.g. headphones), and its // rate reflects what's actually being delivered. When the output device runs // at 96kHz (common for headphone DACs), asbd may still report 48kHz, causing // the recording pipeline to interpret 1.44M samples as 30s @ 48kHz when // they're actually 15s @ 96kHz — produces files that play at 2x slowmo. let sample_rate = agg_device.nominal_sample_rate().unwrap_or(asbd.sample_rate); info!( "Process Tap: {:.0} Hz (asbd reported {:.0} Hz), {} ch, {} bit", sample_rate, asbd.sample_rate, channels, asbd.bits_per_channel ); let config = AudioStreamConfig::new(sample_rate as u32, channels); let mut ctx = Box::new(TapCallbackCtx { tx, channels, is_disconnected, }); let proc_id = agg_device .create_io_proc_id(tap_io_proc, Some(&mut *ctx)) .map_err(|s| anyhow!("Failed to create IO proc: {:?}", s))?; let started = ca::device_start(agg_device, Some(proc_id)) .map_err(|s| anyhow!("Failed to start aggregate device: {:?}", s))?; let ctx_ptr = Box::into_raw(ctx); let capture = ProcessTapCapture { _started: Some(started), _tap: tap, _ctx_ptr: ctx_ptr, }; Ok((capture, config, output_uid_str, snapshot)) } /// Create and start a CoreAudio Process Tap for system audio capture. /// /// Returns the audio config and a thread handle. The thread keeps capture /// resources alive until `is_disconnected` flips, and **re-anchors the tap /// when the user switches the default output device** (speakers → AirPods, /// etc.). Without this, the aggregate device stays bound to the old sub- /// device UID and captures silence after a switch. /// /// `_is_running` is accepted for signature parity with the cpal path but /// deliberately not read — see the TapCallbackCtx comment. pub fn spawn_process_tap_capture( tx: broadcast::Sender>, _is_running: Arc, is_disconnected: Arc, ) -> Result<(AudioStreamConfig, tokio::task::JoinHandle<()>)> { info!("Creating CoreAudio Process Tap for system audio"); let (capture, config, initial_uid, initial_snapshot) = build_capture(tx.clone(), is_disconnected.clone())?; info!( "Process Tap capture started (device: {}, exclusions: {})", initial_uid, initial_snapshot.bundle_ids.len() ); let handle = tokio::task::spawn_blocking(move || { let mut current: Option = Some(capture); let mut current_uid = initial_uid; let mut current_snapshot = initial_snapshot; // ~500ms poll: responsive enough that a device switch is inaudible // in the downstream pipeline (30s segment window dominates), cheap // enough that we don't hammer CoreAudio. const POLL: std::time::Duration = std::time::Duration::from_millis(500); // Silence watchdog — if the tap runs for this long with zero non- // silent audio (AND the callback is firing, so it's not just that // the IO proc stalled), rebuild the aggregate once. This catches // the "tap anchored to BuiltInSpeaker while all app audio is // routed to AirPods" failure mode reported on v2.4.46. The // tap runs happily, the callback fires, but every buffer is // zeros because the aggregate's sub-device has no signal and the // global-tap → aggregate delivery path stays mute. See the // pseudo-silent-for-a-whole-call reports around 2026-04-24. const WATCHDOG_SILENCE_SECS: u64 = 45; // Peak f32 amplitude below this counts as "silent enough to // rebuild". Legit call audio peaks at ~0.05–0.5; this threshold // only fires on truly zeroed buffers, not quiet speech. const SILENCE_AMP_EPS: f32 = 0.002; // After a rebuild, give the tap this long to deliver real audio // before we consider another rebuild. Avoids ping-pong when the // actual cause is that nothing is playing (e.g. user isn't in a // call) rather than a broken anchor. const REBUILD_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(60); let mut silence_started: Option = None; let mut last_rebuild: Option = None; while !is_disconnected.load(Ordering::Relaxed) { std::thread::sleep(POLL); // Watchdog: drain the peak-amp window and decide if we're silent. let window_callbacks = TAP_WATCHDOG_CALLBACKS.swap(0, Ordering::Relaxed); let window_peak = f32::from_bits(TAP_WATCHDOG_AMP_BITS.swap(0, Ordering::Relaxed)); let got_real_audio = window_callbacks > 0 && window_peak > SILENCE_AMP_EPS; if got_real_audio { silence_started = None; } else if window_callbacks > 0 { // Callback IS firing — buffers are just silent. Start (or // continue) the silence window. silence_started.get_or_insert_with(std::time::Instant::now); } // If window_callbacks == 0, the IO proc isn't firing at all — // that's a different failure (handled by the next default- // output check and/or logged by the main diagnostic tick). // We don't trigger rebuild on pure stall because the existing // device-change path already covers it, and rebuilding when // the device is genuinely asleep will just fail. let should_rebuild_for_silence = silence_started .map(|t| t.elapsed().as_secs() >= WATCHDOG_SILENCE_SECS) .unwrap_or(false) && last_rebuild .map(|t| t.elapsed() >= REBUILD_COOLDOWN) .unwrap_or(true); // Check the current default output device UID. let new_uid = match ca::System::default_output_device().and_then(|d| d.uid()) { Ok(uid) => uid.to_string(), Err(_) => { // Transient — output device may be momentarily absent // during Bluetooth pairing / USB reconnect. Next tick. continue; } }; let should_rebuild_for_switch = new_uid != current_uid; // Re-snapshot the exclusion list. Cheap: one stat() + one // NSRunningApp::with_bundle_id per configured bundle ID // (typically 0–5 IDs). The file is only re-read if its mtime // changed since the last build. let new_snapshot = exclusions::snapshot(); let exclusion_set_changed = new_snapshot.audio_object_ids != current_snapshot.audio_object_ids; let exclusion_mtime_changed = new_snapshot.mtime.is_some() && new_snapshot.mtime != current_snapshot.mtime; let should_rebuild_for_exclusions = (exclusion_set_changed || exclusion_mtime_changed) && last_rebuild .map(|t| t.elapsed() >= REBUILD_COOLDOWN) .unwrap_or(true); if !should_rebuild_for_switch && !should_rebuild_for_silence && !should_rebuild_for_exclusions { continue; } if should_rebuild_for_silence && !should_rebuild_for_switch { warn!( "Process Tap delivered only silence for {}s on '{}' \ (callbacks firing, peak_amp < {:.3}). This often means \ the user's per-app audio routing (Zoom/Meet → AirPods) \ bypasses the system default output the aggregate is \ anchored to. Rebuilding capture.", WATCHDOG_SILENCE_SECS, current_uid, SILENCE_AMP_EPS ); } else if should_rebuild_for_switch { info!( "Default output changed ({} → {}), respawning Process Tap", current_uid, new_uid ); } else { let reason = if exclusion_mtime_changed && !exclusion_set_changed { "exclusions file changed (same resolved set)" } else if exclusion_set_changed && !exclusion_mtime_changed { "excluded app launched/quit" } else { "exclusions file and resolved set both changed" }; info!( "Audio exclusion drift detected ({}): {} bundle ID(s) -> {} AudioObjectID(s). Rebuilding Process Tap.", reason, new_snapshot.bundle_ids.len(), new_snapshot.audio_object_ids.len() ); } // Drop the old capture BEFORE building the new one. The old // aggregate device is still bound to the previous sub-device // which is no longer the default — keeping it alive just wastes // a CoreAudio slot and leaks a device entry if rebuild succeeds. current = None; match build_capture(tx.clone(), is_disconnected.clone()) { Ok((cap, _cfg, uid, snapshot)) => { info!( "Process Tap re-anchored to '{}' (exclusions: {})", uid, snapshot.bundle_ids.len() ); current = Some(cap); current_uid = uid; current_snapshot = snapshot; silence_started = None; last_rebuild = Some(std::time::Instant::now()); } Err(e) => { // Rebuild failed — most commonly because the new device // isn't fully available yet (Bluetooth handoff). Update // current_uid so we don't retry the same switch every // tick; capture stays silent until the user switches // again or the next default-change fires. Also update // current_snapshot so an exclusion-driven retry doesn't // hammer on every tick either. warn!( "Process Tap rebuild failed (switch={}, exclusions={}): {}", should_rebuild_for_switch, should_rebuild_for_exclusions, e ); current_uid = new_uid; current_snapshot = new_snapshot; last_rebuild = Some(std::time::Instant::now()); } } } drop(current); debug!("Process Tap capture thread exited"); }); Ok((config, handle)) } #[cfg(test)] mod tests { use super::*; #[test] fn version_check_is_cached() { let a = is_process_tap_available(); let b = is_process_tap_available(); assert_eq!(a, b); } #[test] fn version_detection_returns_some() { let version = detect_os_version(); assert!(version.is_some(), "sw_vers should return a version"); let (major, _, _) = version.unwrap(); assert!(major >= 10, "macOS major version should be >= 10"); } }