snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use crossbeam_channel::{Sender, TrySendError};
use napi::{
bindgen_prelude::Float32Array,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
/// Internal callback abstraction so audio taps can target JS or native
/// pipelines.
#[derive(Clone)]
pub enum AudioCallback {
Js(Arc<ThreadsafeFunction<Float32Array, ()>>),
Channel {
sender: Sender<Vec<f32>>,
overflow_count: Arc<AtomicU64>,
},
}
impl AudioCallback {
pub fn call(&self, samples: Vec<f32>) {
match self {
Self::Js(func) => {
// Non-blocking call into JS; errors are ignored to avoid blocking the
// audio thread.
let _ = func.call(Ok(samples.into()), ThreadsafeFunctionCallMode::NonBlocking);
}
Self::Channel { sender, overflow_count } => match sender.try_send(samples) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
overflow_count.fetch_add(1, Ordering::Relaxed);
}
Err(TrySendError::Disconnected(_)) => {}
},
}
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use crossbeam_channel::bounded;
use super::AudioCallback;
#[test]
fn channel_overflow_only_increments_the_counter() {
let (sender, _rx) = bounded(1);
let overflow_count = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let callback = AudioCallback::Channel {
sender: sender.clone(),
overflow_count: overflow_count.clone(),
};
sender.send(vec![0.0]).unwrap();
callback.call(vec![1.0]);
callback.call(vec![2.0]);
assert_eq!(overflow_count.load(Ordering::Relaxed), 2);
}
}

View File

@@ -0,0 +1,175 @@
use std::{io::Cursor, path::Path};
use napi::{
Task,
bindgen_prelude::{AbortSignal, AsyncTask, Float32Array, Result, Status, Uint8Array},
};
use napi_derive::napi;
use rubato::{Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType};
use symphonia::core::{
audio::{AudioBuffer, Signal},
codecs::DecoderOptions,
errors::Error,
formats::FormatOptions,
io::MediaSourceStream,
meta::MetadataOptions,
probe::Hint,
};
fn decode<B: AsRef<[u8]> + Send + Sync + 'static>(
buf: B,
dest_sample_rate: Option<u32>,
filename: Option<&str>,
) -> std::result::Result<Vec<f32>, Error> {
// Create the media source
let mss = MediaSourceStream::new(Box::new(Cursor::new(buf)), Default::default());
// Create a probe hint using the file extension
let mut hint = Hint::new();
if let Some(ext) = filename.and_then(|filename| Path::new(filename).extension().and_then(|ext| ext.to_str())) {
hint.with_extension(ext);
}
let format_opts = FormatOptions {
enable_gapless: true,
..Default::default()
};
let metadata_opts = MetadataOptions::default();
let decoder_opts = DecoderOptions::default();
let probed = symphonia::default::get_probe().format(&hint, mss, &format_opts, &metadata_opts)?;
let mut format = probed.format;
let track = format
.default_track()
.ok_or(Error::Unsupported("No default track found"))?;
let totol_samples = track
.codec_params
.n_frames
.ok_or(Error::Unsupported("No duration found"))?;
let sample_rate = track
.codec_params
.sample_rate
.ok_or(Error::Unsupported("No samplerate found"))?;
let mut decoder = symphonia::default::get_codecs().make(&track.codec_params, &decoder_opts)?;
let mut output: Vec<f32> = Vec::with_capacity(totol_samples as usize);
// Decode loop
while let Ok(packet) = format.next_packet() {
let decoded = decoder.decode(&packet)?;
let spec = decoded.spec();
let mut audio_buf: AudioBuffer<f32> = AudioBuffer::new(decoded.capacity() as u64, *spec);
decoded.convert(&mut audio_buf);
if spec.channels.count() > 1 {
// Mix all channels into mono
for i in 0..audio_buf.chan(0).len() {
let mut sample_sum = 0.0;
for ch in 0..spec.channels.count() {
sample_sum += audio_buf.chan(ch)[i];
}
output.push(sample_sum / spec.channels.count() as f32);
}
} else {
output.extend_from_slice(audio_buf.chan(0));
}
}
let Some(dest_sample_rate) = dest_sample_rate else {
return Ok(output);
};
if sample_rate != dest_sample_rate {
// Calculate parameters for resampling
let params = SincInterpolationParameters {
sinc_len: 256,
f_cutoff: 0.95,
interpolation: SincInterpolationType::Linear,
oversampling_factor: 256,
window: rubato::WindowFunction::BlackmanHarris2,
};
let mut resampler = SincFixedIn::<f32>::new(
dest_sample_rate as f64 / sample_rate as f64,
2.0,
params,
output.len(),
1,
)
.map_err(|_| Error::Unsupported("Failed to create resampler"))?;
let waves_in = vec![output];
let mut waves_out = resampler
.process(&waves_in, None)
.map_err(|_| Error::Unsupported("Failed to run resampler"))?;
output = waves_out.pop().ok_or(Error::Unsupported("No resampled output found"))?;
}
Ok(output)
}
#[napi]
/// Decode audio file into a Float32Array
pub fn decode_audio_sync(
buf: Uint8Array,
dest_sample_rate: Option<u32>,
filename: Option<String>,
) -> Result<Float32Array> {
decode(buf, dest_sample_rate, filename.as_deref())
.map(Float32Array::new)
.map_err(|e| {
napi::Error::new(
Status::InvalidArg,
format!("Decode audio into Float32Array failed: {e}"),
)
})
}
pub struct DecodeAudioTask {
buf: Uint8Array,
dest_sample_rate: Option<u32>,
filename: Option<String>,
}
#[napi]
impl Task for DecodeAudioTask {
type Output = Vec<f32>;
type JsValue = Float32Array;
fn compute(&mut self) -> Result<Self::Output> {
decode(
std::mem::replace(&mut self.buf, Uint8Array::new(vec![])),
self.dest_sample_rate,
self.filename.as_deref(),
)
.map_err(|e| {
napi::Error::new(
Status::InvalidArg,
format!("Decode audio into Float32Array failed: {e}"),
)
})
}
fn resolve(&mut self, _: napi::Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(Float32Array::new(output))
}
}
#[napi]
pub fn decode_audio(
buf: Uint8Array,
dest_sample_rate: Option<u32>,
filename: Option<String>,
signal: Option<AbortSignal>,
) -> AsyncTask<DecodeAudioTask> {
AsyncTask::with_optional_signal(
DecodeAudioTask {
buf,
dest_sample_rate,
filename,
},
signal,
)
}

View File

@@ -0,0 +1,13 @@
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(target_os = "macos")]
pub(crate) use macos::*;
#[cfg(target_os = "windows")]
pub mod windows;
#[cfg(target_os = "windows")]
pub use windows::*;
pub mod audio_callback;
pub mod audio_decoder;
pub mod recording;

View File

@@ -0,0 +1,154 @@
use napi::bindgen_prelude::{Buffer, Result};
use objc2::{
Encode, Encoding, msg_send,
rc::{Retained, autoreleasepool},
runtime::{AnyClass, AnyObject},
};
use objc2_foundation::NSString;
#[repr(C)]
struct CGSize {
width: f64,
height: f64,
}
#[repr(C)]
struct CGPoint {
x: f64,
y: f64,
}
#[repr(C)]
struct CGRect {
origin: CGPoint,
size: CGSize,
}
unsafe impl Encode for CGSize {
const ENCODING: Encoding = Encoding::Struct("CGSize", &[f64::ENCODING, f64::ENCODING]);
}
unsafe impl Encode for CGPoint {
const ENCODING: Encoding = Encoding::Struct("CGPoint", &[f64::ENCODING, f64::ENCODING]);
}
unsafe impl Encode for CGRect {
const ENCODING: Encoding = Encoding::Struct("CGRect", &[<CGPoint>::ENCODING, <CGSize>::ENCODING]);
}
pub(super) fn application_icon(process_id: i32) -> Result<Buffer> {
autoreleasepool(|_| {
let running_app_class = match AnyClass::get(c"NSRunningApplication") {
Some(class) => class,
None => {
return Ok(Buffer::from(Vec::<u8>::new()));
}
};
let running_app: *mut AnyObject = unsafe {
msg_send![
running_app_class,
runningApplicationWithProcessIdentifier: process_id
]
};
if running_app.is_null() {
return Ok(Buffer::from(Vec::<u8>::new()));
}
unsafe {
let icon: *mut AnyObject = msg_send![running_app, icon];
if icon.is_null() {
return Ok(Buffer::from(Vec::<u8>::new()));
}
let nsimage_class = match AnyClass::get(c"NSImage") {
Some(class) => class,
None => return Ok(Buffer::from(Vec::<u8>::new())),
};
let resized_image: *mut AnyObject = msg_send![nsimage_class, alloc];
if resized_image.is_null() {
return Ok(Buffer::from(Vec::<u8>::new()));
}
let resized_image: *mut AnyObject = msg_send![resized_image, initWithSize: CGSize { width: 64.0, height: 64.0 }];
let Some(resized_image) = Retained::from_raw(resized_image) else {
return Ok(Buffer::from(Vec::<u8>::new()));
};
let _: () = msg_send![&*resized_image, lockFocus];
let draw_rect = CGRect {
origin: CGPoint { x: 0.0, y: 0.0 },
size: CGSize {
width: 64.0,
height: 64.0,
},
};
let from_rect = CGRect {
origin: CGPoint { x: 0.0, y: 0.0 },
size: CGSize {
width: 0.0,
height: 0.0,
},
};
let _: () = msg_send![icon, drawInRect: draw_rect, fromRect: from_rect, operation: 2u64, fraction: 1.0];
let _: () = msg_send![&*resized_image, unlockFocus];
let tiff_data: *mut AnyObject = msg_send![&*resized_image, TIFFRepresentation];
if tiff_data.is_null() {
return Ok(Buffer::from(Vec::<u8>::new()));
}
let bitmap_class = match AnyClass::get(c"NSBitmapImageRep") {
Some(class) => class,
None => return Ok(Buffer::from(Vec::<u8>::new())),
};
let bitmap: *mut AnyObject = msg_send![bitmap_class, imageRepWithData: tiff_data];
if bitmap.is_null() {
return Ok(Buffer::from(Vec::<u8>::new()));
}
let dict_class = match AnyClass::get(c"NSMutableDictionary") {
Some(class) => class,
None => return Ok(Buffer::from(Vec::<u8>::new())),
};
let properties: *mut AnyObject = msg_send![dict_class, dictionary];
if properties.is_null() {
return Ok(Buffer::from(Vec::<u8>::new()));
}
let compression_key = NSString::from_str("NSImageCompressionFactor");
let number_class = match AnyClass::get(c"NSNumber") {
Some(class) => class,
None => return Ok(Buffer::from(Vec::<u8>::new())),
};
let compression_value: *mut AnyObject = msg_send![number_class, numberWithDouble: 0.8];
if compression_value.is_null() {
return Ok(Buffer::from(Vec::<u8>::new()));
}
let _: () = msg_send![properties, setObject: compression_value, forKey: &*compression_key];
let png_data: *mut AnyObject = msg_send![bitmap, representationUsingType: 4u64, properties: properties]; // 4 = PNG
if png_data.is_null() {
return Ok(Buffer::from(Vec::<u8>::new()));
}
let bytes: *const libc::c_void = msg_send![png_data, bytes];
let length: usize = msg_send![png_data, length];
if bytes.is_null() {
return Ok(Buffer::from(Vec::<u8>::new()));
}
let data = std::slice::from_raw_parts(bytes as *const u8, length).to_vec();
Ok(Buffer::from(data))
}
})
}

View File

