mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-11 22:18:54 +08:00
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:
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user