feat(native): windows audio monitoring & recording (#12615)

fix AF-2692

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added comprehensive Windows support for audio and application capture,
including real-time microphone usage detection, combined microphone and
system audio recording, and application state monitoring.
  - The "meetings" setting is now enabled on Windows as well as macOS.
- Conditional UI styling and attributes introduced for Windows
environments in the Electron renderer.

- **Bug Fixes**
- Enhanced file path handling and validation for Windows in Electron
file requests.

- **Refactor**
- Unified application info handling across platforms by consolidating
types into a single `ApplicationInfo` structure.
- Updated native module APIs by removing deprecated types, refining
method signatures, and improving error messages.
- Streamlined audio tapping APIs to use process IDs and consistent
callback types.

- **Documentation**
- Added detailed documentation for the Windows-specific audio recording
and microphone listener modules.

- **Chores**
  - Updated development dependencies in multiple packages.
- Reorganized and added platform-specific dependencies and configuration
for Windows support.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->





#### PR Dependency Tree


* **PR #12615** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>
This commit is contained in:
Peng Xiao
2025-06-18 13:57:01 +08:00
committed by GitHub
parent c844786a7f
commit 899ffd1ad3
27 changed files with 2509 additions and 458 deletions
@@ -2,4 +2,10 @@
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_decoder;
@@ -22,7 +22,7 @@ use coreaudio::sys::{
};
use libc;
use napi::{
bindgen_prelude::{Buffer, Error, Float32Array, Result, Status},
bindgen_prelude::{Buffer, Error, Result, Status},
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use napi_derive::napi;
@@ -90,59 +90,22 @@ static NSRUNNING_APPLICATION_CLASS: LazyLock<Option<&'static AnyClass>> =
LazyLock::new(|| AnyClass::get(c"NSRunningApplication"));
#[napi]
pub struct Application {
pub(crate) process_id: i32,
pub(crate) name: String,
#[derive(Clone)]
pub struct ApplicationInfo {
pub process_id: i32,
pub name: String,
pub object_id: u32,
}
#[napi]
impl Application {
impl ApplicationInfo {
#[napi(constructor)]
pub fn new(process_id: i32) -> Result<Self> {
// Default values for when we can't get information
let mut app = Self {
pub fn new(process_id: i32, name: String, object_id: u32) -> Self {
Self {
process_id,
name: String::new(),
};
// Try to populate fields using NSRunningApplication
if process_id > 0 {
// Get NSRunningApplication class
if let Some(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
]
};
if !running_app.is_null() {
// Get name
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) {
app.name = s.to_string();
}
}
}
}
}
}
name,
object_id,
}
Ok(app)
}
#[napi(getter)]
pub fn process_id(&self) -> i32 {
self.process_id
}
#[napi(getter)]
@@ -191,12 +154,18 @@ impl Application {
}
}
String::new()
}
// If not available, try to get from the audio process property
if self.object_id > 0 {
if 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();
}
}
#[napi(getter)]
pub fn name(&self) -> &str {
&self.name
String::new()
}
#[napi(getter)]
@@ -341,114 +310,6 @@ impl Application {
}
}
#[napi]
pub struct TappableApplication {
pub(crate) app: Application,
pub(crate) object_id: AudioObjectID,
}
#[napi]
impl TappableApplication {
#[napi(constructor)]
pub fn new(object_id: AudioObjectID) -> Result<Self> {
// Get process ID from object_id
let process_id = get_process_property(&object_id, kAudioProcessPropertyPID).unwrap_or(-1);
// Create base Application
let app = Application::new(process_id)?;
Ok(Self { app, object_id })
}
#[napi(factory)]
pub fn from_application(app: &Application, object_id: AudioObjectID) -> Self {
Self {
app: Application {
process_id: app.process_id,
name: app.name.clone(),
},
object_id,
}
}
#[napi(getter)]
pub fn process_id(&self) -> i32 {
self.app.process_id
}
#[napi(getter)]
pub fn process_group_id(&self) -> i32 {
self.app.process_group_id()
}
#[napi(getter)]
pub fn bundle_identifier(&self) -> String {
// First try to get from the Application
let app_bundle_id = self.app.bundle_identifier();
if !app_bundle_id.is_empty() {
return app_bundle_id;
}
// If not available, try to get from the audio process property
match get_process_property::<CFStringRef>(&self.object_id, kAudioProcessPropertyBundleID) {
Ok(bundle_id) => {
// Safely convert CFStringRef to Rust String
let cf_string = unsafe { CFString::wrap_under_create_rule(bundle_id) };
cf_string.to_string()
}
Err(_) => {
// Return empty string if we couldn't get the bundle ID
String::new()
}
}
}
#[napi(getter)]
pub fn name(&self) -> String {
self.app.name.clone()
}
#[napi(getter)]
pub fn object_id(&self) -> u32 {
self.object_id
}
#[napi(getter)]
pub fn icon(&self) -> Result<Buffer> {
self.app.icon()
}
#[napi(getter)]
pub fn get_is_running(&self) -> Result<bool> {
// Use catch_unwind to prevent any panics
let result = std::panic::catch_unwind(|| {
match get_process_property(&self.object_id, kAudioProcessPropertyIsRunningInput) {
Ok(is_running) => Ok(is_running),
Err(_) => Ok(false),
}
});
// Handle any panics
match result {
Ok(result) => result,
Err(_) => Ok(false),
}
}
#[napi]
pub fn tap_audio(
&self,
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>,
) -> Result<AudioCaptureSession> {
// Use AggregateDeviceManager instead of AggregateDevice directly
// This provides automatic default device change detection
let mut device_manager = AggregateDeviceManager::new(self)?;
device_manager.start_capture(audio_stream_callback)?;
let boxed_manager = Box::new(device_manager);
Ok(AudioCaptureSession::new(boxed_manager))
}
}
#[napi]
pub struct ApplicationListChangedSubscriber {
listener_block: RcBlock<dyn Fn(u32, *mut c_void)>,
@@ -538,19 +399,14 @@ pub struct ShareableContent {
_inner: SCShareableContent,
}
#[napi]
#[derive(Default)]
pub struct RecordingPermissions {
pub audio: bool,
pub screen: bool,
}
#[napi]
impl ShareableContent {
#[napi]
pub fn on_application_list_changed(
callback: Arc<ThreadsafeFunction<(), ()>>,
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
@@ -565,9 +421,9 @@ impl ShareableContent {
*running_applications = audio_process_list();
})
{
callback.call(Err(err), ThreadsafeFunctionCallMode::NonBlocking);
callback_clone.call(Err(err), ThreadsafeFunctionCallMode::NonBlocking);
} else {
callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
callback_clone.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
}
});
@@ -598,11 +454,12 @@ impl ShareableContent {
#[napi]
pub fn on_app_state_changed(
app: &TappableApplication,
callback: Arc<ThreadsafeFunction<(), ()>>,
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(
@@ -612,7 +469,7 @@ impl ShareableContent {
})?;
if let Some(subscribers) = lock.get_mut(&object_id) {
subscribers.insert(id, callback);
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| {
@@ -659,7 +516,7 @@ impl ShareableContent {
}
let subscribers = {
let mut map = HashMap::new();
map.insert(id, callback);
map.insert(id, callback_arc.clone());
map
};
lock.insert(object_id, subscribers);
@@ -675,7 +532,7 @@ impl ShareableContent {
}
#[napi]
pub fn applications(&self) -> Result<Vec<TappableApplication>> {
pub fn applications() -> Result<Vec<ApplicationInfo>> {
let app_list = RUNNING_APPLICATIONS
.read()
.map_err(|_| {
@@ -687,13 +544,44 @@ impl ShareableContent {
.iter()
.flatten()
.filter_map(|id| {
let tappable_app = match TappableApplication::new(*id) {
Ok(app) => app,
Err(_) => return None,
};
// Get process ID from object_id
let process_id = get_process_property(id, kAudioProcessPropertyPID).unwrap_or(-1);
if !tappable_app.bundle_identifier().is_empty() {
Some(tappable_app)
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
}
@@ -704,7 +592,13 @@ impl ShareableContent {
}
#[napi]
pub fn application_with_process_id(&self, process_id: u32) -> Option<Application> {
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()?;
@@ -720,40 +614,107 @@ impl ShareableContent {
return None;
}
// Create an Application directly
Application::new(process_id as i32).ok()
// 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))
}
#[napi]
pub fn tappable_application_with_process_id(
&self,
process_id: u32,
) -> Option<TappableApplication> {
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 self.applications() {
match ShareableContent::applications() {
Ok(apps) => {
for app in apps {
if app.process_id() == process_id as i32 {
if app.process_id == process_id as i32 {
return Some(app);
}
}
// If we couldn't find a TappableApplication with this process ID, create a new
// one with a default object_id of 0 (which won't be able to tap audio)
match Application::new(process_id as i32) {
Ok(app) => Some(TappableApplication::from_application(&app, 0)),
Err(_) => None,
}
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() {
if 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)
}
#[napi]
pub fn tap_audio(
process_id: u32,
audio_stream_callback: ThreadsafeFunction<napi::bindgen_prelude::Float32Array, ()>,
) -> 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",
));
}
// Convert ThreadsafeFunction to Arc<ThreadsafeFunction>
let callback_arc = Arc::new(audio_stream_callback);
// Use AggregateDeviceManager instead of AggregateDevice directly
// This provides automatic default device change detection
let mut device_manager = AggregateDeviceManager::new(&app)?;
device_manager.start_capture(callback_arc)?;
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",
))
}
}
#[napi]
pub fn tap_global_audio(
excluded_processes: Option<Vec<&TappableApplication>>,
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>,
excluded_processes: Option<Vec<&ApplicationInfo>>,
audio_stream_callback: ThreadsafeFunction<napi::bindgen_prelude::Float32Array, ()>,
) -> Result<AudioCaptureSession> {
let excluded_object_ids = excluded_processes
.unwrap_or_default()
@@ -761,9 +722,12 @@ impl ShareableContent {
.map(|app| app.object_id)
.collect::<Vec<_>>();
// Convert ThreadsafeFunction to Arc<ThreadsafeFunction>
let callback_arc = Arc::new(audio_stream_callback);
// 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)?;
device_manager.start_capture(callback_arc)?;
let boxed_manager = Box::new(device_manager);
Ok(AudioCaptureSession::new(boxed_manager))
}
@@ -24,9 +24,8 @@ use coreaudio::sys::{
AudioObjectRemovePropertyListenerBlock, AudioTimeStamp, OSStatus,
};
use napi::{
bindgen_prelude::Float32Array,
bindgen_prelude::{Float32Array, Result, Status},
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
Result,
};
use napi_derive::napi;
use objc2::runtime::AnyObject;
@@ -38,7 +37,7 @@ use crate::{
device::get_device_uid,
error::CoreAudioError,
queue::create_audio_tap_queue,
screen_capture_kit::TappableApplication,
screen_capture_kit::ApplicationInfo,
utils::{cfstring_from_bytes_with_nul, get_global_main_property},
};
@@ -69,7 +68,7 @@ pub struct AggregateDevice {
}
impl AggregateDevice {
pub fn new(app: &TappableApplication) -> Result<Self> {
pub fn new(app: &ApplicationInfo) -> Result<Self> {
let object_id = app.object_id;
let tap_description = CATapDescription::init_stereo_mixdown_of_processes(object_id)?;
@@ -242,7 +241,7 @@ impl AggregateDevice {
/// Implementation for the AggregateDevice to start processing audio
pub fn start(
&mut self,
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>,
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, Status, true>>,
// Add original_audio_stats to ensure consistent target rate
original_audio_stats: AudioStats,
) -> Result<AudioTapStream> {
@@ -598,13 +597,13 @@ pub struct AggregateDeviceManager {
app_id: Option<AudioObjectID>,
excluded_processes: Vec<AudioObjectID>,
active_stream: Option<Arc<std::sync::Mutex<Option<AudioTapStream>>>>,
audio_callback: Option<Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>>,
audio_callback: Option<Arc<ThreadsafeFunction<Float32Array, (), Float32Array, Status, true>>>,
original_audio_stats: Option<AudioStats>,
}
impl AggregateDeviceManager {
/// Creates a new AggregateDeviceManager for a specific application
pub fn new(app: &TappableApplication) -> Result<Self> {
pub fn new(app: &ApplicationInfo) -> Result<Self> {
let device = AggregateDevice::new(app)?;
Ok(Self {
@@ -638,7 +637,7 @@ impl AggregateDeviceManager {
/// This sets up the initial stream and listeners.
pub fn start_capture(
&mut self,
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>,
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, Status, true>>,
) -> Result<()> {
// Store the callback for potential device switch later
self.audio_callback = Some(audio_stream_callback.clone());
@@ -717,10 +716,12 @@ impl AggregateDeviceManager {
};
// Create a new device with updated default devices
let result: Result<AggregateDevice> = (|| {
let result: Result<AggregateDevice> = {
if is_app_specific {
if let Some(id) = app_id {
let app = TappableApplication::new(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())
@@ -728,7 +729,7 @@ impl AggregateDeviceManager {
} 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
@@ -0,0 +1,86 @@
# 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.
@@ -0,0 +1,400 @@
use std::{
cell::RefCell,
collections::HashMap,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
thread::JoinHandle,
};
use cpal::{
traits::{DeviceTrait, HostTrait, StreamTrait},
SampleRate,
};
use crossbeam_channel::unbounded;
use napi::{
bindgen_prelude::{Float32Array, Result},
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
Error, Status,
};
use napi_derive::napi;
use rubato::{FastFixedIn, PolynomialDegree, Resampler};
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,
}
}
// Feed planar samples; returns interleaved output (may be empty)
fn feed(&mut self, planar_in: &[Vec<f32>]) -> Vec<f32> {
// Push incoming samples into fifo buffers
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);
}
if let Ok(out_blocks) = self.resampler.process(&chunk, None) {
if !out_blocks.is_empty() && out_blocks.len() == self.channels {
if !self.initial_output_discarded {
self.initial_output_discarded = true;
} else {
let out_len = out_blocks[0].len();
for i in 0..out_len {
for ch in 0..self.channels {
interleaved_out.push(out_blocks[ch][i]);
}
}
}
}
}
}
interleaved_out
}
}
// Thread-local cache for resamplers keyed by (from, to, channels)
thread_local! {
static RESAMPLER_CACHE: RefCell<HashMap<(u32, u32, usize), BufferedResampler>> = RefCell::new(HashMap::new());
}
fn process_audio_with_resampler(
samples: Vec<f32>,
from_sample_rate: u32,
to_sample_rate: u32,
) -> Vec<f32> {
if from_sample_rate == to_sample_rate {
return samples;
}
RESAMPLER_CACHE.with(|cache| {
let mut map = cache.borrow_mut();
let key = (from_sample_rate, to_sample_rate, 1usize); // mono resampler
let resampler = map
.entry(key)
.or_insert_with(|| BufferedResampler::new(from_sample_rate as f64, to_sample_rate as f64, 1));
resampler.feed(&[samples])
})
}
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>,
}
#[napi]
pub struct AudioCaptureSession {
mic_stream: cpal::Stream,
lb_stream: cpal::Stream,
stopped: Arc<AtomicBool>,
sample_rate: SampleRate,
channels: u32,
jh: Option<JoinHandle<()>>, // background mixing thread
}
#[napi]
impl AudioCaptureSession {
#[napi(getter)]
pub fn get_sample_rate(&self) -> f64 {
self.sample_rate.0 as f64
}
#[napi(getter)]
pub fn get_channels(&self) -> u32 {
self.channels
}
#[napi(getter)]
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
}
#[napi]
pub fn stop(&mut self) -> Result<()> {
if self.stopped.load(Ordering::SeqCst) {
return Ok(());
}
self
.mic_stream
.pause()
.map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
self
.lb_stream
.pause()
.map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
self.stopped.store(true, Ordering::SeqCst);
if let Some(jh) = self.jh.take() {
let _ = jh.join(); // ignore poison
}
Ok(())
}
}
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: ThreadsafeFunction<Float32Array, ()>,
) -> 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 = 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 stopped = 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 stopped_flag = stopped.clone();
let jh = std::thread::spawn(move || {
// Accumulators before and after resampling
let mut pre_mic: Vec<f32> = Vec::new();
let mut pre_lb: Vec<f32> = Vec::new();
let mut post_mic: Vec<f32> = Vec::new();
let mut post_lb: Vec<f32> = Vec::new();
while !stopped_flag.load(Ordering::SeqCst) {
// Gather input from channels
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()
};
pre_mic.extend_from_slice(&mono_samples);
}
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()
};
pre_lb.extend_from_slice(&mono_samples);
}
// Resample when enough samples are available
while pre_mic.len() >= RESAMPLER_INPUT_CHUNK {
let to_resample: Vec<f32> = pre_mic.drain(..RESAMPLER_INPUT_CHUNK).collect();
let processed = process_audio_with_resampler(to_resample, mic_sample_rate.0, target_rate.0);
if !processed.is_empty() {
post_mic.extend_from_slice(&processed);
}
}
while pre_lb.len() >= RESAMPLER_INPUT_CHUNK {
let to_resample: Vec<f32> = pre_lb.drain(..RESAMPLER_INPUT_CHUNK).collect();
let processed = process_audio_with_resampler(to_resample, lb_sample_rate.0, target_rate.0);
if !processed.is_empty() {
post_lb.extend_from_slice(&processed);
}
}
// Mix when we have TARGET_FRAME_SIZE samples available from both
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() {
let _ = audio_buffer_callback.call(
Ok(mixed.clone().into()),
ThreadsafeFunctionCallMode::NonBlocking,
);
}
}
// Prevent unbounded growth keep some slack
const MAX_PRE: usize = RESAMPLER_INPUT_CHUNK * 10;
if pre_mic.len() > MAX_PRE {
pre_mic.drain(..pre_mic.len() - MAX_PRE);
}
if pre_lb.len() > MAX_PRE {
pre_lb.drain(..pre_lb.len() - MAX_PRE);
}
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);
}
// Sleep if nothing to do
if 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,
lb_stream,
stopped,
sample_rate: target_rate,
channels: 1, // mono output
jh: Some(jh),
})
}
@@ -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())
}
}
@@ -0,0 +1,645 @@
use std::{
ffi::OsString,
os::windows::ffi::OsStringExt,
process,
sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc, Mutex,
},
};
use napi::{
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
Result,
};
use windows::{
core::Interface,
Win32::{
Foundation::CloseHandle,
Media::Audio::{
eCapture, eCommunications, eConsole, AudioSessionState, AudioSessionStateActive,
IAudioSessionControl, IAudioSessionControl2, IAudioSessionEnumerator, IAudioSessionEvents,
IAudioSessionEvents_Impl, IAudioSessionManager2, IAudioSessionNotification,
IAudioSessionNotification_Impl, IMMDevice, IMMDeviceCollection, IMMDeviceEnumerator,
MMDeviceEnumerator, DEVICE_STATE_ACTIVE,
},
System::{
Com::{CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED},
ProcessStatus::{GetModuleFileNameExW, GetProcessImageFileNameW},
Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ},
},
},
};
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)
}
@@ -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);
}
}
@@ -0,0 +1,449 @@
use std::{
collections::HashSet,
ffi::OsString,
os::windows::ffi::OsStringExt,
sync::{
atomic::{AtomicBool, AtomicU32, Ordering},
Arc, LazyLock, RwLock,
},
thread,
time::Duration,
};
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::{CoInitializeEx, COINIT_MULTITHREADED},
Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
},
ProcessStatus::{GetModuleFileNameExW, GetProcessImageFileNameW},
Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ},
};
// Import the function from microphone_listener
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
}
}
#[napi]
pub fn tap_audio(
_process_id: u32, // Currently unused - Windows captures global audio
audio_stream_callback: ThreadsafeFunction<napi::bindgen_prelude::Float32Array, ()>,
) -> Result<AudioCaptureSession> {
// On Windows with CPAL, we capture global audio (mic + loopback)
// since per-application audio tapping isn't supported the same way as macOS
crate::windows::audio_capture::start_recording(audio_stream_callback)
}
#[napi]
pub fn tap_global_audio(
_excluded_processes: Option<Vec<&ApplicationInfo>>,
audio_stream_callback: ThreadsafeFunction<napi::bindgen_prelude::Float32Array, ()>,
) -> Result<AudioCaptureSession> {
// Delegate to audio_capture::start_recording which handles mixing mic +
// loopback
crate::windows::audio_capture::start_recording(audio_stream_callback)
}
#[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));
}
});
}