Files
WhispAssist/src-tauri/src/audio/mod.rs
T

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));
}
}