@@ -0,0 +1,400 @@
use std::{cmp::Ordering, ffi::c_void};
use coreaudio::sys::kAudioHardwareBadStreamError;
use objc2::{Encode, Encoding, RefEncode};
use crate::{error::CoreAudioError, utils::process_audio_frame};
pub const AUDIO_MIX_OUTPUT_WEIGHT: f32 = 0.75;
/// Mix audio samples using scalar operations (no SIMD)
///
/// # Arguments
/// * `input_samples` - Samples from the input stream
/// * `output_samples` - Samples from the output stream
/// * `mixed_samples` - Buffer to store the result (must be pre-allocated)
/// * `start_index` - Starting index in the buffers
/// * `end_index` - Ending index in the buffers (exclusive)
pub fn mix_audio_samples_scalar(
input_samples: &[f32],
output_samples: &[f32],
mixed_samples: &mut [f32],
start_index: usize,
end_index: usize,
) {
// performance downgraded 4x if apply suggestion from this lint rule
#[allow(clippy::needless_range_loop)]
for sample_index in start_index..end_index {
let sample_in = input_samples.get(sample_index).unwrap_or(&0.0);
let sample_out = output_samples.get(sample_index).unwrap_or(&0.0);
mixed_samples[sample_index] = sample_in + sample_out * AUDIO_MIX_OUTPUT_WEIGHT;
}
}
/// Mix audio samples from input and output streams with specified weights
/// Uses NEON SIMD acceleration on supported platforms
///
/// # Arguments
/// * `input_samples` - Samples from the input stream
/// * `output_samples` - Samples from the output stream
///
/// # Returns
/// A vector of mixed audio samples
pub fn mix_audio_samples(input_samples: &[f32], output_samples: &[f32]) -> Vec<f32> {
let mixed_samples_length = input_samples.len();
let mut mixed_samples = vec![0.0; mixed_samples_length];
// For very small arrays, use scalar implementation
if mixed_samples_length < 16 {
mix_audio_samples_scalar(
input_samples,
output_samples,
&mut mixed_samples,
0,
mixed_samples_length,
);
return mixed_samples;
}
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
unsafe {
use std::arch::aarch64::{vdupq_n_f32, vld1q_f32, vmlaq_f32, vst1q_f32};
let output_weight_vec = vdupq_n_f32(AUDIO_MIX_OUTPUT_WEIGHT);
// Process the common length where both arrays have data
let common_length = input_samples.len();
// Main SIMD loop - process each block of 4 samples
let input_ptr = input_samples.as_ptr();
let output_ptr = output_samples.as_ptr();
let result_ptr = mixed_samples.as_mut_ptr();
let mut offset: usize = 0;
let mut remaining_offset: Option<usize> = None;
// Process 16 samples at a time (4 SIMD vectors)
while offset < common_length {
// Load 4 vectors of 4 floats each
let in_vec1 = vld1q_f32(input_ptr.add(offset));
let out_vec1 = vld1q_f32(output_ptr.add(offset));
let in_vec2 = vld1q_f32(input_ptr.add(offset + 4));
let out_vec2 = vld1q_f32(output_ptr.add(offset + 4));
let in_vec3 = vld1q_f32(input_ptr.add(offset + 8));
let out_vec3 = vld1q_f32(output_ptr.add(offset + 8));
let in_vec4 = vld1q_f32(input_ptr.add(offset + 12));
let out_vec4 = vld1q_f32(output_ptr.add(offset + 12));
// Using fused multiply-add: (a * b) + c in one operation
// First multiply input by weight
let result1 = vmlaq_f32(in_vec1, out_vec1, output_weight_vec);
let result2 = vmlaq_f32(in_vec2, out_vec2, output_weight_vec);
let result3 = vmlaq_f32(in_vec3, out_vec3, output_weight_vec);
let result4 = vmlaq_f32(in_vec4, out_vec4, output_weight_vec);
// Store results
vst1q_f32(result_ptr.add(offset), result1);
vst1q_f32(result_ptr.add(offset + 4), result2);
vst1q_f32(result_ptr.add(offset + 8), result3);
vst1q_f32(result_ptr.add(offset + 12), result4);
offset += 16;
// accept clippy lint suggestion would downgrade the performance by 15%
#[allow(clippy::comparison_chain)]
// fast path for aligned length
if offset == common_length {
break;
} else if offset > common_length {
remaining_offset = Some(offset - 16);
} else {
let remaining = common_length - offset;
if remaining < 16 {
remaining_offset = Some(offset);
break;
}
}
}
if let Some(remaining_offset) = remaining_offset {
mix_audio_samples_scalar(
input_samples,
output_samples,
&mut mixed_samples,
remaining_offset,
common_length,
);
}
}
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))]
{
// Fallback for non-ARM architectures
mix_audio_samples_scalar(
input_samples,
output_samples,
&mut mixed_samples,
0,
mixed_samples_length,
);
}
mixed_samples
}
/// [Apple's documentation](https://developer.apple.com/documentation/coreaudiotypes/audiobuffer?language=objc)
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq)]
#[allow(non_snake_case)]
pub struct AudioBuffer {
pub mNumberChannels: u32,
pub mDataByteSize: u32,
pub mData: *mut c_void,
}
unsafe impl Encode for AudioBuffer {
const ENCODING: Encoding = Encoding::Struct(
"AudioBuffer",
&[<u32>::ENCODING, <u32>::ENCODING, <*mut c_void>::ENCODING],
);
}
unsafe impl RefEncode for AudioBuffer {
const ENCODING_REF: Encoding = Encoding::Pointer(&Self::ENCODING);
}
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq)]
#[allow(non_snake_case)]
pub struct AudioBufferList {
pub mNumberBuffers: u32,
pub mBuffers: [AudioBuffer; 2],
}
unsafe impl Encode for AudioBufferList {
const ENCODING: Encoding = Encoding::Struct("AudioBufferList", &[<u32>::ENCODING, <[AudioBuffer; 1]>::ENCODING]);
}
unsafe impl RefEncode for AudioBufferList {
const ENCODING_REF: Encoding = Encoding::Pointer(&Self::ENCODING);
}
/// Represent the audio buffer contains input and output PCM buffers
#[repr(transparent)]
pub struct InputAndOutputAudioBufferList(pub AudioBufferList);
impl InputAndOutputAudioBufferList {
/// # Safety
///
/// The caller must ensure that the input data is a valid AudioBufferList
pub unsafe fn from_raw(in_input_data: *mut c_void) -> Result<Self, i32> {
let buffer_list: AudioBufferList = unsafe { *in_input_data.cast() };
if buffer_list.mNumberBuffers != 2 {
return Err(kAudioHardwareBadStreamError as i32);
}
Ok(Self(buffer_list))
}
pub fn mix_input_and_output(
&self,
target_sample_rate: f64,
input_sample_rate: f64,
output_sample_rate: f64,
) -> Result<Vec<f32>, CoreAudioError> {
let mut mixed_samples = Vec::new();
// Directly access buffers from the list
let [input_buffer, output_buffer] = self.0.mBuffers;
if let Some(processed_input) = process_audio_frame(
input_buffer.mData,
input_buffer.mDataByteSize,
input_buffer.mNumberChannels,
input_sample_rate,
target_sample_rate,
) {
mixed_samples = processed_input;
}
if let Some(processed_output) = process_audio_frame(
output_buffer.mData,
output_buffer.mDataByteSize,
output_buffer.mNumberChannels,
output_sample_rate,
target_sample_rate,
) {
if mixed_samples.is_empty() {
mixed_samples = processed_output;
} else {
let len1 = mixed_samples.len();
let len2 = processed_output.len();
match len1.cmp(&len2) {
Ordering::Less => {
mixed_samples.resize(len2, 0.0);
}
Ordering::Greater => {
let mut padded_output = processed_output;
padded_output.resize(len1, 0.0);
for (sample1, sample2) in mixed_samples.iter_mut().zip(padded_output.iter()) {
*sample1 = (*sample1 + *sample2) / 2.0;
}
return Ok(mixed_samples);
}
_ => {}
}
for (sample1, sample2) in mixed_samples.iter_mut().zip(processed_output.iter()) {
*sample1 = (*sample1 + *sample2) / 2.0;
}
}
}
Ok(mixed_samples)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mix_audio_samples_empty() {
let input: Vec<f32> = vec![];
let output: Vec<f32> = vec![];
let mixed = mix_audio_samples(&input, &output);
assert_eq!(mixed.len(), 0);
}
#[test]
fn test_mix_audio_samples_equal_length() {
let input = vec![0.1, 0.2, 0.3, 0.4, 0.5];
let output = vec![0.5, 0.4, 0.3, 0.2, 0.1];
let mixed = mix_audio_samples(&input, &output);
assert_eq!(mixed.len(), 5);
// Verify calculations: (input + output * 0.75)
let expected = [
(0.1 + 0.5 * AUDIO_MIX_OUTPUT_WEIGHT),
(0.2 + 0.4 * AUDIO_MIX_OUTPUT_WEIGHT),
(0.3 + 0.3 * AUDIO_MIX_OUTPUT_WEIGHT),
(0.4 + 0.2 * AUDIO_MIX_OUTPUT_WEIGHT),
(0.5 + 0.1 * AUDIO_MIX_OUTPUT_WEIGHT),
];
for i in 0..mixed.len() {
assert!(
(mixed[i] - expected[i]).abs() < 1e-6,
"Mismatch at index {}: expected {}, got {}",
i,
expected[i],
mixed[i]
);
}
}
#[test]
fn test_mix_audio_samples_input_longer() {
let input = vec![0.1, 0.2, 0.3, 0.4, 0.5];
let output = vec![0.5, 0.4, 0.3];
let mixed = mix_audio_samples(&input, &output);
assert_eq!(mixed.len(), 5);
// Verify calculations
let expected = [
(0.1 + 0.5 * AUDIO_MIX_OUTPUT_WEIGHT),
(0.2 + 0.4 * AUDIO_MIX_OUTPUT_WEIGHT),
(0.3 + 0.3 * AUDIO_MIX_OUTPUT_WEIGHT),
(0.4 + 0.0 * AUDIO_MIX_OUTPUT_WEIGHT),
(0.5 + 0.0 * AUDIO_MIX_OUTPUT_WEIGHT),
];
for i in 0..mixed.len() {
assert!(
(mixed[i] - expected[i]).abs() < 1e-6,
"Mismatch at index {}: expected {}, got {}",
i,
expected[i],
mixed[i]
);
}
}
#[test]
fn test_mix_audio_samples_custom_weights() {
// Note: We're using the constant weights so we can't really test custom
// values directly
let input = vec![0.1, 0.2, 0.3];
let output = vec![0.5, 0.4, 0.3];
let mixed = mix_audio_samples(&input, &output);
// Calculate expected values based on the constants
let expected = [
(0.1 + 0.5 * AUDIO_MIX_OUTPUT_WEIGHT),
(0.2 + 0.4 * AUDIO_MIX_OUTPUT_WEIGHT),
(0.3 + 0.3 * AUDIO_MIX_OUTPUT_WEIGHT),
];
for i in 0..mixed.len() {
assert!(
(mixed[i] - expected[i]).abs() < 1e-6,
"Mismatch at index {}: expected {}, got {}",
i,
expected[i],
mixed[i]
);
}
}
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[test]
fn test_simd_implementation_used() {
const BUFFER_SIZES: [usize; 4] = [100, 127, 128, 512];
for size in BUFFER_SIZES {
// Create arrays large enough to trigger SIMD path
let input: Vec<f32> = (0..size).map(|i| i as f32 * 0.01).collect();
let output: Vec<f32> = (0..size).map(|i| (size - i) as f32 * 0.01).collect();
// Mix with standard weights
let mixed = mix_audio_samples(&input, &output);
// Compute the same mix using scalar implementation for comparison
let mut expected = vec![0.0; input.len()];
mix_audio_samples_scalar(&input, &output, &mut expected, 0, input.len());
// Verify results match between SIMD and scalar implementations
for i in 0..mixed.len() {
assert!(
(mixed[i] - expected[i]).abs() < 1e-6,
"SIMD and scalar implementations should produce identical results at index {}",
i
);
}
}
}
#[test]
fn test_small_vector_uses_scalar() {
// Create small arrays that should use scalar path even with SIMD available
let input = vec![0.1, 0.2, 0.3];
let output = vec![0.5, 0.4, 0.3];
// Mix with standard weights
let mixed = mix_audio_samples(&input, &output);
// Calculate expected values manually
let expected = [
(0.1 + 0.5 * AUDIO_MIX_OUTPUT_WEIGHT),
(0.2 + 0.4 * AUDIO_MIX_OUTPUT_WEIGHT),
(0.3 + 0.3 * AUDIO_MIX_OUTPUT_WEIGHT),
];
// Verify results
for i in 0..mixed.len() {
assert!(
(mixed[i] - expected[i]).abs() < 1e-6,
"Small vector mixing should be correct at index {}",
i
);
}
}
}

View File

@@ -0,0 +1,261 @@
use std::fmt::Display;
use coreaudio::sys::{AudioObjectID, kAudioTapPropertyFormat};
use objc2::{Encode, Encoding, RefEncode};
use crate::{error::CoreAudioError, utils::get_global_main_property};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum AudioFormatID {
LinearPcm = 0x6C70636D, // 'lpcm'
Ac3 = 0x61632D33, // 'ac-3'
Ac360958 = 0x63616333, // 'cac3'
AppleIma4 = 0x696D6134, // 'ima4'
Mpeg4Aac = 0x61616320, // 'aac '
Mpeg4Celp = 0x63656C70, // 'celp'
Mpeg4Hvxc = 0x68767863, // 'hvxc'
Mpeg4TwinVq = 0x74777671, // 'twvq'
Mace3 = 0x4D414333, // 'MAC3'
Mace6 = 0x4D414336, // 'MAC6'
ULaw = 0x756C6177, // 'ulaw'
ALaw = 0x616C6177, // 'alaw'
QDesign = 0x51444D43, // 'QDMC'
QDesign2 = 0x51444D32, // 'QDM2'
Qualcomm = 0x51636C70, // 'Qclp'
MpegLayer1 = 0x2E6D7031, // '.mp1'
MpegLayer2 = 0x2E6D7032, // '.mp2'
MpegLayer3 = 0x2E6D7033, // '.mp3'
TimeCode = 0x74696D65, // 'time'
MidiStream = 0x6D696469, // 'midi'
ParameterValueStream = 0x61707673, // 'apvs'
AppleLossless = 0x616C6163, // 'alac'
Mpeg4AacHe = 0x61616368, // 'aach'
Mpeg4AacLd = 0x6161636C, // 'aacl'
Mpeg4AacEld = 0x61616365, // 'aace'
Mpeg4AacEldSbr = 0x61616366, // 'aacf'
Mpeg4AacEldV2 = 0x61616367, // 'aacg'
Mpeg4AacHeV2 = 0x61616370, // 'aacp'
Mpeg4AacSpatial = 0x61616373, // 'aacs'
MpegdUsac = 0x75736163, // 'usac'
Amr = 0x73616D72, // 'samr'
AmrWb = 0x73617762, // 'sawb'
Audible = 0x41554442, // 'AUDB'
ILbc = 0x696C6263, // 'ilbc'
DviIntelIma = 0x6D730011,
MicrosoftGsm = 0x6D730031,
Aes3 = 0x61657333, // 'aes3'
EnhancedAc3 = 0x65632D33, // 'ec-3'
Flac = 0x666C6163, // 'flac'
Opus = 0x6F707573, // 'opus'
Apac = 0x61706163, // 'apac'
Unknown = 0x00000000,
}
impl From<u32> for AudioFormatID {
fn from(value: u32) -> Self {
match value {
0x6C70636D => Self::LinearPcm,
0x61632D33 => Self::Ac3,
0x63616333 => Self::Ac360958,
0x696D6134 => Self::AppleIma4,
0x61616320 => Self::Mpeg4Aac,
0x63656C70 => Self::Mpeg4Celp,
0x68767863 => Self::Mpeg4Hvxc,
0x74777671 => Self::Mpeg4TwinVq,
0x4D414333 => Self::Mace3,
0x4D414336 => Self::Mace6,
0x756C6177 => Self::ULaw,
0x616C6177 => Self::ALaw,
0x51444D43 => Self::QDesign,
0x51444D32 => Self::QDesign2,
0x51636C70 => Self::Qualcomm,
0x2E6D7031 => Self::MpegLayer1,
0x2E6D7032 => Self::MpegLayer2,
0x2E6D7033 => Self::MpegLayer3,
0x74696D65 => Self::TimeCode,
0x6D696469 => Self::MidiStream,
0x61707673 => Self::ParameterValueStream,
0x616C6163 => Self::AppleLossless,
0x61616368 => Self::Mpeg4AacHe,
0x6161636C => Self::Mpeg4AacLd,
0x61616365 => Self::Mpeg4AacEld,
0x61616366 => Self::Mpeg4AacEldSbr,
0x61616367 => Self::Mpeg4AacEldV2,
0x61616370 => Self::Mpeg4AacHeV2,
0x61616373 => Self::Mpeg4AacSpatial,
0x75736163 => Self::MpegdUsac,
0x73616D72 => Self::Amr,
0x73617762 => Self::AmrWb,
0x41554442 => Self::Audible,
0x696C6263 => Self::ILbc,
0x6D730011 => Self::DviIntelIma,
0x6D730031 => Self::MicrosoftGsm,
0x61657333 => Self::Aes3,
0x65632D33 => Self::EnhancedAc3,
0x666C6163 => Self::Flac,
0x6F707573 => Self::Opus,
0x61706163 => Self::Apac,
_ => Self::Unknown,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct AudioFormatFlags(pub u32);
#[allow(unused)]
impl AudioFormatFlags {
pub const IS_FLOAT: u32 = 1 << 0;
pub const IS_BIG_ENDIAN: u32 = 1 << 1;
pub const IS_SIGNED_INTEGER: u32 = 1 << 2;
pub const IS_PACKED: u32 = 1 << 3;
pub const IS_ALIGNED_HIGH: u32 = 1 << 4;
pub const IS_NON_INTERLEAVED: u32 = 1 << 5;
pub const IS_NON_MIXABLE: u32 = 1 << 6;
pub const ARE_ALL_CLEAR: u32 = 0x80000000;
pub const LINEAR_PCM_IS_FLOAT: u32 = Self::IS_FLOAT;
pub const LINEAR_PCM_IS_BIG_ENDIAN: u32 = Self::IS_BIG_ENDIAN;
pub const LINEAR_PCM_IS_SIGNED_INTEGER: u32 = Self::IS_SIGNED_INTEGER;
pub const LINEAR_PCM_IS_PACKED: u32 = Self::IS_PACKED;
pub const LINEAR_PCM_IS_ALIGNED_HIGH: u32 = Self::IS_ALIGNED_HIGH;
pub const LINEAR_PCM_IS_NON_INTERLEAVED: u32 = Self::IS_NON_INTERLEAVED;
pub const LINEAR_PCM_IS_NON_MIXABLE: u32 = Self::IS_NON_MIXABLE;
pub const LINEAR_PCM_SAMPLE_FRACTION_SHIFT: u32 = 7;
pub const LINEAR_PCM_SAMPLE_FRACTION_MASK: u32 = 0x3F << Self::LINEAR_PCM_SAMPLE_FRACTION_SHIFT;
pub const LINEAR_PCM_ARE_ALL_CLEAR: u32 = Self::ARE_ALL_CLEAR;
pub const APPLE_LOSSLESS_FORMAT_FLAG_16_BIT_SOURCE_DATA: u32 = 1;
pub const APPLE_LOSSLESS_FORMAT_FLAG_20_BIT_SOURCE_DATA: u32 = 2;
pub const APPLE_LOSSLESS_FORMAT_FLAG_24_BIT_SOURCE_DATA: u32 = 3;
pub const APPLE_LOSSLESS_FORMAT_FLAG_32_BIT_SOURCE_DATA: u32 = 4;
}
impl std::fmt::Display for AudioFormatFlags {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut flags = Vec::new();
if self.0 & Self::IS_FLOAT != 0 {
flags.push("FLOAT");
}
if self.0 & Self::IS_BIG_ENDIAN != 0 {
flags.push("BIG_ENDIAN");
}
if self.0 & Self::IS_SIGNED_INTEGER != 0 {
flags.push("SIGNED_INTEGER");
}
if self.0 & Self::IS_PACKED != 0 {
flags.push("PACKED");
}
if self.0 & Self::IS_ALIGNED_HIGH != 0 {
flags.push("ALIGNED_HIGH");
}
if self.0 & Self::IS_NON_INTERLEAVED != 0 {
flags.push("NON_INTERLEAVED");
}
if self.0 & Self::IS_NON_MIXABLE != 0 {
flags.push("NON_MIXABLE");
}
if self.0 & Self::ARE_ALL_CLEAR != 0 {
flags.push("ALL_CLEAR");
}
if flags.is_empty() {
write!(f, "NONE")
} else {
write!(f, "{}", flags.join(" | "))
}
}
}
impl std::fmt::Debug for AudioFormatFlags {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "AudioFormatFlags({self})")
}
}
impl From<u32> for AudioFormatFlags {
fn from(value: u32) -> Self {
Self(value)
}
}
/// [Apple's documentation](https://developer.apple.com/documentation/coreaudiotypes/audiostreambasicdescription?language=objc)
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq)]
#[allow(non_snake_case)]
pub struct AudioStreamBasicDescription {
pub mSampleRate: f64,
pub mFormatID: u32,
pub mFormatFlags: u32,
pub mBytesPerPacket: u32,
pub mFramesPerPacket: u32,
pub mBytesPerFrame: u32,
pub mChannelsPerFrame: u32,
pub mBitsPerChannel: u32,
pub mReserved: u32,
}
unsafe impl Encode for AudioStreamBasicDescription {
const ENCODING: Encoding = Encoding::Struct(
"AudioStreamBasicDescription",
&[
<f64>::ENCODING,
<u32>::ENCODING,
<u32>::ENCODING,
<u32>::ENCODING,
<u32>::ENCODING,
<u32>::ENCODING,
<u32>::ENCODING,
<u32>::ENCODING,
<u32>::ENCODING,
],
);
}
unsafe impl RefEncode for AudioStreamBasicDescription {
const ENCODING_REF: Encoding = Encoding::Pointer(&Self::ENCODING);
}
#[derive(Debug, Clone, Copy)]
#[repr(transparent)]
pub struct AudioStreamDescription(pub(crate) AudioStreamBasicDescription);
impl Display for AudioStreamDescription {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"AudioStreamBasicDescription {{ mSampleRate: {}, mFormatID: {:?}, mFormatFlags: {}, mBytesPerPacket: {}, \
mFramesPerPacket: {}, mBytesPerFrame: {}, mChannelsPerFrame: {}, mBitsPerChannel: {}, mReserved: {} }}",
self.0.mSampleRate,
AudioFormatID::from(self.0.mFormatID),
AudioFormatFlags(self.0.mFormatFlags),
self.0.mBytesPerPacket,
self.0.mFramesPerPacket,
self.0.mBytesPerFrame,
self.0.mChannelsPerFrame,
self.0.mBitsPerChannel,
self.0.mReserved
)
}
}
pub fn read_audio_stream_basic_description(
tap_id: AudioObjectID,
) -> std::result::Result<AudioStreamDescription, CoreAudioError> {
let mut data = AudioStreamBasicDescription {
mSampleRate: 0.0,
mFormatID: 0,
mFormatFlags: 0,
mBytesPerPacket: 0,
mFramesPerPacket: 0,
mBytesPerFrame: 0,
mChannelsPerFrame: 0,
mBitsPerChannel: 0,
mReserved: 0,
};
get_global_main_property(tap_id, kAudioTapPropertyFormat, &mut data)?;
Ok(AudioStreamDescription(data))
}

View File

@@ -0,0 +1,64 @@
use std::ptr;
use objc2::{
AllocAnyThread, msg_send,
runtime::{AnyClass, AnyObject},
};
use objc2_foundation::{NSDictionary, NSError, NSNumber, NSString, NSUInteger, NSURL};
use crate::{av_audio_format::AVAudioFormat, av_audio_pcm_buffer::AVAudioPCMBuffer, error::CoreAudioError};
#[allow(unused)]
pub(crate) struct AVAudioFile {
inner: *mut AnyObject,
}
#[allow(unused)]
impl AVAudioFile {
pub(crate) fn new(url: &str, format: &AVAudioFormat) -> Result<Self, CoreAudioError> {
let cls = AnyClass::get(c"AVAudioFile").ok_or(CoreAudioError::AVAudioFileClassNotFound)?;
let obj: *mut AnyObject = unsafe { msg_send![cls, alloc] };
if obj.is_null() {
return Err(CoreAudioError::AllocAVAudioFileFailed);
}
let url: &NSURL = &*unsafe { NSURL::fileURLWithPath(&NSString::from_str(url)) };
let settings = &*NSDictionary::from_retained_objects(
&[
&*NSString::from_str("AVFormatIDKey"),
&*NSString::from_str("AVSampleRateKey"),
&*NSString::from_str("AVNumberOfChannelsKey"),
],
&[
NSNumber::initWithUnsignedInt(NSNumber::alloc(), format.audio_stream_basic_description.0.mFormatID),
NSNumber::initWithDouble(NSNumber::alloc(), format.get_sample_rate()),
NSNumber::initWithUnsignedInt(NSNumber::alloc(), format.get_channel_count()),
],
);
let is_interleaved = format.is_interleaved();
let mut error: *mut NSError = ptr::null_mut();
let common_format: NSUInteger = 1;
let obj: *mut AnyObject = unsafe {
msg_send![
obj,
initForWriting: url,
settings: settings,
commonFormat: common_format,
interleaved: is_interleaved,
error: &mut error
]
};
if obj.is_null() {
return Err(CoreAudioError::InitAVAudioFileFailed);
}
Ok(Self { inner: obj })
}
pub(crate) fn write(&self, buffer: AVAudioPCMBuffer) -> Result<(), CoreAudioError> {
let mut error: *mut NSError = ptr::null_mut();
let success: bool = unsafe { msg_send![self.inner, writeFromBuffer: buffer.inner, error: &mut error] };
if !success {
return Err(CoreAudioError::WriteAVAudioFileFailed);
}
Ok(())
}
}

View File

@@ -0,0 +1,87 @@
use objc2::{
Encode, Encoding, RefEncode, msg_send,
runtime::{AnyClass, AnyObject},
};
use crate::{audio_stream_basic_desc::AudioStreamDescription, error::CoreAudioError};
#[derive(Debug)]
#[allow(unused)]
pub(crate) struct AVAudioFormat {
pub(crate) inner: AVAudioFormatRef,
pub(crate) audio_stream_basic_description: AudioStreamDescription,
}
#[repr(transparent)]
#[derive(Debug, Clone, Copy)]
pub(crate) struct AVAudioFormatRef(pub(crate) *mut AnyObject);
unsafe impl Encode for AVAudioFormatRef {
const ENCODING: Encoding = Encoding::Struct(
"AVAudioFormat",
&[
Encoding::Double,
Encoding::UInt,
Encoding::Pointer(&Encoding::Struct(
"AVAudioChannelLayout",
&[
Encoding::UInt,
Encoding::UInt,
Encoding::Pointer(&Encoding::Struct(
"AudioChannelLayout",
&[
Encoding::UInt,
Encoding::UInt,
Encoding::Array(
1,
&Encoding::Struct(
"AudioChannelDescription",
&[Encoding::UInt, Encoding::UInt, Encoding::Array(3, &Encoding::Float)],
),
),
Encoding::UInt,
Encoding::UInt,
],
)),
Encoding::UInt,
],
)),
Encoding::Pointer(&Encoding::Object),
],
);
}
unsafe impl RefEncode for AVAudioFormatRef {
const ENCODING_REF: Encoding = Encoding::Pointer(&Self::ENCODING);
}
#[allow(unused)]
impl AVAudioFormat {
pub fn new(audio_stream_basic_description: AudioStreamDescription) -> Result<Self, CoreAudioError> {
let cls = AnyClass::get(c"AVAudioFormat").ok_or(CoreAudioError::AVAudioFormatClassNotFound)?;
let obj: *mut AnyObject = unsafe { msg_send![cls, alloc] };
if obj.is_null() {
return Err(CoreAudioError::AllocAVAudioFormatFailed);
}
let obj: *mut AnyObject = unsafe { msg_send![obj, initWithStreamDescription: &audio_stream_basic_description.0] };
if obj.is_null() {
return Err(CoreAudioError::InitAVAudioFormatFailed);
}
Ok(Self {
inner: AVAudioFormatRef(obj),
audio_stream_basic_description,
})
}
pub(crate) fn get_sample_rate(&self) -> f64 {
unsafe { msg_send![self.inner.0, sampleRate] }
}
pub(crate) fn get_channel_count(&self) -> u32 {
unsafe { msg_send![self.inner.0, channelCount] }
}
pub(crate) fn is_interleaved(&self) -> bool {
unsafe { msg_send![self.inner.0, isInterleaved] }
}
}

