use simplelog::{Config, LevelFilter, SimpleLogger};
use std::cell::Cell;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
use wasapi::*;
pub const CABLE_INTERFACE_NAME: &str = "VB-Audio Virtual Cable";
pub const RUN_TIME: Duration = Duration::from_millis(500);
const POLL_SLEEP: Duration = Duration::from_millis(5);
const EVENT_TIMEOUT_MS: u32 = 2000;
const MAX_ITERATIONS: u32 = 10_000;
const MAX_READ_FRAMES: u32 = 192_000;
static DEVICE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
thread_local! {
static COM_READY: Cell<bool> = const { Cell::new(false) };
}
pub struct Cable {
pub render: Device,
pub capture: Device,
}
pub struct Fixture {
pub enumerator: DeviceEnumerator,
pub cable: Cable,
_lock: MutexGuard<'static, ()>,
}
pub fn fixture() -> Option<Fixture> {
let lock = DEVICE_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
init_log();
init_com();
let enumerator = DeviceEnumerator::new().expect("failed to create a DeviceEnumerator");
match find_cable(&enumerator) {
Some(cable) => Some(Fixture {
enumerator,
cable,
_lock: lock,
}),
None => {
skip("no VB-Cable render and capture endpoint found");
None
}
}
}
pub fn init_com() {
COM_READY.with(|ready| {
if !ready.get() {
initialize_mta()
.ok()
.expect("failed to initialize COM as MTA");
ready.set(true);
}
});
}
pub fn init_log() {
static LOGGER: OnceLock<()> = OnceLock::new();
LOGGER.get_or_init(|| {
let level = match std::env::var("WASAPI_TEST_LOG")
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"error" => LevelFilter::Error,
"warn" => LevelFilter::Warn,
"info" => LevelFilter::Info,
"debug" => LevelFilter::Debug,
"trace" => LevelFilter::Trace,
_ => LevelFilter::Off,
};
if level != LevelFilter::Off {
let _ = SimpleLogger::init(level, Config::default());
}
});
}
pub fn skip(reason: &str) {
let name = std::thread::current().name().unwrap_or("test").to_owned();
let message = format!("SKIPPED {name}: {reason}");
assert!(!require_device(), "{message}");
println!("{message}");
}
pub fn require_device() -> bool {
std::env::var_os("WASAPI_REQUIRE_DEVICE").is_some()
}
pub fn cable_endpoints(enumerator: &DeviceEnumerator, direction: &Direction) -> Vec<Device> {
let Ok(collection) = enumerator.get_device_collection(direction) else {
return Vec::new();
};
(&collection)
.into_iter()
.flatten()
.filter(|device| {
device
.get_interface_friendlyname()
.is_ok_and(|name| name == CABLE_INTERFACE_NAME)
})
.collect()
}
fn find_cable(enumerator: &DeviceEnumerator) -> Option<Cable> {
let is_multichannel = |device: &Device| {
device
.get_description()
.is_ok_and(|desc| desc.to_lowercase().replace(' ', "").contains("16ch"))
};
let (multichannel, stereo): (Vec<Device>, Vec<Device>) =
cable_endpoints(enumerator, &Direction::Render)
.into_iter()
.partition(is_multichannel);
drop(multichannel);
Some(Cable {
render: stereo.into_iter().next()?,
capture: cable_endpoints(enumerator, &Direction::Capture)
.into_iter()
.next()?,
})
}
pub fn is_hresult(error: &WasapiError, expected: windows::core::HRESULT) -> bool {
matches!(error, WasapiError::Windows(e) if e.code() == expected)
}
pub fn describe(device: &Device) -> String {
let name = |result: Result<String, WasapiError>| result.unwrap_or_else(|e| format!("<{e}>"));
format!(
"{:?} \"{}\" desc \"{}\" iface \"{}\" id {}",
device.get_direction(),
name(device.get_friendlyname()),
name(device.get_description()),
name(device.get_interface_friendlyname()),
name(device.get_id()),
)
}
pub struct Run {
pub frames: u64,
pub elapsed: Duration,
pub buffer_size: u32,
pub packets: Vec<(u32, BufferInfo)>,
pub event_timeout: bool,
}
impl Run {
fn new(buffer_size: u32) -> Self {
Run {
frames: 0,
elapsed: Duration::ZERO,
buffer_size,
packets: Vec::new(),
event_timeout: false,
}
}
pub fn rate(&self) -> f64 {
self.frames as f64 / self.elapsed.as_secs_f64()
}
}
pub struct Opened {
pub client: AudioClient,
pub format: WaveFormat,
}
pub fn open(
device: &Device,
direction: &Direction,
share: ShareMode,
timing: TimingMode,
) -> Result<Opened, WasapiError> {
let mut client = device.get_iaudioclient()?;
let (def_period, _min_period) = client.get_device_period()?;
let format = match share {
ShareMode::Shared => client.get_mixformat()?,
ShareMode::Exclusive => client.is_supported_exclusive_with_quirks(&WaveFormat::new(
16,
16,
&SampleType::Int,
48000,
2,
None,
))?,
};
let mode = mode_for(&client, share, timing, def_period, &format)?;
client.initialize_client(&format, direction, &mode)?;
Ok(Opened { client, format })
}
pub fn mode_for(
client: &AudioClient,
share: ShareMode,
timing: TimingMode,
def_period: i64,
format: &WaveFormat,
) -> Result<StreamMode, WasapiError> {
Ok(match (share, timing) {
(ShareMode::Shared, TimingMode::Events) => StreamMode::EventsShared {
autoconvert: false,
buffer_duration_hns: def_period,
},
(ShareMode::Shared, TimingMode::Polling) => StreamMode::PollingShared {
autoconvert: false,
buffer_duration_hns: def_period,
},
(ShareMode::Exclusive, timing) => {
let period = client.calculate_aligned_period_near(def_period, Some(128), format)?;
match timing {
TimingMode::Events => StreamMode::EventsExclusive { period_hns: period },
TimingMode::Polling => StreamMode::PollingExclusive {
period_hns: period,
buffer_duration_hns: 16 * period,
},
}
}
})
}
pub fn open_or_skip(
device: &Device,
direction: &Direction,
share: ShareMode,
timing: TimingMode,
) -> Option<Opened> {
match open(device, direction, share, timing) {
Ok(opened) => Some(opened),
Err(e) => {
skip(&format!("cannot open {share} {timing:?}: {e}"));
None
}
}
}
fn wait(event: Option<&Handle>) -> bool {
match event {
Some(handle) => handle.wait_for_event(EVENT_TIMEOUT_MS).is_ok(),
None => {
std::thread::sleep(POLL_SLEEP);
true
}
}
}
fn event_handle(client: &AudioClient) -> Result<Option<Handle>, WasapiError> {
match client.get_timing_mode() {
Some(TimingMode::Events) => Ok(Some(client.set_get_eventhandle()?)),
_ => Ok(None),
}
}
pub fn render_for(
client: &AudioClient,
fmt: &WaveFormat,
dur: Duration,
) -> Result<Run, WasapiError> {
let blockalign = fmt.get_blockalign() as usize;
let buffer_size = client.get_buffer_size()?;
let render_client = client.get_audiorenderclient()?;
let event = event_handle(client)?;
let silence = vec![0u8; buffer_size as usize * blockalign];
let prefill = client.get_available_space_in_frames()? as usize;
render_client.write_to_device(prefill, &silence[..prefill * blockalign], None)?;
let mut run = Run::new(buffer_size);
let start = Instant::now();
client.start_stream()?;
for _ in 0..MAX_ITERATIONS {
if start.elapsed() >= dur {
break;
}
if !wait(event.as_ref()) {
run.event_timeout = true;
break;
}
let frames = client.get_available_space_in_frames()? as usize;
render_client.write_to_device(frames, &silence[..frames * blockalign], None)?;
run.frames += frames as u64;
}
run.elapsed = start.elapsed();
client.stop_stream()?;
Ok(run)
}
pub fn capture_for(
client: &AudioClient,
fmt: &WaveFormat,
dur: Duration,
) -> Result<Run, WasapiError> {
let blockalign = fmt.get_blockalign() as usize;
let buffer_size = client.get_buffer_size()?;
let capture_client = client.get_audiocaptureclient()?;
let event = event_handle(client)?;
let read_frames = 2 * buffer_size.min(MAX_READ_FRAMES) as usize;
let mut buffer = vec![0u8; read_frames * blockalign];
let mut run = Run::new(buffer_size);
let start = Instant::now();
client.start_stream()?;
for _ in 0..MAX_ITERATIONS {
if start.elapsed() >= dur {
break;
}
if !wait(event.as_ref()) {
run.event_timeout = true;
break;
}
for _ in 0..MAX_ITERATIONS {
let (frames, info) = capture_client.read_from_device(&mut buffer)?;
if frames == 0 {
break;
}
run.frames += u64::from(frames);
run.packets.push((frames, info));
}
}
run.elapsed = start.elapsed();
client.stop_stream()?;
Ok(run)
}
const RATE_TOLERANCE: f64 = 0.20;
pub fn assert_rate(run: &Run, format: &WaveFormat, what: &str) {
assert!(
!run.event_timeout,
"{what}: timed out waiting for the event handle after {} frames",
run.frames
);
let nominal = f64::from(format.get_samplespersec());
let measured = run.rate();
println!(
"{what}: {} frames in {:.0} ms, {measured:.0} Hz against {nominal:.0} Hz, \
buffer {} frames",
run.frames,
run.elapsed.as_secs_f64() * 1000.0,
run.buffer_size
);
assert!(run.frames > 0, "{what}: no frames at all");
let error = (measured - nominal).abs() / nominal;
assert!(
error < RATE_TOLERANCE,
"{what}: {measured:.0} Hz is {:.0} % off the format's {nominal:.0} Hz",
error * 100.0
);
}