#![warn(missing_docs)]
pub use flexaudio_core as core;
pub mod device_watcher;
mod mix;
pub mod mock;
mod processes;
pub mod stream;
pub use device_watcher::DeviceWatcher;
pub use mock::MockBackend;
pub use processes::processes;
pub use stream::Stream;
pub use flexaudio_core::backend::CaptureBackend;
pub use flexaudio_core::types::{
AudioChunk, ChunkFlags, DeviceEvent, DeviceInfo, Error, Event, OutputFormat, Permission,
ProcessInfo, ProcessMode, Result, SecondaryChunk, SourceKind, StreamConfig,
};
pub fn devices() -> Result<Vec<DeviceInfo>> {
#[allow(unused_mut)]
let mut all = flexaudio_mic::list_devices()?;
#[cfg(target_os = "linux")]
{
let linux = flexaudio_os_linux::list_devices()?;
all.extend(linux);
}
#[cfg(target_os = "windows")]
{
let win = flexaudio_os_windows::list_output_devices()?;
all.extend(win);
}
#[cfg(target_os = "macos")]
{
let mac = flexaudio_os_macos::list_output_devices()?;
all.extend(mac);
}
Ok(all)
}
pub fn watch_devices() -> Result<DeviceWatcher> {
device_watcher::watch_devices()
}
pub fn open(config: StreamConfig) -> Result<Stream> {
config.output.validate()?;
validate_exclude_pids(&config)?;
let backend = build_backend(&config)?;
Stream::open(config, backend)
}
pub(crate) fn validate_exclude_pids(config: &StreamConfig) -> Result<()> {
if matches!(config.kind, SourceKind::SystemLoopback | SourceKind::Mix)
&& config.exclude_pids.contains(&0)
{
return Err(Error::InvalidArg(
"exclude_pids: pid 0 is not a valid process id".into(),
));
}
Ok(())
}
pub(crate) fn build_backend(config: &StreamConfig) -> Result<Box<dyn CaptureBackend>> {
use flexaudio_core::types::Error;
let backend: Box<dyn CaptureBackend> = match config.kind {
SourceKind::Mic => Box::new(flexaudio_mic::CpalMicBackend::try_new(
config.device_id.clone(),
)?),
SourceKind::SystemLoopback => build_system_backend(
config.exclude_self,
config.exclude_pids.clone(),
config.device_id.clone(),
)?,
SourceKind::ProcessLoopback => {
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
{
let pid = config.target_pid.ok_or_else(|| {
Error::InvalidArg("ProcessLoopback requires target_pid".into())
})?;
#[cfg(target_os = "linux")]
{
Box::new(flexaudio_os_linux::PwProcessBackend::new(pid, config.mode))
}
#[cfg(target_os = "windows")]
{
Box::new(flexaudio_os_windows::WasapiProcessBackend::new(
pid,
config.mode,
))
}
#[cfg(target_os = "macos")]
{
Box::new(flexaudio_os_macos::MacProcessBackend::new(pid, config.mode))
}
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
{
return Err(Error::Unsupported);
}
}
SourceKind::Mix => {
for (name, gain) in [
("mix_mic_gain", config.mix_mic_gain),
("mix_system_gain", config.mix_system_gain),
] {
if !gain.is_finite() || gain < 0.0 {
return Err(Error::InvalidArg(format!(
"{name} must be finite and >= 0.0, got {gain}"
)));
}
}
let mic = Box::new(flexaudio_mic::CpalMicBackend::try_new(
config.mix_mic_device_id.clone(),
)?);
let system = build_system_backend(
config.exclude_self,
config.exclude_pids.clone(),
config.mix_system_device_id.clone(),
)?;
Box::new(mix::CompositeBackend::new(
mic,
system,
config.mix_mic_gain,
config.mix_system_gain,
))
}
};
Ok(backend)
}
fn build_system_backend(
exclude_self: bool,
exclude_pids: Vec<u32>,
device_id: Option<String>,
) -> Result<Box<dyn CaptureBackend>> {
#[cfg(target_os = "linux")]
{
Ok(Box::new(
flexaudio_os_linux::PwSystemBackend::new(exclude_self, device_id)
.with_exclude_pids(exclude_pids),
))
}
#[cfg(target_os = "windows")]
{
Ok(Box::new(
flexaudio_os_windows::WasapiSystemBackend::new(exclude_self, device_id)
.with_exclude_pids(exclude_pids),
))
}
#[cfg(target_os = "macos")]
{
Ok(Box::new(
flexaudio_os_macos::MacSystemBackend::new(exclude_self, device_id)
.with_exclude_pids(exclude_pids),
))
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
{
let _ = (exclude_self, exclude_pids, device_id);
Err(flexaudio_core::types::Error::Unsupported)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_rejects_zero_exclusion_pid_for_system_capture() {
for kind in [SourceKind::SystemLoopback, SourceKind::Mix] {
let config = StreamConfig {
kind,
exclude_pids: vec![0],
..Default::default()
};
assert!(matches!(
open(config),
Err(Error::InvalidArg(message))
if message == "exclude_pids: pid 0 is not a valid process id"
));
}
}
#[test]
fn exclusion_pid_validation_accepts_positive_and_ignored_pids() {
for kind in [SourceKind::SystemLoopback, SourceKind::Mix] {
for exclude_pids in [vec![], vec![42]] {
let config = StreamConfig {
kind,
exclude_pids,
..Default::default()
};
assert!(validate_exclude_pids(&config).is_ok());
}
}
for kind in [SourceKind::Mic, SourceKind::ProcessLoopback] {
let config = StreamConfig {
kind,
exclude_pids: vec![0],
..Default::default()
};
assert!(validate_exclude_pids(&config).is_ok());
}
}
#[test]
fn mix_config_rejects_invalid_side_gains() {
for (mic_gain, system_gain) in [
(-1.0f32, 1.0f32),
(1.0, -0.5),
(f32::NAN, 1.0),
(1.0, f32::INFINITY),
] {
let config = StreamConfig {
kind: SourceKind::Mix,
mix_mic_gain: mic_gain,
mix_system_gain: system_gain,
..Default::default()
};
match open(config) {
Ok(_) => {
panic!("mix gains ({mic_gain}, {system_gain}) should be rejected as InvalidArg")
}
Err(Error::InvalidArg(msg)) => {
assert!(
msg.contains("mix_mic_gain") || msg.contains("mix_system_gain"),
"message should identify which gain is invalid: {msg}"
);
}
Err(other) => panic!("expected InvalidArg, got a different error: {other:?}"),
}
}
}
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
#[test]
fn mix_config_with_valid_gains_opens() {
let config = StreamConfig {
kind: SourceKind::Mix,
mix_mic_gain: 0.5,
mix_system_gain: 2.0,
..Default::default()
};
let stream = match open(config) {
Ok(stream) => stream,
#[cfg(any(target_os = "windows", target_os = "macos"))]
Err(
error @ Error::PermissionDenied {
permission: Permission::Microphone,
..
},
) => {
eprintln!("Skipping mix_config_with_valid_gains_opens: host microphone permission is denied: {error}");
return;
}
Err(error) => panic!("valid Mix config should open successfully: {error:?}"),
};
assert_eq!(stream.native_format(), (48_000, 2));
}
}