View File

@@ -0,0 +1,31 @@
use block2::RcBlock;
use objc2::{
msg_send,
runtime::{AnyClass, AnyObject},
};
use crate::{audio_buffer::AudioBufferList, av_audio_format::AVAudioFormat, error::CoreAudioError};
#[allow(unused)]
pub(crate) struct AVAudioPCMBuffer {
pub(crate) inner: *mut AnyObject,
}
#[allow(unused)]
impl AVAudioPCMBuffer {
pub(crate) fn new(audio_format: &AVAudioFormat, buffer_list: *const AudioBufferList) -> Result<Self, CoreAudioError> {
let cls = AnyClass::get(c"AVAudioPCMBuffer").ok_or(CoreAudioError::AVAudioPCMBufferClassNotFound)?;
let obj: *mut AnyObject = unsafe { msg_send![cls, alloc] };
if obj.is_null() {
return Err(CoreAudioError::AllocAVAudioPCMBufferFailed);
}
let deallocator = RcBlock::new(|_buffer_list: *const AudioBufferList| {});
let obj: *mut AnyObject = unsafe {
msg_send![obj, initWithPCMFormat: audio_format.inner.0, bufferListNoCopy: buffer_list, deallocator: &*deallocator]
};
if obj.is_null() {
return Err(CoreAudioError::InitAVAudioPCMBufferFailed);
}
Ok(Self { inner: obj })
}
}

View File

@@ -0,0 +1,80 @@
use core_foundation::{
base::{FromVoid, ItemRef},
string::CFString,
};
use coreaudio::sys::AudioObjectID;
use objc2::{
AllocAnyThread, msg_send,
runtime::{AnyClass, AnyObject},
};
use objc2_foundation::{NSArray, NSNumber, NSString, NSUUID};
use crate::error::CoreAudioError;
pub(crate) struct CATapDescription {
pub(crate) inner: *mut AnyObject,
}
impl CATapDescription {
pub fn init_stereo_mixdown_of_processes(process: AudioObjectID) -> std::result::Result<Self, CoreAudioError> {
let cls = AnyClass::get(c"CATapDescription").ok_or(CoreAudioError::CATapDescriptionClassNotFound)?;
let obj: *mut AnyObject = unsafe { msg_send![cls, alloc] };
if obj.is_null() {
return Err(CoreAudioError::AllocCATapDescriptionFailed);
}
let processes_array = NSArray::from_retained_slice(&[NSNumber::initWithUnsignedInt(NSNumber::alloc(), process)]);
let obj: *mut AnyObject = unsafe { msg_send![obj, initStereoMixdownOfProcesses: &*processes_array] };
if obj.is_null() {
return Err(CoreAudioError::InitStereoMixdownOfProcessesFailed);
}
unsafe {
let _: () = msg_send![obj, setMuteBehavior: 0_i64];
}
Ok(Self { inner: obj })
}
pub fn init_stereo_global_tap_but_exclude_processes(
processes: &[AudioObjectID],
) -> std::result::Result<Self, CoreAudioError> {
let cls = AnyClass::get(c"CATapDescription").ok_or(CoreAudioError::CATapDescriptionClassNotFound)?;
let obj: *mut AnyObject = unsafe { msg_send![cls, alloc] };
if obj.is_null() {
return Err(CoreAudioError::AllocCATapDescriptionFailed);
}
let processes_array = NSArray::from_retained_slice(
processes
.iter()
.map(|p| NSNumber::initWithUnsignedInt(NSNumber::alloc(), *p))
.collect::<Vec<_>>()
.as_slice(),
);
let obj: *mut AnyObject = unsafe { msg_send![obj, initStereoGlobalTapButExcludeProcesses: &*processes_array] };
if obj.is_null() {
return Err(CoreAudioError::InitStereoGlobalTapButExcludeProcessesFailed);
}
Ok(Self { inner: obj })
}
pub fn get_uuid(&self) -> std::result::Result<ItemRef<'_, CFString>, CoreAudioError> {
let uuid: *mut NSUUID = unsafe { msg_send![self.inner, UUID] };
if uuid.is_null() {
return Err(CoreAudioError::GetCATapDescriptionUUIDFailed);
}
let uuid_string: *mut NSString = unsafe { msg_send![uuid, UUIDString] };
if uuid_string.is_null() {
return Err(CoreAudioError::ConvertUUIDToCFStringFailed);
}
Ok(unsafe { CFString::from_void(uuid_string.cast()) })
}
}
impl Drop for CATapDescription {
fn drop(&mut self) {
unsafe {
let _: () = msg_send![self.inner, release];
}
}
}

View File

@@ -0,0 +1,86 @@
use core_foundation::{
array::CFArray,
base::{CFType, TCFType},
boolean::CFBoolean,
dictionary::CFDictionary,
string::CFString,
};
use crate::utils::cfstring_from_bytes_with_nul;
pub trait ToCoreFoundation {
fn to_cf(&self) -> CFType;
}
impl ToCoreFoundation for CFString {
fn to_cf(&self) -> CFType {
self.as_CFType()
}
}
impl ToCoreFoundation for &CFString {
fn to_cf(&self) -> CFType {
self.as_CFType()
}
}
impl ToCoreFoundation for String {
fn to_cf(&self) -> CFType {
CFString::new(self).as_CFType()
}
}
impl ToCoreFoundation for &str {
fn to_cf(&self) -> CFType {
CFString::new(self).as_CFType()
}
}
impl ToCoreFoundation for bool {
fn to_cf(&self) -> CFType {
if *self {
CFBoolean::true_value().as_CFType()
} else {
CFBoolean::false_value().as_CFType()
}
}
}
impl ToCoreFoundation for &[u8] {
fn to_cf(&self) -> CFType {
cfstring_from_bytes_with_nul(self).as_CFType()
}
}
impl ToCoreFoundation for CFDictionary<CFType, CFType> {
fn to_cf(&self) -> CFType {
self.as_CFType()
}
}
impl<T: ToCoreFoundation> ToCoreFoundation for Vec<T> {
fn to_cf(&self) -> CFType {
CFArray::from_CFTypes(self.iter().map(|t| t.to_cf()).collect::<Vec<_>>().as_slice()).as_CFType()
}
}
pub struct CFDictionaryBuilder {
pairs: Vec<(CFType, CFType)>,
}
impl CFDictionaryBuilder {
pub fn new() -> Self {
Self {
pairs: Vec::with_capacity(16),
}
}
pub fn add<K: ToCoreFoundation, V: ToCoreFoundation>(&mut self, key: K, value: V) -> &mut Self {
self.pairs.push((key.to_cf(), value.to_cf()));
self
}
pub fn build(self) -> CFDictionary<CFType, CFType> {
CFDictionary::from_CFType_pairs(self.pairs.as_slice())
}
}

View File

@@ -0,0 +1,28 @@
use std::ptr;
use core_foundation::{base::TCFType, string::CFString};
use coreaudio::sys::{
AudioDeviceID, AudioObjectID, CFStringRef, kAudioDevicePropertyDeviceUID, kAudioObjectSystemObject,
};
use crate::{error::CoreAudioError, utils::get_global_main_property};
pub(crate) fn get_device_uid(
device_id: AudioDeviceID,
) -> std::result::Result<(AudioObjectID, CFString), CoreAudioError> {
let system_audio_id = get_device_audio_id(device_id)?;
let mut output_uid: CFStringRef = ptr::null_mut();
get_global_main_property(system_audio_id, kAudioDevicePropertyDeviceUID, &mut output_uid)?;
Ok((system_audio_id, unsafe {
CFString::wrap_under_create_rule(output_uid.cast())
}))
}
pub(crate) fn get_device_audio_id(device_id: AudioDeviceID) -> std::result::Result<AudioObjectID, CoreAudioError> {
let mut system_output_id: AudioObjectID = 0;
get_global_main_property(kAudioObjectSystemObject, device_id, &mut system_output_id)?;
Ok(system_output_id)
}

View File

@@ -0,0 +1,93 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum CoreAudioError {
#[error("Map pid {0} to AudioObjectID failed")]
PidNotFound(i32),
#[error("Create process tap failed, status: {0}")]
CreateProcessTapFailed(i32),
#[error("Get default device failed, status: {0}")]
GetDefaultDeviceFailed(i32),
#[error("Get device uid failed, status: {0}")]
GetDeviceUidFailed(i32),
#[error("Create aggregate device failed, status: {0}")]
CreateAggregateDeviceFailed(i32),
#[error("Get process object list size failed, status: {0}")]
GetProcessObjectListSizeFailed(i32),
#[error("Get process object list failed, status: {0}")]
GetProcessObjectListFailed(i32),
#[error("AudioObjectGetPropertyDataSize failed, status: {0}")]
AudioObjectGetPropertyDataSizeFailed(i32),
#[error("CATapDescription class not found")]
CATapDescriptionClassNotFound,
#[error("Alloc CATapDescription failed")]
AllocCATapDescriptionFailed,
#[error("Call initStereoMixdownOfProcesses on CATapDescription failed")]
InitStereoMixdownOfProcessesFailed,
#[error("Call initStereoGlobalTapButExcludeProcesses on CATapDescription failed")]
InitStereoGlobalTapButExcludeProcessesFailed,
#[error("Get UUID on CATapDescription failed")]
GetCATapDescriptionUUIDFailed,
#[error("Get mute behavior on CATapDescription failed")]
GetMuteBehaviorFailed,
#[error("Convert UUID to CFString failed")]
ConvertUUIDToCFStringFailed,
#[error("Get AudioStreamBasicDescription failed, status: {0}")]
GetAudioStreamBasicDescriptionFailed(i32),
#[error("AVAudioFormat class not found")]
AVAudioFormatClassNotFound,
#[error("Alloc AVAudioFormat failed")]
AllocAVAudioFormatFailed,
#[error("Init AVAudioFormat failed")]
InitAVAudioFormatFailed,
#[error("Create IOProcIDWithBlock failed, status: {0}")]
CreateIOProcIDWithBlockFailed(i32),
#[error("Get hardware devices failed, status: {0}")]
GetHardwareDevicesFailed(i32),
#[error("AudioDeviceStart failed, status: {0}")]
AudioDeviceStartFailed(i32),
#[error("AudioDeviceStop failed, status: {0}")]
AudioDeviceStopFailed(i32),
#[error("AudioDeviceDestroyIOProcID failed, status: {0}")]
AudioDeviceDestroyIOProcIDFailed(i32),
#[error("AudioHardwareDestroyAggregateDevice failed, status: {0}")]
AudioHardwareDestroyAggregateDeviceFailed(i32),
#[error("AudioHardwareDestroyProcessTap failed, status: {0}")]
AudioHardwareDestroyProcessTapFailed(i32),
#[error("Get aggregate device property full sub device list failed, status: {0}")]
GetAggregateDevicePropertyFullSubDeviceListFailed(i32),
#[error("Add property listener block failed, status: {0}")]
AddPropertyListenerBlockFailed(i32),
#[error("AudioObjectGetPropertyData failed, status: {0}")]
AudioObjectGetPropertyDataFailed(i32),
#[error("AVAudioFile class not found")]
AVAudioFileClassNotFound,
#[error("Alloc AVAudioFile failed")]
AllocAVAudioFileFailed,
#[error("Init AVAudioFile failed")]
InitAVAudioFileFailed,
#[error("AVAudioPCMBuffer class not found")]
AVAudioPCMBufferClassNotFound,
#[error("Alloc AVAudioPCMBuffer failed")]
AllocAVAudioPCMBufferFailed,
#[error("Init AVAudioPCMBuffer failed")]
InitAVAudioPCMBufferFailed,
#[error("Write AVAudioFile failed")]
WriteAVAudioFileFailed,
#[error("Input frame contains no data")]
InputFrameContainsNoData,
#[error("Output frame contains no data")]
OutputFrameContainsNoData,
#[error("Get property data size failed, status: {0}")]
GetPropertyDataSizeFailed(i32),
#[error("Get property data failed, status: {0}")]
GetPropertyDataFailed(i32),
#[error("Process audio {0} frame failed")]
ProcessAudioFrameFailed(&'static str),
}
impl From<CoreAudioError> for napi::Error {
fn from(value: CoreAudioError) -> Self {
napi::Error::new(napi::Status::GenericFailure, value.to_string())
}
}

View File

@@ -0,0 +1,17 @@
mod application_icon;
use application_icon::application_icon;
pub mod audio_buffer;
pub mod audio_stream_basic_desc;
pub mod av_audio_file;
pub mod av_audio_format;
pub mod av_audio_pcm_buffer;
pub mod ca_tap_description;
pub(crate) mod cf_types;
pub mod device;
pub(crate) mod error;
pub mod pid;
pub mod queue;
pub mod screen_capture_kit;
pub mod tap_audio;
pub(crate) mod utils;

View File

@@ -0,0 +1,88 @@
use std::{mem::MaybeUninit, ptr};
use coreaudio::sys::{
AudioObjectGetPropertyData, AudioObjectGetPropertyDataSize, AudioObjectID, AudioObjectPropertyAddress,
AudioObjectPropertySelector, kAudioHardwareNoError, kAudioHardwarePropertyProcessObjectList,
kAudioObjectPropertyElementMain, kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject,
};
use crate::error::CoreAudioError;
pub fn audio_process_list() -> Result<Vec<AudioObjectID>, CoreAudioError> {
let address = AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyProcessObjectList,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
};
let mut data_size = 0u32;
let status =
unsafe { AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &address, 0, ptr::null_mut(), &mut data_size) };
if status != kAudioHardwareNoError as i32 {
return Err(CoreAudioError::GetProcessObjectListSizeFailed(status));
}
let mut process_list: Vec<AudioObjectID> = vec![0; data_size as usize];
let status = unsafe {
AudioObjectGetPropertyData(
kAudioObjectSystemObject,
&address,
0,
ptr::null_mut(),
(&mut data_size as *mut u32).cast(),
process_list.as_mut_ptr().cast(),
)
};
if status != kAudioHardwareNoError as i32 {
return Err(CoreAudioError::GetProcessObjectListFailed(status));
}
Ok(process_list)
}
pub fn get_process_property<T: Sized>(
object: &AudioObjectID,
selector: AudioObjectPropertySelector,
) -> Result<T, CoreAudioError> {
let object_id = *object;
let address = AudioObjectPropertyAddress {
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
};
let mut data_size = 0u32;
let status = unsafe { AudioObjectGetPropertyDataSize(object_id, &address, 0, ptr::null_mut(), &mut data_size) };
if status != kAudioHardwareNoError as i32 {
return Err(CoreAudioError::AudioObjectGetPropertyDataSizeFailed(status));
}
get_property_data(object_id, &address, &mut data_size)
}
pub fn get_property_data<T: Sized>(
object_id: AudioObjectID,
address: &AudioObjectPropertyAddress,
data_size: &mut u32,
) -> Result<T, CoreAudioError> {
let mut property = MaybeUninit::<T>::uninit();
let status = unsafe {
AudioObjectGetPropertyData(
object_id,
address,
0,
ptr::null_mut(),
(data_size as *mut u32).cast(),
property.as_mut_ptr().cast(),
)
};
if status != kAudioHardwareNoError as i32 {
return Err(CoreAudioError::AudioObjectGetPropertyDataFailed(status));
}
Ok(unsafe { property.assume_init() })
}

View File

@@ -0,0 +1,8 @@
pub(crate) fn create_audio_tap_queue() -> dispatch2::DispatchRetained<dispatch2::DispatchQueue> {
let queue_attr = dispatch2::DispatchQueueAttr::with_qos_class(
dispatch2::DispatchQueueAttr::SERIAL,
dispatch2::DispatchQoS::UserInteractive,
0,
);
dispatch2::DispatchQueue::new("ProcessTapRecorder", Some(&queue_attr))
}

View File

