mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(desktop): capture screen share audio from chosen devices (#3456)
This commit is contained in:
1 parent
123c4d5bbf
commit
97b2ac91ca
56 files changed
+2338
-291
No files matched your search
@@ -19,6 +19,7 @@ napi-derive = "3.6.7"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = {version = "0.62.2", features = [
|
||||
"Win32_Devices_FunctionDiscovery",
|
||||
"Win32_Foundation",
|
||||
"Win32_Media_Audio",
|
||||
"Win32_Media_KernelStreaming",
|
||||
@@ -31,6 +32,7 @@ windows = {version = "0.62.2", features = [
|
||||
"Win32_System_Performance",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Variant",
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
]}
|
||||
windows-core = "0.62.2"
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ProcessLoopbackOptions {
|
||||
scope?: 'process' | 'system' | 'session-mixer';
|
||||
sampleRate?: 48000;
|
||||
channels?: 2;
|
||||
endpoints?: Array<string>;
|
||||
}
|
||||
|
||||
export interface AudioFrame {
|
||||
@@ -40,6 +41,8 @@ export declare class ProcessLoopback extends EventEmitter {
|
||||
|
||||
start(): Promise<void>;
|
||||
|
||||
setEndpoints(endpoints: Array<string>): boolean;
|
||||
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
@@ -66,3 +69,20 @@ export declare function getBackendInfo(): WinProcessLoopbackBackendInfo;
|
||||
export declare function pidFromHwnd(hwnd: bigint): number;
|
||||
|
||||
export declare function resolveAudioRootPid(pid: number): number;
|
||||
|
||||
export interface PlaybackDeviceSession {
|
||||
pid: number;
|
||||
rootPid: number;
|
||||
exe: string;
|
||||
state: 'active' | 'inactive';
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
export interface PlaybackDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
sessions: Array<PlaybackDeviceSession>;
|
||||
}
|
||||
|
||||
export declare function listPlaybackDevices(): Promise<Array<PlaybackDevice>>;
|
||||
@@ -121,6 +121,11 @@ function pidFromHwnd(hwnd) {
|
||||
}
|
||||
}
|
||||
|
||||
function listPlaybackDevices() {
|
||||
if (!binding) return Promise.resolve([]);
|
||||
return binding.listPlaybackDevices();
|
||||
}
|
||||
|
||||
function resolveAudioRootPid(pid) {
|
||||
if (!binding) return pid;
|
||||
try {
|
||||
@@ -148,7 +153,7 @@ class ProcessLoopback extends EventEmitter {
|
||||
this._rejectStart = null;
|
||||
this._native = new binding.ProcessLoopback(
|
||||
pid,
|
||||
{includeProcessTree, sampleRate, channels, captureScope},
|
||||
{includeProcessTree, sampleRate, channels, captureScope, endpoints: opts.endpoints},
|
||||
(frame) => this.emit('frame', frame),
|
||||
(err) => {
|
||||
if (!this._started && this._rejectStart) {
|
||||
@@ -204,6 +209,10 @@ class ProcessLoopback extends EventEmitter {
|
||||
return this._startPromise;
|
||||
}
|
||||
|
||||
setEndpoints(endpoints) {
|
||||
return this._stopped ? false : this._native.setEndpoints(endpoints);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._stopped) return;
|
||||
this._stopped = true;
|
||||
@@ -220,6 +229,7 @@ module.exports = {
|
||||
getBackendInfo,
|
||||
pidFromHwnd,
|
||||
resolveAudioRootPid,
|
||||
listPlaybackDevices,
|
||||
ProcessLoopback,
|
||||
get loadError() {
|
||||
return loadError;
|
||||
|
||||
@@ -10,8 +10,10 @@ mod windows_version;
|
||||
use std::sync::Arc;
|
||||
|
||||
use napi::{
|
||||
Env,
|
||||
bindgen_prelude::{Error, Float32Array, Function, Result, Status, ToNapiValue, Unknown},
|
||||
Env, Task,
|
||||
bindgen_prelude::{
|
||||
AsyncTask, Error, Float32Array, Function, Result, Status, ToNapiValue, Unknown,
|
||||
},
|
||||
sys,
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue},
|
||||
};
|
||||
@@ -46,11 +48,50 @@ enum CaptureScope {
|
||||
SessionMixer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
enum EndpointSelector {
|
||||
All,
|
||||
Devices { default: bool, ids: Vec<String> },
|
||||
}
|
||||
|
||||
const DEFAULT_ENDPOINT: &str = "default";
|
||||
const MAX_ENDPOINT_ENTRIES: usize = 16;
|
||||
const MAX_ENDPOINT_ID_CHARS: usize = 512;
|
||||
|
||||
fn parse_endpoints(raw: Option<Vec<String>>) -> Result<EndpointSelector> {
|
||||
let Some(entries) = raw else {
|
||||
return Ok(EndpointSelector::All);
|
||||
};
|
||||
if entries.is_empty() || entries.len() > MAX_ENDPOINT_ENTRIES {
|
||||
return Err(Error::new(Status::InvalidArg, "invalid endpoints"));
|
||||
}
|
||||
let mut default = false;
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
for entry in entries {
|
||||
if entry == DEFAULT_ENDPOINT {
|
||||
default = true;
|
||||
continue;
|
||||
}
|
||||
if entry.chars().count() > MAX_ENDPOINT_ID_CHARS
|
||||
|| entry.contains('\0')
|
||||
|| !entry.starts_with('{')
|
||||
{
|
||||
return Err(Error::new(Status::InvalidArg, "invalid endpoints"));
|
||||
}
|
||||
if !ids.contains(&entry) {
|
||||
ids.push(entry);
|
||||
}
|
||||
}
|
||||
Ok(EndpointSelector::Devices { default, ids })
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
struct LoopbackOptions {
|
||||
include_tree: bool,
|
||||
capture_scope: CaptureScope,
|
||||
endpoints: EndpointSelector,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -66,6 +107,7 @@ impl Default for LoopbackOptions {
|
||||
Self {
|
||||
include_tree: true,
|
||||
capture_scope: CaptureScope::Process,
|
||||
endpoints: EndpointSelector::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,9 +144,17 @@ fn validate_options(raw: RawOptions) -> Result<LoopbackOptions> {
|
||||
CaptureScope::System | CaptureScope::SessionMixer => false,
|
||||
CaptureScope::Process => raw.include_process_tree.unwrap_or(true),
|
||||
};
|
||||
let endpoints = parse_endpoints(raw.endpoints)?;
|
||||
if endpoints != EndpointSelector::All && capture_scope != CaptureScope::SessionMixer {
|
||||
return Err(Error::new(
|
||||
Status::InvalidArg,
|
||||
"endpoints require session-mixer scope",
|
||||
));
|
||||
}
|
||||
Ok(LoopbackOptions {
|
||||
include_tree,
|
||||
capture_scope,
|
||||
endpoints,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,6 +164,7 @@ struct RawOptions {
|
||||
capture_scope: Option<String>,
|
||||
sample_rate: Option<u32>,
|
||||
channels: Option<u32>,
|
||||
endpoints: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn read_raw_options(env: &Env, value: Unknown) -> Result<RawOptions> {
|
||||
@@ -128,6 +179,7 @@ fn read_raw_options(env: &Env, value: Unknown) -> Result<RawOptions> {
|
||||
capture_scope: object.get("captureScope").ok().flatten(),
|
||||
sample_rate: object.get("sampleRate").ok().flatten(),
|
||||
channels: object.get("channels").ok().flatten(),
|
||||
endpoints: object.get("endpoints")?,
|
||||
})
|
||||
.map(|mut opts| {
|
||||
let _ = env;
|
||||
@@ -405,6 +457,11 @@ impl ProcessLoopback {
|
||||
pub fn dispose(&self) {
|
||||
self.inner.dispose();
|
||||
}
|
||||
|
||||
#[napi(js_name = "setEndpoints")]
|
||||
pub fn set_endpoints(&self, endpoints: Vec<String>) -> Result<bool> {
|
||||
self.inner.set_endpoints(endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProcessLoopback {
|
||||
@@ -611,6 +668,43 @@ pub fn resolve_audio_root_pid(pid: u32) -> u32 {
|
||||
platform::resolve_audio_root_pid(pid)
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct PlaybackDeviceSession {
|
||||
pub pid: u32,
|
||||
pub root_pid: u32,
|
||||
pub exe: String,
|
||||
pub state: String,
|
||||
pub excluded: bool,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct PlaybackDevice {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub is_default: bool,
|
||||
pub sessions: Vec<PlaybackDeviceSession>,
|
||||
}
|
||||
|
||||
pub struct ListPlaybackDevicesTask;
|
||||
|
||||
impl Task for ListPlaybackDevicesTask {
|
||||
type Output = Vec<PlaybackDevice>;
|
||||
type JsValue = Vec<PlaybackDevice>;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
platform::list_playback_devices()
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "listPlaybackDevices")]
|
||||
pub fn list_playback_devices() -> AsyncTask<ListPlaybackDevicesTask> {
|
||||
AsyncTask::new(ListPlaybackDevicesTask)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -621,9 +715,96 @@ mod tests {
|
||||
capture_scope: scope.map(|s| s.to_string()),
|
||||
sample_rate: None,
|
||||
channels: None,
|
||||
endpoints: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_with_endpoints(scope: &str, endpoints: &[&str]) -> RawOptions {
|
||||
RawOptions {
|
||||
endpoints: Some(endpoints.iter().map(|entry| entry.to_string()).collect()),
|
||||
..raw_with_scope(Some(scope))
|
||||
}
|
||||
}
|
||||
|
||||
const SPEAKERS_ID: &str = "{0.0.0.00000000}.{6f1e8a2c-0000-4000-8000-000000000001}";
|
||||
|
||||
#[test]
|
||||
fn missing_endpoints_capture_every_device() {
|
||||
let opts = validate_options(raw_with_scope(Some("session-mixer"))).expect("validates");
|
||||
assert_eq!(opts.endpoints, EndpointSelector::All);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_endpoint_is_accepted_with_session_mixer() {
|
||||
let opts = validate_options(raw_with_endpoints("session-mixer", &["default"]))
|
||||
.expect("default endpoint validates");
|
||||
assert_eq!(
|
||||
opts.endpoints,
|
||||
EndpointSelector::Devices {
|
||||
default: true,
|
||||
ids: Vec::new()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_ids_and_default_are_accepted_and_deduplicated() {
|
||||
let opts = validate_options(raw_with_endpoints(
|
||||
"session-mixer",
|
||||
&[SPEAKERS_ID, "default", SPEAKERS_ID],
|
||||
))
|
||||
.expect("device ids validate");
|
||||
assert_eq!(
|
||||
opts.endpoints,
|
||||
EndpointSelector::Devices {
|
||||
default: true,
|
||||
ids: vec![SPEAKERS_ID.to_string()]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoints_require_session_mixer_scope() {
|
||||
for scope in ["system", "process"] {
|
||||
let err = validate_options(raw_with_endpoints(scope, &["default"]))
|
||||
.expect_err("endpoints outside session-mixer should fail");
|
||||
assert_eq!(err.status, Status::InvalidArg);
|
||||
assert_eq!(err.reason, "endpoints require session-mixer scope");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_endpoint_lists_are_rejected() {
|
||||
let too_many: Vec<String> = (0..17).map(|index| format!("{{{index}}}")).collect();
|
||||
let too_long = format!("{{{}", "a".repeat(512));
|
||||
let cases: Vec<Vec<String>> = vec![
|
||||
Vec::new(),
|
||||
too_many,
|
||||
vec!["speakers".to_string()],
|
||||
vec!["{abc\0}".to_string()],
|
||||
vec![too_long],
|
||||
];
|
||||
for endpoints in cases {
|
||||
let raw = RawOptions {
|
||||
endpoints: Some(endpoints),
|
||||
..raw_with_scope(Some("session-mixer"))
|
||||
};
|
||||
let err = validate_options(raw).expect_err("malformed endpoints should fail");
|
||||
assert_eq!(err.status, Status::InvalidArg);
|
||||
assert_eq!(err.reason, "invalid endpoints");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_id_at_the_length_limit_is_accepted() {
|
||||
let at_limit = format!("{{{}", "a".repeat(511));
|
||||
let raw = RawOptions {
|
||||
endpoints: Some(vec![at_limit]),
|
||||
..raw_with_scope(Some("session-mixer"))
|
||||
};
|
||||
validate_options(raw).expect("512-char id validates");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_include_tree_under_process_scope() {
|
||||
let opts = validate_options(RawOptions::default()).expect("defaults validate");
|
||||
@@ -829,6 +1010,7 @@ mod tests {
|
||||
capture_scope: Some("process".to_string()),
|
||||
sample_rate: Some(48_000),
|
||||
channels: Some(2),
|
||||
endpoints: None,
|
||||
};
|
||||
validate_options(raw).expect("canonical 48k stereo should validate");
|
||||
}
|
||||
@@ -882,15 +1064,15 @@ mod tests {
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use super::{
|
||||
Callbacks, CaptureScope, FRAME_QUEUE_LIMIT, FramePayload, FrameSamples, LoopbackOptions,
|
||||
ProcessLoopbackRuntimeProbe,
|
||||
Callbacks, CaptureScope, EndpointSelector, FRAME_QUEUE_LIMIT, FramePayload, FrameSamples,
|
||||
LoopbackOptions, PlaybackDevice, PlaybackDeviceSession, ProcessLoopbackRuntimeProbe,
|
||||
parse_endpoints,
|
||||
};
|
||||
use crate::pcm_pool::{PCM_SLOT_SAMPLES_MAX, PcmFramePool, PcmPoolError, PooledPcmFrame};
|
||||
use crate::{audio_contract, process_tree, session_mixer};
|
||||
use fluxer_rt_thread::{PriorityProfile, RealtimePriorityGuard};
|
||||
use napi::bindgen_prelude::{Error, Result, Status};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
ffi::c_void,
|
||||
mem::{ManuallyDrop, size_of, zeroed},
|
||||
sync::{
|
||||
@@ -900,6 +1082,7 @@ mod platform {
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use windows::Win32::Devices::FunctionDiscovery::PKEY_Device_FriendlyName;
|
||||
use windows::Win32::Foundation::{
|
||||
CloseHandle, HANDLE, HWND, INVALID_HANDLE_VALUE, WAIT_TIMEOUT,
|
||||
};
|
||||
@@ -909,13 +1092,14 @@ mod platform {
|
||||
AUDCLNT_STREAMFLAGS_LOOPBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
|
||||
AUDIOCLIENT_ACTIVATION_PARAMS, AUDIOCLIENT_ACTIVATION_PARAMS_0,
|
||||
AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK, AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS,
|
||||
ActivateAudioInterfaceAsync, AudioSessionStateExpired, DEVICE_STATE_ACTIVE,
|
||||
IActivateAudioInterfaceAsyncOperation, IActivateAudioInterfaceCompletionHandler,
|
||||
IActivateAudioInterfaceCompletionHandler_Impl, IAudioCaptureClient, IAudioClient,
|
||||
IAudioSessionControl, IAudioSessionControl2, IAudioSessionManager2, IMMDeviceEnumerator,
|
||||
MMDeviceEnumerator, PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE,
|
||||
ActivateAudioInterfaceAsync, AudioSessionStateActive, AudioSessionStateExpired,
|
||||
AudioSessionStateInactive, DEVICE_STATE_ACTIVE, IActivateAudioInterfaceAsyncOperation,
|
||||
IActivateAudioInterfaceCompletionHandler, IActivateAudioInterfaceCompletionHandler_Impl,
|
||||
IAudioCaptureClient, IAudioClient, IAudioSessionControl, IAudioSessionControl2,
|
||||
IAudioSessionManager2, IMMDevice, IMMDeviceEnumerator, IMMEndpoint, MMDeviceEnumerator,
|
||||
PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE,
|
||||
PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE, WAVE_FORMAT_PCM, WAVEFORMATEX,
|
||||
WAVEFORMATEXTENSIBLE, WAVEFORMATEXTENSIBLE_0, eRender,
|
||||
WAVEFORMATEXTENSIBLE, WAVEFORMATEXTENSIBLE_0, eConsole, eRender,
|
||||
};
|
||||
use windows::Win32::Media::KernelStreaming::WAVE_FORMAT_EXTENSIBLE;
|
||||
use windows::Win32::Media::Multimedia::KSDATAFORMAT_SUBTYPE_IEEE_FLOAT;
|
||||
@@ -923,11 +1107,12 @@ mod platform {
|
||||
GetFileVersionInfoSizeW, GetFileVersionInfoW, VS_FIXEDFILEINFO, VerQueryValueW,
|
||||
};
|
||||
use windows::Win32::System::Com::StructuredStorage::{
|
||||
PROPVARIANT, PROPVARIANT_0, PROPVARIANT_0_0, PROPVARIANT_0_0_0,
|
||||
PROPVARIANT, PROPVARIANT_0, PROPVARIANT_0_0, PROPVARIANT_0_0_0, PropVariantClear,
|
||||
PropVariantToStringAlloc,
|
||||
};
|
||||
use windows::Win32::System::Com::{
|
||||
BLOB, CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
|
||||
IAgileObject, IAgileObject_Impl,
|
||||
BLOB, CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree,
|
||||
CoUninitialize, IAgileObject, IAgileObject_Impl, STGM_READ,
|
||||
};
|
||||
use windows::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
|
||||
@@ -938,7 +1123,7 @@ mod platform {
|
||||
};
|
||||
use windows::Win32::System::Variant::VT_BLOB;
|
||||
use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId;
|
||||
use windows::core::{Interface, PCWSTR, implement};
|
||||
use windows::core::{Interface, PCWSTR, PWSTR, implement};
|
||||
|
||||
const PROCESS_LOOPBACK_DEVICE: &[u16] = &[
|
||||
b'V' as u16,
|
||||
@@ -980,10 +1165,11 @@ mod platform {
|
||||
const BACKSLASH_UTF16: &[u16] = &[b'\\' as u16, 0];
|
||||
const PROCESS_LOOPBACK_PROBE_TIMEOUT_MS: u32 = 1_500;
|
||||
const PROCESS_LOOPBACK_PROBE_COUNT: u64 = 2;
|
||||
const SESSION_MIXER_REFRESH_INTERVAL: Duration = Duration::from_millis(1_000);
|
||||
const SESSION_MIXER_WAIT_TIMEOUT_MS: u32 = 250;
|
||||
const SESSION_MIXER_REFRESH_INTERVAL: Duration = Duration::from_millis(500);
|
||||
const SESSION_MIXER_WAIT_TIMEOUT_MS: u32 = 10;
|
||||
const SESSION_MIXER_HOLD_US: i64 = 40_000;
|
||||
const MAX_SESSION_MIXER_CAPTURES: usize = 48;
|
||||
const MAX_SESSION_MIXER_EMIT_FRAMES: usize = audio_contract::TARGET_SAMPLE_RATE as usize / 2;
|
||||
const PLAYBACK_DEVICE_LISTING_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const PROCESS_LOOPBACK_ACTIVATION_ATTEMPTS: usize = 3;
|
||||
|
||||
struct OwnedHandle(HANDLE);
|
||||
@@ -1048,6 +1234,14 @@ mod platform {
|
||||
worker: Mutex<WorkerState>,
|
||||
stop_signal: Arc<AtomicBool>,
|
||||
disposed: AtomicBool,
|
||||
endpoints: Arc<Mutex<EndpointSelector>>,
|
||||
endpoints_dirty: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
fn lock_endpoints(endpoints: &Mutex<EndpointSelector>) -> MutexGuard<'_, EndpointSelector> {
|
||||
endpoints
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
@@ -1066,9 +1260,12 @@ mod platform {
|
||||
"ProcessLoopback refuses to capture Fluxer's own process tree",
|
||||
));
|
||||
}
|
||||
let endpoints = Arc::new(Mutex::new(options.endpoints.clone()));
|
||||
Ok(Self {
|
||||
pid,
|
||||
options,
|
||||
endpoints,
|
||||
endpoints_dirty: Arc::new(AtomicBool::new(false)),
|
||||
callbacks: Arc::new(callbacks),
|
||||
worker: Mutex::new(WorkerState {
|
||||
worker: None,
|
||||
@@ -1109,6 +1306,8 @@ mod platform {
|
||||
let stop_signal = self.stop_signal.clone();
|
||||
let stop_event_for_thread = stop_event.clone();
|
||||
let callbacks = self.callbacks.clone();
|
||||
let endpoints = self.endpoints.clone();
|
||||
let endpoints_dirty = self.endpoints_dirty.clone();
|
||||
|
||||
let join = match thread::Builder::new()
|
||||
.name("fluxer-win-process-loopback".to_string())
|
||||
@@ -1120,6 +1319,10 @@ mod platform {
|
||||
&callbacks,
|
||||
stop_event_for_thread.raw(),
|
||||
stop_signal.as_ref(),
|
||||
SessionMixerEndpoints {
|
||||
selector: endpoints.as_ref(),
|
||||
dirty: endpoints_dirty.as_ref(),
|
||||
},
|
||||
);
|
||||
if let Err(message) = result {
|
||||
callbacks.emit_error(message);
|
||||
@@ -1162,6 +1365,22 @@ mod platform {
|
||||
}
|
||||
self.stop();
|
||||
}
|
||||
|
||||
pub(crate) fn set_endpoints(&self, endpoints: Vec<String>) -> Result<bool> {
|
||||
if self.options.capture_scope != CaptureScope::SessionMixer {
|
||||
return Ok(false);
|
||||
}
|
||||
let selector = parse_endpoints(Some(endpoints))?;
|
||||
*lock_endpoints(&self.endpoints) = selector;
|
||||
self.endpoints_dirty.store(true, Ordering::SeqCst);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct SessionMixerEndpoints<'a> {
|
||||
selector: &'a Mutex<EndpointSelector>,
|
||||
dirty: &'a AtomicBool,
|
||||
}
|
||||
|
||||
fn generic(message: impl Into<String>) -> Error {
|
||||
@@ -1388,11 +1607,12 @@ mod platform {
|
||||
callbacks: &Callbacks,
|
||||
stop_event: HANDLE,
|
||||
stop_signal: &AtomicBool,
|
||||
endpoints: SessionMixerEndpoints<'_>,
|
||||
) -> std::result::Result<(), String> {
|
||||
let _com = ComApartment::initialize().map_err(|err| err.reason.to_string())?;
|
||||
let _rt_guard = RealtimePriorityGuard::acquire(PriorityProfile::Audio).ok();
|
||||
if capture_scope == CaptureScope::SessionMixer {
|
||||
return session_mixer_capture_thread(callbacks, stop_event, stop_signal);
|
||||
return session_mixer_capture_thread(callbacks, stop_event, stop_signal, endpoints);
|
||||
}
|
||||
|
||||
let client = match activate_loopback_client(pid, include_tree, capture_scope, stop_event) {
|
||||
@@ -1626,25 +1846,49 @@ mod platform {
|
||||
callbacks: &Callbacks,
|
||||
stop_event: HANDLE,
|
||||
stop_signal: &AtomicBool,
|
||||
endpoints: SessionMixerEndpoints<'_>,
|
||||
) -> std::result::Result<(), String> {
|
||||
let enumerator = create_device_enumerator().map_err(|err| err.reason.to_string())?;
|
||||
let sample_rate = audio_contract::TARGET_SAMPLE_RATE;
|
||||
let channels = usize::from(audio_contract::TARGET_CHANNELS);
|
||||
let mut captures: Vec<SessionCapture> = Vec::new();
|
||||
refresh_session_mixer_captures(&mut captures, stop_event)?;
|
||||
let mut activity = session_mixer::SessionActivity::default();
|
||||
endpoints.dirty.store(false, Ordering::SeqCst);
|
||||
let selector = lock_endpoints(endpoints.selector).clone();
|
||||
refresh_session_mixer_captures(
|
||||
&mut captures,
|
||||
&mut activity,
|
||||
&enumerator,
|
||||
&selector,
|
||||
stop_event,
|
||||
)
|
||||
.map_err(|err| err.reason.to_string())?;
|
||||
callbacks.emit_started();
|
||||
let mut handles: Vec<HANDLE> = Vec::with_capacity(MAX_SESSION_MIXER_CAPTURES + 1);
|
||||
rebuild_session_mixer_handles(&mut handles, &captures, stop_event);
|
||||
let mut packets: Vec<CapturedPacket> = Vec::new();
|
||||
let mut timeline = session_mixer::MixTimeline::default();
|
||||
let mut last_refresh = Instant::now();
|
||||
while !stop_signal.load(Ordering::SeqCst) {
|
||||
if last_refresh.elapsed() >= SESSION_MIXER_REFRESH_INTERVAL {
|
||||
refresh_session_mixer_captures(&mut captures, stop_event)?;
|
||||
let endpoints_changed = endpoints.dirty.swap(false, Ordering::SeqCst);
|
||||
if endpoints_changed || last_refresh.elapsed() >= SESSION_MIXER_REFRESH_INTERVAL {
|
||||
if endpoints_changed {
|
||||
activity.clear();
|
||||
}
|
||||
let selector = lock_endpoints(endpoints.selector).clone();
|
||||
refresh_session_mixer_captures(
|
||||
&mut captures,
|
||||
&mut activity,
|
||||
&enumerator,
|
||||
&selector,
|
||||
stop_event,
|
||||
)
|
||||
.map_err(|err| err.reason.to_string())?;
|
||||
rebuild_session_mixer_handles(&mut handles, &captures, stop_event);
|
||||
last_refresh = Instant::now();
|
||||
}
|
||||
let wait =
|
||||
unsafe { WaitForMultipleObjects(&handles, false, SESSION_MIXER_WAIT_TIMEOUT_MS) };
|
||||
if wait == WAIT_TIMEOUT {
|
||||
continue;
|
||||
}
|
||||
if wait.0 == u32::MAX {
|
||||
return Err("WaitForMultipleObjects failed in session mixer".to_string());
|
||||
}
|
||||
@@ -1655,7 +1899,20 @@ mod platform {
|
||||
for capture in &captures {
|
||||
drain_packets_into(&capture.capture, capture.sample_format, &mut packets)?;
|
||||
}
|
||||
packets = emit_mixed_packets(std::mem::take(&mut packets), callbacks);
|
||||
for packet in packets.drain(..) {
|
||||
timeline.push(packet, sample_rate, channels);
|
||||
}
|
||||
if let Some(mixed) = timeline.drain_ready(
|
||||
current_performance_timestamp_us(),
|
||||
SESSION_MIXER_HOLD_US,
|
||||
sample_rate,
|
||||
channels,
|
||||
) {
|
||||
callbacks.emit_frame(FramePayload {
|
||||
samples: FrameSamples::Owned(mixed.samples),
|
||||
timestamp_us: mixed.timestamp_us,
|
||||
});
|
||||
}
|
||||
}
|
||||
for capture in &captures {
|
||||
capture.stop();
|
||||
@@ -1679,10 +1936,14 @@ mod platform {
|
||||
|
||||
fn refresh_session_mixer_captures(
|
||||
captures: &mut Vec<SessionCapture>,
|
||||
activity: &mut session_mixer::SessionActivity,
|
||||
enumerator: &IMMDeviceEnumerator,
|
||||
selector: &EndpointSelector,
|
||||
stop_event: HANDLE,
|
||||
) -> std::result::Result<(), String> {
|
||||
let desired =
|
||||
enumerate_shareable_audio_session_pids().map_err(|err| err.reason.to_string())?;
|
||||
) -> Result<()> {
|
||||
let Some(desired) = session_mixer_capture_pids(enumerator, selector, activity)? else {
|
||||
return Ok(());
|
||||
};
|
||||
captures.retain(|capture| {
|
||||
let keep = desired.contains(&capture.pid);
|
||||
if !keep {
|
||||
@@ -1705,27 +1966,158 @@ mod platform {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enumerate_shareable_audio_session_pids() -> std::result::Result<Vec<u32>, Error> {
|
||||
let enumerator: IMMDeviceEnumerator = unsafe {
|
||||
fn session_mixer_capture_pids(
|
||||
enumerator: &IMMDeviceEnumerator,
|
||||
selector: &EndpointSelector,
|
||||
activity: &mut session_mixer::SessionActivity,
|
||||
) -> Result<Option<Vec<u32>>> {
|
||||
let devices = resolve_render_endpoints(enumerator, selector)?;
|
||||
let Some(processes) = snapshot_processes_with_exe() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let process_entries = process_entries_from(&processes);
|
||||
let self_pid = unsafe { GetCurrentProcessId() };
|
||||
let sessions = collect_render_sessions(&devices, &processes, &process_entries, self_pid);
|
||||
let candidates: Vec<session_mixer::SessionCandidate> = sessions
|
||||
.iter()
|
||||
.filter(|session| !session.excluded)
|
||||
.map(|session| session_mixer::SessionCandidate {
|
||||
root_pid: session.root_pid,
|
||||
active: session.active,
|
||||
})
|
||||
.collect();
|
||||
let mut excluded: Vec<u32> = sessions
|
||||
.iter()
|
||||
.filter(|session| session.excluded)
|
||||
.map(|session| session.root_pid)
|
||||
.collect();
|
||||
excluded.sort_unstable();
|
||||
excluded.dedup();
|
||||
let selected = activity.select(&candidates, current_performance_timestamp_us());
|
||||
Ok(Some(
|
||||
process_tree::deduplicate_capture_roots(&process_entries, &selected, &excluded)
|
||||
.into_iter()
|
||||
.take(MAX_SESSION_MIXER_CAPTURES)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn create_device_enumerator() -> Result<IMMDeviceEnumerator> {
|
||||
unsafe {
|
||||
CoCreateInstance(
|
||||
&MMDeviceEnumerator,
|
||||
None::<&windows::core::IUnknown>,
|
||||
CLSCTX_ALL,
|
||||
)
|
||||
}
|
||||
.map_err(|err| hresult_error("CoCreateInstance(MMDeviceEnumerator)", err.code().0))?;
|
||||
let devices = unsafe { enumerator.EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE) }
|
||||
.map_err(|err| hresult_error("CoCreateInstance(MMDeviceEnumerator)", err.code().0))
|
||||
}
|
||||
|
||||
fn active_render_devices(enumerator: &IMMDeviceEnumerator) -> Result<Vec<IMMDevice>> {
|
||||
let collection = unsafe { enumerator.EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE) }
|
||||
.map_err(|err| hresult_error("EnumAudioEndpoints", err.code().0))?;
|
||||
let device_count = unsafe { devices.GetCount() }
|
||||
let count = unsafe { collection.GetCount() }
|
||||
.map_err(|err| hresult_error("IMMDeviceCollection::GetCount", err.code().0))?;
|
||||
let process_entries = snapshot_process_entries().unwrap_or_default();
|
||||
let self_pid = unsafe { GetCurrentProcessId() };
|
||||
let mut pids = BTreeSet::new();
|
||||
let mut excluded_pids = BTreeSet::new();
|
||||
for device_index in 0..device_count {
|
||||
let Ok(device) = (unsafe { devices.Item(device_index) }) else {
|
||||
Ok((0..count)
|
||||
.filter_map(|index| unsafe { collection.Item(index) }.ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn default_render_device(enumerator: &IMMDeviceEnumerator) -> Option<IMMDevice> {
|
||||
unsafe { enumerator.GetDefaultAudioEndpoint(eRender, eConsole) }.ok()
|
||||
}
|
||||
|
||||
fn take_co_task_string(value: PWSTR) -> Option<String> {
|
||||
if value.is_null() {
|
||||
return None;
|
||||
}
|
||||
let text = unsafe { value.to_string() }.ok();
|
||||
unsafe {
|
||||
CoTaskMemFree(Some(value.0 as *const c_void));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn device_id(device: &IMMDevice) -> Option<String> {
|
||||
take_co_task_string(unsafe { device.GetId() }.ok()?)
|
||||
}
|
||||
|
||||
fn device_friendly_name(device: &IMMDevice) -> Option<String> {
|
||||
let store = unsafe { device.OpenPropertyStore(STGM_READ) }.ok()?;
|
||||
let mut value = unsafe { store.GetValue(&PKEY_Device_FriendlyName) }.ok()?;
|
||||
let text = unsafe { PropVariantToStringAlloc(&value) };
|
||||
unsafe {
|
||||
let _ = PropVariantClear(&mut value);
|
||||
}
|
||||
take_co_task_string(text.ok()?)
|
||||
}
|
||||
|
||||
fn push_unique_device(devices: &mut Vec<IMMDevice>, seen: &mut Vec<String>, device: IMMDevice) {
|
||||
let Some(id) = device_id(&device) else {
|
||||
return;
|
||||
};
|
||||
if seen.contains(&id) {
|
||||
return;
|
||||
}
|
||||
seen.push(id);
|
||||
devices.push(device);
|
||||
}
|
||||
|
||||
fn is_render_device(device: &IMMDevice) -> bool {
|
||||
device
|
||||
.cast::<IMMEndpoint>()
|
||||
.and_then(|endpoint| unsafe { endpoint.GetDataFlow() })
|
||||
.is_ok_and(|flow| flow == eRender)
|
||||
}
|
||||
|
||||
fn resolve_render_endpoints(
|
||||
enumerator: &IMMDeviceEnumerator,
|
||||
selector: &EndpointSelector,
|
||||
) -> Result<Vec<IMMDevice>> {
|
||||
let (include_default, ids) = match selector {
|
||||
EndpointSelector::All => return active_render_devices(enumerator),
|
||||
EndpointSelector::Devices { default, ids } => (*default, ids),
|
||||
};
|
||||
let mut devices = Vec::new();
|
||||
let mut seen = Vec::new();
|
||||
if include_default && let Some(device) = default_render_device(enumerator) {
|
||||
push_unique_device(&mut devices, &mut seen, device);
|
||||
}
|
||||
for id in ids {
|
||||
let wide: Vec<u16> = id.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let Ok(device) = (unsafe { enumerator.GetDevice(PCWSTR(wide.as_ptr())) }) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(unsafe { device.GetState() }, Ok(state) if state == DEVICE_STATE_ACTIVE)
|
||||
|| !is_render_device(&device)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
push_unique_device(&mut devices, &mut seen, device);
|
||||
}
|
||||
if devices.is_empty()
|
||||
&& let Some(device) = default_render_device(enumerator)
|
||||
{
|
||||
devices.push(device);
|
||||
}
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
struct RenderSession {
|
||||
pid: u32,
|
||||
root_pid: u32,
|
||||
active: bool,
|
||||
excluded: bool,
|
||||
}
|
||||
|
||||
fn collect_render_sessions(
|
||||
devices: &[IMMDevice],
|
||||
processes: &[ResolveEntry],
|
||||
process_entries: &[process_tree::ProcessEntry],
|
||||
self_pid: u32,
|
||||
) -> Vec<RenderSession> {
|
||||
let mut out = Vec::new();
|
||||
for device in devices {
|
||||
let Ok(manager) =
|
||||
(unsafe { device.Activate::<IAudioSessionManager2>(CLSCTX_ALL, None) })
|
||||
else {
|
||||
@@ -1743,10 +2135,11 @@ mod platform {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if matches!(unsafe { session.GetState() }, Ok(state) if state == AudioSessionStateExpired)
|
||||
{
|
||||
let state = unsafe { session.GetState() }.unwrap_or(AudioSessionStateInactive);
|
||||
if state == AudioSessionStateExpired {
|
||||
continue;
|
||||
}
|
||||
let active = state == AudioSessionStateActive;
|
||||
let Ok(session2) = session.cast::<IAudioSessionControl2>() else {
|
||||
continue;
|
||||
};
|
||||
@@ -1756,33 +2149,89 @@ mod platform {
|
||||
let Ok(pid) = (unsafe { session2.GetProcessId() }) else {
|
||||
continue;
|
||||
};
|
||||
if pid == 0 {
|
||||
if pid == 0 || !processes.iter().any(|entry| entry.pid == pid) {
|
||||
continue;
|
||||
}
|
||||
if process_tree::pid_overlaps_our_process_tree(&process_entries, pid, self_pid) {
|
||||
excluded_pids.insert(pid);
|
||||
if process_tree::pid_overlaps_our_process_tree(process_entries, pid, self_pid) {
|
||||
out.push(RenderSession {
|
||||
pid,
|
||||
root_pid: pid,
|
||||
active,
|
||||
excluded: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let root_pid = resolve_audio_root_pid(pid);
|
||||
let root_pid = resolve_audio_root_pid_in(processes, pid, self_pid);
|
||||
if root_pid == 0 {
|
||||
continue;
|
||||
}
|
||||
if process_tree::pid_overlaps_our_process_tree(&process_entries, root_pid, self_pid)
|
||||
{
|
||||
excluded_pids.insert(root_pid);
|
||||
continue;
|
||||
}
|
||||
pids.insert(root_pid);
|
||||
out.push(RenderSession {
|
||||
pid,
|
||||
root_pid,
|
||||
active,
|
||||
excluded: process_tree::pid_overlaps_our_process_tree(
|
||||
process_entries,
|
||||
root_pid,
|
||||
self_pid,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
let capture_pids = pids.into_iter().collect::<Vec<_>>();
|
||||
let exclude_pids = excluded_pids.into_iter().collect::<Vec<_>>();
|
||||
Ok(
|
||||
process_tree::deduplicate_capture_roots(&process_entries, &capture_pids, &exclude_pids)
|
||||
.into_iter()
|
||||
.take(MAX_SESSION_MIXER_CAPTURES)
|
||||
.collect(),
|
||||
)
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn list_playback_devices() -> Result<Vec<PlaybackDevice>> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
thread::Builder::new()
|
||||
.name("fluxer-win-loopback-devices".to_owned())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(list_playback_devices_on_thread());
|
||||
})
|
||||
.map_err(|err| generic(format!("failed to spawn playback device listing: {err}")))?;
|
||||
rx.recv_timeout(PLAYBACK_DEVICE_LISTING_TIMEOUT)
|
||||
.map_err(|_| generic("playback device listing timed out"))?
|
||||
}
|
||||
|
||||
fn list_playback_devices_on_thread() -> Result<Vec<PlaybackDevice>> {
|
||||
let _com = ComApartment::initialize()?;
|
||||
let enumerator = create_device_enumerator()?;
|
||||
let default_id = default_render_device(&enumerator).and_then(|device| device_id(&device));
|
||||
let processes =
|
||||
snapshot_processes_with_exe().ok_or_else(|| generic("process snapshot failed"))?;
|
||||
let process_entries = process_entries_from(&processes);
|
||||
let self_pid = unsafe { GetCurrentProcessId() };
|
||||
let mut out = Vec::new();
|
||||
for device in active_render_devices(&enumerator)? {
|
||||
let Some(id) = device_id(&device) else {
|
||||
continue;
|
||||
};
|
||||
let sessions = collect_render_sessions(
|
||||
std::slice::from_ref(&device),
|
||||
&processes,
|
||||
&process_entries,
|
||||
self_pid,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|session| PlaybackDeviceSession {
|
||||
pid: session.pid,
|
||||
root_pid: session.root_pid,
|
||||
exe: processes
|
||||
.iter()
|
||||
.find(|entry| entry.pid == session.pid)
|
||||
.map(|entry| entry.exe.clone())
|
||||
.unwrap_or_default(),
|
||||
state: if session.active { "active" } else { "inactive" }.to_owned(),
|
||||
excluded: session.excluded,
|
||||
})
|
||||
.collect();
|
||||
out.push(PlaybackDevice {
|
||||
is_default: default_id.as_deref() == Some(id.as_str()),
|
||||
name: device_friendly_name(&device).unwrap_or_default(),
|
||||
id,
|
||||
sessions,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn drain_packets_into(
|
||||
@@ -1845,25 +2294,6 @@ mod platform {
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_mixed_packets(
|
||||
packets: Vec<CapturedPacket>,
|
||||
callbacks: &Callbacks,
|
||||
) -> Vec<CapturedPacket> {
|
||||
let mut mixed = session_mixer::mix_packets(
|
||||
packets,
|
||||
audio_contract::TARGET_CHANNELS,
|
||||
audio_contract::TARGET_SAMPLE_RATE,
|
||||
MAX_SESSION_MIXER_EMIT_FRAMES,
|
||||
);
|
||||
for packet in mixed.drain(..) {
|
||||
callbacks.emit_frame(FramePayload {
|
||||
samples: FrameSamples::Owned(packet.samples),
|
||||
timestamp_us: packet.timestamp_us,
|
||||
});
|
||||
}
|
||||
mixed
|
||||
}
|
||||
|
||||
fn pump_capture_loop(
|
||||
capture: &IAudioCaptureClient,
|
||||
sample_format: CaptureSampleFormat,
|
||||
@@ -2188,7 +2618,12 @@ mod platform {
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_audio_root_pid(pid: u32) -> u32 {
|
||||
resolve_audio_root_pid_impl(pid).unwrap_or(pid)
|
||||
match snapshot_processes_with_exe() {
|
||||
Some(entries) => {
|
||||
resolve_audio_root_pid_in(&entries, pid, unsafe { GetCurrentProcessId() })
|
||||
}
|
||||
None => pid,
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolveEntry {
|
||||
@@ -2197,9 +2632,7 @@ mod platform {
|
||||
exe: String,
|
||||
}
|
||||
|
||||
fn resolve_audio_root_pid_impl(start_pid: u32) -> Option<u32> {
|
||||
let entries = snapshot_processes_with_exe()?;
|
||||
let self_pid = unsafe { GetCurrentProcessId() };
|
||||
fn resolve_audio_root_pid_in(entries: &[ResolveEntry], start_pid: u32, self_pid: u32) -> u32 {
|
||||
let mut current = start_pid;
|
||||
let mut last_browserlike: u32 = if start_pid == self_pid { 0 } else { start_pid };
|
||||
for _ in 0..16 {
|
||||
@@ -2215,7 +2648,7 @@ mod platform {
|
||||
current = entry.parent;
|
||||
}
|
||||
if last_browserlike == 0 || last_browserlike == self_pid {
|
||||
return Some(start_pid);
|
||||
return start_pid;
|
||||
}
|
||||
|
||||
let mut self_walk = self_pid;
|
||||
@@ -2224,14 +2657,14 @@ mod platform {
|
||||
break;
|
||||
};
|
||||
if self_walk == last_browserlike {
|
||||
return Some(start_pid);
|
||||
return start_pid;
|
||||
}
|
||||
if entry.parent == 0 || entry.parent == self_walk {
|
||||
break;
|
||||
}
|
||||
self_walk = entry.parent;
|
||||
}
|
||||
Some(last_browserlike)
|
||||
last_browserlike
|
||||
}
|
||||
|
||||
fn is_browser_like(exe: &str) -> bool {
|
||||
@@ -2268,6 +2701,16 @@ mod platform {
|
||||
Some(entries)
|
||||
}
|
||||
|
||||
fn process_entries_from(processes: &[ResolveEntry]) -> Vec<process_tree::ProcessEntry> {
|
||||
processes
|
||||
.iter()
|
||||
.map(|entry| process_tree::ProcessEntry {
|
||||
pid: entry.pid,
|
||||
parent: entry.parent,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn snapshot_process_entries() -> Option<Vec<process_tree::ProcessEntry>> {
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.ok()?;
|
||||
let snapshot = OwnedHandle::new(snapshot)?;
|
||||
@@ -2346,7 +2789,7 @@ mod platform {
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod platform {
|
||||
use super::{Callbacks, LoopbackOptions, ProcessLoopbackRuntimeProbe};
|
||||
use super::{Callbacks, LoopbackOptions, PlaybackDevice, ProcessLoopbackRuntimeProbe};
|
||||
use napi::bindgen_prelude::{Error, Result, Status};
|
||||
|
||||
pub(crate) struct Inner;
|
||||
@@ -2373,6 +2816,14 @@ mod platform {
|
||||
pub(crate) fn stop(&self) {}
|
||||
|
||||
pub(crate) fn dispose(&self) {}
|
||||
|
||||
pub(crate) fn set_endpoints(&self, _endpoints: Vec<String>) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn list_playback_devices() -> Result<Vec<PlaybackDevice>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub(crate) fn is_supported() -> bool {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MixerPacket {
|
||||
pub timestamp_us: i64,
|
||||
@@ -18,90 +20,180 @@ pub fn timestamp_delta_to_frames(
|
||||
((delta_us * u128::from(sample_rate)) / 1_000_000) as usize
|
||||
}
|
||||
|
||||
fn normalize_packet_to_whole_frames(
|
||||
mut packet: MixerPacket,
|
||||
channel_count: usize,
|
||||
) -> Option<MixerPacket> {
|
||||
if channel_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let whole = (packet.samples.len() / channel_count) * channel_count;
|
||||
if whole == 0 {
|
||||
return None;
|
||||
}
|
||||
packet.samples.truncate(whole);
|
||||
for sample in &mut packet.samples {
|
||||
*sample = (*sample).clamp(-1.0, 1.0);
|
||||
}
|
||||
Some(packet)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SessionCandidate {
|
||||
pub root_pid: u32,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
pub fn mix_packets(
|
||||
mut packets: Vec<MixerPacket>,
|
||||
channels: u16,
|
||||
sample_rate: u32,
|
||||
max_emit_frames: usize,
|
||||
) -> Vec<MixerPacket> {
|
||||
let channel_count = usize::from(channels);
|
||||
if channel_count == 0 || sample_rate == 0 {
|
||||
return Vec::new();
|
||||
pub fn select_capture_pids(candidates: &[SessionCandidate], recently_active: &[u32]) -> Vec<u32> {
|
||||
let mut selected: Vec<u32> = candidates
|
||||
.iter()
|
||||
.filter(|candidate| candidate.root_pid != 0)
|
||||
.filter(|candidate| candidate.active || recently_active.contains(&candidate.root_pid))
|
||||
.map(|candidate| candidate.root_pid)
|
||||
.collect();
|
||||
selected.sort_unstable();
|
||||
selected.dedup();
|
||||
selected
|
||||
}
|
||||
|
||||
pub const SESSION_INACTIVE_GRACE_US: i64 = 5_000_000;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SessionActivity {
|
||||
last_active_us: Vec<(u32, i64)>,
|
||||
}
|
||||
|
||||
impl SessionActivity {
|
||||
pub fn clear(&mut self) {
|
||||
self.last_active_us.clear();
|
||||
}
|
||||
packets.retain(|packet| packet.samples.len() >= channel_count);
|
||||
if packets.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
packets.sort_by_key(|packet| packet.timestamp_us);
|
||||
let base_timestamp = packets[0].timestamp_us;
|
||||
let mut output_frames = 0usize;
|
||||
for packet in &packets {
|
||||
let offset_frames =
|
||||
timestamp_delta_to_frames(base_timestamp, packet.timestamp_us, sample_rate);
|
||||
let frames = packet.samples.len() / channel_count;
|
||||
output_frames = output_frames.max(offset_frames.saturating_add(frames));
|
||||
}
|
||||
if output_frames == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
if output_frames > max_emit_frames {
|
||||
return packets
|
||||
.into_iter()
|
||||
.filter_map(|packet| normalize_packet_to_whole_frames(packet, channel_count))
|
||||
.collect();
|
||||
}
|
||||
let mut mixed = vec![0.0f32; output_frames * channel_count];
|
||||
for packet in packets {
|
||||
let offset = timestamp_delta_to_frames(base_timestamp, packet.timestamp_us, sample_rate)
|
||||
* channel_count;
|
||||
let full_frame_samples = (packet.samples.len() / channel_count) * channel_count;
|
||||
for (idx, sample) in packet
|
||||
.samples
|
||||
|
||||
pub fn select(&mut self, candidates: &[SessionCandidate], now_us: i64) -> Vec<u32> {
|
||||
for candidate in candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.take(full_frame_samples)
|
||||
.enumerate()
|
||||
.filter(|candidate| candidate.active && candidate.root_pid != 0)
|
||||
{
|
||||
if let Some(slot) = mixed.get_mut(offset + idx) {
|
||||
*slot += sample;
|
||||
match self
|
||||
.last_active_us
|
||||
.iter_mut()
|
||||
.find(|(pid, _)| *pid == candidate.root_pid)
|
||||
{
|
||||
Some(entry) => entry.1 = now_us,
|
||||
None => self.last_active_us.push((candidate.root_pid, now_us)),
|
||||
}
|
||||
}
|
||||
self.last_active_us
|
||||
.retain(|(_, at_us)| now_us.saturating_sub(*at_us) < SESSION_INACTIVE_GRACE_US);
|
||||
let recently_active: Vec<u32> = self.last_active_us.iter().map(|(pid, _)| *pid).collect();
|
||||
select_capture_pids(candidates, &recently_active)
|
||||
}
|
||||
for sample in &mut mixed {
|
||||
*sample = (*sample).clamp(-1.0, 1.0);
|
||||
}
|
||||
|
||||
pub const MIX_TIMELINE_MAX_AHEAD_US: i64 = 500_000;
|
||||
pub const MIX_TIMELINE_IDLE_RESET_US: i64 = 1_000_000;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MixTimeline {
|
||||
origin_us: Option<i64>,
|
||||
next_frame: i64,
|
||||
last_input_us: i64,
|
||||
acc: VecDeque<f32>,
|
||||
}
|
||||
|
||||
fn frame_position(origin_us: i64, timestamp_us: i64, sample_rate: u32) -> i64 {
|
||||
if timestamp_us >= origin_us {
|
||||
timestamp_delta_to_frames(origin_us, timestamp_us, sample_rate) as i64
|
||||
} else {
|
||||
-(timestamp_delta_to_frames(timestamp_us, origin_us, sample_rate) as i64)
|
||||
}
|
||||
}
|
||||
|
||||
fn frame_timestamp_us(origin_us: i64, frame: i64, sample_rate: u32) -> i64 {
|
||||
origin_us
|
||||
.saturating_add(((i128::from(frame) * 1_000_000) / i128::from(sample_rate.max(1))) as i64)
|
||||
}
|
||||
|
||||
fn max_ahead_frames(sample_rate: u32) -> i64 {
|
||||
timestamp_delta_to_frames(0, MIX_TIMELINE_MAX_AHEAD_US, sample_rate) as i64
|
||||
}
|
||||
|
||||
impl MixTimeline {
|
||||
fn reset(&mut self) {
|
||||
self.origin_us = None;
|
||||
self.next_frame = 0;
|
||||
self.acc.clear();
|
||||
}
|
||||
|
||||
pub fn push(&mut self, packet: MixerPacket, sample_rate: u32, channels: usize) {
|
||||
if sample_rate == 0 || channels == 0 {
|
||||
return;
|
||||
}
|
||||
let packet_frames = (packet.samples.len() / channels) as i64;
|
||||
if packet_frames == 0 {
|
||||
return;
|
||||
}
|
||||
let origin_us = *self.origin_us.get_or_insert(packet.timestamp_us);
|
||||
self.last_input_us = self.last_input_us.max(packet.timestamp_us);
|
||||
let start_frame = frame_position(origin_us, packet.timestamp_us, sample_rate);
|
||||
let skip_frames = (self.next_frame - start_frame).max(0);
|
||||
if skip_frames >= packet_frames {
|
||||
return;
|
||||
}
|
||||
let offset_frames = (start_frame + skip_frames) - self.next_frame;
|
||||
let limit_frames = max_ahead_frames(sample_rate);
|
||||
if offset_frames >= limit_frames {
|
||||
return;
|
||||
}
|
||||
let copy_frames = (packet_frames - skip_frames).min(limit_frames - offset_frames);
|
||||
let offset = offset_frames as usize * channels;
|
||||
let needed = offset + copy_frames as usize * channels;
|
||||
if self.acc.len() < needed {
|
||||
self.acc.resize(needed, 0.0);
|
||||
}
|
||||
let source_start = skip_frames as usize * channels;
|
||||
let source = &packet.samples[source_start..source_start + copy_frames as usize * channels];
|
||||
for (index, sample) in source.iter().copied().enumerate() {
|
||||
self.acc[offset + index] += sample;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drain_ready(
|
||||
&mut self,
|
||||
now_us: i64,
|
||||
hold_us: i64,
|
||||
sample_rate: u32,
|
||||
channels: usize,
|
||||
) -> Option<MixerPacket> {
|
||||
if sample_rate == 0 || channels == 0 {
|
||||
return None;
|
||||
}
|
||||
let origin_us = self.origin_us?;
|
||||
if now_us.saturating_sub(self.last_input_us) > MIX_TIMELINE_IDLE_RESET_US {
|
||||
self.reset();
|
||||
return None;
|
||||
}
|
||||
let end_frame = frame_position(origin_us, now_us.saturating_sub(hold_us), sample_rate);
|
||||
if end_frame <= self.next_frame {
|
||||
return None;
|
||||
}
|
||||
let limit_frames = max_ahead_frames(sample_rate);
|
||||
if end_frame - self.next_frame > limit_frames {
|
||||
let dropped_frames = end_frame - limit_frames - self.next_frame;
|
||||
let dropped_samples = (dropped_frames as usize * channels).min(self.acc.len());
|
||||
self.acc.drain(..dropped_samples);
|
||||
self.next_frame += dropped_frames;
|
||||
}
|
||||
let frames = (end_frame - self.next_frame) as usize;
|
||||
let sample_count = frames * channels;
|
||||
let available = sample_count.min(self.acc.len());
|
||||
let mut samples: Vec<f32> = self
|
||||
.acc
|
||||
.drain(..available)
|
||||
.map(|sample| sample.clamp(-1.0, 1.0))
|
||||
.collect();
|
||||
samples.resize(sample_count, 0.0);
|
||||
let timestamp_us = frame_timestamp_us(origin_us, self.next_frame, sample_rate);
|
||||
self.next_frame = end_frame;
|
||||
Some(MixerPacket {
|
||||
timestamp_us,
|
||||
samples,
|
||||
})
|
||||
}
|
||||
vec![MixerPacket {
|
||||
timestamp_us: base_timestamp,
|
||||
samples: mixed,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn packet(timestamp_us: i64, samples: &[f32]) -> MixerPacket {
|
||||
const RATE: u32 = 48_000;
|
||||
const CHANNELS: usize = 2;
|
||||
|
||||
fn packet(timestamp_us: i64, frames: usize, value: f32) -> MixerPacket {
|
||||
MixerPacket {
|
||||
timestamp_us,
|
||||
samples: samples.to_vec(),
|
||||
samples: vec![value; frames * CHANNELS],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,125 +206,225 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_ignores_empty_input() {
|
||||
assert!(mix_packets(Vec::new(), 2, 48_000, 24_000).is_empty());
|
||||
assert!(mix_packets(vec![packet(0, &[])], 2, 48_000, 24_000).is_empty());
|
||||
fn select_capture_pids_ignores_inactive_only_sessions() {
|
||||
let candidates = [SessionCandidate {
|
||||
root_pid: 10,
|
||||
active: false,
|
||||
}];
|
||||
assert!(select_capture_pids(&candidates, &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_rejects_invalid_format_shape() {
|
||||
assert!(mix_packets(vec![packet(0, &[1.0, 1.0])], 0, 48_000, 24_000).is_empty());
|
||||
assert!(mix_packets(vec![packet(0, &[1.0, 1.0])], 2, 0, 24_000).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_sums_packets_with_matching_timestamps() {
|
||||
let mixed = mix_packets(
|
||||
vec![packet(10, &[0.25, 0.5]), packet(10, &[0.25, -0.25])],
|
||||
2,
|
||||
48_000,
|
||||
24_000,
|
||||
);
|
||||
assert_eq!(mixed, vec![packet(10, &[0.5, 0.25])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_clamps_overlapping_samples() {
|
||||
let mixed = mix_packets(
|
||||
vec![packet(10, &[0.75, -0.75]), packet(10, &[0.75, -0.75])],
|
||||
2,
|
||||
48_000,
|
||||
24_000,
|
||||
);
|
||||
assert_eq!(mixed, vec![packet(10, &[1.0, -1.0])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_offsets_by_timestamp() {
|
||||
let mixed = mix_packets(
|
||||
vec![packet(1_000, &[1.0, 0.0]), packet(2_000, &[0.0, 1.0])],
|
||||
2,
|
||||
48_000,
|
||||
24_000,
|
||||
);
|
||||
assert_eq!(mixed.len(), 1);
|
||||
assert_eq!(mixed[0].timestamp_us, 1_000);
|
||||
assert_eq!(&mixed[0].samples[0..2], &[1.0, 0.0]);
|
||||
assert_eq!(&mixed[0].samples[96..98], &[0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_sorts_packets_before_mixing() {
|
||||
let mixed = mix_packets(
|
||||
vec![packet(2_000, &[0.0, 1.0]), packet(1_000, &[1.0, 0.0])],
|
||||
2,
|
||||
48_000,
|
||||
24_000,
|
||||
);
|
||||
assert_eq!(mixed.len(), 1);
|
||||
assert_eq!(mixed[0].timestamp_us, 1_000);
|
||||
assert_eq!(&mixed[0].samples[0..2], &[1.0, 0.0]);
|
||||
assert_eq!(&mixed[0].samples[96..98], &[0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_falls_back_to_sorted_packets_when_span_is_too_large() {
|
||||
let packets = vec![packet(2_000, &[0.0, 1.0]), packet(1_000, &[1.0, 0.0])];
|
||||
let mixed = mix_packets(packets, 2, 48_000, 1);
|
||||
assert_eq!(
|
||||
mixed,
|
||||
vec![packet(1_000, &[1.0, 0.0]), packet(2_000, &[0.0, 1.0])]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_fallback_truncates_partial_frames_to_whole_stereo() {
|
||||
let packets = vec![
|
||||
packet(2_000, &[0.0, 1.0, 0.5]),
|
||||
packet(1_000, &[1.0, 0.0, 0.25, 0.75]),
|
||||
fn select_capture_pids_admits_active_sessions_sorted() {
|
||||
let candidates = [
|
||||
SessionCandidate {
|
||||
root_pid: 30,
|
||||
active: true,
|
||||
},
|
||||
SessionCandidate {
|
||||
root_pid: 10,
|
||||
active: true,
|
||||
},
|
||||
SessionCandidate {
|
||||
root_pid: 10,
|
||||
active: false,
|
||||
},
|
||||
];
|
||||
let mixed = mix_packets(packets, 2, 48_000, 1);
|
||||
assert_eq!(
|
||||
mixed,
|
||||
vec![
|
||||
packet(1_000, &[1.0, 0.0, 0.25, 0.75]),
|
||||
packet(2_000, &[0.0, 1.0]),
|
||||
]
|
||||
);
|
||||
for emitted in &mixed {
|
||||
assert_eq!(emitted.samples.len() % 2, 0);
|
||||
}
|
||||
assert_eq!(select_capture_pids(&candidates, &[]), vec![10, 30]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_fallback_clamps_out_of_range_samples() {
|
||||
let packets = vec![
|
||||
packet(2_000, &[0.0, 1.0]),
|
||||
packet(1_000, &[4.0, -4.0, 0.5, -0.5]),
|
||||
];
|
||||
let mixed = mix_packets(packets, 2, 48_000, 1);
|
||||
fn select_capture_pids_keeps_recently_active_root_that_went_inactive() {
|
||||
let candidates = [SessionCandidate {
|
||||
root_pid: 10,
|
||||
active: false,
|
||||
}];
|
||||
assert_eq!(select_capture_pids(&candidates, &[10]), vec![10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_capture_pids_drops_recently_active_root_without_sessions() {
|
||||
let candidates = [SessionCandidate {
|
||||
root_pid: 20,
|
||||
active: true,
|
||||
}];
|
||||
assert_eq!(select_capture_pids(&candidates, &[10]), vec![20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_activity_keeps_paused_root_only_within_grace() {
|
||||
let mut activity = SessionActivity::default();
|
||||
let playing = [SessionCandidate {
|
||||
root_pid: 10,
|
||||
active: true,
|
||||
}];
|
||||
let paused = [SessionCandidate {
|
||||
root_pid: 10,
|
||||
active: false,
|
||||
}];
|
||||
assert_eq!(activity.select(&playing, 0), vec![10]);
|
||||
assert_eq!(
|
||||
mixed,
|
||||
vec![
|
||||
packet(1_000, &[1.0, -1.0, 0.5, -0.5]),
|
||||
packet(2_000, &[0.0, 1.0]),
|
||||
]
|
||||
activity.select(&paused, SESSION_INACTIVE_GRACE_US - 1),
|
||||
vec![10]
|
||||
);
|
||||
assert!(
|
||||
activity
|
||||
.select(&paused, SESSION_INACTIVE_GRACE_US)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
activity
|
||||
.select(&paused, SESSION_INACTIVE_GRACE_US + 1)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_fallback_drops_subframe_packets() {
|
||||
let packets = vec![packet(2_000, &[0.0, 1.0]), packet(1_000, &[0.5])];
|
||||
let mixed = mix_packets(packets, 2, 48_000, 1);
|
||||
assert_eq!(mixed, vec![packet(2_000, &[0.0, 1.0])]);
|
||||
for emitted in &mixed {
|
||||
assert_eq!(emitted.samples.len() % 2, 0);
|
||||
}
|
||||
fn session_activity_clear_drops_paused_roots() {
|
||||
let mut activity = SessionActivity::default();
|
||||
let playing = [SessionCandidate {
|
||||
root_pid: 10,
|
||||
active: true,
|
||||
}];
|
||||
let paused = [SessionCandidate {
|
||||
root_pid: 10,
|
||||
active: false,
|
||||
}];
|
||||
assert_eq!(activity.select(&playing, 0), vec![10]);
|
||||
activity.clear();
|
||||
assert!(activity.select(&paused, 1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_truncates_partial_trailing_frames() {
|
||||
let mixed = mix_packets(vec![packet(10, &[0.25, 0.5, 0.75])], 2, 48_000, 24_000);
|
||||
assert_eq!(mixed, vec![packet(10, &[0.25, 0.5])]);
|
||||
fn timeline_sums_interleaved_sources_into_continuous_output() {
|
||||
let mut timeline = MixTimeline::default();
|
||||
let start = 1_000_000;
|
||||
let mut emitted_frames = 0usize;
|
||||
let mut first_timestamp = None;
|
||||
for step in 0..100i64 {
|
||||
let ts = start + step * 10_000;
|
||||
timeline.push(packet(ts, 480, 0.25), RATE, CHANNELS);
|
||||
timeline.push(packet(ts + 5_000, 480, 0.25), RATE, CHANNELS);
|
||||
if let Some(out) = timeline.drain_ready(ts + 10_000 + 40_000, 40_000, RATE, CHANNELS) {
|
||||
assert_eq!(out.samples.len() % CHANNELS, 0);
|
||||
if step > 2 {
|
||||
assert!(
|
||||
out.samples
|
||||
.iter()
|
||||
.all(|sample| (*sample - 0.5).abs() < 1e-6)
|
||||
);
|
||||
}
|
||||
first_timestamp.get_or_insert(out.timestamp_us);
|
||||
emitted_frames += out.samples.len() / CHANNELS;
|
||||
}
|
||||
}
|
||||
assert_eq!(first_timestamp, Some(start));
|
||||
assert_eq!(emitted_frames, 100 * 480);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_trims_frames_that_arrive_after_their_slot_was_emitted() {
|
||||
let mut timeline = MixTimeline::default();
|
||||
timeline.push(packet(0, 480, 0.5), RATE, CHANNELS);
|
||||
let first = timeline
|
||||
.drain_ready(10_000, 0, RATE, CHANNELS)
|
||||
.expect("first block");
|
||||
assert_eq!(first.samples.len(), 480 * CHANNELS);
|
||||
timeline.push(packet(5_000, 480, 0.25), RATE, CHANNELS);
|
||||
let second = timeline
|
||||
.drain_ready(15_000, 0, RATE, CHANNELS)
|
||||
.expect("second block");
|
||||
assert_eq!(second.timestamp_us, 10_000);
|
||||
assert_eq!(second.samples.len(), 240 * CHANNELS);
|
||||
assert!(second.samples.iter().all(|sample| *sample == 0.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_zero_fills_gaps_between_packets() {
|
||||
let mut timeline = MixTimeline::default();
|
||||
timeline.push(packet(0, 480, 0.5), RATE, CHANNELS);
|
||||
timeline.push(packet(20_000, 480, 0.5), RATE, CHANNELS);
|
||||
let out = timeline
|
||||
.drain_ready(30_000, 0, RATE, CHANNELS)
|
||||
.expect("block");
|
||||
assert_eq!(out.samples.len(), 1_440 * CHANNELS);
|
||||
assert!(out.samples[..480 * CHANNELS].iter().all(|s| *s == 0.5));
|
||||
assert!(
|
||||
out.samples[480 * CHANNELS..960 * CHANNELS]
|
||||
.iter()
|
||||
.all(|s| *s == 0.0)
|
||||
);
|
||||
assert!(out.samples[960 * CHANNELS..].iter().all(|s| *s == 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_keeps_emitting_silence_while_idle_and_resets_after_a_second() {
|
||||
let mut timeline = MixTimeline::default();
|
||||
timeline.push(packet(0, 480, 0.5), RATE, CHANNELS);
|
||||
let first = timeline
|
||||
.drain_ready(500_000, 0, RATE, CHANNELS)
|
||||
.expect("block");
|
||||
assert_eq!(first.samples.len(), 24_000 * CHANNELS);
|
||||
assert!(timeline.drain_ready(1_000_001, 0, RATE, CHANNELS).is_none());
|
||||
assert!(timeline.drain_ready(1_100_000, 0, RATE, CHANNELS).is_none());
|
||||
timeline.push(packet(2_000_000, 480, 0.5), RATE, CHANNELS);
|
||||
let restarted = timeline
|
||||
.drain_ready(2_010_000, 0, RATE, CHANNELS)
|
||||
.expect("re-anchored block");
|
||||
assert_eq!(restarted.timestamp_us, 2_000_000);
|
||||
assert_eq!(restarted.samples.len(), 480 * CHANNELS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_clamps_summed_output() {
|
||||
let mut timeline = MixTimeline::default();
|
||||
timeline.push(packet(0, 480, 0.75), RATE, CHANNELS);
|
||||
timeline.push(packet(0, 480, 0.75), RATE, CHANNELS);
|
||||
timeline.push(packet(10_000, 480, -0.75), RATE, CHANNELS);
|
||||
timeline.push(packet(10_000, 480, -0.75), RATE, CHANNELS);
|
||||
let out = timeline
|
||||
.drain_ready(20_000, 0, RATE, CHANNELS)
|
||||
.expect("block");
|
||||
assert!(out.samples[..480 * CHANNELS].iter().all(|s| *s == 1.0));
|
||||
assert!(out.samples[480 * CHANNELS..].iter().all(|s| *s == -1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_truncates_partial_trailing_frames() {
|
||||
let mut timeline = MixTimeline::default();
|
||||
timeline.push(
|
||||
MixerPacket {
|
||||
timestamp_us: 0,
|
||||
samples: vec![0.5, 0.5, 0.5],
|
||||
},
|
||||
RATE,
|
||||
CHANNELS,
|
||||
);
|
||||
timeline.push(
|
||||
MixerPacket {
|
||||
timestamp_us: 1_000,
|
||||
samples: vec![0.25],
|
||||
},
|
||||
RATE,
|
||||
CHANNELS,
|
||||
);
|
||||
let out = timeline
|
||||
.drain_ready(1_000, 0, RATE, CHANNELS)
|
||||
.expect("block");
|
||||
assert_eq!(out.samples.len(), 48 * CHANNELS);
|
||||
assert_eq!(&out.samples[..2], &[0.5, 0.5]);
|
||||
assert!(out.samples[2..].iter().all(|s| *s == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_drops_data_beyond_the_lookahead_window() {
|
||||
let mut timeline = MixTimeline::default();
|
||||
timeline.push(packet(0, 480, 0.5), RATE, CHANNELS);
|
||||
timeline.push(packet(600_000, 480, 0.5), RATE, CHANNELS);
|
||||
let out = timeline
|
||||
.drain_ready(610_000, 0, RATE, CHANNELS)
|
||||
.expect("block");
|
||||
assert_eq!(out.samples.len(), 24_000 * CHANNELS);
|
||||
assert!(out.samples.iter().all(|s| *s == 0.0));
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
NativeAudioAvailability,
|
||||
NativeAudioEndMessage,
|
||||
NativeAudioFrameMessage,
|
||||
NativeAudioPlaybackDevice,
|
||||
NativeAudioStartOptions,
|
||||
NativeAudioStartResult,
|
||||
NativeScreenCaptureLifecycleMessage,
|
||||
@@ -183,6 +184,8 @@ export interface NativeAudioApi {
|
||||
resolveAudioRootPidForSource: (sourceId: string) => Promise<number | null>;
|
||||
start: (options: NativeAudioStartOptions) => Promise<NativeAudioStartResult>;
|
||||
setRule: (captureId: string, linuxRule: NonNullable<NativeAudioStartOptions['linuxRule']>) => Promise<boolean>;
|
||||
listPlaybackDevices: () => Promise<Array<NativeAudioPlaybackDevice>>;
|
||||
setWinEndpoints: (captureId: string, endpoints: Array<string>) => Promise<boolean>;
|
||||
stop: (captureId: string) => Promise<void>;
|
||||
onFrame: (callback: (message: NativeAudioFrameMessage) => void) => () => void;
|
||||
onEnd: (callback: (message: NativeAudioEndMessage) => void) => () => void;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
NativeAudioApplication,
|
||||
NativeAudioAvailability,
|
||||
NativeAudioEndReason,
|
||||
NativeAudioPlaybackDevice,
|
||||
NativeAudioStartOptions,
|
||||
NativeAudioStartResult,
|
||||
VirtmicLinkOptions,
|
||||
@@ -22,7 +23,9 @@ import {
|
||||
isValidAudioFrame,
|
||||
isValidLinuxRule,
|
||||
isValidTargetPid,
|
||||
isValidWinEndpoints,
|
||||
normalizeTimestampUs,
|
||||
resolveWindowsCaptureScope,
|
||||
} from '@electron/main/NativeAudioValidation';
|
||||
import {ipcMain} from 'electron';
|
||||
|
||||
@@ -39,6 +42,7 @@ interface NativeCaptureInstance {
|
||||
start(): Promise<void> | void;
|
||||
stop(): Promise<void> | void;
|
||||
setRoutingRule?: (target: {linuxRule: NonNullable<NativeAudioStartOptions['linuxRule']>}) => boolean;
|
||||
setEndpoints?: (endpoints: Array<string>) => boolean;
|
||||
}
|
||||
|
||||
interface WindowsNativeAudioModule {
|
||||
@@ -59,12 +63,14 @@ interface WindowsNativeAudioModule {
|
||||
};
|
||||
pidFromHwnd: (hwnd: bigint) => number;
|
||||
resolveAudioRootPid: (pid: number) => number;
|
||||
listPlaybackDevices?: () => Promise<Array<NativeAudioPlaybackDevice>>;
|
||||
ProcessLoopback: new (
|
||||
pid: number,
|
||||
options?: {
|
||||
includeProcessTree?: boolean;
|
||||
captureScope?: 'process' | 'system' | 'session-mixer';
|
||||
winCaptureScope?: 'process' | 'system' | 'session-mixer';
|
||||
endpoints?: Array<string>;
|
||||
},
|
||||
) => NativeCaptureInstance;
|
||||
loadError?: Error | null;
|
||||
@@ -270,6 +276,7 @@ function loadNativeAudioAddon(): NativeAddonLoadResult {
|
||||
processInclude: false,
|
||||
processExclude: false,
|
||||
sessionMixer: false,
|
||||
endpointSelect: false,
|
||||
systemLoopbackMode: 'unavailable',
|
||||
},
|
||||
},
|
||||
@@ -290,6 +297,7 @@ function loadNativeAudioAddon(): NativeAddonLoadResult {
|
||||
processInclude,
|
||||
processExclude,
|
||||
sessionMixer,
|
||||
endpointSelect: sessionMixer,
|
||||
systemLoopbackMode:
|
||||
systemLoopbackMode === 'process-exclude' || systemLoopbackMode === 'session-mixer'
|
||||
? systemLoopbackMode
|
||||
@@ -510,6 +518,21 @@ async function listNativeAudioApplications(): Promise<Array<NativeAudioApplicati
|
||||
}
|
||||
}
|
||||
|
||||
async function listWindowsPlaybackDevices(): Promise<Array<NativeAudioPlaybackDevice>> {
|
||||
const loadResult = loadNativeAudioAddon();
|
||||
if (loadResult.platform !== 'win32' || !loadResult.availability.available || !loadResult.addon) {
|
||||
return [];
|
||||
}
|
||||
const addon = loadResult.addon as WindowsNativeAudioModule;
|
||||
if (typeof addon.listPlaybackDevices !== 'function') return [];
|
||||
try {
|
||||
return await addon.listPlaybackDevices();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to list Windows playback devices', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAudioRootPidForSource(sourceId: unknown): Promise<number | null> {
|
||||
const windowToken = parseDesktopWindowSourceToken(sourceId);
|
||||
if (!windowToken) {
|
||||
@@ -646,6 +669,19 @@ function reconfigureCaptureById(
|
||||
}
|
||||
}
|
||||
|
||||
function reconfigureWindowsEndpointsById(senderId: number, captureId: string, endpoints: Array<string>): boolean {
|
||||
const session = activeSessions.get(captureId);
|
||||
if (!session || session.sender.id !== senderId) return false;
|
||||
if (session.finalized || session.stopping) return false;
|
||||
if (typeof session.capture.setEndpoints !== 'function') return false;
|
||||
try {
|
||||
return session.capture.setEndpoints(endpoints);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to change Windows playback devices in place', {captureId, error});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function makeRoomForSenderSession(senderId: number): Promise<void> {
|
||||
const senderSessions = activeSessionIdsBySenderId.get(senderId);
|
||||
if (!senderSessions) return;
|
||||
@@ -679,6 +715,10 @@ async function startNativeAudioCapture(
|
||||
: 'process';
|
||||
const winSystemCapture =
|
||||
loadResult.platform === 'win32' && (winCaptureScope === 'system' || winCaptureScope === 'session-mixer');
|
||||
const winEndpoints = loadResult.platform === 'win32' ? options?.winEndpoints : undefined;
|
||||
if (winEndpoints !== undefined && !isValidWinEndpoints(winEndpoints)) {
|
||||
throw new Error('Invalid Windows playback device selection');
|
||||
}
|
||||
const targetPid = options?.targetPid;
|
||||
if (!options || (!linuxRule && !macSystemCapture && !winSystemCapture && !isValidTargetPid(targetPid))) {
|
||||
throw new Error('Invalid native audio target PID');
|
||||
@@ -715,13 +755,7 @@ async function startNativeAudioCapture(
|
||||
if (requestedScope === 'system' && availability.capabilities?.systemExcludesSelf !== true) {
|
||||
throw new Error(`Native ${loadResult.platform} system audio capture does not guarantee Fluxer self-exclusion`);
|
||||
}
|
||||
const effectiveWinCaptureScope =
|
||||
loadResult.platform === 'win32' &&
|
||||
winCaptureScope === 'system' &&
|
||||
availability.capabilities?.processExclude === false &&
|
||||
availability.capabilities?.sessionMixer === true
|
||||
? 'session-mixer'
|
||||
: winCaptureScope;
|
||||
const windowsCapture = resolveWindowsCaptureScope(winCaptureScope, winEndpoints, availability.capabilities);
|
||||
if (sender.isDestroyed()) {
|
||||
throw new Error('Cannot start native audio capture for destroyed renderer');
|
||||
}
|
||||
@@ -739,7 +773,8 @@ async function startNativeAudioCapture(
|
||||
if (!isValidTargetPid(targetPidForCapture)) throw new Error('Invalid native audio target PID');
|
||||
capture = new (loadResult.addon as WindowsNativeAudioModule).ProcessLoopback(targetPidForCapture, {
|
||||
includeProcessTree: winSystemCapture ? false : (options.includeProcessTree ?? true),
|
||||
captureScope: effectiveWinCaptureScope,
|
||||
captureScope: windowsCapture.captureScope,
|
||||
...(windowsCapture.endpoints ? {endpoints: windowsCapture.endpoints} : {}),
|
||||
});
|
||||
} else if (loadResult.platform === 'darwin') {
|
||||
if (!isValidTargetPid(targetPidForCapture)) throw new Error('Invalid native audio target PID');
|
||||
@@ -883,6 +918,16 @@ export function registerNativeAudioHandlers(): void {
|
||||
return reconfigureCaptureById(event.sender.id, captureId, linuxRule);
|
||||
},
|
||||
);
|
||||
ipcMain.handle('native-audio:set-win-endpoints', (event, captureId: unknown, endpoints: unknown): boolean => {
|
||||
if (typeof captureId !== 'string' || !isValidWinEndpoints(endpoints)) {
|
||||
return false;
|
||||
}
|
||||
return reconfigureWindowsEndpointsById(event.sender.id, captureId, endpoints);
|
||||
});
|
||||
ipcMain.handle(
|
||||
'native-audio:list-playback-devices',
|
||||
(): Promise<Array<NativeAudioPlaybackDevice>> => listWindowsPlaybackDevices(),
|
||||
);
|
||||
ipcMain.handle('native-audio:stop', async (event, captureId: string): Promise<void> => {
|
||||
const session = activeSessions.get(captureId);
|
||||
if (!session || session.sender.id !== event.sender.id) {
|
||||
@@ -899,6 +944,8 @@ export function cleanupNativeAudio(): void {
|
||||
ipcMain.removeHandler('native-audio:resolve-root-pid');
|
||||
ipcMain.removeHandler('native-audio:start');
|
||||
ipcMain.removeHandler('native-audio:set-rule');
|
||||
ipcMain.removeHandler('native-audio:set-win-endpoints');
|
||||
ipcMain.removeHandler('native-audio:list-playback-devices');
|
||||
ipcMain.removeHandler('native-audio:stop');
|
||||
handlersRegistered = false;
|
||||
for (const session of [...activeSessions.values()]) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {describe, test} from 'node:test';
|
||||
import {loadTsModule} from './fixtures/TsModuleLoader.mjs';
|
||||
|
||||
const {isValidWinEndpoints, resolveWindowsCaptureScope} = loadTsModule('@electron/main/NativeAudioValidation');
|
||||
|
||||
const SPEAKERS_ID = '{0.0.0.00000000}.{6f1e8a2c-0000-4000-8000-000000000001}';
|
||||
|
||||
describe('isValidWinEndpoints', () => {
|
||||
test('accepts the default entry and device ids', () => {
|
||||
assert.equal(isValidWinEndpoints(['default']), true);
|
||||
assert.equal(isValidWinEndpoints(['default', SPEAKERS_ID]), true);
|
||||
assert.equal(isValidWinEndpoints([`{${'a'.repeat(511)}`]), true);
|
||||
});
|
||||
|
||||
test('rejects malformed selections', () => {
|
||||
assert.equal(isValidWinEndpoints(undefined), false);
|
||||
assert.equal(isValidWinEndpoints('default'), false);
|
||||
assert.equal(isValidWinEndpoints([]), false);
|
||||
assert.equal(isValidWinEndpoints(Array.from({length: 17}, (_, index) => `{${index}}`)), false);
|
||||
assert.equal(isValidWinEndpoints(['speakers']), false);
|
||||
assert.equal(isValidWinEndpoints(['{abc\0}']), false);
|
||||
assert.equal(isValidWinEndpoints([`{${'a'.repeat(512)}`]), false);
|
||||
assert.equal(isValidWinEndpoints([42]), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWindowsCaptureScope', () => {
|
||||
const fullCapabilities = {
|
||||
process: true,
|
||||
system: true,
|
||||
processExclude: true,
|
||||
sessionMixer: true,
|
||||
endpointSelect: true,
|
||||
};
|
||||
|
||||
test('device selection needs the session mixer and the endpoint capability', () => {
|
||||
assert.deepEqual(resolveWindowsCaptureScope('session-mixer', ['default'], fullCapabilities), {
|
||||
captureScope: 'session-mixer',
|
||||
endpoints: ['default'],
|
||||
});
|
||||
assert.throws(() =>
|
||||
resolveWindowsCaptureScope('session-mixer', ['default'], {...fullCapabilities, endpointSelect: false}),
|
||||
);
|
||||
assert.throws(() => resolveWindowsCaptureScope('session-mixer', ['default'], undefined));
|
||||
assert.throws(() => resolveWindowsCaptureScope('system', ['default'], fullCapabilities));
|
||||
});
|
||||
|
||||
test('include-only hosts downgrade entire system audio to the session mixer', () => {
|
||||
assert.deepEqual(resolveWindowsCaptureScope('system', undefined, {...fullCapabilities, processExclude: false}), {
|
||||
captureScope: 'session-mixer',
|
||||
});
|
||||
});
|
||||
|
||||
test('entire system audio keeps process-exclude loopback when it is available', () => {
|
||||
assert.deepEqual(resolveWindowsCaptureScope('system', undefined, fullCapabilities), {captureScope: 'system'});
|
||||
assert.deepEqual(resolveWindowsCaptureScope('process', undefined, fullCapabilities), {captureScope: 'process'});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {isPlainRecord as isPlainObject} from '@electron/common/PlainRecord';
|
||||
|
||||
import type {NativeAudioStartOptions, VirtmicNode} from '@electron/common/Types';
|
||||
import type {NativeAudioAvailability, NativeAudioStartOptions, VirtmicNode} from '@electron/common/Types';
|
||||
|
||||
interface InternalAudioFrame {
|
||||
samples: Float32Array;
|
||||
@@ -20,6 +20,11 @@ const MAX_LINUX_RULE_KEYS_PER_PATTERN = 32;
|
||||
const MAX_LINUX_RULE_KEY_LENGTH = 128;
|
||||
const MAX_LINUX_RULE_VALUE_LENGTH = 512;
|
||||
const MAX_UINT32 = 0xffffffff;
|
||||
const DEFAULT_WIN_ENDPOINT = 'default';
|
||||
const MAX_WIN_ENDPOINTS = 16;
|
||||
const MAX_WIN_ENDPOINT_ID_LENGTH = 512;
|
||||
|
||||
type WindowsCaptureScope = NonNullable<NativeAudioStartOptions['winCaptureScope']>;
|
||||
|
||||
export function isValidTargetPid(pid: unknown): pid is number {
|
||||
return typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0 && pid <= MAX_UINT32;
|
||||
@@ -77,6 +82,38 @@ export function isValidLinuxRule(
|
||||
);
|
||||
}
|
||||
|
||||
function isValidWinEndpoint(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
if (value === DEFAULT_WIN_ENDPOINT) return true;
|
||||
return value.length <= MAX_WIN_ENDPOINT_ID_LENGTH && !value.includes('\0') && value.startsWith('{');
|
||||
}
|
||||
|
||||
export function isValidWinEndpoints(value: unknown): value is Array<string> {
|
||||
return (
|
||||
Array.isArray(value) && value.length > 0 && value.length <= MAX_WIN_ENDPOINTS && value.every(isValidWinEndpoint)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveWindowsCaptureScope(
|
||||
requested: WindowsCaptureScope,
|
||||
winEndpoints: Array<string> | undefined,
|
||||
capabilities: NativeAudioAvailability['capabilities'],
|
||||
): {captureScope: WindowsCaptureScope; endpoints?: Array<string>} {
|
||||
if (winEndpoints) {
|
||||
if (requested !== 'session-mixer') {
|
||||
throw new Error('Windows playback device selection requires the session-mixer scope');
|
||||
}
|
||||
if (capabilities?.endpointSelect !== true) {
|
||||
throw new Error('Windows playback device selection is unavailable');
|
||||
}
|
||||
return {captureScope: 'session-mixer', endpoints: winEndpoints};
|
||||
}
|
||||
if (requested === 'system' && capabilities?.processExclude === false && capabilities?.sessionMixer === true) {
|
||||
return {captureScope: 'session-mixer'};
|
||||
}
|
||||
return {captureScope: requested};
|
||||
}
|
||||
|
||||
export function normalizeTimestampUs(timestampUs: number | bigint): number {
|
||||
const numericTimestamp = typeof timestampUs === 'bigint' ? Number(timestampUs) : timestampUs;
|
||||
if (!Number.isFinite(numericTimestamp)) return 0;
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
NativeAudioAvailability,
|
||||
NativeAudioEndMessage,
|
||||
NativeAudioFrameMessage,
|
||||
NativeAudioPlaybackDevice,
|
||||
NativeAudioStartOptions,
|
||||
NativeAudioStartResult,
|
||||
NativeScreenCaptureLifecycleEventKind,
|
||||
@@ -779,6 +780,10 @@ const api: ElectronAPI = {
|
||||
ipcRenderer.invoke('native-audio:start', options),
|
||||
setRule: (captureId: string, linuxRule: NonNullable<NativeAudioStartOptions['linuxRule']>): Promise<boolean> =>
|
||||
ipcRenderer.invoke('native-audio:set-rule', captureId, linuxRule),
|
||||
listPlaybackDevices: (): Promise<Array<NativeAudioPlaybackDevice>> =>
|
||||
ipcRenderer.invoke('native-audio:list-playback-devices'),
|
||||
setWinEndpoints: (captureId: string, endpoints: Array<string>): Promise<boolean> =>
|
||||
ipcRenderer.invoke('native-audio:set-win-endpoints', captureId, endpoints),
|
||||
stop: (captureId: string): Promise<void> => ipcRenderer.invoke('native-audio:stop', captureId),
|
||||
onFrame: (callback: (message: NativeAudioFrameMessage) => void): (() => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, message: NativeAudioFrameMessage): void => {
|
||||
|
||||
Reference in new issue
Block a user