1118 lines
44 KiB
Rust
1118 lines
44 KiB
Rust
//! Audio capture service — WASAPI loopback (Phase 1, FR-CAP-1/2).
|
|
//!
|
|
//! Design notes (see docs/02-architecture.md):
|
|
//! - Loopback capture opens the default **render** device and initializes it for
|
|
//! `Direction::Capture` (see `initialize_client` below). The `wasapi` crate wires
|
|
//! `AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_LOOPBACK` together for
|
|
//! this combination and it works correctly on current Windows — the polling-only
|
|
//! claim in `docs/07-research-findings.md` was written before verifying against the
|
|
//! crate's actual source and doesn't hold for this dependency; the capture loop
|
|
//! below waits on the WASAPI event handle (with a short timeout so it can also
|
|
//! notice a stop/pause request) rather than busy-polling.
|
|
//! - Writes PCM to disk continuously as 16-bit at the device's native rate/channels
|
|
//! (FR-CAP-8), so `audio.wav` is a compact but faithful recording (audio = source
|
|
//! of truth) — about half the size of the 32-bit-float mix. A downmixed/resampled
|
|
//! 16kHz-mono copy (from the full-resolution samples) is pushed into the bounded
|
|
//! `FrameSink` for live transcription;
|
|
//! a full consumer drops preview frames (`try_send`) without blocking the disk
|
|
//! write or the capture thread.
|
|
//! - Does no inference itself.
|
|
|
|
use hound::{SampleFormat, WavSpec, WavWriter};
|
|
use std::fs::File;
|
|
use std::io::BufWriter;
|
|
use std::path::Path;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::mpsc::SyncSender;
|
|
use std::sync::Arc;
|
|
use std::thread::{self, JoinHandle};
|
|
use std::time::{Duration, Instant};
|
|
use wasapi::{
|
|
AudioCaptureClient, AudioClient, Direction, Handle, SampleType, ShareMode, WaveFormat,
|
|
};
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AudioError {
|
|
#[error("audio device unavailable: {0}")]
|
|
Device(String),
|
|
#[error("capture failed: {0}")]
|
|
Capture(String),
|
|
#[error("io error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
}
|
|
|
|
/// Opaque handle to a running capture, returned by `start` and consumed by `stop`.
|
|
pub struct CaptureHandle {
|
|
running: Arc<AtomicBool>,
|
|
paused: Arc<AtomicBool>,
|
|
thread: JoinHandle<Result<CaptureSummary, AudioError>>,
|
|
}
|
|
|
|
/// Where captured frames are delivered for live transcription: mono f32 @ 16kHz,
|
|
/// bounded so a slow/absent consumer can never stall the capture thread.
|
|
pub type FrameSink = SyncSender<Vec<f32>>;
|
|
|
|
/// Instantaneous input level for one capture chunk, throttled to ~20Hz by the
|
|
/// capture loop (FR-CAP-5 waveform/level meter).
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct AudioLevel {
|
|
pub rms: f32,
|
|
pub peak: f32,
|
|
}
|
|
|
|
/// Out-of-band capture notices, lower-volume than `FrameSink`/level updates.
|
|
#[derive(Debug, Clone)]
|
|
pub enum CaptureEvent {
|
|
Level(AudioLevel),
|
|
/// The capture device stopped responding mid-recording (unplugged, default
|
|
/// device switched, etc.) — `recovered = true` once a reconnect to
|
|
/// whatever is now the default device succeeded without losing the
|
|
/// session (FR-CAP-6); `false` while recovery is in progress/failed.
|
|
DeviceChanged {
|
|
recovered: bool,
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
pub type EventSink = SyncSender<CaptureEvent>;
|
|
|
|
pub struct CaptureSummary {
|
|
pub duration_ms: u64,
|
|
pub sample_rate: u32,
|
|
pub channels: u16,
|
|
}
|
|
|
|
pub trait AudioCapture: Send + Sync {
|
|
/// `device_id` is a `Device::get_id()` string (Settings.audio_output_device);
|
|
/// `None` uses the system default render device, same as before this was
|
|
/// selectable at all.
|
|
fn start(
|
|
&self,
|
|
wav_path: &Path,
|
|
device_id: Option<&str>,
|
|
frame_sink: FrameSink,
|
|
event_sink: EventSink,
|
|
) -> Result<CaptureHandle, AudioError>;
|
|
/// Capture the user's **microphone** (an input device — normal capture, no
|
|
/// loopback) into `frame_sink` for live transcription (FR-CAP-7). No WAV is
|
|
/// written here: the byte-accurate recording stays the loopback WAV; the mic
|
|
/// is mixed into the transcript stream (see `spawn_mixer`). `device_id` is a
|
|
/// `Device::get_id()`; `None` uses the system default capture device.
|
|
fn start_microphone(
|
|
&self,
|
|
device_id: Option<&str>,
|
|
frame_sink: FrameSink,
|
|
event_sink: EventSink,
|
|
) -> Result<CaptureHandle, AudioError>;
|
|
fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError>;
|
|
fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError>;
|
|
fn stop(&self, h: CaptureHandle) -> Result<CaptureSummary, AudioError>;
|
|
}
|
|
|
|
/// One enumerated render (playback) device — the id is what gets persisted in
|
|
/// Settings and passed back into `AudioCapture::start`; the name is display-only.
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct AudioDeviceInfo {
|
|
pub id: String,
|
|
pub name: String,
|
|
}
|
|
|
|
/// Enumerates active devices in one direction (Render = playback for loopback,
|
|
/// Capture = microphones) for the Settings device pickers.
|
|
#[cfg(feature = "audio")]
|
|
fn list_devices(direction: &Direction) -> Result<Vec<AudioDeviceInfo>, AudioError> {
|
|
wasapi::initialize_mta()
|
|
.ok()
|
|
.map_err(|e| AudioError::Device(format!("COM init failed: {e}")))?;
|
|
let collection = wasapi::DeviceCollection::new(direction)
|
|
.map_err(|e| AudioError::Device(format!("enumerate devices failed: {e}")))?;
|
|
let mut devices = Vec::new();
|
|
for device in &collection {
|
|
let device =
|
|
device.map_err(|e| AudioError::Device(format!("device enumeration error: {e}")))?;
|
|
let id = device
|
|
.get_id()
|
|
.map_err(|e| AudioError::Device(format!("device id: {e}")))?;
|
|
let name = device.get_friendlyname().unwrap_or_else(|_| id.clone());
|
|
devices.push(AudioDeviceInfo { id, name });
|
|
}
|
|
Ok(devices)
|
|
}
|
|
|
|
/// Lists active render (playback) devices for the loopback "Audio Devices"
|
|
/// picker (Settings -> Hardware); WhispAssist records whatever the chosen output
|
|
/// plays (FR-CAP-1).
|
|
#[cfg(feature = "audio")]
|
|
pub fn list_render_devices() -> Result<Vec<AudioDeviceInfo>, AudioError> {
|
|
list_devices(&Direction::Render)
|
|
}
|
|
|
|
/// Lists active capture (microphone) devices for the "Microphone" picker
|
|
/// (FR-CAP-7) — the user's own voice, mixed into the live transcript.
|
|
#[cfg(feature = "audio")]
|
|
pub fn list_capture_devices() -> Result<Vec<AudioDeviceInfo>, AudioError> {
|
|
list_devices(&Direction::Capture)
|
|
}
|
|
|
|
/// Default Windows WASAPI implementation (Phase 1, feature `audio`).
|
|
#[cfg(feature = "audio")]
|
|
pub struct WasapiCapture;
|
|
|
|
#[cfg(feature = "audio")]
|
|
impl WasapiCapture {
|
|
/// Shared spawn path for both loopback and microphone capture. `wav_path` is
|
|
/// `Some` only for the loopback stream (the byte-accurate recording); the mic
|
|
/// stream produces frames for transcription but no WAV. `direction` selects
|
|
/// loopback (Render) vs. plain microphone capture (Capture).
|
|
fn start_capture(
|
|
&self,
|
|
thread_name: &str,
|
|
wav_path: Option<&Path>,
|
|
direction: Direction,
|
|
device_id: Option<&str>,
|
|
frame_sink: FrameSink,
|
|
event_sink: EventSink,
|
|
) -> Result<CaptureHandle, AudioError> {
|
|
let running = Arc::new(AtomicBool::new(true));
|
|
let paused = Arc::new(AtomicBool::new(false));
|
|
let running_th = running.clone();
|
|
let paused_th = paused.clone();
|
|
let wav_path = wav_path.map(Path::to_path_buf);
|
|
let device_id = device_id.map(str::to_string);
|
|
|
|
let thread = thread::Builder::new()
|
|
.name(thread_name.into())
|
|
.spawn(move || {
|
|
capture_loop(
|
|
wav_path.as_deref(),
|
|
direction,
|
|
device_id.as_deref(),
|
|
&frame_sink,
|
|
&event_sink,
|
|
&running_th,
|
|
&paused_th,
|
|
)
|
|
})
|
|
.map_err(|e| AudioError::Capture(format!("spawn failed: {e}")))?;
|
|
|
|
Ok(CaptureHandle {
|
|
running,
|
|
paused,
|
|
thread,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "audio")]
|
|
impl AudioCapture for WasapiCapture {
|
|
fn start(
|
|
&self,
|
|
wav_path: &Path,
|
|
device_id: Option<&str>,
|
|
frame_sink: FrameSink,
|
|
event_sink: EventSink,
|
|
) -> Result<CaptureHandle, AudioError> {
|
|
self.start_capture(
|
|
"wa-audio-capture",
|
|
Some(wav_path),
|
|
Direction::Render,
|
|
device_id,
|
|
frame_sink,
|
|
event_sink,
|
|
)
|
|
}
|
|
|
|
fn start_microphone(
|
|
&self,
|
|
device_id: Option<&str>,
|
|
frame_sink: FrameSink,
|
|
event_sink: EventSink,
|
|
) -> Result<CaptureHandle, AudioError> {
|
|
self.start_capture(
|
|
"wa-mic-capture",
|
|
None,
|
|
Direction::Capture,
|
|
device_id,
|
|
frame_sink,
|
|
event_sink,
|
|
)
|
|
}
|
|
|
|
fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError> {
|
|
h.paused.store(true, Ordering::SeqCst);
|
|
Ok(())
|
|
}
|
|
|
|
fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError> {
|
|
h.paused.store(false, Ordering::SeqCst);
|
|
Ok(())
|
|
}
|
|
|
|
fn stop(&self, h: CaptureHandle) -> Result<CaptureSummary, AudioError> {
|
|
h.running.store(false, Ordering::SeqCst);
|
|
h.thread
|
|
.join()
|
|
.map_err(|_| AudioError::Capture("capture thread panicked".into()))?
|
|
}
|
|
}
|
|
|
|
/// One open WASAPI capture session — bundled so it can be torn down and
|
|
/// re-opened wholesale on device recovery (FR-CAP-6) without duplicating the
|
|
/// open sequence at each call site.
|
|
#[cfg(feature = "audio")]
|
|
struct CaptureSession {
|
|
audio_client: AudioClient,
|
|
event_handle: Handle,
|
|
capture_client: AudioCaptureClient,
|
|
format: WaveFormat,
|
|
}
|
|
|
|
/// Resolves the configured device (in the given direction) by id, falling back
|
|
/// to the system default if it's no longer present (unplugged/renamed since it
|
|
/// was picked) — same "degrade gracefully rather than fail the recording" spirit
|
|
/// as the device-recovery reconnect below.
|
|
#[cfg(feature = "audio")]
|
|
fn find_device(direction: &Direction, device_id: Option<&str>) -> Result<wasapi::Device, AudioError> {
|
|
let Some(id) = device_id else {
|
|
return wasapi::get_default_device(direction)
|
|
.map_err(|e| AudioError::Device(format!("no default audio device: {e}")));
|
|
};
|
|
let collection = wasapi::DeviceCollection::new(direction)
|
|
.map_err(|e| AudioError::Device(format!("enumerate devices failed: {e}")))?;
|
|
for device in &collection {
|
|
let device =
|
|
device.map_err(|e| AudioError::Device(format!("device enumeration error: {e}")))?;
|
|
if device.get_id().map(|d| d == id).unwrap_or(false) {
|
|
return Ok(device);
|
|
}
|
|
}
|
|
wasapi::get_default_device(direction)
|
|
.map_err(|e| AudioError::Device(format!("configured device not found and no default: {e}")))
|
|
}
|
|
|
|
/// Opens the configured render device (or the current default, if none is
|
|
/// configured) for loopback capture. Called both for the initial open and to
|
|
/// reconnect after the device disappears mid-recording (FR-CAP-6) — each call
|
|
/// re-resolves the same selection, so a transient loss reconnects to the same
|
|
/// device while a truly-gone device falls back to whatever is now default.
|
|
#[cfg(feature = "audio")]
|
|
fn open_capture_session(
|
|
direction: &Direction,
|
|
device_id: Option<&str>,
|
|
) -> Result<CaptureSession, AudioError> {
|
|
let device = find_device(direction, device_id)?;
|
|
let mut audio_client = device
|
|
.get_iaudioclient()
|
|
.map_err(|e| AudioError::Device(format!("activate IAudioClient failed: {e}")))?;
|
|
let format = audio_client
|
|
.get_mixformat()
|
|
.map_err(|e| AudioError::Device(format!("GetMixFormat failed: {e}")))?;
|
|
let (_default_period, min_period) = audio_client
|
|
.get_periods()
|
|
.map_err(|e| AudioError::Device(format!("GetDevicePeriod failed: {e}")))?;
|
|
|
|
// `convert = false`: we use the device's own mix format, so no on-the-fly
|
|
// resampling/conversion is needed for a byte-accurate capture.
|
|
audio_client
|
|
.initialize_client(
|
|
&format,
|
|
min_period,
|
|
&Direction::Capture,
|
|
&ShareMode::Shared,
|
|
false,
|
|
)
|
|
.map_err(|e| AudioError::Capture(format!("Initialize failed: {e}")))?;
|
|
let event_handle = audio_client
|
|
.set_get_eventhandle()
|
|
.map_err(|e| AudioError::Capture(format!("SetEventHandle failed: {e}")))?;
|
|
let capture_client = audio_client
|
|
.get_audiocaptureclient()
|
|
.map_err(|e| AudioError::Capture(format!("GetService(IAudioCaptureClient) failed: {e}")))?;
|
|
|
|
Ok(CaptureSession {
|
|
audio_client,
|
|
event_handle,
|
|
capture_client,
|
|
format,
|
|
})
|
|
}
|
|
|
|
/// True if two formats are close enough to keep writing into the same
|
|
/// already-open WAV file (channels/rate/bit-depth/sample-type all match).
|
|
/// A reconnect landing on an incompatible format can't safely continue the
|
|
/// same file, so `capture_loop` treats that as unrecoverable rather than
|
|
/// silently corrupting `audio.wav`.
|
|
#[cfg(feature = "audio")]
|
|
fn format_compatible(a: &WaveFormat, b: &WaveFormat) -> bool {
|
|
a.get_nchannels() == b.get_nchannels()
|
|
&& a.get_samplespersec() == b.get_samplespersec()
|
|
&& a.get_bitspersample() == b.get_bitspersample()
|
|
&& matches!(
|
|
(a.get_subformat(), b.get_subformat()),
|
|
(Ok(x), Ok(y)) if std::mem::discriminant(&x) == std::mem::discriminant(&y)
|
|
)
|
|
}
|
|
|
|
/// Amplitude of one chunk of mono samples, for the live level meter (FR-CAP-5).
|
|
#[cfg(feature = "audio")]
|
|
fn audio_level(mono: &[f32]) -> AudioLevel {
|
|
if mono.is_empty() {
|
|
return AudioLevel {
|
|
rms: 0.0,
|
|
peak: 0.0,
|
|
};
|
|
}
|
|
let sum_sq: f32 = mono.iter().map(|s| s * s).sum();
|
|
let peak = mono.iter().fold(0.0f32, |m, s| m.max(s.abs()));
|
|
AudioLevel {
|
|
rms: (sum_sq / mono.len() as f32).sqrt(),
|
|
peak,
|
|
}
|
|
}
|
|
|
|
/// How often level updates are forwarded — plenty for a waveform/meter UI
|
|
/// without flooding the frontend with an event per ~10ms WASAPI callback.
|
|
const LEVEL_EMIT_INTERVAL: Duration = Duration::from_millis(50);
|
|
|
|
/// Runs on a dedicated OS thread for the lifetime of a `CaptureHandle`. Owns the
|
|
/// WASAPI client and the WAV writer; exits (and finalizes the WAV) once `running`
|
|
/// is cleared.
|
|
#[cfg(feature = "audio")]
|
|
fn capture_loop(
|
|
wav_path: Option<&Path>,
|
|
direction: Direction,
|
|
device_id: Option<&str>,
|
|
frame_sink: &FrameSink,
|
|
event_sink: &EventSink,
|
|
running: &AtomicBool,
|
|
paused: &AtomicBool,
|
|
) -> Result<CaptureSummary, AudioError> {
|
|
wasapi::initialize_mta()
|
|
.ok()
|
|
.map_err(|e| AudioError::Device(format!("COM init failed: {e}")))?;
|
|
|
|
// Only the loopback stream emits level updates (FR-CAP-5) and writes a WAV;
|
|
// the mic stream just contributes frames to the transcript mixer (FR-CAP-7).
|
|
let emit_level = wav_path.is_some();
|
|
let mut session = open_capture_session(&direction, device_id)?;
|
|
let spec = wav_spec_for(&session.format)?;
|
|
let mut writer = match wav_path {
|
|
Some(path) => Some(WavWriter::create(path, spec).map_err(|e| {
|
|
AudioError::Capture(format!("could not create {}: {e}", path.display()))
|
|
})?),
|
|
None => None,
|
|
};
|
|
|
|
session
|
|
.audio_client
|
|
.start_stream()
|
|
.map_err(|e| AudioError::Capture(format!("start_stream failed: {e}")))?;
|
|
|
|
let mut resampler = Resampler::new(session.format.get_samplespersec());
|
|
let mut queue: std::collections::VecDeque<u8> = std::collections::VecDeque::new();
|
|
let mut frames_written: u64 = 0;
|
|
let mut last_level_emit = Instant::now() - LEVEL_EMIT_INTERVAL;
|
|
|
|
while running.load(Ordering::Relaxed) {
|
|
// Short timeout so we periodically re-check `running` even with no data.
|
|
let _ = session.event_handle.wait_for_event(100);
|
|
if let Err(e) = session.capture_client.read_from_device_to_deque(&mut queue) {
|
|
// The device likely disappeared mid-recording (unplugged, default
|
|
// output switched, etc.) — reconnect to whatever is now the
|
|
// default device instead of losing the rest of the meeting
|
|
// (FR-CAP-6). Only continue on the same WAV file if the new
|
|
// device's format still matches; otherwise this is unrecoverable.
|
|
let _ = event_sink.try_send(CaptureEvent::DeviceChanged {
|
|
recovered: false,
|
|
message: e.to_string(),
|
|
});
|
|
session.audio_client.stop_stream().ok();
|
|
let new_session = open_capture_session(&direction, device_id).map_err(|re| {
|
|
AudioError::Capture(format!("device lost, reconnect failed: {re}"))
|
|
})?;
|
|
if !format_compatible(&session.format, &new_session.format) {
|
|
return Err(AudioError::Capture(
|
|
"reconnected to a device with an incompatible format; cannot continue the same recording".into(),
|
|
));
|
|
}
|
|
new_session
|
|
.audio_client
|
|
.start_stream()
|
|
.map_err(|e| AudioError::Capture(format!("restart_stream failed: {e}")))?;
|
|
resampler = Resampler::new(new_session.format.get_samplespersec());
|
|
session = new_session;
|
|
let _ = event_sink.try_send(CaptureEvent::DeviceChanged {
|
|
recovered: true,
|
|
message: "reconnected to the default audio device".into(),
|
|
});
|
|
continue;
|
|
}
|
|
if queue.is_empty() {
|
|
continue;
|
|
}
|
|
let bytes: Vec<u8> = queue.drain(..).collect();
|
|
|
|
// Must keep pulling WASAPI buffers even while paused (required to avoid
|
|
// overrun); only skip persisting/forwarding the audio.
|
|
if paused.load(Ordering::Relaxed) {
|
|
continue;
|
|
}
|
|
|
|
if let Some(w) = writer.as_mut() {
|
|
frames_written += write_wav_bytes(w, &bytes, &session.format)?;
|
|
}
|
|
let mono = decode_mono_f32(&bytes, &session.format)?;
|
|
|
|
if emit_level && last_level_emit.elapsed() >= LEVEL_EMIT_INTERVAL {
|
|
let _ = event_sink.try_send(CaptureEvent::Level(audio_level(&mono)));
|
|
last_level_emit = Instant::now();
|
|
}
|
|
|
|
let resampled = resampler.process(&mono);
|
|
if !resampled.is_empty() {
|
|
let _ = frame_sink.try_send(resampled); // drop on backpressure; disk write is unaffected
|
|
}
|
|
}
|
|
|
|
session.audio_client.stop_stream().ok();
|
|
if let Some(w) = writer {
|
|
w.finalize()
|
|
.map_err(|e| AudioError::Capture(format!("wav finalize failed: {e}")))?;
|
|
}
|
|
|
|
let sample_rate = session.format.get_samplespersec();
|
|
Ok(CaptureSummary {
|
|
duration_ms: (frames_written * 1000) / sample_rate.max(1) as u64,
|
|
sample_rate,
|
|
channels: session.format.get_nchannels(),
|
|
})
|
|
}
|
|
|
|
/// The on-disk recording is always 16-bit PCM at the device's native rate and
|
|
/// channel count — half the size of the 32-bit-float mix format WASAPI hands us,
|
|
/// with no perceptible quality loss for speech (FR-CAP-8). The full-resolution
|
|
/// native samples still feed transcription untouched (see `decode_mono_f32`).
|
|
fn wav_spec_for(format: &WaveFormat) -> Result<WavSpec, AudioError> {
|
|
Ok(WavSpec {
|
|
channels: format.get_nchannels(),
|
|
sample_rate: format.get_samplespersec(),
|
|
bits_per_sample: 16,
|
|
sample_format: SampleFormat::Int,
|
|
})
|
|
}
|
|
|
|
/// Write raw WASAPI capture bytes to the WAV writer as 16-bit PCM (FR-CAP-8),
|
|
/// quantizing the 32-bit-float mix down on the way so `audio.wav` is ~half the
|
|
/// size. Returns the number of frames written. Only the two mix formats WASAPI
|
|
/// shared mode actually produces (32-bit float, 16-bit PCM) are supported;
|
|
/// anything else is a loud error rather than a silently corrupt recording.
|
|
fn write_wav_bytes(
|
|
writer: &mut WavWriter<BufWriter<File>>,
|
|
bytes: &[u8],
|
|
format: &WaveFormat,
|
|
) -> Result<u64, AudioError> {
|
|
let sample_type = format
|
|
.get_subformat()
|
|
.map_err(|e| AudioError::Device(format!("unrecognized mix format: {e}")))?;
|
|
let channels = format.get_nchannels() as u64;
|
|
match (sample_type, format.get_bitspersample()) {
|
|
(SampleType::Float, 32) => {
|
|
for chunk in bytes.chunks_exact(4) {
|
|
let v = f32::from_le_bytes(chunk.try_into().unwrap());
|
|
writer
|
|
.write_sample(f32_to_i16(v))
|
|
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
|
}
|
|
Ok(bytes.len() as u64 / 4 / channels.max(1))
|
|
}
|
|
(SampleType::Int, 16) => {
|
|
for chunk in bytes.chunks_exact(2) {
|
|
let v = i16::from_le_bytes(chunk.try_into().unwrap());
|
|
writer
|
|
.write_sample(v)
|
|
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
|
}
|
|
Ok(bytes.len() as u64 / 2 / channels.max(1))
|
|
}
|
|
(st, bits) => Err(AudioError::Device(format!(
|
|
"unsupported capture format: {st} {bits}-bit"
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Quantize a `[-1.0, 1.0]` float sample to 16-bit PCM, clamping out-of-range
|
|
/// values so an over-unity sample wraps to a click instead of full-scale noise.
|
|
fn f32_to_i16(v: f32) -> i16 {
|
|
(v.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
|
|
}
|
|
|
|
/// Downmix raw WASAPI capture bytes to mono `f32` in `[-1.0, 1.0]`, at the
|
|
/// device's native sample rate (resampling to 16kHz happens separately).
|
|
fn decode_mono_f32(bytes: &[u8], format: &WaveFormat) -> Result<Vec<f32>, AudioError> {
|
|
let sample_type = format
|
|
.get_subformat()
|
|
.map_err(|e| AudioError::Device(format!("unrecognized mix format: {e}")))?;
|
|
let channels = format.get_nchannels() as usize;
|
|
if channels == 0 {
|
|
return Ok(Vec::new());
|
|
}
|
|
let mut mono = Vec::new();
|
|
match (sample_type, format.get_bitspersample()) {
|
|
(SampleType::Float, 32) => {
|
|
for frame in bytes.chunks_exact(4 * channels) {
|
|
let sum: f32 = frame
|
|
.chunks_exact(4)
|
|
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
|
|
.sum();
|
|
mono.push(sum / channels as f32);
|
|
}
|
|
}
|
|
(SampleType::Int, 16) => {
|
|
for frame in bytes.chunks_exact(2 * channels) {
|
|
let sum: f32 = frame
|
|
.chunks_exact(2)
|
|
.map(|c| i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32)
|
|
.sum();
|
|
mono.push(sum / channels as f32);
|
|
}
|
|
}
|
|
(st, bits) => {
|
|
return Err(AudioError::Device(format!(
|
|
"unsupported capture format: {st} {bits}-bit"
|
|
)))
|
|
}
|
|
}
|
|
Ok(mono)
|
|
}
|
|
|
|
const TARGET_SAMPLE_RATE: u32 = 16_000;
|
|
|
|
/// Streaming linear-interpolation resampler, mono f32 in -> mono f32 @ 16kHz out.
|
|
/// Carries fractional position and unconsumed tail samples across calls so
|
|
/// chunk boundaries don't introduce phase discontinuities.
|
|
/// `// ponytail: linear resampler, ceiling is STT-grade quality; swap for a sinc
|
|
/// resampler if transcription accuracy complaints trace back to aliasing.`
|
|
struct Resampler {
|
|
src_rate: u32,
|
|
pos: f64,
|
|
carry: Vec<f32>,
|
|
}
|
|
|
|
impl Resampler {
|
|
fn new(src_rate: u32) -> Self {
|
|
Self {
|
|
src_rate,
|
|
pos: 0.0,
|
|
carry: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn process(&mut self, mono_in: &[f32]) -> Vec<f32> {
|
|
if mono_in.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
if self.src_rate == TARGET_SAMPLE_RATE {
|
|
return mono_in.to_vec();
|
|
}
|
|
|
|
let mut buf = std::mem::take(&mut self.carry);
|
|
buf.extend_from_slice(mono_in);
|
|
if buf.len() < 2 {
|
|
self.carry = buf;
|
|
return Vec::new();
|
|
}
|
|
|
|
let ratio = self.src_rate as f64 / TARGET_SAMPLE_RATE as f64;
|
|
let mut out = Vec::new();
|
|
while (self.pos as usize) + 1 < buf.len() {
|
|
let i = self.pos as usize;
|
|
let frac = (self.pos - i as f64) as f32;
|
|
out.push(buf[i] * (1.0 - frac) + buf[i + 1] * frac);
|
|
self.pos += ratio;
|
|
}
|
|
|
|
let keep_from = (self.pos as usize).min(buf.len() - 1);
|
|
self.pos -= keep_from as f64;
|
|
self.carry = buf[keep_from..].to_vec();
|
|
out
|
|
}
|
|
}
|
|
|
|
/// Read a WAV file written by this app's own `WavWriter` (Float32 or Int16;
|
|
/// see `write_wav_bytes`) and return mono `f32` samples at 16kHz — the batch
|
|
/// counterpart to the live capture path's downmix/resample, used for crash
|
|
/// recovery (`Transcriber::transcribe_file`, T2.8).
|
|
///
|
|
/// A working WAV left behind by an abnormal exit never had `finalize()` patch
|
|
/// its RIFF/`data` chunk sizes, so they read back as 0 — exactly the file
|
|
/// recovery has to work from (FR-REL-1). Trusting hound's own duration/sample
|
|
/// iterator would silently see zero frames despite real PCM bytes on disk (and
|
|
/// feeding whisper.cpp zero samples hangs rather than erroring — verified).
|
|
/// So this reads the `data` payload as raw bytes directly off disk instead of
|
|
/// through hound's size-aware iterator; hound is only used to parse the
|
|
/// format/`fmt ` chunk.
|
|
pub fn read_wav_mono_16k(path: &Path) -> Result<Vec<f32>, AudioError> {
|
|
let raw = std::fs::read(path).map_err(|e| AudioError::Capture(format!("read wav: {e}")))?;
|
|
// Decrypt if the recording was sealed at rest (T8.8); plaintext passes through.
|
|
let file =
|
|
crate::vault::open(&raw).map_err(|e| AudioError::Capture(format!("decrypt wav: {e}")))?;
|
|
|
|
let spec = hound::WavReader::new(std::io::Cursor::new(file.as_slice()))
|
|
.map_err(|e| AudioError::Capture(format!("open wav: {e}")))?
|
|
.spec();
|
|
let channels = spec.channels.max(1) as usize;
|
|
|
|
let data_marker = file
|
|
.windows(4)
|
|
.position(|w| w == b"data")
|
|
.ok_or_else(|| AudioError::Capture("wav has no data chunk".into()))?;
|
|
let payload_start = data_marker + 8; // "data" + 4-byte (possibly unfinalized) size field
|
|
let bytes = file.get(payload_start..).unwrap_or(&[]);
|
|
|
|
let mono: Vec<f32> = match (spec.sample_format, spec.bits_per_sample) {
|
|
(SampleFormat::Float, 32) => bytes
|
|
.chunks_exact(4 * channels)
|
|
.map(|frame| {
|
|
frame
|
|
.chunks_exact(4)
|
|
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
|
|
.sum::<f32>()
|
|
/ channels as f32
|
|
})
|
|
.collect(),
|
|
(SampleFormat::Int, 16) => bytes
|
|
.chunks_exact(2 * channels)
|
|
.map(|frame| {
|
|
frame
|
|
.chunks_exact(2)
|
|
.map(|c| i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32)
|
|
.sum::<f32>()
|
|
/ channels as f32
|
|
})
|
|
.collect(),
|
|
(fmt, bits) => {
|
|
return Err(AudioError::Device(format!(
|
|
"unsupported wav format: {fmt:?} {bits}-bit"
|
|
)))
|
|
}
|
|
};
|
|
|
|
let mut resampler = Resampler::new(spec.sample_rate);
|
|
Ok(resampler.process(&mono))
|
|
}
|
|
|
|
/// Ceiling on how far the still-flowing stream may run ahead of a stalled
|
|
/// partner before the mixer forwards it alone — so a muted/dead microphone (or a
|
|
/// silent system output) can't hold up the live transcript. 0.5s @ 16kHz.
|
|
const MIXER_MAX_LAG: usize = 8_000;
|
|
|
|
/// Sums two already-16kHz-mono streams (loopback + microphone, FR-CAP-7) sample
|
|
/// for sample into the single transcript stream. Pure struct so the alignment
|
|
/// logic is unit-testable without threads or real devices.
|
|
struct Mixer {
|
|
loopback: std::collections::VecDeque<f32>,
|
|
mic: std::collections::VecDeque<f32>,
|
|
}
|
|
|
|
impl Mixer {
|
|
fn new() -> Self {
|
|
Self {
|
|
loopback: std::collections::VecDeque::new(),
|
|
mic: std::collections::VecDeque::new(),
|
|
}
|
|
}
|
|
|
|
/// Emit what's ready: the overlapping head of both buffers summed, plus — if
|
|
/// a source has ended (`*_open == false`) or stalled while the other backs up
|
|
/// past `MIXER_MAX_LAG` — the leftover of the still-flowing source on its own.
|
|
fn drain_ready(&mut self, loop_open: bool, mic_open: bool) -> Vec<f32> {
|
|
let n = self.loopback.len().min(self.mic.len());
|
|
let mut out = Vec::with_capacity(n);
|
|
for _ in 0..n {
|
|
let s = self.loopback.pop_front().unwrap() + self.mic.pop_front().unwrap();
|
|
// ponytail: hard-clip on sum; only distorts when both sources peak at
|
|
// once, and STT tolerates it far better than a wrapped sample.
|
|
out.push(s.clamp(-1.0, 1.0));
|
|
}
|
|
// Microphone gone/stalled -> forward the loopback tail rather than wait.
|
|
if !mic_open || (self.mic.is_empty() && self.loopback.len() >= MIXER_MAX_LAG) {
|
|
out.extend(self.loopback.drain(..));
|
|
}
|
|
// Symmetric: loopback gone/stalled -> forward the mic tail.
|
|
if !loop_open || (self.loopback.is_empty() && self.mic.len() >= MIXER_MAX_LAG) {
|
|
out.extend(self.mic.drain(..));
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// Spawns the mixer thread and returns the two frame sinks to hand to the
|
|
/// loopback and microphone captures respectively; their summed output flows into
|
|
/// `out` (the transcription worker). Only used when the mic is enabled — with it
|
|
/// off, loopback frames go straight to `out` as before (no mixer overhead).
|
|
pub fn spawn_mixer(out: FrameSink) -> (FrameSink, FrameSink) {
|
|
let (loop_tx, loop_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
|
|
let (mic_tx, mic_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
|
|
let _ = thread::Builder::new()
|
|
.name("wa-audio-mixer".into())
|
|
.spawn(move || mixer_loop(loop_rx, mic_rx, &out));
|
|
(loop_tx, mic_tx)
|
|
}
|
|
|
|
fn mixer_loop(
|
|
loop_rx: std::sync::mpsc::Receiver<Vec<f32>>,
|
|
mic_rx: std::sync::mpsc::Receiver<Vec<f32>>,
|
|
out: &FrameSink,
|
|
) {
|
|
use std::sync::mpsc::{RecvTimeoutError, TryRecvError};
|
|
let mut mixer = Mixer::new();
|
|
let mut loop_open = true;
|
|
let mut mic_open = true;
|
|
|
|
while loop_open || mic_open || !mixer.loopback.is_empty() || !mixer.mic.is_empty() {
|
|
// Loopback is the guaranteed primary source and drives the cadence: block
|
|
// on it briefly so we wake on its ~10ms chunks instead of busy-spinning.
|
|
if loop_open {
|
|
match loop_rx.recv_timeout(Duration::from_millis(20)) {
|
|
Ok(chunk) => mixer.loopback.extend(chunk),
|
|
Err(RecvTimeoutError::Timeout) => {}
|
|
Err(RecvTimeoutError::Disconnected) => loop_open = false,
|
|
}
|
|
} else {
|
|
thread::sleep(Duration::from_millis(20));
|
|
}
|
|
// Drain whatever the mic has queued without blocking.
|
|
loop {
|
|
match mic_rx.try_recv() {
|
|
Ok(chunk) => mixer.mic.extend(chunk),
|
|
Err(TryRecvError::Empty) => break,
|
|
Err(TryRecvError::Disconnected) => {
|
|
mic_open = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let ready = mixer.drain_ready(loop_open, mic_open);
|
|
// Blocking send applies backpressure (which then makes the capture threads
|
|
// drop frames, as they already do); a send error means the transcription
|
|
// consumer is gone, so there's nothing left to mix.
|
|
if !ready.is_empty() && out.send(ready).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn mixer_sums_aligned_streams_and_keeps_the_unmatched_tail() {
|
|
let mut m = Mixer::new();
|
|
m.loopback.extend([0.1, 0.2, 0.3, 0.4]);
|
|
m.mic.extend([0.5, 0.5]);
|
|
// Both still open: only the 2-sample overlap is emitted; the loopback tail
|
|
// is held back to align with future mic samples (tail < MIXER_MAX_LAG).
|
|
let out = m.drain_ready(true, true);
|
|
assert_eq!(out, vec![0.6, 0.7]);
|
|
assert_eq!(m.loopback.len(), 2);
|
|
assert!(m.mic.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn mixer_clamps_a_summed_overload() {
|
|
let mut m = Mixer::new();
|
|
m.loopback.extend([0.8, -0.8]);
|
|
m.mic.extend([0.8, -0.8]);
|
|
let out = m.drain_ready(true, true);
|
|
assert_eq!(out, vec![1.0, -1.0]); // 1.6 / -1.6 clamped to the valid range
|
|
}
|
|
|
|
#[test]
|
|
fn mixer_flushes_the_survivor_when_the_mic_ends() {
|
|
let mut m = Mixer::new();
|
|
m.loopback.extend([0.1, 0.2, 0.3]);
|
|
m.mic.extend([0.5]);
|
|
// Mic closed: sum the 1-sample overlap, then forward the loopback tail
|
|
// alone rather than stall the transcript forever.
|
|
let out = m.drain_ready(true, false);
|
|
assert_eq!(out, vec![0.6, 0.2, 0.3]);
|
|
assert!(m.loopback.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn wav_writer_writes_16bit_from_a_float_mix_and_keeps_duration() {
|
|
// A 32-bit-float mix is captured but written as 16-bit PCM at the same
|
|
// rate/channels (FR-CAP-8) — half the bytes, same frame count.
|
|
let dir = std::env::temp_dir().join(format!("wa-test-{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("audio.wav");
|
|
|
|
// format.get_subformat()/get_bitspersample() need a real WaveFormat; build one
|
|
// via WaveFormat::new instead of a live device for this pure unit test.
|
|
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
|
let spec = wav_spec_for(&format).unwrap();
|
|
assert_eq!(spec.bits_per_sample, 16);
|
|
assert_eq!(spec.sample_format, SampleFormat::Int);
|
|
|
|
let mut writer = WavWriter::create(&path, spec).unwrap();
|
|
let samples: Vec<f32> = (0..2000).map(|i| (i as f32 / 2000.0) - 0.5).collect();
|
|
let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
|
|
write_wav_bytes(&mut writer, &bytes, &format).unwrap();
|
|
writer.finalize().unwrap();
|
|
|
|
let reader = hound::WavReader::open(&path).unwrap();
|
|
let read_spec = reader.spec();
|
|
assert_eq!(read_spec.channels, 2);
|
|
assert_eq!(read_spec.sample_rate, 48_000);
|
|
assert_eq!(read_spec.bits_per_sample, 16);
|
|
let frame_count = reader.duration();
|
|
assert_eq!(frame_count as usize, samples.len() / 2);
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn f32_to_i16_clamps_and_scales() {
|
|
assert_eq!(f32_to_i16(0.0), 0);
|
|
assert_eq!(f32_to_i16(1.0), i16::MAX);
|
|
assert_eq!(f32_to_i16(2.0), i16::MAX); // clamped, no wrap
|
|
assert_eq!(f32_to_i16(-2.0), -i16::MAX); // clamped to -32767
|
|
}
|
|
|
|
#[test]
|
|
fn read_wav_mono_16k_reads_a_normally_finalized_file() {
|
|
let dir = std::env::temp_dir().join(format!("wa-test-{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("audio.wav");
|
|
|
|
let spec = WavSpec {
|
|
channels: 1,
|
|
sample_rate: 16_000,
|
|
bits_per_sample: 32,
|
|
sample_format: SampleFormat::Float,
|
|
};
|
|
let mut writer = WavWriter::create(&path, spec).unwrap();
|
|
for i in 0..1600 {
|
|
writer.write_sample((i as f32 / 1600.0) - 0.5).unwrap();
|
|
}
|
|
writer.finalize().unwrap();
|
|
|
|
let mono = read_wav_mono_16k(&path).unwrap();
|
|
assert_eq!(mono.len(), 1600); // already 16kHz mono, no resampling needed
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn read_wav_mono_16k_recovers_samples_from_an_unfinalized_crash_file() {
|
|
// hound's own `WavWriter` patches the RIFF/data sizes in both
|
|
// `finalize()` and `flush()`, and its `Drop` impl finalizes too — so
|
|
// there's no way to get it to leave an unfinalized file on disk short
|
|
// of `mem::forget`-ing mid-write, which depends on `BufWriter`'s
|
|
// internal (unspecified) flush threshold. Building the crashed file
|
|
// by hand instead gives a deterministic repro of exactly what a real
|
|
// crash leaves: a valid `fmt ` chunk with the RIFF/data sizes at 0.
|
|
let dir = std::env::temp_dir().join(format!("wa-test-{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("audio.wav");
|
|
|
|
let samples: Vec<f32> = (0..1600).map(|i| (i as f32 / 1600.0) - 0.5).collect();
|
|
let pcm: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
|
|
|
|
let mut file = Vec::new();
|
|
file.extend_from_slice(b"RIFF");
|
|
file.extend_from_slice(&0u32.to_le_bytes()); // unfinalized RIFF size
|
|
file.extend_from_slice(b"WAVE");
|
|
file.extend_from_slice(b"fmt ");
|
|
file.extend_from_slice(&16u32.to_le_bytes()); // fmt chunk size
|
|
file.extend_from_slice(&3u16.to_le_bytes()); // WAVE_FORMAT_IEEE_FLOAT
|
|
file.extend_from_slice(&1u16.to_le_bytes()); // channels
|
|
file.extend_from_slice(&16_000u32.to_le_bytes()); // sample rate
|
|
file.extend_from_slice(&(16_000 * 4u32).to_le_bytes()); // byte rate
|
|
file.extend_from_slice(&4u16.to_le_bytes()); // block align
|
|
file.extend_from_slice(&32u16.to_le_bytes()); // bits per sample
|
|
file.extend_from_slice(b"data");
|
|
file.extend_from_slice(&0u32.to_le_bytes()); // unfinalized data size
|
|
file.extend_from_slice(&pcm);
|
|
std::fs::write(&path, &file).unwrap();
|
|
|
|
let mono = read_wav_mono_16k(&path).unwrap();
|
|
assert_eq!(
|
|
mono.len(),
|
|
1600,
|
|
"should recover real samples despite the zeroed header"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn decode_mono_f32_downmixes_stereo() {
|
|
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
|
// One frame: left=1.0, right=-1.0 -> mono average 0.0.
|
|
let mut bytes = Vec::new();
|
|
bytes.extend_from_slice(&1.0f32.to_le_bytes());
|
|
bytes.extend_from_slice(&(-1.0f32).to_le_bytes());
|
|
let mono = decode_mono_f32(&bytes, &format).unwrap();
|
|
assert_eq!(mono, vec![0.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn resampler_exact_integer_ratio_picks_aligned_samples() {
|
|
// 48kHz -> 16kHz is an exact 3:1 ratio, so linear interpolation should land
|
|
// exactly on the original samples at indices 0, 3, 6, ...
|
|
let mut r = Resampler::new(48_000);
|
|
let input: Vec<f32> = (0..30).map(|i| i as f32).collect();
|
|
let out = r.process(&input);
|
|
let expected: Vec<f32> = (0..10).map(|i| (i * 3) as f32).collect();
|
|
assert_eq!(out, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn resampler_continues_correctly_across_chunk_boundaries() {
|
|
let mut r = Resampler::new(48_000);
|
|
let first: Vec<f32> = (0..30).map(|i| i as f32).collect();
|
|
let second: Vec<f32> = (30..60).map(|i| i as f32).collect();
|
|
let mut out = r.process(&first);
|
|
out.extend(r.process(&second));
|
|
let expected: Vec<f32> = (0..20).map(|i| (i * 3) as f32).collect();
|
|
assert_eq!(out, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn resampler_passthrough_when_already_target_rate() {
|
|
let mut r = Resampler::new(16_000);
|
|
let input = vec![0.1f32, 0.2, 0.3];
|
|
assert_eq!(r.process(&input), input);
|
|
}
|
|
|
|
#[test]
|
|
fn list_render_devices_succeeds_and_ids_are_unique() {
|
|
// Real COM/WASAPI enumeration against whatever hardware this machine
|
|
// actually has — device count/names aren't asserted (machine-
|
|
// dependent), just that enumeration works and ids are stable/unique
|
|
// (the property the Settings picker and find_render_device rely on).
|
|
let devices = list_render_devices().unwrap();
|
|
let mut ids: Vec<&str> = devices.iter().map(|d| d.id.as_str()).collect();
|
|
ids.sort_unstable();
|
|
ids.dedup();
|
|
assert_eq!(ids.len(), devices.len());
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "opens the real default microphone; run with --ignored on a machine that has one"]
|
|
fn microphone_capture_opens_and_stops_cleanly() {
|
|
// FR-CAP-7 against real hardware: the default mic opens for capture and
|
|
// stops without error. Frame *delivery* isn't asserted here — WASAPI
|
|
// doesn't pump default-device audio into a `cargo test` process on some
|
|
// machines (same quirk that makes get_default_device(Render) fail under
|
|
// test), so that end-to-end check belongs in the running app. The logged
|
|
// frame count is informational.
|
|
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
|
let (event_tx, _event_rx) = std::sync::mpsc::sync_channel::<CaptureEvent>(8);
|
|
let handle = WasapiCapture
|
|
.start_microphone(None, frame_tx, event_tx)
|
|
.expect("open default microphone");
|
|
std::thread::sleep(Duration::from_millis(600));
|
|
let summary = WasapiCapture.stop(handle).expect("stop mic");
|
|
let samples: usize = frame_rx.try_iter().map(|c| c.len()).sum();
|
|
eprintln!(
|
|
"[mic] device opened at {}Hz; delivered {samples} frames @16kHz over ~0.6s",
|
|
summary.sample_rate
|
|
);
|
|
assert!(summary.sample_rate > 0, "mic session did not open a device");
|
|
}
|
|
|
|
#[test]
|
|
fn list_capture_devices_succeeds_and_ids_are_unique() {
|
|
// Same contract as the render picker, for the microphone picker
|
|
// (FR-CAP-7): enumeration works and ids are stable/unique so
|
|
// find_device can resolve a persisted selection.
|
|
let devices = list_capture_devices().unwrap();
|
|
let mut ids: Vec<&str> = devices.iter().map(|d| d.id.as_str()).collect();
|
|
ids.sort_unstable();
|
|
ids.dedup();
|
|
assert_eq!(ids.len(), devices.len());
|
|
}
|
|
|
|
#[test]
|
|
fn find_render_device_resolves_a_real_enumerated_id() {
|
|
let devices = list_render_devices().unwrap();
|
|
let Some(first) = devices.first() else {
|
|
return; // no render device on this machine/CI runner
|
|
};
|
|
assert!(find_device(&Direction::Render, Some(&first.id)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn find_render_device_none_matches_get_default_device() {
|
|
// Documents the actual environment rather than assuming one: if this
|
|
// machine/CI runner has no default render device at all,
|
|
// wasapi::get_default_device itself errors — same as it always has,
|
|
// unrelated to device selection — and find_device(None) must
|
|
// fail identically rather than pretending to succeed.
|
|
let default_ok = wasapi::get_default_device(&Direction::Render).is_ok();
|
|
assert_eq!(find_device(&Direction::Render, None).is_ok(), default_ok);
|
|
}
|
|
|
|
#[test]
|
|
fn find_render_device_unknown_id_falls_back_exactly_like_none() {
|
|
// An id that can never be real should resolve identically to "no
|
|
// selection" — same success/failure outcome as whatever this
|
|
// environment's default-device lookup actually does.
|
|
let none_ok = find_device(&Direction::Render, None).is_ok();
|
|
let unknown_ok = find_device(&Direction::Render, Some("not-a-real-device-id")).is_ok();
|
|
assert_eq!(none_ok, unknown_ok);
|
|
}
|
|
|
|
#[test]
|
|
fn audio_level_of_empty_chunk_is_silence() {
|
|
let level = audio_level(&[]);
|
|
assert_eq!(level.rms, 0.0);
|
|
assert_eq!(level.peak, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn audio_level_computes_rms_and_peak() {
|
|
// Two samples of equal magnitude: RMS equals that magnitude, peak too.
|
|
let level = audio_level(&[0.5, -0.5]);
|
|
assert!((level.rms - 0.5).abs() < 1e-6);
|
|
assert!((level.peak - 0.5).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn audio_level_peak_tracks_the_largest_magnitude_sample() {
|
|
let level = audio_level(&[0.1, -0.9, 0.3]);
|
|
assert!((level.peak - 0.9).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn format_compatible_matches_identical_formats() {
|
|
let a = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
|
let b = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
|
assert!(format_compatible(&a, &b));
|
|
}
|
|
|
|
#[test]
|
|
fn format_compatible_rejects_a_different_sample_rate() {
|
|
let a = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
|
let b = WaveFormat::new(32, 32, &SampleType::Float, 44_100, 2, None);
|
|
assert!(!format_compatible(&a, &b));
|
|
}
|
|
|
|
#[test]
|
|
fn format_compatible_rejects_a_different_channel_count() {
|
|
let a = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
|
let b = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 1, None);
|
|
assert!(!format_compatible(&a, &b));
|
|
}
|
|
|
|
#[test]
|
|
fn format_compatible_rejects_a_different_sample_type() {
|
|
let a = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
|
let b = WaveFormat::new(16, 16, &SampleType::Int, 48_000, 2, None);
|
|
assert!(!format_compatible(&a, &b));
|
|
}
|
|
}
|