@@ -0,0 +1,536 @@
use std::{
collections::HashMap,
ffi::c_void,
ptr,
sync::{
Arc, LazyLock, RwLock,
atomic::{AtomicPtr, Ordering},
},
};
use block2::{Block, RcBlock};
use core_foundation::{
base::TCFType,
string::{CFString, CFStringRef},
};
use coreaudio::sys::{
AudioObjectAddPropertyListenerBlock, AudioObjectID, AudioObjectPropertyAddress,
AudioObjectRemovePropertyListenerBlock, kAudioHardwarePropertyProcessObjectList, kAudioObjectPropertyElementMain,
kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject, kAudioProcessPropertyBundleID,
kAudioProcessPropertyIsRunning, kAudioProcessPropertyIsRunningInput, kAudioProcessPropertyPID,
};
use libc;
use napi::{
bindgen_prelude::{Buffer, Error, Result, Status},
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use napi_derive::napi;
use objc2::{
msg_send,
runtime::{AnyClass, AnyObject},
};
use objc2_foundation::NSString;
use screencapturekit::shareable_content::SCShareableContent;
use uuid::Uuid;
use crate::{
audio_callback::AudioCallback,
error::CoreAudioError,
pid::{audio_process_list, get_process_property},
tap_audio::{AggregateDeviceManager, AudioCaptureSession},
};
static RUNNING_APPLICATIONS: LazyLock<RwLock<std::result::Result<Vec<AudioObjectID>, CoreAudioError>>> =
LazyLock::new(|| RwLock::new(audio_process_list()));
type ApplicationStateChangedSubscriberMap = HashMap<AudioObjectID, HashMap<Uuid, Arc<ThreadsafeFunction<(), ()>>>>;
static APPLICATION_STATE_CHANGED_SUBSCRIBERS: LazyLock<RwLock<ApplicationStateChangedSubscriberMap>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
static APPLICATION_STATE_CHANGED_LISTENER_BLOCKS: LazyLock<RwLock<HashMap<AudioObjectID, AtomicPtr<c_void>>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
static NSRUNNING_APPLICATION_CLASS: LazyLock<Option<&'static AnyClass>> =
LazyLock::new(|| AnyClass::get(c"NSRunningApplication"));
#[napi]
#[derive(Clone)]
pub struct ApplicationInfo {
pub process_id: i32,
pub name: String,
pub object_id: u32,
}
#[napi]
impl ApplicationInfo {
#[napi(constructor)]
pub fn new(process_id: i32, name: String, object_id: u32) -> Self {
Self {
process_id,
name,
object_id,
}
}
#[napi(getter)]
pub fn process_group_id(&self) -> i32 {
if self.process_id > 0 {
let pgid = unsafe { libc::getpgid(self.process_id) };
if pgid != -1 {
return pgid;
}
// Fall back to process_id if getpgid fails
return self.process_id;
}
-1
}
#[napi(getter)]
pub fn bundle_identifier(&self) -> String {
if self.process_id <= 0 {
return String::new();
}
// Try to get bundle identifier using NSRunningApplication
if let Some(running_app_class) = NSRUNNING_APPLICATION_CLASS.as_ref() {
let running_app: *mut AnyObject = unsafe {
msg_send![
*running_app_class,
runningApplicationWithProcessIdentifier: self.process_id
]
};
if !running_app.is_null() {
unsafe {
let bundle_id_ptr: *mut NSString = msg_send![running_app, bundleIdentifier];
if !bundle_id_ptr.is_null() {
let length: usize = msg_send![bundle_id_ptr, length];
let utf8_ptr: *const u8 = msg_send![bundle_id_ptr, UTF8String];
if !utf8_ptr.is_null() {
let bytes = std::slice::from_raw_parts(utf8_ptr, length);
if let Ok(s) = std::str::from_utf8(bytes) {
return s.to_string();
}
}
}
}
}
}
// If not available, try to get from the audio process property
if self.object_id > 0
&& let Ok(bundle_id) = get_process_property::<CFStringRef>(&self.object_id, kAudioProcessPropertyBundleID)
{
// Safely convert CFStringRef to Rust String
let cf_string = unsafe { CFString::wrap_under_get_rule(bundle_id) };
return cf_string.to_string();
}
String::new()
}
#[napi(getter)]
pub fn icon(&self) -> Result<Buffer> {
super::application_icon(self.process_id)
}
}
#[napi]
pub struct ApplicationListChangedSubscriber {
listener_block: RcBlock<dyn Fn(u32, *mut c_void)>,
}
#[napi]
impl ApplicationListChangedSubscriber {
#[napi]
pub fn unsubscribe(&self) -> Result<()> {
// Wrap in catch_unwind to prevent crashes during shutdown
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe {
AudioObjectRemovePropertyListenerBlock(
kAudioObjectSystemObject,
&AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyProcessObjectList,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
},
ptr::null_mut(),
(&*self.listener_block as *const Block<dyn Fn(u32, *mut c_void)>)
.cast_mut()
.cast(),
)
}));
match result {
Ok(status) => {
if status != 0 {
return Err(Error::new(Status::GenericFailure, "Failed to remove property listener"));
}
Ok(())
}
Err(_) => {
// If we panicked (likely during shutdown), consider it success
Ok(())
}
}
}
}
#[napi]
pub struct ApplicationStateChangedSubscriber {
id: Uuid,
object_id: AudioObjectID,
}
#[napi]
impl ApplicationStateChangedSubscriber {
#[napi]
pub fn unsubscribe(&self) {
if let Ok(mut lock) = APPLICATION_STATE_CHANGED_SUBSCRIBERS.write()
&& let Some(subscribers) = lock.get_mut(&self.object_id)
{
subscribers.remove(&self.id);
if subscribers.is_empty() {
lock.remove(&self.object_id);
if let Some(listener_block) = APPLICATION_STATE_CHANGED_LISTENER_BLOCKS
.write()
.ok()
.as_mut()
.and_then(|map| map.remove(&self.object_id))
{
// Wrap in catch_unwind to prevent crashes during shutdown
let _ = std::panic::catch_unwind(|| unsafe {
AudioObjectRemovePropertyListenerBlock(
self.object_id,
&AudioObjectPropertyAddress {
mSelector: kAudioProcessPropertyIsRunning,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
},
ptr::null_mut(),
listener_block.load(Ordering::Relaxed),
);
});
}
}
}
}
}
#[napi]
pub struct ShareableContent {
_inner: SCShareableContent,
}
#[napi]
impl ShareableContent {
#[napi]
pub fn on_application_list_changed(callback: ThreadsafeFunction<(), ()>) -> Result<ApplicationListChangedSubscriber> {
let callback_arc = Arc::new(callback);
let callback_clone = callback_arc.clone();
let callback_block: RcBlock<dyn Fn(u32, *mut c_void)> =
RcBlock::new(move |_in_number_addresses, _in_addresses: *mut c_void| {
if let Err(err) = RUNNING_APPLICATIONS
.write()
.map_err(|_| {
Error::new(
Status::GenericFailure,
"Poisoned RwLock while writing RunningApplications",
)
})
.map(|mut running_applications| {
*running_applications = audio_process_list();
})
{
callback_clone.call(Err(err), ThreadsafeFunctionCallMode::NonBlocking);
} else {
callback_clone.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
}
});
let status = unsafe {
AudioObjectAddPropertyListenerBlock(
kAudioObjectSystemObject,
&AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyProcessObjectList,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
},
ptr::null_mut(),
(&*callback_block as *const Block<dyn Fn(u32, *mut c_void)>)
.cast_mut()
.cast(),
)
};
if status != 0 {
return Err(Error::new(Status::GenericFailure, "Failed to add property listener"));
}
Ok(ApplicationListChangedSubscriber {
listener_block: callback_block,
})
}
#[napi]
pub fn on_app_state_changed(
app: &ApplicationInfo,
callback: ThreadsafeFunction<(), ()>,
) -> Result<ApplicationStateChangedSubscriber> {
let id = Uuid::new_v4();
let object_id = app.object_id;
let callback_arc = Arc::new(callback);
let mut lock = APPLICATION_STATE_CHANGED_SUBSCRIBERS.write().map_err(|_| {
Error::new(
Status::GenericFailure,
"Poisoned RwLock while writing ApplicationStateChangedSubscribers",
)
})?;
if let Some(subscribers) = lock.get_mut(&object_id) {
subscribers.insert(id, callback_arc.clone());
} else {
let list_change: RcBlock<dyn Fn(u32, *mut c_void)> =
RcBlock::new(move |in_number_addresses, in_addresses: *mut c_void| {
let addresses = unsafe {
std::slice::from_raw_parts(
in_addresses as *mut AudioObjectPropertyAddress,
in_number_addresses as usize,
)
};
for address in addresses {
if address.mSelector == kAudioProcessPropertyIsRunning
&& let Some(subscribers) = APPLICATION_STATE_CHANGED_SUBSCRIBERS
.read()
.ok()
.as_ref()
.and_then(|map| map.get(&object_id))
{
for callback in subscribers.values() {
callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
}
}
}
});
let address = AudioObjectPropertyAddress {
mSelector: kAudioProcessPropertyIsRunning,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
};
let listener_block = &*list_change as *const Block<dyn Fn(u32, *mut c_void)>;
let status = unsafe {
AudioObjectAddPropertyListenerBlock(object_id, &address, ptr::null_mut(), listener_block.cast_mut().cast())
};
if status != 0 {
return Err(Error::new(Status::GenericFailure, "Failed to add property listener"));
}
let subscribers = {
let mut map = HashMap::new();
map.insert(id, callback_arc.clone());
map
};
lock.insert(object_id, subscribers);
}
Ok(ApplicationStateChangedSubscriber { id, object_id })
}
#[napi(constructor)]
pub fn new() -> Result<Self> {
Ok(Self {
_inner: SCShareableContent::get().map_err(|err| Error::new(Status::GenericFailure, err))?,
})
}
#[napi]
pub fn applications() -> Result<Vec<ApplicationInfo>> {
let app_list = RUNNING_APPLICATIONS
.read()
.map_err(|_| {
Error::new(
Status::GenericFailure,
"Poisoned RwLock while reading RunningApplications",
)
})?
.iter()
.flatten()
.filter_map(|id| {
// Get process ID from object_id
let process_id = get_process_property(id, kAudioProcessPropertyPID).unwrap_or(-1);
if process_id <= 0 {
return None;
}
// Get application name using NSRunningApplication
let mut name = String::new();
if let Some(running_app_class) = NSRUNNING_APPLICATION_CLASS.as_ref() {
let running_app: *mut AnyObject = unsafe {
msg_send![
*running_app_class,
runningApplicationWithProcessIdentifier: process_id
]
};
if !running_app.is_null() {
unsafe {
let name_ptr: *mut NSString = msg_send![running_app, localizedName];
if !name_ptr.is_null() {
let length: usize = msg_send![name_ptr, length];
let utf8_ptr: *const u8 = msg_send![name_ptr, UTF8String];
if !utf8_ptr.is_null() {
let bytes = std::slice::from_raw_parts(utf8_ptr, length);
if let Ok(s) = std::str::from_utf8(bytes) {
name = s.to_string();
}
}
}
}
}
}
let app = ApplicationInfo::new(process_id, name, *id);
if !app.bundle_identifier().is_empty() {
Some(app)
} else {
None
}
})
.collect::<Vec<_>>();
Ok(app_list)
}
#[napi]
pub fn application_with_process_id(process_id: u32) -> Option<ApplicationInfo> {
// check if the process is tappable
let tappable = ShareableContent::tappable_application_with_process_id(process_id);
if let Some(tappable) = tappable {
return Some(tappable);
}
// Get NSRunningApplication class
let running_app_class = NSRUNNING_APPLICATION_CLASS.as_ref()?;
// Get running application with PID
let running_app: *mut AnyObject = unsafe {
msg_send![
*running_app_class,
runningApplicationWithProcessIdentifier: process_id as i32
]
};
if running_app.is_null() {
return None;
}
// Get application name
let mut name = String::new();
unsafe {
let name_ptr: *mut NSString = msg_send![running_app, localizedName];
if !name_ptr.is_null() {
let length: usize = msg_send![name_ptr, length];
let utf8_ptr: *const u8 = msg_send![name_ptr, UTF8String];
if !utf8_ptr.is_null() {
let bytes = std::slice::from_raw_parts(utf8_ptr, length);
if let Ok(s) = std::str::from_utf8(bytes) {
name = s.to_string();
}
}
}
}
// Create an ApplicationInfo with the proper name and object_id 0 (since we
// don't have audio object_id from process_id alone)
Some(ApplicationInfo::new(process_id as i32, name, 0))
}
pub fn tappable_application_with_process_id(process_id: u32) -> Option<ApplicationInfo> {
// Find the TappableApplication with this process ID in the list of running
// applications
match ShareableContent::applications() {
Ok(apps) => {
for app in apps {
if app.process_id == process_id as i32 {
return Some(app);
}
}
None
}
Err(_) => None,
}
}
#[napi]
pub fn is_using_microphone(process_id: u32) -> Result<bool> {
if process_id == 0 {
return Ok(false);
}
// Find the audio object ID for this process
if let Ok(app_list) = RUNNING_APPLICATIONS.read()
&& let Ok(app_list) = app_list.as_ref()
{
for object_id in app_list {
let pid = get_process_property(object_id, kAudioProcessPropertyPID).unwrap_or(-1);
if pid == process_id as i32 {
// Check if the process is actively using input (microphone)
match get_process_property(object_id, kAudioProcessPropertyIsRunningInput) {
Ok(is_running) => return Ok(is_running),
Err(_) => continue,
}
}
}
}
Ok(false)
}
pub(crate) fn tap_audio_with_callback(
process_id: u32,
audio_stream_callback: AudioCallback,
) -> Result<AudioCaptureSession> {
let app = ShareableContent::applications()?
.into_iter()
.find(|app| app.process_id == process_id as i32);
if let Some(app) = app {
if app.object_id == 0 {
return Err(Error::new(
Status::GenericFailure,
"Cannot tap audio: invalid object_id",
));
}
// Use AggregateDeviceManager instead of AggregateDevice directly
// This provides automatic default device change detection
let mut device_manager = AggregateDeviceManager::new(&app)?;
device_manager.start_capture(audio_stream_callback)?;
let boxed_manager = Box::new(device_manager);
Ok(AudioCaptureSession::new(boxed_manager))
} else {
Err(Error::new(
Status::GenericFailure,
"Application not found or not available for audio tapping",
))
}
}
pub(crate) fn tap_global_audio_with_callback(
excluded_processes: Option<Vec<&ApplicationInfo>>,
audio_stream_callback: AudioCallback,
) -> Result<AudioCaptureSession> {
let excluded_object_ids = excluded_processes
.unwrap_or_default()
.iter()
.map(|app| app.object_id)
.collect::<Vec<_>>();
// Use the new AggregateDeviceManager for automatic device adaptation
let mut device_manager = AggregateDeviceManager::new_global(&excluded_object_ids)?;
device_manager.start_capture(audio_stream_callback)?;
let boxed_manager = Box::new(device_manager);
Ok(AudioCaptureSession::new(boxed_manager))
}
}

View File

@@ -0,0 +1,984 @@
use std::{ffi::c_void, ptr, sync::Arc};
use block2::{Block, RcBlock};
use core_foundation::{
base::{CFType, ItemRef, TCFType},
dictionary::CFDictionary,
string::CFString,
uuid::CFUUID,
};
use coreaudio::sys::{
AudioDeviceCreateIOProcIDWithBlock, AudioDeviceDestroyIOProcID, AudioDeviceIOProcID, AudioDeviceStart,
AudioDeviceStop, AudioHardwareCreateAggregateDevice, AudioHardwareDestroyAggregateDevice,
AudioObjectAddPropertyListenerBlock, AudioObjectGetPropertyDataSize, AudioObjectID, AudioObjectPropertyAddress,
AudioObjectRemovePropertyListenerBlock, AudioTimeStamp, OSStatus, kAudioAggregateDeviceClockDeviceKey,
kAudioAggregateDeviceIsPrivateKey, kAudioAggregateDeviceIsStackedKey, kAudioAggregateDeviceMainSubDeviceKey,
kAudioAggregateDeviceNameKey, kAudioAggregateDeviceSubDeviceListKey, kAudioAggregateDeviceTapAutoStartKey,
kAudioAggregateDeviceTapListKey, kAudioAggregateDeviceUIDKey, kAudioDevicePropertyDeviceIsAlive,
kAudioDevicePropertyNominalSampleRate, kAudioHardwareBadDeviceError, kAudioHardwareBadStreamError,
kAudioHardwareNoError, kAudioHardwarePropertyDefaultInputDevice, kAudioHardwarePropertyDefaultOutputDevice,
kAudioObjectPropertyElementMain, kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject, kAudioSubDeviceUIDKey,
kAudioSubTapUIDKey,
};
use napi::bindgen_prelude::Result;
use objc2::runtime::AnyObject;
use crate::{
audio_buffer::InputAndOutputAudioBufferList,
audio_callback::AudioCallback,
ca_tap_description::CATapDescription,
cf_types::CFDictionaryBuilder,
device::get_device_uid,
error::CoreAudioError,
queue::create_audio_tap_queue,
screen_capture_kit::ApplicationInfo,
utils::{cfstring_from_bytes_with_nul, get_global_main_property},
};
unsafe extern "C" {
fn AudioHardwareCreateProcessTap(inDescription: *mut AnyObject, outTapID: *mut AudioObjectID) -> OSStatus;
fn AudioHardwareDestroyProcessTap(tapID: AudioObjectID) -> OSStatus;
}
// Audio statistics structure to track audio format information
#[derive(Clone, Copy, Debug)]
pub struct AudioStats {
pub sample_rate: f64,
pub channels: u32,
}
pub struct AggregateDevice {
pub tap_id: AudioObjectID,
pub id: AudioObjectID,
pub audio_stats: Option<AudioStats>,
pub input_device_id: AudioObjectID,
pub output_device_id: AudioObjectID,
pub input_proc_id: Option<AudioDeviceIOProcID>,
pub output_proc_id: Option<AudioDeviceIOProcID>,
}
impl AggregateDevice {
pub fn new(app: &ApplicationInfo) -> Result<Self> {
let object_id = app.object_id;
let tap_description = CATapDescription::init_stereo_mixdown_of_processes(object_id)?;
let mut tap_id: AudioObjectID = 0;
let status = unsafe { AudioHardwareCreateProcessTap(tap_description.inner, &mut tap_id) };
if status != 0 {
return Err(CoreAudioError::CreateProcessTapFailed(status).into());
}
let (input_device_id, default_input_uid) = get_device_uid(kAudioHardwarePropertyDefaultInputDevice)?;
// Get the default output device ID
let (output_device_id, output_device_uid) = get_device_uid(kAudioHardwarePropertyDefaultOutputDevice)?;
let description_dict = Self::create_aggregate_description(
tap_id,
tap_description.get_uuid()?,
default_input_uid,
output_device_uid,
)?;
let mut aggregate_device_id: AudioObjectID = 0;
let status = unsafe {
AudioHardwareCreateAggregateDevice(description_dict.as_concrete_TypeRef().cast(), &mut aggregate_device_id)
};
if status != 0 {
return Err(CoreAudioError::CreateAggregateDeviceFailed(status).into());
}
Ok(Self {
tap_id,
id: aggregate_device_id,
audio_stats: None,
input_device_id,
output_device_id,
input_proc_id: None,
output_proc_id: None,
})
}
pub fn create_global_tap_but_exclude_processes(processes: &[AudioObjectID]) -> Result<Self> {
let mut tap_id: AudioObjectID = 0;
let tap_description = CATapDescription::init_stereo_global_tap_but_exclude_processes(processes)?;
let status = unsafe { AudioHardwareCreateProcessTap(tap_description.inner, &mut tap_id) };
if status != 0 {
return Err(CoreAudioError::CreateProcessTapFailed(status).into());
}
// Get the default input device (microphone) UID and ID
let (input_device_id, default_input_uid) = get_device_uid(kAudioHardwarePropertyDefaultInputDevice)?;
// Get the default output device ID
let (output_device_id, output_device_uid) = get_device_uid(kAudioHardwarePropertyDefaultOutputDevice)?;
let description_dict = Self::create_aggregate_description(
tap_id,
tap_description.get_uuid()?,
default_input_uid,
output_device_uid,
)?;
let mut aggregate_device_id: AudioObjectID = 0;
let status = unsafe {
AudioHardwareCreateAggregateDevice(description_dict.as_concrete_TypeRef().cast(), &mut aggregate_device_id)
};
// Check the status and return the appropriate result
if status != 0 {
return Err(CoreAudioError::CreateAggregateDeviceFailed(status).into());
}
// Create a device with stored device IDs
let mut device = Self {
tap_id,
id: aggregate_device_id,
audio_stats: None,
input_device_id,
output_device_id,
input_proc_id: None,
output_proc_id: None,
};
// Restore the activation logic as it seems necessary for audio flow
// Configure the aggregate device to ensure proper handling of both input
// and output
device.get_aggregate_device_stats()?;
// Activate both the input and output devices and store their proc IDs
let input_proc_id = device.activate_audio_device(input_device_id)?;
let output_proc_id = device.activate_audio_device(output_device_id)?;
device.input_proc_id = Some(input_proc_id);
device.output_proc_id = Some(output_proc_id);
Ok(device)
}
fn get_aggregate_device_stats(&self) -> Result<AudioStats> {
let mut sample_rate: f64 = 0.0;
get_global_main_property(self.id, kAudioDevicePropertyNominalSampleRate, &mut sample_rate)?;
let audio_stats = AudioStats {
sample_rate,
channels: 2,
};
Ok(audio_stats)
}
// Activates an audio device by creating a dummy IO proc
fn activate_audio_device(&self, device_id: AudioObjectID) -> Result<AudioDeviceIOProcID> {
// Create a simple no-op dummy proc
let dummy_block = RcBlock::new(
|_: *mut c_void, _: *mut c_void, _: *mut c_void, _: *mut c_void, _: *mut c_void| {
// No-op function that just returns success
kAudioHardwareNoError as i32
},
);
let mut dummy_proc_id: AudioDeviceIOProcID = None;
// Create the IO proc with our dummy block
let status = unsafe {
AudioDeviceCreateIOProcIDWithBlock(
&mut dummy_proc_id,
device_id,
ptr::null_mut(),
(&*dummy_block.copy() as *const Block<dyn Fn(_, _, _, _, _) -> i32>)
.cast_mut()
.cast(),
)
};
if status != 0 {
return Err(CoreAudioError::CreateIOProcIDWithBlockFailed(status).into());
}
// Start the device to activate it
let status = unsafe { AudioDeviceStart(device_id, dummy_proc_id) };
if status != 0 {
// Clean up the IO proc if AudioDeviceStart fails
let _ = unsafe { AudioDeviceDestroyIOProcID(device_id, dummy_proc_id) };
return Err(CoreAudioError::AudioDeviceStartFailed(status).into());
}
// Return the proc ID for later cleanup
Ok(dummy_proc_id)
}
/// Implementation for the AggregateDevice to start processing audio
pub fn start(
&mut self,
audio_stream_callback: AudioCallback,
// Add original_audio_stats to ensure consistent target rate
original_audio_stats: AudioStats,
) -> Result<AudioTapStream> {
let mut current_audio_stats = self.get_aggregate_device_stats()?;
let queue = create_audio_tap_queue();
let mut in_proc_id: AudioDeviceIOProcID = None;
// Get the current input and output sample rates
let mut input_device_sample_rate: f64 = 0.0;
get_global_main_property(
self.input_device_id,
kAudioDevicePropertyNominalSampleRate,
&mut input_device_sample_rate,
)?;
let output_sample_rate = current_audio_stats.sample_rate;
// Use the consistent original sample rate as the target for the IO block
let target_sample_rate = original_audio_stats.sample_rate;
// Update the device's reported stats to the consistent one
current_audio_stats.sample_rate = target_sample_rate;
current_audio_stats.channels = original_audio_stats.channels;
self.audio_stats = Some(current_audio_stats);
// Use the consistent stats for the stream object returned
let audio_stats_for_stream = current_audio_stats;
let in_io_block: RcBlock<dyn Fn(*mut c_void, *mut c_void, *mut c_void, *mut c_void, *mut c_void) -> i32>;
{
in_io_block = RcBlock::new(
move |_in_now: *mut c_void,
in_input_data: *mut c_void,
in_input_time: *mut c_void,
_in_output_data: *mut c_void,
_in_output_time: *mut c_void| {
let AudioTimeStamp { mSampleTime, .. } = unsafe { &*in_input_time.cast() };
// ignore pre-roll
if *mSampleTime < 0.0 {
return kAudioHardwareNoError as i32;
}
let Ok(dua_audio_buffer_list) = (unsafe { InputAndOutputAudioBufferList::from_raw(in_input_data) }) else {
return kAudioHardwareBadDeviceError as i32;
};
let Ok(mixed_samples) = dua_audio_buffer_list.mix_input_and_output(
target_sample_rate,
input_device_sample_rate,
output_sample_rate,
) else {
return kAudioHardwareBadStreamError as i32;
};
// Send the processed audio data to the configured sink
audio_stream_callback.call(mixed_samples);
kAudioHardwareNoError as i32
},
);
}
let status = unsafe {
AudioDeviceCreateIOProcIDWithBlock(
&mut in_proc_id,
self.id,
dispatch2::DispatchRetained::as_ptr(&queue).as_ptr().cast(),
(&*in_io_block as *const Block<dyn Fn(*mut c_void, *mut c_void, *mut c_void, *mut c_void, *mut c_void) -> i32>)
.cast_mut()
.cast(),
)
};
if status != 0 {
return Err(CoreAudioError::CreateIOProcIDWithBlockFailed(status).into());
}
let status = unsafe { AudioDeviceStart(self.id, in_proc_id) };
if status != 0 {
// Attempt to clean up the IO proc if start failed
let _cleanup_status = unsafe { AudioDeviceDestroyIOProcID(self.id, in_proc_id) };
return Err(CoreAudioError::AudioDeviceStartFailed(status).into());
}
Ok(AudioTapStream {
device_id: self.id,
in_proc_id,
stop_called: false,
audio_stats: audio_stats_for_stream,
input_device_id: self.input_device_id,
output_device_id: self.output_device_id,
input_proc_id: self.input_proc_id,
output_proc_id: self.output_proc_id,
queue: Some(queue),
})
}
fn create_aggregate_description(
tap_id: AudioObjectID,
tap_uuid_string: ItemRef<CFString>,
input_device_id: CFString,
output_device_id: CFString,
) -> Result<CFDictionary<CFType, CFType>> {
let aggregate_device_name = CFString::new(&format!("Tap-{tap_id}"));
let aggregate_device_uid: uuid::Uuid = CFUUID::new().into();
let aggregate_device_uid_string = aggregate_device_uid.to_string();
let mut sub_device_input_dict = CFDictionaryBuilder::new();
sub_device_input_dict.add(kAudioSubDeviceUIDKey.as_slice(), &input_device_id);
let tap_device_dict = CFDictionary::from_CFType_pairs(&[(
cfstring_from_bytes_with_nul(kAudioSubTapUIDKey).as_CFType(),
tap_uuid_string.as_CFType(),
)]);
let capture_device_list = vec![sub_device_input_dict.build()];
// Create the aggregate device description dictionary with a balanced
// configuration
let mut cf_dict_builder = CFDictionaryBuilder::new();
cf_dict_builder
.add(kAudioAggregateDeviceNameKey.as_slice(), aggregate_device_name)
.add(kAudioAggregateDeviceUIDKey.as_slice(), aggregate_device_uid_string)
.add(kAudioAggregateDeviceMainSubDeviceKey.as_slice(), &output_device_id)
.add(kAudioAggregateDeviceIsPrivateKey.as_slice(), true)
// can't be stacked because we're using a tap
.add(kAudioAggregateDeviceIsStackedKey.as_slice(), false)
.add(kAudioAggregateDeviceTapAutoStartKey.as_slice(), true)
.add(kAudioAggregateDeviceSubDeviceListKey.as_slice(), capture_device_list)
.add(kAudioAggregateDeviceClockDeviceKey.as_slice(), input_device_id)
.add(kAudioAggregateDeviceTapListKey.as_slice(), vec![tap_device_dict]);
Ok(cf_dict_builder.build())
}
}
pub struct AudioTapStream {
device_id: AudioObjectID,
in_proc_id: AudioDeviceIOProcID,
stop_called: bool,
audio_stats: AudioStats,
input_device_id: AudioObjectID,
output_device_id: AudioObjectID,
input_proc_id: Option<AudioDeviceIOProcID>,
output_proc_id: Option<AudioDeviceIOProcID>,
queue: Option<dispatch2::DispatchRetained<dispatch2::DispatchQueue>>,
}
impl AudioTapStream {
pub fn stop(&mut self) -> Result<()> {
if self.stop_called {
return Ok(());
}
self.stop_called = true;
// Check if device exists before attempting to stop it
let mut device_exists = false;
let mut dummy_size: u32 = 0;
let device_check_status = unsafe {
AudioObjectGetPropertyDataSize(
self.device_id,
&AudioObjectPropertyAddress {
mSelector: kAudioDevicePropertyDeviceIsAlive,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
},
0,
ptr::null(),
&mut dummy_size,
)
};
if device_check_status == 0 {
device_exists = true;
}
// Stop the main aggregate device - ignore errors as device might already be
// stopped or disconnected
if device_exists {
let status = unsafe { AudioDeviceStop(self.device_id, self.in_proc_id) };
// Don't fail the whole stop process if this fails, just log the error and
// continue cleanup
if status != 0 {
// kAudioHardwareBadDeviceError (560227702 / 0x2166616E in hex)
// indicates the device is gone, which is expected in some
// scenarios (like device unplug). Treat this as non-existent.
if status == kAudioHardwareBadDeviceError as i32 {
device_exists = false; // Treat as non-existent for subsequent steps
}
}
}
// Stop the input device if it was activated
if let Some(proc_id) = self.input_proc_id {
// Ignore errors as device might be disconnected
let status = unsafe { AudioDeviceStop(self.input_device_id, proc_id) };
if status != 0 {
println!("DEBUG: WARNING: Input device stop failed with status: {status}");
}
let status = unsafe { AudioDeviceDestroyIOProcID(self.input_device_id, proc_id) };
if status != 0 {
println!("DEBUG: WARNING: Input device destroy IO proc failed with status: {status}");
}
}
// Stop the output device if it was activated
if let Some(proc_id) = self.output_proc_id {
// Ignore errors as device might be disconnected
let status = unsafe { AudioDeviceStop(self.output_device_id, proc_id) };
if status != 0 {
println!("DEBUG: WARNING: Output device stop failed with status: {status}");
}
let status = unsafe { AudioDeviceDestroyIOProcID(self.output_device_id, proc_id) };
if status != 0 {
println!("DEBUG: WARNING: Output device destroy IO proc failed with status: {status}");
}
}
// Destroy the main IO proc if device still exists
if device_exists {
let status = unsafe { AudioDeviceDestroyIOProcID(self.device_id, self.in_proc_id) };
if status != 0 {
println!("DEBUG: WARNING: Destroy IO proc failed with status: {status}");
}
}
let status = unsafe { AudioHardwareDestroyAggregateDevice(self.device_id) };
if status != 0 {
println!("DEBUG: WARNING: AudioHardwareDestroyAggregateDevice failed with status: {status}");
}
// Destroy the process tap - don't fail if this fails
let status = unsafe { AudioHardwareDestroyProcessTap(self.device_id) };
if status != 0 {
println!("DEBUG: WARNING: AudioHardwareDestroyProcessTap failed with status: {status}");
}
// destroy the queue
drop(self.queue.take());
// Always return success to prevent errors from bubbling up to JavaScript
// since we've made a best effort to clean up
Ok(())
}
pub fn get_sample_rate(&self) -> f64 {
self.audio_stats.sample_rate
}
/// Gets the actual sample rate of the current device
///
/// This can be different from the original sample rate if the default device
/// has changed. The original sample rate is maintained for consistency in
/// audio processing, but applications might need to know the actual device
/// sample rate for certain operations.
pub fn get_actual_sample_rate(&self) -> Result<f64> {
let device_id = self.output_device_id;
let mut actual_sample_rate: f64 = 0.0;
let status = unsafe {
let address = AudioObjectPropertyAddress {
mSelector: kAudioDevicePropertyNominalSampleRate,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
};
let mut size = std::mem::size_of::<f64>() as u32;
coreaudio::sys::AudioObjectGetPropertyData(
device_id,
&address,
0,
ptr::null(),
&mut size,
&mut actual_sample_rate as *mut f64 as *mut c_void,
)
};
if status != 0 {
return Err(CoreAudioError::GetPropertyDataFailed(status).into());
}
Ok(actual_sample_rate)
}
pub fn get_channels(&self) -> u32 {
self.audio_stats.channels
}
/// Mark the stream as stopped without performing actual cleanup
/// This is used when the device is known to be in an invalid state
pub fn mark_stopped_without_cleanup(&mut self) {
self.stop_called = true;
}
}
/// A manager for audio device handling that automatically adapts to device
/// changes
pub struct AggregateDeviceManager {
device: AggregateDevice,
default_devices_listener: Option<*mut c_void>,
is_app_specific: bool,
app_id: Option<AudioObjectID>,
excluded_processes: Vec<AudioObjectID>,
active_stream: Option<Arc<std::sync::Mutex<Option<AudioTapStream>>>>,
audio_callback: Option<AudioCallback>,
original_audio_stats: Option<AudioStats>,
}
impl AggregateDeviceManager {
/// Creates a new AggregateDeviceManager for a specific application
pub fn new(app: &ApplicationInfo) -> Result<Self> {
let device = AggregateDevice::new(app)?;
Ok(Self {
device,
default_devices_listener: None,
is_app_specific: true,
app_id: Some(app.object_id),
excluded_processes: Vec::new(),
active_stream: None,
audio_callback: None,
original_audio_stats: None,
})
}
/// Creates a new AggregateDeviceManager for global audio with option to
/// exclude processes
pub fn new_global(excluded_processes: &[AudioObjectID]) -> Result<Self> {
let device = AggregateDevice::create_global_tap_but_exclude_processes(excluded_processes)?;
Ok(Self {
device,
default_devices_listener: None,
is_app_specific: false,
app_id: None,
excluded_processes: excluded_processes.to_vec(),
active_stream: None,
audio_callback: None,
original_audio_stats: None,
})
}
/// This sets up the initial stream and listeners.
pub fn start_capture(&mut self, audio_stream_callback: AudioCallback) -> Result<()> {
// Store the callback for potential device switch later
self.audio_callback = Some(audio_stream_callback.clone());
// Create a shared reference for the active stream
let stream_mutex = Arc::new(std::sync::Mutex::new(None));
self.active_stream = Some(stream_mutex.clone());
// Start the initial stream
// Pass the initially determined consistent audio stats
let original_audio_stats = self.device.get_aggregate_device_stats().unwrap_or(AudioStats {
sample_rate: 48000.0, // Match fallback in setup_device_change_listeners
channels: 2,
});
self.original_audio_stats = Some(original_audio_stats); // Store for listener use
let initial_audio_tap_stream = self.device.start(audio_stream_callback.clone(), original_audio_stats)?; // Pass clone of callback
// Setup device change listeners AFTER getting initial stats and stream
self.setup_device_change_listeners()?;
// Store a reference to the stream
if let Ok(mut stream_guard) = stream_mutex.lock() {
*stream_guard = Some(initial_audio_tap_stream);
} else {
println!("DEBUG: Failed to lock stream_mutex to store AudioTapStream reference");
// If we can't store the initial stream, something is wrong.
// Attempt to stop the stream we just created? Or just return error?
// For now, return an error.
return Err(napi::Error::from_reason(
"Failed to lock internal stream mutex during startup",
));
}
Ok(())
}
/// Sets up listeners for default device changes
fn setup_device_change_listeners(&mut self) -> Result<()> {
// We need to clean up any existing listeners first
self.cleanup_device_listeners();
// Create a weak reference to self to avoid circular references
let stream_arc = self.active_stream.clone();
let callback_arc = self.audio_callback.clone();
let is_app_specific = self.is_app_specific;
let app_id = self.app_id;
let excluded_processes = self.excluded_processes.clone();
// Retrieve the stored original audio stats
let Some(original_audio_stats) = self.original_audio_stats else {
return Err(napi::Error::from_reason(
"Internal error: Original audio stats not available for listener.",
));
};
// Create a block that will handle device changes
let device_changed_block = RcBlock::new(move |_in_number_addresses: u32, _in_addresses: *mut c_void| {
// Skip if we don't have all required information
let Some(stream_mutex) = stream_arc.as_ref() else {
return;
};
let Some(callback) = callback_arc.as_ref() else {
return;
};
// Try to lock the stream mutex
let Ok(mut stream_guard) = stream_mutex.lock() else {
return;
};
// Create a new device with updated default devices
let result: Result<AggregateDevice> = {
if is_app_specific {
if let Some(id) = app_id {
// For device change listener, we need to create a minimal
// ApplicationInfo We don't have the name here, so we'll
// use an empty string
let app = ApplicationInfo::new(id as i32, String::new(), id);
AggregateDevice::new(&app)
} else {
Err(CoreAudioError::CreateProcessTapFailed(0).into())
}
} else {
AggregateDevice::create_global_tap_but_exclude_processes(&excluded_processes)
}
};
// If we successfully created a new device, stop the old stream and start
// a new one
match result {
Ok(mut new_device) => {
// Stop and drop the old stream if it exists
if let Some(mut old_stream) = stream_guard.take() {
// Explicitly drop the old stream's Box before creating the new
// device. The drop implementation handles cleanup.
// We call stop() directly.
let stop_result = old_stream.stop();
match stop_result {
Ok(_) => {}
Err(e) => println!("DEBUG: Error stopping old stream (proceeding anyway): {e}"),
};
drop(old_stream); // Ensure it's dropped now
}
match new_device.start(callback.clone(), original_audio_stats) {
Ok(new_stream) => {
// Use the existing stream_guard which already holds the lock
*stream_guard = Some(new_stream);
}
Err(e) => {
println!("DEBUG: Failed to start new stream: {e}");
}
}
}
Err(e) => {
println!("DEBUG: Failed to create new device: {e}");
}
}
});
// Create pointers to the device_changed_block that can be used in C
// functions
let block_ptr = &*device_changed_block as *const Block<dyn Fn(u32, *mut c_void)>;
let block_ptr_cast = block_ptr.cast_mut().cast();
// Register listeners for both input and output device changes
unsafe {
let status = AudioObjectAddPropertyListenerBlock(
kAudioObjectSystemObject,
&AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
},
ptr::null_mut(),
block_ptr_cast,
);
if status != 0 {
println!("DEBUG: Failed to register input device listener, status: {status}");
return Err(CoreAudioError::AddPropertyListenerBlockFailed(status).into());
}
let status = AudioObjectAddPropertyListenerBlock(
kAudioObjectSystemObject,
&AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
},
ptr::null_mut(),
block_ptr_cast,
);
if status != 0 {
println!("DEBUG: Failed to register output device listener, status: {status}");
// Clean up the first listener if the second one fails
AudioObjectRemovePropertyListenerBlock(
kAudioObjectSystemObject,
&AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
},
ptr::null_mut(),
block_ptr_cast,
);
return Err(CoreAudioError::AddPropertyListenerBlockFailed(status).into());
}
}
// Store the listener pointer for cleanup
self.default_devices_listener = Some(block_ptr_cast);
Ok(())
}
/// Cleans up device change listeners
fn cleanup_device_listeners(&mut self) {
if let Some(listener) = self.default_devices_listener.take() {
unsafe {
// Add a runtime check to ensure we're not in shutdown
let is_system_shutting_down = std::panic::catch_unwind(|| {
// Try a simple CoreAudio API call to see if the system is still
// responsive
let mut size: u32 = 0;
AudioObjectGetPropertyDataSize(
kAudioObjectSystemObject,
&AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
},
0,
ptr::null(),
&mut size,
)
})
.is_err();
if is_system_shutting_down {
// Don't try to remove listeners if the system is shutting down
return;
}
// Remove input device change listener
let status = AudioObjectRemovePropertyListenerBlock(
kAudioObjectSystemObject,
&AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
},
ptr::null_mut(),
listener,
);
if status != 0 {
// Don't log errors during shutdown to avoid additional issues
}
let status = AudioObjectRemovePropertyListenerBlock(
kAudioObjectSystemObject,
&AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
},
ptr::null_mut(),
listener,
);
if status != 0 {
// Don't log errors during shutdown to avoid additional issues
}
}
}
}
/// Stops the active stream and cleans up listeners.
pub fn stop_capture(&mut self) -> Result<()> {
self.cleanup_device_listeners();
let stream_to_stop = if let Some(stream_mutex) = &self.active_stream {
if let Ok(mut stream_guard) = stream_mutex.lock() {
stream_guard.take() // Take ownership from the Option<Arc<Mutex<...>>>
} else {
println!("DEBUG: Failed to lock stream mutex during stop");
None
}
} else {
None
};
if let Some(mut stream) = stream_to_stop {
match stream.stop() {
Ok(_) => {}
Err(e) => println!("DEBUG: Error stopping stream in stop_capture (ignored): {e}"),
}
// Explicitly drop here after stopping
drop(stream);
}
// Clear related fields
self.active_stream = None;
self.audio_callback = None;
self.original_audio_stats = None;
Ok(())
}
/// Gets the stats of the currently active stream, if any.
pub fn get_current_stats(&self) -> Option<AudioStats> {
if let Some(stream_mutex) = &self.active_stream {
if let Ok(stream_guard) = stream_mutex.lock() {
// Borrow the stream Option, then map to get stats
stream_guard.as_ref().map(|stream| stream.audio_stats)
} else {
println!("DEBUG: Failed to lock stream mutex for get_current_stats");
None
}
} else {
None
}
}
/// Gets the actual sample rate of the currently active stream's output
/// device.
pub fn get_current_actual_sample_rate(&self) -> Result<Option<f64>> {
let maybe_stream_ref = if let Some(stream_mutex) = &self.active_stream {
match stream_mutex.lock() {
Ok(guard) => guard,
Err(_) => {
println!("DEBUG: Failed to lock stream mutex for get_current_actual_sample_rate");
// Return Ok(None) or an error? Let's return None.
return Ok(None);
}
}
} else {
return Ok(None); // No active stream manager
};
if let Some(stream) = maybe_stream_ref.as_ref() {
// Call the existing non-napi method on AudioTapStream
match stream.get_actual_sample_rate() {
Ok(rate) => Ok(Some(rate)),
Err(e) => {
println!("DEBUG: Error getting actual sample rate from stream: {e}");
// Propagate the error
Err(e)
}
}
} else {
Ok(None) // No active stream
}
}
}
impl Drop for AggregateDeviceManager {
fn drop(&mut self) {
// Call stop_capture which handles listener cleanup and stream stopping
match self.stop_capture() {
Ok(_) => {}
Err(e) => println!("DEBUG: Error during stop_capture in Drop (ignored): {e}"),
}
}
}
pub struct AudioCaptureSession {
// Use Option<Box<...>> to allow taking ownership in stop()
manager: Option<Box<AggregateDeviceManager>>,
sample_rate: Option<f64>,
channels: Option<u32>,
}
impl AudioCaptureSession {
pub(crate) fn new(manager: Box<AggregateDeviceManager>) -> Self {
Self {
manager: Some(manager),
sample_rate: None,
channels: None,
}
}
pub fn stop(&mut self) -> Result<()> {
if let Some(manager) = self.manager.take() {
// Cache the stats before dropping
if let Some(stats) = manager.get_current_stats() {
self.sample_rate = Some(stats.sample_rate);
self.channels = Some(stats.channels);
}
// Drop the manager
drop(manager);
Ok(())
} else {
println!("DEBUG: AudioCaptureSession.stop() called, but manager was already taken");
// Return Ok even if called multiple times, idempotent behavior
Ok(())
}
}
pub fn get_sample_rate(&self) -> Result<f64> {
if let Some(manager) = &self.manager {
manager
.get_current_stats()
.map(|stats| stats.sample_rate)
.ok_or_else(|| napi::Error::from_reason("No active audio stream to get sample rate from"))
} else if let Some(cached_rate) = self.sample_rate {
// Return cached value when session is stopped
Ok(cached_rate)
} else {
Err(napi::Error::from_reason(
"Audio session is stopped and no cached sample rate available",
))
}
}
pub fn get_channels(&self) -> Result<u32> {
if let Some(manager) = &self.manager {
manager
.get_current_stats()
.map(|stats| stats.channels)
.ok_or_else(|| napi::Error::from_reason("No active audio stream to get channels from"))
} else if let Some(cached_channels) = self.channels {
// Return cached value when session is stopped
Ok(cached_channels)
} else {
Err(napi::Error::from_reason(
"Audio session is stopped and no cached channels available",
))
}
}
pub fn get_actual_sample_rate(&self) -> Result<f64> {
if let Some(manager) = &self.manager {
manager
.get_current_actual_sample_rate()? // Propagate CoreAudioError
.ok_or_else(|| napi::Error::from_reason("No active audio stream to get actual sample rate from"))
} else if let Some(cached_rate) = self.sample_rate {
// Return cached sample rate as the best approximation when session is
// stopped
Ok(cached_rate)
} else {
Err(napi::Error::from_reason(
"Audio session is stopped and no cached sample rate available",
))
}
}
}
// Ensure the manager is dropped if the session object is dropped without
// calling stop()
impl Drop for AudioCaptureSession {
fn drop(&mut self) {
// Automatically calls drop on self.manager if it's Some
if let Some(manager) = self.manager.take() {
drop(manager);
}
}
}

View File

@@ -0,0 +1,226 @@
use std::{cell::RefCell, collections::HashMap, ffi::c_void, mem::size_of};
use core_foundation::string::CFString;
use coreaudio::sys::{
AudioObjectGetPropertyData, AudioObjectID, AudioObjectPropertyAddress, kAudioObjectPropertyElementMain,
kAudioObjectPropertyScopeGlobal,
};
use rubato::{FastFixedIn, PolynomialDegree, Resampler};
use crate::error::CoreAudioError;
// ------------------------------------------------------------
// A simple wrapper that buffers incoming planar frames so that we always feed
// the Rubato resampler its preferred fixed block-length. This avoids the
// artefacts caused by recreating the resampler every callback.
// ------------------------------------------------------------
const RESAMPLER_INPUT_CHUNK: usize = 1024; // samples per channel
struct BufferedResampler {
resampler: FastFixedIn<f32>,
channels: usize,
fifo: Vec<Vec<f32>>, // per-channel queue
initial_output_discarded: bool, // Flag to track if the first output has been discarded
}
impl BufferedResampler {
fn new(from_sr: f64, to_sr: f64, channels: usize) -> Self {
let ratio = to_sr / from_sr;
let resampler = FastFixedIn::<f32>::new(
ratio,
1.0, // max_resample_ratio_relative (must be >= 1.0, use 1.0 for fixed ratio)
PolynomialDegree::Linear, // Use Linear interpolation quality
RESAMPLER_INPUT_CHUNK,
channels,
)
.expect("Failed to create FastFixedIn resampler (5-arg attempt)");
BufferedResampler {
resampler,
channels,
fifo: vec![Vec::<f32>::new(); channels],
initial_output_discarded: false,
}
}
// feed planar samples; returns interleaved output (may be empty if not
// enough samples accumulated yet).
fn feed(&mut self, planar_in: &[Vec<f32>]) -> Vec<f32> {
// Append incoming to fifo
for (ch, data) in planar_in.iter().enumerate() {
self.fifo[ch].extend_from_slice(data);
}
let mut interleaved_out: Vec<f32> = Vec::new();
while self.fifo[0].len() >= RESAMPLER_INPUT_CHUNK {
// Drain exactly one chunk per channel
let mut chunk: Vec<Vec<f32>> = Vec::with_capacity(self.channels);
for ch in 0..self.channels {
let tail = self.fifo[ch].drain(..RESAMPLER_INPUT_CHUNK).collect::<Vec<_>>();
chunk.push(tail);
}
if let Ok(out_blocks) = self.resampler.process(&chunk, None) {
// out_blocks is Vec<Vec<f32>> planar
if !out_blocks.is_empty() && out_blocks.len() == self.channels {
// Check if we should discard the initial output
if !self.initial_output_discarded {
self.initial_output_discarded = true;
} else {
// interleave
let out_len = out_blocks[0].len();
#[allow(clippy::needless_range_loop)]
for i in 0..out_len {
// apply clippy lint suggestion would regress performance
for ch in 0..self.channels {
interleaved_out.push(out_blocks[ch][i]);
}
}
}
}
}
}
interleaved_out
}
}
// thread-local cache so that each audio‐tap thread keeps its own resamplers
thread_local! {
static RESAMPLER_CACHE: RefCell<HashMap<(u32,u32,usize), BufferedResampler>> = RefCell::new(HashMap::new());
}
pub fn cfstring_from_bytes_with_nul(bytes: &[u8]) -> CFString {
CFString::new(
unsafe { std::ffi::CStr::from_bytes_with_nul_unchecked(bytes) }
.to_string_lossy()
.as_ref(),
)
}
pub fn get_global_main_property<T: Sized>(
id: AudioObjectID,
selector: u32,
data: *mut T,
) -> Result<(), CoreAudioError> {
unsafe {
let address = AudioObjectPropertyAddress {
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
};
let mut data_size = size_of::<T>() as u32;
let status = AudioObjectGetPropertyData(
id,
&address as *const AudioObjectPropertyAddress,
0,
std::ptr::null(),
&mut data_size,
data.cast(),
);
if status != 0 {
return Err(CoreAudioError::GetPropertyDataFailed(status));
}
Ok(())
}
}
pub fn process_audio_frame(
m_data: *mut c_void,
m_data_byte_size: u32,
m_number_channels: u32,
current_sample_rate: f64,
target_sample_rate: f64,
) -> Option<Vec<f32>> {
// Only create slice if we have valid data
if m_data.is_null() || m_data_byte_size == 0 {
return None;
}
// Calculate total number of samples (total bytes / bytes per sample)
let total_samples = m_data_byte_size as usize / 4; // 4 bytes per f32
// Create a slice of all samples
let samples: &[f32] = unsafe { std::slice::from_raw_parts(m_data.cast::<f32>(), total_samples) };
// Check the channel count and data format
let channel_count = m_number_channels as usize;
// If the audio has two or more channels, keep (at most) the first two
// channels and return them in interleaved stereo format. Otherwise keep
// mono as-is.
let interleaved_samples: Vec<f32> = if channel_count >= 2 {
// Split interleaved input into the first two channels (L, R)
let mut left: Vec<f32> = Vec::with_capacity(total_samples / channel_count);
let mut right: Vec<f32> = Vec::with_capacity(total_samples / channel_count);
for chunk in samples.chunks(channel_count) {
// SAFETY: chunk has at least 2 items because channel_count >= 2
left.push(chunk[0]);
right.push(chunk[1]);
}
if current_sample_rate != target_sample_rate {
// Use (or create) a persistent BufferedResampler
RESAMPLER_CACHE.with(|cache| {
let mut map = cache.borrow_mut();
let key = (current_sample_rate as u32, target_sample_rate as u32, 2usize);
let resampler = map
.entry(key)
.or_insert_with(|| BufferedResampler::new(current_sample_rate, target_sample_rate, 2));
resampler.feed(&[left, right])
})
} else {
// No resampling needed, just interleave existing left/right data
let mut interleaved: Vec<f32> = Vec::with_capacity(left.len() * 2);
for i in 0..left.len() {
interleaved.push(left[i]);
interleaved.push(right[i]);
}
interleaved
}
} else {
// Mono path – behave as before (optionally resample)
let mut mono_samples = samples.to_vec();
if current_sample_rate != target_sample_rate {
let out_vec = RESAMPLER_CACHE.with(|cache| {
let mut map = cache.borrow_mut();
let key = (current_sample_rate as u32, target_sample_rate as u32, 1usize);
let resampler = map
.entry(key)
.or_insert_with(|| BufferedResampler::new(current_sample_rate, target_sample_rate, 1));
resampler.feed(&[mono_samples])
});
// resampler returns interleaved (1 channel) but we still need planar mono
// (vector of samples) before upmix; since feed returns interleaved single
// channel, it is planar already.
mono_samples = out_vec;
}
// Upmix mono to stereo by duplicating each sample so that mixing with
// interleaved stereo streams keeps channel counts aligned.
let mut stereo_samples: Vec<f32> = Vec::with_capacity(mono_samples.len() * 2);
for s in &mono_samples {
stereo_samples.push(*s);
stereo_samples.push(*s);
}
stereo_samples
};
if interleaved_samples.is_empty() {
None
} else {
Some(interleaved_samples)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
# Windows Audio Recording
This module provides Windows-specific audio recording functionality using the Windows Audio Session API (WASAPI).
## Features
- **Microphone Activity Detection**: Monitor when applications are using the microphone
- **Process Identification**: Identify which process is using the microphone
- **Real-time Notifications**: Get callbacks when microphone usage starts/stops
## Usage
### MicrophoneListener
The `MicrophoneListener` class provides real-time monitoring of microphone usage:
```typescript
import { MicrophoneListener } from '@affine/native';
const listener = new MicrophoneListener(
(isRunning: boolean, processName: string) => {
console.log(
`Microphone ${isRunning ? 'started' : 'stopped'} by ${processName}`
);
}
);
// Check current status
console.log('Is microphone currently active:', listener.is_running());
```
### Callback Parameters
The callback receives two parameters:
- `isRunning: boolean` - Whether the microphone is currently active
- `processName: string` - Name of the process using the microphone
## Implementation Details
### Audio Session Monitoring
The implementation uses Windows Audio Session API to:
1. **Enumerate Audio Sessions**: Get all active audio sessions
2. **Monitor Session State**: Track when sessions become active/inactive
3. **Process Identification**: Map audio sessions to process names
4. **Event Handling**: Provide real-time notifications
### COM Initialization
The module automatically initializes COM (Component Object Model) with `COINIT_MULTITHREADED` for proper Windows API interaction.
### Error Handling
All Windows API errors are wrapped in `WindowsAudioError` enum and converted to NAPI errors for JavaScript consumption.
## Cross-Platform Compatibility
This Windows implementation maintains API compatibility with the macOS version, providing the same JavaScript interface while using Windows-specific APIs underneath.
## Platform Requirements
- Windows 10 or later
- Microphone access permissions
- Audio devices available
## Dependencies
- `windows` crate v0.61 with Audio and Process features
- `windows-core` crate v0.61
- `napi` and `napi-derive` for JavaScript bindings
## Technical Notes
### Thread Safety
The implementation uses thread-safe callbacks to JavaScript with `ThreadsafeFunction<(bool, String), ()>` to ensure proper communication between the Windows audio session monitoring thread and the JavaScript runtime.
### Process Name Resolution
Process names are resolved using Windows APIs:
- `GetModuleFileNameExW` for full executable path
- `GetProcessImageFileNameW` as fallback
- Automatic extraction of filename from full path
### Session Filtering
The implementation automatically filters out system audio sessions (like `AudioSrv`) to focus on user applications.

View File

@@ -0,0 +1,491 @@
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread::JoinHandle,
};
use cpal::{
SampleRate,
traits::{DeviceTrait, HostTrait, StreamTrait},
};
use crossbeam_channel::unbounded;
use napi::{Error, Status, bindgen_prelude::Result};
use napi_derive::napi;
use rubato::{FastFixedIn, PolynomialDegree, Resampler};
use crate::audio_callback::AudioCallback;
const RESAMPLER_INPUT_CHUNK: usize = 1024; // samples per channel
const TARGET_FRAME_SIZE: usize = 1024; // frame size returned to JS (in mono samples)
struct BufferedResampler {
resampler: FastFixedIn<f32>,
channels: usize,
fifo: Vec<Vec<f32>>, // per-channel queue
initial_output_discarded: bool, // Flag to discard first output block (warm-up)
}
impl BufferedResampler {
fn new(from_sr: f64, to_sr: f64, channels: usize) -> Self {
let ratio = to_sr / from_sr;
let resampler = FastFixedIn::<f32>::new(
ratio,
1.0, // max_resample_ratio_relative (>= 1.0, fixed ratio)
PolynomialDegree::Linear, // balance quality/perf
RESAMPLER_INPUT_CHUNK,
channels,
)
.expect("Failed to create FastFixedIn resampler");
Self {
resampler,
channels,
fifo: vec![Vec::<f32>::new(); channels],
initial_output_discarded: false,
}
}
fn append_output(&mut self, out_blocks: &[Vec<f32>], interleaved_out: &mut Vec<f32>, final_flush: bool) {
if out_blocks.is_empty() || out_blocks.len() != self.channels {
return;
}
if !self.initial_output_discarded && !final_flush {
self.initial_output_discarded = true;
return;
}
self.initial_output_discarded = true;
let out_len = out_blocks[0].len();
for i in 0..out_len {
for channel in out_blocks.iter().take(self.channels) {
interleaved_out.push(channel[i]);
}
}
}
fn process_chunk(&mut self, chunk: Vec<Vec<f32>>, interleaved_out: &mut Vec<f32>, final_flush: bool) {
if let Ok(out_blocks) = self.resampler.process(&chunk, None) {
self.append_output(&out_blocks, interleaved_out, final_flush);
}
}
// Feed planar samples; returns interleaved output (may be empty)
fn feed(&mut self, planar_in: &[Vec<f32>]) -> Vec<f32> {
for (ch, data) in planar_in.iter().enumerate() {
if ch < self.fifo.len() {
self.fifo[ch].extend_from_slice(data);
}
}
let mut interleaved_out = Vec::new();
while self.fifo[0].len() >= RESAMPLER_INPUT_CHUNK {
// Take exactly RESAMPLER_INPUT_CHUNK per channel
let mut chunk: Vec<Vec<f32>> = Vec::with_capacity(self.channels);
for ch in 0..self.channels {
let tail: Vec<f32> = self.fifo[ch].drain(..RESAMPLER_INPUT_CHUNK).collect();
chunk.push(tail);
}
self.process_chunk(chunk, &mut interleaved_out, false);
}
interleaved_out
}
fn finish(&mut self) -> Vec<f32> {
let mut interleaved_out = Vec::new();
if self.fifo.first().is_none_or(|channel| channel.is_empty()) {
return interleaved_out;
}
let mut chunk: Vec<Vec<f32>> = Vec::with_capacity(self.channels);
for ch in 0..self.channels {
let mut tail = std::mem::take(&mut self.fifo[ch]);
tail.resize(RESAMPLER_INPUT_CHUNK, 0.0);
chunk.push(tail);
}
self.process_chunk(chunk, &mut interleaved_out, true);
interleaved_out
}
}
fn to_mono(frame: &[f32]) -> f32 {
if frame.is_empty() {
return 0.0;
}
let sum: f32 = frame.iter().filter(|s| s.is_finite()).copied().sum();
let mono = if frame.len() == 1 {
sum // already mono, no reduction needed
} else {
// For multi-channel, take the sum but don't divide by channel count
// This preserves more energy while still avoiding simple doubling
sum * 0.7 // slight reduction to prevent clipping, but preserve energy
};
mono.clamp(-1.0, 1.0)
}
fn mix(a: &[f32], b: &[f32]) -> Vec<f32> {
let min_len = a.len().min(b.len());
if min_len == 0 {
return Vec::new();
}
const MIC_GAIN: f32 = 3.0; // Higher gain for microphone input
const LOOPBACK_GAIN: f32 = 1.5; // Moderate gain for loopback
const OVERALL_GAIN: f32 = 1.2; // Final boost
a.iter()
.take(min_len)
.zip(b.iter().take(min_len))
.map(|(x, y)| {
let x_clean = if x.is_finite() { *x } else { 0.0 };
let y_clean = if y.is_finite() { *y } else { 0.0 };
// Apply individual gains to mic (x) and loopback (y), then mix
let mic_boosted = x_clean * MIC_GAIN;
let loopback_boosted = y_clean * LOOPBACK_GAIN;
let mixed = (mic_boosted + loopback_boosted) * OVERALL_GAIN;
// Soft limiting using tanh for more natural sound than hard clipping
if mixed.abs() > 1.0 {
mixed.signum() * (1.0 - (-mixed.abs()).exp())
} else {
mixed
}
})
.collect()
}
struct AudioBuffer {
data: Vec<f32>,
}
fn extend_post_buffer(post_buffer: &mut Vec<f32>, mono_samples: Vec<f32>, resampler: &mut Option<BufferedResampler>) {
if let Some(resampler) = resampler.as_mut() {
post_buffer.extend(resampler.feed(&[mono_samples]));
} else {
post_buffer.extend(mono_samples);
}
}
fn emit_mixed_frames(
post_mic: &mut Vec<f32>,
post_lb: &mut Vec<f32>,
audio_buffer_callback: &AudioCallback,
final_flush: bool,
) {
while post_mic.len() >= TARGET_FRAME_SIZE && post_lb.len() >= TARGET_FRAME_SIZE {
let mic_chunk: Vec<f32> = post_mic.drain(..TARGET_FRAME_SIZE).collect();
let lb_chunk: Vec<f32> = post_lb.drain(..TARGET_FRAME_SIZE).collect();
let mixed = mix(&mic_chunk, &lb_chunk);
if !mixed.is_empty() {
audio_buffer_callback.call(mixed);
}
}
if final_flush && !post_mic.is_empty() && !post_lb.is_empty() {
let tail_len = post_mic.len().min(post_lb.len());
let mic_chunk: Vec<f32> = post_mic.drain(..tail_len).collect();
let lb_chunk: Vec<f32> = post_lb.drain(..tail_len).collect();
let mixed = mix(&mic_chunk, &lb_chunk);
if !mixed.is_empty() {
audio_buffer_callback.call(mixed);
}
post_mic.clear();
post_lb.clear();
}
}
pub struct AudioCaptureSession {
mic_stream: Option<cpal::Stream>,
lb_stream: Option<cpal::Stream>,
stop_requested: Arc<AtomicBool>,
sample_rate: SampleRate,
channels: u32,
jh: Option<JoinHandle<()>>, // background mixing thread
}
fn teardown_audio_capture_resources<S, F>(
mic_stream: &mut Option<S>,
lb_stream: &mut Option<S>,
stop_requested: &Arc<AtomicBool>,
jh: &mut Option<JoinHandle<()>>,
pause_stream: F,
) -> Result<()>
where
F: Fn(&S) -> std::result::Result<(), String>,
{
if mic_stream.is_none() && lb_stream.is_none() && jh.is_none() {
return Ok(());
}
let mic_stream = mic_stream.take();
let lb_stream = lb_stream.take();
let jh = jh.take();
let mut pause_errors = Vec::new();
if let Some(stream) = mic_stream.as_ref() {
if let Err(error) = pause_stream(stream) {
pause_errors.push(format!("pause mic stream: {error}"));
}
}
if let Some(stream) = lb_stream.as_ref() {
if let Err(error) = pause_stream(stream) {
pause_errors.push(format!("pause loopback stream: {error}"));
}
}
stop_requested.store(true, Ordering::SeqCst);
drop(mic_stream);
drop(lb_stream);
if let Some(jh) = jh {
let _ = jh.join(); // ignore poison
}
if pause_errors.is_empty() {
Ok(())
} else {
Err(Error::new(Status::GenericFailure, pause_errors.join("; ")))
}
}
impl AudioCaptureSession {
pub fn get_sample_rate(&self) -> f64 {
self.sample_rate.0 as f64
}
pub fn get_channels(&self) -> u32 {
self.channels
}
pub fn get_actual_sample_rate(&self) -> f64 {
// For CPAL we always operate at the target rate which is sample_rate
self.sample_rate.0 as f64
}
pub fn stop(&mut self) -> Result<()> {
teardown_audio_capture_resources(
&mut self.mic_stream,
&mut self.lb_stream,
&self.stop_requested,
&mut self.jh,
|stream| stream.pause().map_err(|error| error.to_string()),
)
}
}
impl Drop for AudioCaptureSession {
fn drop(&mut self) {
let _ = self.stop(); // Ensure cleanup even if JS forgets to call stop()
}
}
pub fn start_recording(
audio_buffer_callback: AudioCallback,
target_sample_rate: Option<SampleRate>,
) -> Result<AudioCaptureSession> {
let available_hosts = cpal::available_hosts();
let host_id = available_hosts
.first()
.ok_or_else(|| Error::new(Status::GenericFailure, "No CPAL hosts available"))?;
let host = cpal::host_from_id(*host_id).map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
let mic = host
.default_input_device()
.ok_or_else(|| Error::new(Status::GenericFailure, "No default input device"))?;
let loopback_device = host
.default_output_device()
.ok_or_else(|| Error::new(Status::GenericFailure, "No default output/loopback device"))?;
let mic_config = mic
.default_input_config()
.map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
let lb_config = loopback_device
.default_output_config()
.map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
let mic_sample_rate = mic_config.sample_rate();
let lb_sample_rate = lb_config.sample_rate();
let target_rate = target_sample_rate.unwrap_or(SampleRate(mic_sample_rate.min(lb_sample_rate).0));
let mic_channels = mic_config.channels();
let lb_channels = lb_config.channels();
// Convert supported configs to concrete StreamConfigs
let mic_stream_config: cpal::StreamConfig = mic_config.clone().into();
let lb_stream_config: cpal::StreamConfig = lb_config.clone().into();
let stop_requested = Arc::new(AtomicBool::new(false));
// Channels for passing raw buffers between callback and mixer thread
let (tx_mic, rx_mic) = unbounded::<AudioBuffer>();
let (tx_lb, rx_lb) = unbounded::<AudioBuffer>();
// Build microphone input stream
let mic_stream = mic
.build_input_stream(
&mic_stream_config,
move |data: &[f32], _| {
let _ = tx_mic.send(AudioBuffer { data: data.to_vec() });
},
|err| eprintln!("CPAL mic stream error: {err}"),
None,
)
.map_err(|e| Error::new(Status::GenericFailure, format!("build_input_stream: {e}")))?;
// Build loopback stream by creating input stream on output device (WASAPI
// supports this)
let lb_stream = loopback_device
.build_input_stream(
&lb_stream_config,
move |data: &[f32], _| {
let _ = tx_lb.send(AudioBuffer { data: data.to_vec() });
},
|err| eprintln!("CPAL loopback stream error: {err}"),
None,
)
.map_err(|e| Error::new(Status::GenericFailure, format!("build_lb_stream: {e}")))?;
let stop_requested_flag = stop_requested.clone();
let jh = std::thread::spawn(move || {
let mut post_mic: Vec<f32> = Vec::new();
let mut post_lb: Vec<f32> = Vec::new();
let mut mic_resampler = (mic_sample_rate != target_rate)
.then(|| BufferedResampler::new(mic_sample_rate.0 as f64, target_rate.0 as f64, 1));
let mut lb_resampler =
(lb_sample_rate != target_rate).then(|| BufferedResampler::new(lb_sample_rate.0 as f64, target_rate.0 as f64, 1));
let mut flushed_tail = false;
loop {
while let Ok(buf) = rx_mic.try_recv() {
let mono_samples: Vec<f32> = if mic_channels == 1 {
buf.data
} else {
buf.data.chunks(mic_channels as usize).map(to_mono).collect()
};
extend_post_buffer(&mut post_mic, mono_samples, &mut mic_resampler);
}
while let Ok(buf) = rx_lb.try_recv() {
let mono_samples: Vec<f32> = if lb_channels == 1 {
buf.data
} else {
buf.data.chunks(lb_channels as usize).map(to_mono).collect()
};
extend_post_buffer(&mut post_lb, mono_samples, &mut lb_resampler);
}
emit_mixed_frames(&mut post_mic, &mut post_lb, &audio_buffer_callback, false);
const MAX_POST: usize = TARGET_FRAME_SIZE * 10;
if post_mic.len() > MAX_POST {
post_mic.drain(..post_mic.len() - MAX_POST);
}
if post_lb.len() > MAX_POST {
post_lb.drain(..post_lb.len() - MAX_POST);
}
let stop_requested = stop_requested_flag.load(Ordering::SeqCst);
if stop_requested && !flushed_tail && rx_mic.is_empty() && rx_lb.is_empty() {
if let Some(mut resampler) = mic_resampler.take() {
post_mic.extend(resampler.finish());
}
if let Some(mut resampler) = lb_resampler.take() {
post_lb.extend(resampler.finish());
}
emit_mixed_frames(&mut post_mic, &mut post_lb, &audio_buffer_callback, true);
flushed_tail = true;
}
if stop_requested && flushed_tail && rx_mic.is_empty() && rx_lb.is_empty() {
break;
}
if !stop_requested
&& rx_mic.is_empty()
&& rx_lb.is_empty()
&& post_mic.len() < TARGET_FRAME_SIZE
&& post_lb.len() < TARGET_FRAME_SIZE
{
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
});
mic_stream
.play()
.map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
lb_stream
.play()
.map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
Ok(AudioCaptureSession {
mic_stream: Some(mic_stream),
lb_stream: Some(lb_stream),
stop_requested,
sample_rate: target_rate,
channels: 1, // mono output
jh: Some(jh),
})
}
#[cfg(test)]
mod tests {
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use super::teardown_audio_capture_resources;
#[test]
fn teardown_consumes_resources_even_when_pause_fails() {
let mut mic_stream = Some("mic");
let mut lb_stream = Some("loopback");
let stop_requested = Arc::new(AtomicBool::new(false));
let joined = Arc::new(AtomicBool::new(false));
let joined_flag = joined.clone();
let mut jh = Some(std::thread::spawn(move || {
joined_flag.store(true, Ordering::SeqCst);
}));
let result = teardown_audio_capture_resources(&mut mic_stream, &mut lb_stream, &stop_requested, &mut jh, |_| {
Err("pause failed".to_owned())
});
assert!(result.is_err());
assert!(stop_requested.load(Ordering::SeqCst));
assert!(mic_stream.is_none());
assert!(lb_stream.is_none());
assert!(jh.is_none());
assert!(joined.load(Ordering::SeqCst));
}
#[test]
fn teardown_is_idempotent_after_resources_are_consumed() {
let mut mic_stream: Option<&'static str> = None;
let mut lb_stream: Option<&'static str> = None;
let stop_requested = Arc::new(AtomicBool::new(true));
let mut jh = None;
let result = teardown_audio_capture_resources(
&mut mic_stream,
&mut lb_stream,
&stop_requested,
&mut jh,
|_| -> std::result::Result<(), String> { panic!("pause should not be called when resources are already gone") },
);
assert!(result.is_ok());
}
}

View File

@@ -0,0 +1,39 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum WindowsAudioError {
#[error("Failed to initialize COM: {0}")]
ComInitializationFailed(String),
#[error("Failed to create device enumerator: {0}")]
DeviceEnumeratorCreationFailed(String),
#[error("Failed to get default audio endpoint: {0}")]
DefaultAudioEndpointFailed(String),
#[error("Failed to activate audio session manager: {0}")]
AudioSessionManagerActivationFailed(String),
#[error("Failed to register session notification: {0}")]
SessionNotificationRegistrationFailed(String),
#[error("Failed to get session enumerator: {0}")]
SessionEnumeratorFailed(String),
#[error("Failed to get session count: {0}")]
SessionCountFailed(String),
#[error("Failed to get session: {0}")]
GetSessionFailed(String),
#[error("Failed to get process ID: {0}")]
ProcessIdFailed(String),
#[error("Failed to get session state: {0}")]
SessionStateFailed(String),
#[error("Failed to register audio session notification: {0}")]
AudioSessionNotificationFailed(String),
#[error("Failed to unregister audio session notification: {0}")]
AudioSessionUnregisterFailed(String),
#[error("Failed to open process: {0}")]
ProcessOpenFailed(String),
#[error("Failed to get process name: {0}")]
ProcessNameFailed(String),
}
impl From<WindowsAudioError> for napi::Error {
fn from(value: WindowsAudioError) -> Self {
napi::Error::new(napi::Status::GenericFailure, value.to_string())
}
}

View File

@@ -0,0 +1,625 @@
use std::{
ffi::OsString,
os::windows::ffi::OsStringExt,
process,
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
};
use napi::{
Result,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use windows::{
Win32::{
Foundation::CloseHandle,
Media::Audio::{
AudioSessionState, AudioSessionStateActive, DEVICE_STATE_ACTIVE, IAudioSessionControl, IAudioSessionControl2,
IAudioSessionEnumerator, IAudioSessionEvents, IAudioSessionEvents_Impl, IAudioSessionManager2,
IAudioSessionNotification, IAudioSessionNotification_Impl, IMMDevice, IMMDeviceCollection, IMMDeviceEnumerator,
MMDeviceEnumerator, eCapture, eCommunications, eConsole,
},
System::{
Com::{CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx},
ProcessStatus::{GetModuleFileNameExW, GetProcessImageFileNameW},
Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ},
},
},
core::Interface,
};
use windows_core::implement;
pub struct AudioProcess {
pub process_name: String,
pub process_id: u32,
pub device_id: String,
pub device_name: String,
pub is_running: bool,
}
pub struct AudioDevice {
pub device_id: String,
pub device_name: String,
pub is_default_communications: bool,
pub is_default_console: bool,
pub has_active_sessions: bool,
}
// Simple struct for callback data - not a NAPI object
#[derive(Clone)]
pub struct MicrophoneActivateCallback {
pub is_running: bool,
pub process_name: String,
pub device_id: String,
pub device_name: String,
}
#[implement(IAudioSessionEvents)]
struct SessionEvents {
process_name: String,
device_id: String,
device_name: String,
callback: Arc<ThreadsafeFunction<(bool, String, String, String)>>,
ctrl: IAudioSessionControl,
events_ref: Arc<Mutex<Option<IAudioSessionEvents>>>,
is_running: Arc<AtomicBool>,
active_sessions: Arc<AtomicUsize>,
session_is_active: AtomicBool,
}
impl IAudioSessionEvents_Impl for SessionEvents_Impl {
fn OnChannelVolumeChanged(
&self,
_channelcount: u32,
_newchannelvolumearray: *const f32,
_changedchannel: u32,
_eventcontext: *const windows_core::GUID,
) -> windows_core::Result<()> {
Ok(())
}
fn OnDisplayNameChanged(
&self,
_newdisplayname: &windows_core::PCWSTR,
_eventcontext: *const windows_core::GUID,
) -> windows_core::Result<()> {
Ok(())
}
fn OnGroupingParamChanged(
&self,
_newgroupingparam: *const windows_core::GUID,
_eventcontext: *const windows_core::GUID,
) -> windows_core::Result<()> {
Ok(())
}
fn OnIconPathChanged(
&self,
_newiconpath: &windows_core::PCWSTR,
_eventcontext: *const windows_core::GUID,
) -> windows_core::Result<()> {
Ok(())
}
fn OnSessionDisconnected(
&self,
_disconnectreason: windows::Win32::Media::Audio::AudioSessionDisconnectReason,
) -> windows_core::Result<()> {
if let Some(events) = self.events_ref.lock().unwrap().take() {
unsafe { self.ctrl.UnregisterAudioSessionNotification(&events)? };
}
// If this session was active, decrement the global counter
if self.session_is_active.swap(false, Ordering::SeqCst) {
let prev = self.active_sessions.fetch_sub(1, Ordering::SeqCst);
if prev == 1 {
// Last active session ended
self.is_running.store(false, Ordering::Relaxed);
// Notify JS side that recording has stopped
self.callback.call(
Ok((
false,
self.process_name.clone(),
self.device_id.clone(),
self.device_name.clone(),
)),
ThreadsafeFunctionCallMode::NonBlocking,
);
}
}
Ok(())
}
fn OnSimpleVolumeChanged(
&self,
_newvolume: f32,
_newmute: windows_core::BOOL,
_eventcontext: *const windows_core::GUID,
) -> windows_core::Result<()> {
Ok(())
}
fn OnStateChanged(&self, newstate: AudioSessionState) -> windows_core::Result<()> {
// Determine the new recording state for this session
let currently_recording = newstate == AudioSessionStateActive;
// Atomically swap the flag tracking this particular session
let previously_recording = self.session_is_active.swap(currently_recording, Ordering::SeqCst);
// Update the global counter accordingly
if !previously_recording && currently_recording {
// Session started
let prev = self.active_sessions.fetch_add(1, Ordering::SeqCst);
if prev == 0 {
// First active session across the whole system
self.is_running.store(true, Ordering::Relaxed);
}
} else if previously_recording && !currently_recording {
// Session stopped
let prev = self.active_sessions.fetch_sub(1, Ordering::SeqCst);
if prev == 1 {
// Last active session just stopped
self.is_running.store(false, Ordering::Relaxed);
}
}
let overall_is_running = self.active_sessions.load(Ordering::SeqCst) > 0;
// Notify JS side (non-blocking)
self.callback.call(
Ok((
overall_is_running,
self.process_name.clone(),
self.device_id.clone(),
self.device_name.clone(),
)),
ThreadsafeFunctionCallMode::NonBlocking,
);
Ok(())
}
}
#[implement(IAudioSessionNotification)]
struct SessionNotifier {
_mgr: IAudioSessionManager2, // keep mgr alive
device_id: String,
device_name: String,
ctrl: Mutex<Option<(IAudioSessionControl2, IAudioSessionEvents)>>, /* keep the ctrl2 and
* events alive */
callback: Arc<ThreadsafeFunction<(bool, String, String, String)>>,
is_running: Arc<AtomicBool>, // Shared is_running flag
active_sessions: Arc<AtomicUsize>, // Global counter of active sessions
}
impl SessionNotifier {
fn new(
mgr: &IAudioSessionManager2,
device_id: String,
device_name: String,
callback: Arc<ThreadsafeFunction<(bool, String, String, String)>>,
is_running: Arc<AtomicBool>,
active_sessions: Arc<AtomicUsize>,
) -> Self {
Self {
_mgr: mgr.clone(),
device_id,
device_name,
ctrl: Default::default(),
callback,
is_running,
active_sessions,
}
}
fn refresh_state(&self, ctrl: &IAudioSessionControl) -> windows_core::Result<()> {
let ctrl2: IAudioSessionControl2 = ctrl.cast()?;
let process_id = unsafe { ctrl2.GetProcessId()? };
// Skip current process to avoid self-detection
if process_id == process::id() {
return Ok(());
}
let process_name = match get_process_name(process_id) {
Some(n) => n,
None => unsafe { ctrl2.GetDisplayName()?.to_string()? },
};
// Skip system-sounds session
// The `IsSystemSoundsSession` always true for unknown reason
if process_name.contains("AudioSrv") {
return Ok(());
}
// Active ⇒ microphone is recording
if unsafe { ctrl.GetState()? } == AudioSessionStateActive {
let mut should_notify = false;
if let Ok(mut optional_ctrl) = self.ctrl.lock() {
// Increment the active session counter. If this was the first, flip
// is_running to true.
let prev = self.active_sessions.fetch_add(1, Ordering::SeqCst);
if prev == 0 {
self.is_running.store(true, Ordering::Relaxed);
}
let events_ref = Arc::new(Mutex::new(None));
let events: IAudioSessionEvents = SessionEvents {
callback: self.callback.clone(),
process_name: process_name.clone(),
device_id: self.device_id.clone(),
device_name: self.device_name.clone(),
events_ref: events_ref.clone(),
ctrl: ctrl.clone(),
is_running: self.is_running.clone(),
active_sessions: self.active_sessions.clone(),
session_is_active: AtomicBool::new(true),
}
.into();
let mut events_mut_ref = events_ref.lock().unwrap();
*events_mut_ref = Some(events.clone());
unsafe { ctrl.RegisterAudioSessionNotification(&events)? };
// keep the ctrl2 alive so that the notification can be called
*optional_ctrl = Some((ctrl2, events));
should_notify = true;
}
if should_notify {
self.callback.call(
Ok((true, process_name, self.device_id.clone(), self.device_name.clone())),
ThreadsafeFunctionCallMode::NonBlocking,
);
}
return Ok(());
}
Ok(())
}
}
impl IAudioSessionNotification_Impl for SessionNotifier_Impl {
fn OnSessionCreated(
&self,
ctrl_ref: windows_core::Ref<'_, windows::Win32::Media::Audio::IAudioSessionControl>,
) -> windows_core::Result<()> {
let Some(ctrl) = ctrl_ref.as_ref() else {
return Ok(());
};
self.refresh_state(ctrl)?;
Ok(())
}
}
pub fn register_audio_device_status_callback(
is_running: Arc<AtomicBool>,
active_sessions: Arc<AtomicUsize>,
callback: Arc<ThreadsafeFunction<(bool, String, String, String)>>,
) -> windows_core::Result<Vec<IAudioSessionNotification>> {
unsafe {
let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?;
// Get all active capture devices
let device_collection: IMMDeviceCollection = enumerator.EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE)?;
let device_count = device_collection.GetCount()?;
let mut session_notifiers = Vec::new();
for i in 0..device_count {
let device: IMMDevice = device_collection.Item(i)?;
// Device identifiers
let device_id_pwstr = device.GetId()?;
let device_id = device_id_pwstr.to_string()?;
let device_name = format!("Audio Device {}", i);
// Activate session manager for this device
let mgr: IAudioSessionManager2 = device.Activate(CLSCTX_ALL, None)?;
// Create notifier for this device
let session_notifier = SessionNotifier::new(
&mgr,
device_id.clone(),
device_name.clone(),
callback.clone(),
is_running.clone(),
active_sessions.clone(),
);
// Enumerate existing sessions to update counters and state immediately
let list: IAudioSessionEnumerator = mgr.GetSessionEnumerator()?;
let sessions = list.GetCount()?;
for idx in 0..sessions {
let ctrl = list.GetSession(idx)?;
session_notifier.refresh_state(&ctrl)?;
}
let session_notifier_impl: IAudioSessionNotification = session_notifier.into();
mgr.RegisterSessionNotification(&session_notifier_impl)?;
session_notifiers.push(session_notifier_impl);
}
Ok(session_notifiers)
}
}
pub struct MicrophoneListener {
_session_notifiers: Vec<IAudioSessionNotification>, // keep the session_notifiers alive
is_running: Arc<AtomicBool>,
}
impl MicrophoneListener {
pub fn new(callback: ThreadsafeFunction<(bool, String, String, String)>) -> Self {
unsafe {
if CoInitializeEx(None, COINIT_MULTITHREADED).is_err() {
// If COM initialization fails, create a listener with empty notifiers
return Self {
is_running: Arc::new(AtomicBool::new(false)),
_session_notifiers: Vec::new(),
};
}
};
let is_running = Arc::new(AtomicBool::new(false));
let active_sessions = Arc::new(AtomicUsize::new(0));
let session_notifiers =
match register_audio_device_status_callback(is_running.clone(), active_sessions.clone(), Arc::new(callback)) {
Ok(notifiers) => notifiers,
Err(_) => {
// If registration fails, create a listener with empty notifiers
Vec::new()
}
};
Self {
is_running,
_session_notifiers: session_notifiers,
}
}
pub fn is_running(&self) -> bool {
self.is_running.load(Ordering::Relaxed)
}
// Static method to check if a specific process is using microphone
// This is used by TappableApplication::is_running()
pub fn is_process_using_microphone(process_id: u32) -> bool {
// Use the proven get_all_audio_processes logic
match get_all_audio_processes() {
Ok(processes) => processes.iter().any(|p| p.process_id == process_id && p.is_running),
Err(_) => false,
}
}
}
fn get_mgr_audio_session_running_status(mgr: &IAudioSessionManager2) -> windows_core::Result<(bool, String)> {
let list: IAudioSessionEnumerator = unsafe { mgr.GetSessionEnumerator()? };
let sessions = unsafe { list.GetCount()? };
for idx in 0..sessions {
let ctrl = unsafe { list.GetSession(idx)? };
let ctrl2: IAudioSessionControl2 = ctrl.cast()?;
let process_id = unsafe { ctrl2.GetProcessId()? };
// Skip current process to avoid self-detection
if process_id == process::id() {
continue;
}
let process_name = match get_process_name(process_id) {
Some(n) => n,
None => unsafe { ctrl2.GetDisplayName()?.to_string()? },
};
// Skip system-sounds session
// The `IsSystemSoundsSession` always true for unknown reason
if process_name.contains("AudioSrv") {
continue;
}
// Active ⇒ microphone is recording
if unsafe { ctrl.GetState()? } == AudioSessionStateActive {
return Ok((true, process_name));
}
}
Ok((false, String::new()))
}
fn get_process_name(pid: u32) -> Option<String> {
unsafe {
// Open process with required access rights
let process_handle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid).ok()?;
// Allocate a buffer large enough to hold extended-length paths (up to ~32K
// characters) instead of the legacy MAX_PATH (260) limit.
let mut buffer: Vec<u16> = std::iter::repeat(0).take(32_768).collect();
// Try GetModuleFileNameExW first (gives full path with extension)
let length = GetModuleFileNameExW(
Some(process_handle),
None, // NULL for the process executable
&mut buffer,
);
// If that fails, try GetProcessImageFileNameW
let length = if length == 0 {
GetProcessImageFileNameW(process_handle, &mut buffer)
} else {
length
};
// Clean up
CloseHandle(process_handle).ok()?;
if length == 0 {
return None;
}
// Convert to OsString then to a regular String. Truncate buffer first.
buffer.truncate(length as usize);
let os_string = OsString::from_wide(&buffer);
// Extract the file name from the path
let path_str = os_string.to_string_lossy().to_string();
path_str.rsplit('\\').next().map(|s| s.to_string())
}
}
pub fn list_audio_processes() -> Result<Vec<AudioProcess>> {
unsafe {
// Try to initialize COM, but don't fail if it's already initialized
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
};
let result =
get_all_audio_processes().map_err(|err| napi::Error::new(napi::Status::GenericFailure, err.message()))?;
Ok(result)
}
pub fn list_audio_devices() -> Result<Vec<AudioDevice>> {
unsafe {
// Try to initialize COM, but don't fail if it's already initialized
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
};
let result = get_all_audio_devices().map_err(|err| napi::Error::new(napi::Status::GenericFailure, err.message()))?;
Ok(result)
}
fn get_all_audio_processes() -> windows_core::Result<Vec<AudioProcess>> {
unsafe {
let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?;
let device_collection: IMMDeviceCollection = enumerator.EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE)?;
let device_count = device_collection.GetCount()?;
let mut all_processes = Vec::new();
let current_pid = process::id();
for i in 0..device_count {
let device: IMMDevice = device_collection.Item(i)?;
let device_id_pwstr = device.GetId()?;
let device_id = device_id_pwstr.to_string()?;
let device_name = format!("Audio Device {}", i);
let mgr: IAudioSessionManager2 = device.Activate(CLSCTX_ALL, None)?;
let list: IAudioSessionEnumerator = mgr.GetSessionEnumerator()?;
let sessions = list.GetCount()?;
for idx in 0..sessions {
let ctrl = list.GetSession(idx)?;
let ctrl2: IAudioSessionControl2 = ctrl.cast()?;
let process_id = ctrl2.GetProcessId()?;
// Skip current process to avoid self-detection
if process_id == current_pid {
continue;
}
let process_name = match get_process_name(process_id) {
Some(n) => n,
None => ctrl2.GetDisplayName()?.to_string()?,
};
// Skip system-sounds session
if process_name.contains("AudioSrv") {
continue;
}
let is_running = ctrl.GetState()? == AudioSessionStateActive;
all_processes.push(AudioProcess {
process_name,
process_id,
device_id: device_id.clone(),
device_name: device_name.clone(),
is_running,
});
}
}
Ok(all_processes)
}
}
fn get_all_audio_devices() -> windows_core::Result<Vec<AudioDevice>> {
unsafe {
let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?;
let device_collection: IMMDeviceCollection = enumerator.EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE)?;
let device_count = device_collection.GetCount()?;
let mut devices = Vec::new();
// Get default devices for comparison
let default_comm_device_id = enumerator
.GetDefaultAudioEndpoint(eCapture, eCommunications)
.and_then(|d| d.GetId())
.and_then(|id| Ok(id.to_string().unwrap_or_default()))
.ok();
let default_console_device_id = enumerator
.GetDefaultAudioEndpoint(eCapture, eConsole)
.and_then(|d| d.GetId())
.and_then(|id| Ok(id.to_string().unwrap_or_default()))
.ok();
for i in 0..device_count {
let device: IMMDevice = device_collection.Item(i)?;
let device_id_pwstr = device.GetId()?;
let device_id = device_id_pwstr.to_string()?;
let device_name = format!("Audio Device {}", i);
let is_default_communications = default_comm_device_id.as_ref() == Some(&device_id);
let is_default_console = default_console_device_id.as_ref() == Some(&device_id);
let mgr: IAudioSessionManager2 = device.Activate(CLSCTX_ALL, None)?;
let (has_active_sessions, _) = get_mgr_audio_session_running_status(&mgr)?;
devices.push(AudioDevice {
device_id,
device_name,
is_default_communications,
is_default_console,
has_active_sessions,
});
}
Ok(devices)
}
}
pub fn get_active_audio_processes() -> Result<Vec<AudioProcess>> {
unsafe {
// Try to initialize COM, but don't fail if it's already initialized
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
};
let result =
get_all_audio_processes().map_err(|err| napi::Error::new(napi::Status::GenericFailure, err.message()))?;
// Filter to only return active/running processes
let active_processes = result.into_iter().filter(|p| p.is_running).collect();
Ok(active_processes)
}
pub fn is_process_actively_using_microphone(pid: u32) -> Result<bool> {
unsafe {
// Try to initialize COM, but don't fail if it's already initialized
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
};
let result =
get_all_audio_processes().map_err(|err| napi::Error::new(napi::Status::GenericFailure, err.message()))?;
// Check if the PID exists in the list of active processes
let is_active = result
.iter()
.any(|process| process.process_id == pid && process.is_running);
Ok(is_active)
}

View File

@@ -0,0 +1,17 @@
pub mod audio_capture;
pub(crate) mod error;
pub mod microphone_listener;
pub mod screen_capture_kit;
pub use audio_capture::*;
pub use microphone_listener::*;
pub use screen_capture_kit::*;
#[cfg(test)]
mod tests {
#[test]
fn test_windows_module_loads() {
// Simple test to ensure the Windows module compiles and loads correctly
assert!(true);
}
}

View File

@@ -0,0 +1,430 @@
use std::{
collections::HashSet,
ffi::OsString,
os::windows::ffi::OsStringExt,
sync::{
Arc, LazyLock, RwLock,
atomic::{AtomicBool, AtomicU32, Ordering},
},
thread,
time::Duration,
};
use cpal::SampleRate;
use napi::{
bindgen_prelude::{Buffer, Error, Result, Status},
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use napi_derive::napi;
// Windows API imports
use windows::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; // HWND removed
use windows::Win32::System::{
Com::{COINIT_MULTITHREADED, CoInitializeEx},
Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
},
ProcessStatus::{GetModuleFileNameExW, GetProcessImageFileNameW},
Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ},
};
// Import the function from microphone_listener
use crate::audio_callback::AudioCallback;
use crate::windows::microphone_listener::is_process_actively_using_microphone;
// Type alias to match macOS API
pub type AudioObjectID = u32;
// Global storage for running applications (Windows equivalent of macOS audio
// process list)
static RUNNING_APPLICATIONS: LazyLock<RwLock<Vec<u32>>> = LazyLock::new(|| RwLock::new(get_running_processes()));
// Simple counter for generating unique handles
static NEXT_HANDLE: AtomicU32 = AtomicU32::new(1);
// Global storage for active watchers
static ACTIVE_APP_WATCHERS: LazyLock<RwLock<Vec<(u32, u32, Arc<ThreadsafeFunction<(), ()>>, Arc<AtomicBool>)>>> =
LazyLock::new(|| RwLock::new(Vec::new()));
static ACTIVE_LIST_WATCHERS: LazyLock<RwLock<Vec<(u32, Arc<ThreadsafeFunction<(), ()>>, Arc<AtomicBool>)>>> =
LazyLock::new(|| RwLock::new(Vec::new()));
// Plain struct for efficient transmission via napi-rs
#[napi]
#[derive(Clone)]
pub struct ApplicationInfo {
pub process_id: i32,
pub name: String,
pub object_id: u32,
}
#[napi]
impl ApplicationInfo {
#[napi(constructor)]
pub fn new(process_id: i32, name: String, object_id: u32) -> Self {
Self {
process_id,
name,
object_id,
}
}
#[napi(getter)]
pub fn process_group_id(&self) -> i32 {
// Windows doesn't have process groups like Unix, return the process ID
self.process_id
}
#[napi(getter)]
pub fn bundle_identifier(&self) -> String {
// For Windows, return the fully-qualified path to the .exe on disk
let path = get_process_executable_path(self.process_id as u32).unwrap_or_default();
// Escape invalid filename characters for Windows
escape_filename(&path)
}
#[napi(getter)]
pub fn icon(&self) -> Buffer {
// For now, return empty buffer. In a full implementation, you would extract
// the icon from the executable file using Windows APIs
Buffer::from(Vec::<u8>::new())
}
}
#[napi]
pub struct ApplicationListChangedSubscriber {
handle: u32,
// We'll store the callback and manage it through a background thread
_callback: Arc<ThreadsafeFunction<(), ()>>,
}
#[napi]
impl ApplicationListChangedSubscriber {
#[napi]
pub fn unsubscribe(&self) -> Result<()> {
if let Ok(mut watchers) = ACTIVE_LIST_WATCHERS.write() {
if let Some(pos) = watchers.iter().position(|(handle, _, _)| *handle == self.handle) {
let (_, _, should_stop) = &watchers[pos];
should_stop.store(true, Ordering::Relaxed);
watchers.remove(pos);
}
}
Ok(())
}
}
#[napi]
pub struct ApplicationStateChangedSubscriber {
handle: u32,
process_id: u32,
_callback: Arc<ThreadsafeFunction<(), ()>>,
}
#[napi]
impl ApplicationStateChangedSubscriber {
pub fn process_id(&self) -> u32 {
self.process_id
}
#[napi]
pub fn unsubscribe(&self) {
if let Ok(mut watchers) = ACTIVE_APP_WATCHERS.write() {
if let Some(pos) = watchers.iter().position(|(handle, _, _, _)| *handle == self.handle) {
let (_, _, _, should_stop) = &watchers[pos];
should_stop.store(true, Ordering::Relaxed);
watchers.remove(pos);
}
}
}
}
#[napi]
pub struct ShareableContent {
// Windows doesn't need an inner SCShareableContent equivalent
}
#[napi]
impl ShareableContent {
#[napi]
pub fn on_application_list_changed(callback: ThreadsafeFunction<(), ()>) -> Result<ApplicationListChangedSubscriber> {
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
let callback_arc = Arc::new(callback);
// Start monitoring for application list changes
start_list_monitoring(handle, callback_arc.clone());
Ok(ApplicationListChangedSubscriber {
handle,
_callback: callback_arc,
})
}
#[napi]
pub fn on_app_state_changed(
app: &ApplicationInfo,
callback: ThreadsafeFunction<(), ()>,
) -> Result<ApplicationStateChangedSubscriber> {
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
let process_id = app.process_id as u32;
let callback_arc = Arc::new(callback);
// Start monitoring for this specific process's microphone state
start_process_monitoring(handle, process_id, callback_arc.clone());
Ok(ApplicationStateChangedSubscriber {
handle,
process_id,
_callback: callback_arc,
})
}
#[napi(constructor)]
pub fn new() -> Self {
unsafe {
CoInitializeEx(None, COINIT_MULTITHREADED).ok().unwrap_or_else(|_| {
// COM initialization failed, but we can't return an error from
// constructor This is typically not fatal as COM might
// already be initialized
});
}
Self {}
}
#[napi]
pub fn applications() -> Result<Vec<ApplicationInfo>> {
let processes = RUNNING_APPLICATIONS
.read()
.map_err(|_| Error::new(Status::GenericFailure, "Failed to read running applications"))?;
let mut apps = Vec::new();
for &process_id in processes.iter() {
let name = get_process_name(process_id).unwrap_or_else(|| format!("Process {}", process_id));
if !name.is_empty() && name != format!("Process {}", process_id) {
let app_info = ApplicationInfo::new(process_id as i32, name, process_id);
apps.push(app_info);
}
}
Ok(apps)
}
#[napi]
pub fn application_with_process_id(process_id: u32) -> Option<ApplicationInfo> {
if is_process_running(process_id) {
let name = get_process_name(process_id).unwrap_or_else(|| format!("Process {}", process_id));
Some(ApplicationInfo::new(process_id as i32, name, process_id))
} else {
None
}
}
pub(crate) fn tap_audio_with_callback(
_process_id: u32,
audio_stream_callback: AudioCallback,
target_sample_rate: Option<u32>,
) -> Result<AudioCaptureSession> {
let target = target_sample_rate.map(SampleRate);
crate::windows::audio_capture::start_recording(audio_stream_callback, target)
}
pub(crate) fn tap_global_audio_with_callback(
_excluded_processes: Option<Vec<&ApplicationInfo>>,
audio_stream_callback: AudioCallback,
target_sample_rate: Option<u32>,
) -> Result<AudioCaptureSession> {
let target = target_sample_rate.map(SampleRate);
// Delegate to audio_capture::start_recording which handles mixing mic +
// loopback
crate::windows::audio_capture::start_recording(audio_stream_callback, target)
}
#[napi]
pub fn is_using_microphone(process_id: u32) -> Result<bool> {
is_process_actively_using_microphone(process_id)
}
}
// Re-export the concrete audio capture session implemented in audio_capture.rs
pub use crate::windows::audio_capture::AudioCaptureSession;
// Helper function to escape invalid filename characters
fn escape_filename(path: &str) -> String {
// Replace invalid filename characters with underscores
// Invalid chars on Windows: < > : " | ? * \ spaces and control chars (0-31)
path
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '|' | '?' | '*' | '\\' | ' ' => '_',
c if c.is_control() => '_',
c => c,
})
.collect::<String>()
.to_lowercase()
}
// Helper functions for Windows process management
fn get_running_processes() -> Vec<u32> {
let mut processes_set = HashSet::new(); // Use HashSet to avoid duplicates from the start
unsafe {
let h_snapshot_result = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
let h_snapshot = match h_snapshot_result {
Ok(handle) => {
if handle == INVALID_HANDLE_VALUE {
// eprintln!("CreateToolhelp32Snapshot returned
// INVALID_HANDLE_VALUE");
return Vec::new();
}
handle
}
Err(_e) => {
// eprintln!("CreateToolhelp32Snapshot failed: {:?}", e);
return Vec::new();
}
};
let mut pe32 = PROCESSENTRY32W::default();
pe32.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
if Process32FirstW(h_snapshot, &mut pe32).is_ok() {
loop {
processes_set.insert(pe32.th32ProcessID);
if Process32NextW(h_snapshot, &mut pe32).is_err() {
break;
}
}
}
CloseHandle(h_snapshot).unwrap_or_else(|_e| {
// eprintln!("CloseHandle failed for snapshot: {:?}", e);
});
}
let mut processes_vec: Vec<u32> = processes_set.into_iter().collect();
processes_vec.sort_unstable(); // Sort for consistent ordering, though not strictly necessary for functionality
processes_vec
}
fn is_process_running(process_id: u32) -> bool {
unsafe {
match OpenProcess(PROCESS_QUERY_INFORMATION, false, process_id) {
Ok(handle) => CloseHandle(handle).is_ok(),
Err(_) => false,
}
}
}
fn get_process_name(pid: u32) -> Option<String> {
unsafe {
let process_handle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid).ok()?;
// Allocate a buffer large enough to hold extended-length paths (up to ~32K
// characters) instead of the legacy MAX_PATH (260) limit. 32 768 is the
// maximum length supported by the Win32 APIs when the path is prefixed
// with "\\?\".
let mut buffer: Vec<u16> = std::iter::repeat(0).take(32_768).collect();
let length = GetModuleFileNameExW(Some(process_handle), None, &mut buffer);
CloseHandle(process_handle).ok()?;
if length == 0 {
return None;
}
// Truncate the buffer to the length returned by the Windows API before
// doing the UTF-16 → UTF-8 conversion.
buffer.truncate(length as usize);
let os_string = OsString::from_wide(&buffer);
let path_str = os_string.to_string_lossy().to_string();
path_str.rsplit('\\').next().map(|s| s.to_string())
}
}
fn get_process_executable_path(pid: u32) -> Option<String> {
unsafe {
let process_handle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid).ok()?;
// Use a buffer that can hold extended-length paths. See rationale above.
let mut buffer: Vec<u16> = std::iter::repeat(0).take(32_768).collect();
let length = GetProcessImageFileNameW(process_handle, &mut buffer);
CloseHandle(process_handle).ok()?;
if length == 0 {
return None;
}
buffer.truncate(length as usize);
let os_string = OsString::from_wide(&buffer);
let path_str = os_string.to_string_lossy().to_string();
Some(path_str)
}
}
// Helper function to start monitoring a specific process
fn start_process_monitoring(handle: u32, process_id: u32, callback: Arc<ThreadsafeFunction<(), ()>>) {
let should_stop = Arc::new(AtomicBool::new(false));
let should_stop_clone = should_stop.clone();
// Store the watcher info
if let Ok(mut watchers) = ACTIVE_APP_WATCHERS.write() {
watchers.push((handle, process_id, callback.clone(), should_stop.clone()));
}
// Start monitoring thread
thread::spawn(move || {
let mut last_state = false;
loop {
if should_stop_clone.load(Ordering::Relaxed) {
break;
}
// Check current microphone state
let current_state = is_process_actively_using_microphone(process_id).unwrap_or(false);
// If state changed, trigger callback
if current_state != last_state {
let _ = callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
last_state = current_state;
}
// Sleep for a short interval before checking again
thread::sleep(Duration::from_millis(500));
}
});
}
// Helper function to start monitoring application list changes
fn start_list_monitoring(handle: u32, callback: Arc<ThreadsafeFunction<(), ()>>) {
let should_stop = Arc::new(AtomicBool::new(false));
let should_stop_clone = should_stop.clone();
// Store the watcher info
if let Ok(mut watchers) = ACTIVE_LIST_WATCHERS.write() {
watchers.push((handle, callback.clone(), should_stop.clone()));
}
// Start monitoring thread
thread::spawn(move || {
let mut last_processes = get_running_processes();
loop {
if should_stop_clone.load(Ordering::Relaxed) {
break;
}
// Check current process list
let current_processes = get_running_processes();
// If process list changed, trigger callback
if current_processes != last_processes {
let _ = callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
last_processes = current_processes;
// Update global process list
if let Ok(mut apps) = RUNNING_APPLICATIONS.write() {
*apps = last_processes.clone();
}
}
// Sleep for a longer interval for process list changes
thread::sleep(Duration::from_millis(2000));
}
});
}