Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,464 @@
//! PCM16 mono capture via cpal for streaming STT.
//!
//! Prefers a device input config that natively supports the target rate (16 kHz)
//! so no resampling is needed; otherwise it uses the device default (typically
//! 48 kHz stereo F32 on macOS) and downmixes + resamples to 16 kHz mono for the
//! STT API. cpal streams are not `Send` on all platforms; capture runs on a
//! dedicated std thread and forwards PCM chunks through a sync channel.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::TrySendError;
use std::thread::{self, JoinHandle};
use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, Sample, SampleFormat, SizedSample};
use tokio::sync::mpsc as async_mpsc;
use crate::error::VoiceError;
/// Stop handle for the cpal input stream (owned by a background thread).
pub struct CaptureHandle {
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
bridge: tokio::task::JoinHandle<()>,
}
impl CaptureHandle {
/// Stop capture and wait for the thread to exit.
///
/// Dropping a `CaptureHandle` also stops capture (see the `Drop` impl), but
/// without joining; call `stop()` when you need to be sure the device is
/// released before continuing.
pub fn stop(mut self) {
self.stop.store(true, Ordering::Release);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
// `Drop` runs next and aborts the bridge task.
}
}
impl Drop for CaptureHandle {
fn drop(&mut self) {
// Always signal the capture thread to exit so the mic is released even
// when `stop()` was never called — e.g. the STT session ended on its
// own (server close / error) or the pipeline shut down mid-utterance.
// The thread observes the flag within one poll interval and exits,
// dropping the cpal stream. We deliberately do not join here so `Drop`
// never blocks (it may run on an async executor).
self.stop.store(true, Ordering::Release);
self.bridge.abort();
}
}
/// Spawn cpal capture; PCM16 LE chunks are forwarded to `pcm_tx`.
pub fn spawn_pcm_capture(
sample_rate: u32,
pcm_tx: async_mpsc::Sender<Vec<u8>>,
) -> Result<CaptureHandle, VoiceError> {
let (sync_tx, sync_rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(64);
// Bridge the cpal callback's std sync channel to the async STT sender. The
// `recv()` blocks between audio chunks for the whole session, so it runs on
// the blocking pool (via `spawn_blocking` + `blocking_send`) instead of a
// core runtime worker — parking a worker here would shrink executor capacity
// under pager load. The loop exits on its own when capture stops (sync_tx is
// dropped) or the STT consumer goes away (`blocking_send` errors), so the
// `abort()` in `CaptureHandle`'s teardown is only a backstop.
let bridge = tokio::task::spawn_blocking(move || {
while let Ok(bytes) = sync_rx.recv() {
if pcm_tx.blocking_send(bytes).is_err() {
break;
}
}
});
let stop = Arc::new(AtomicBool::new(false));
let stop_flag = Arc::clone(&stop);
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<Result<(), VoiceError>>(1);
let thread = thread::spawn(move || {
run_capture_loop(sample_rate, sync_tx, stop_flag, ready_tx);
});
// Wait briefly for the device to actually open (mirrors the STT
// `wait_ready` handshake) so device/permission failures propagate to the
// caller — and on to a `VoiceEvent::Error` toast — instead of leaving the
// session "listening" with no audio.
match ready_rx.recv_timeout(Duration::from_secs(5)) {
Ok(Ok(())) => {}
Ok(Err(e)) => {
let _ = thread.join();
bridge.abort();
return Err(e);
}
Err(_) => {
stop.store(true, Ordering::Release);
let _ = thread.join();
bridge.abort();
return Err(VoiceError::Config(
"voice capture did not start within 5s".into(),
));
}
}
Ok(CaptureHandle {
stop,
thread: Some(thread),
bridge,
})
}
/// Record mono PCM16 LE for a fixed duration (probe / diagnostics).
pub fn capture_pcm_for_duration(
sample_rate: u32,
seconds: u32,
) -> Result<(Vec<u8>, u32), VoiceError> {
let (sync_tx, sync_rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(256);
let stop = Arc::new(AtomicBool::new(false));
let stop_flag = Arc::clone(&stop);
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<Result<(), VoiceError>>(1);
let thread = thread::spawn(move || {
run_capture_loop(sample_rate, sync_tx, stop_flag, ready_tx);
});
// Surface device-open failures before recording instead of returning empty.
match ready_rx.recv_timeout(Duration::from_secs(2)) {
Ok(Ok(())) => {}
Ok(Err(e)) => {
let _ = thread.join();
return Err(e);
}
Err(_) => {
stop.store(true, Ordering::Release);
let _ = thread.join();
return Err(VoiceError::Config(
"voice capture did not start within 2s".into(),
));
}
}
thread::sleep(Duration::from_secs(seconds.max(1) as u64));
stop.store(true, Ordering::Release);
let _ = thread.join();
let mut pcm = Vec::new();
let mut chunks = 0u32;
while let Ok(chunk) = sync_rx.try_recv() {
chunks += 1;
pcm.extend_from_slice(&chunk);
}
Ok((pcm, chunks))
}
struct CaptureStreamParams<'a> {
device: &'a cpal::Device,
stream_config: cpal::StreamConfig,
in_channels: u16,
stream_rate: u32,
target_rate: u32,
sync_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
stop: Arc<AtomicBool>,
/// Count of PCM chunks dropped because the channel was full. Logged off the
/// audio thread by `run_capture_loop`.
dropped: Arc<AtomicUsize>,
}
fn run_capture_loop(
sample_rate: u32,
sync_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
stop: Arc<AtomicBool>,
ready_tx: std::sync::mpsc::SyncSender<Result<(), VoiceError>>,
) {
let dropped = Arc::new(AtomicUsize::new(0));
// Open the device first and report success/failure to the caller (the
// capture-side equivalent of the STT `wait_ready` handshake) so that
// device/permission errors surface as a `VoiceError` instead of being
// logged silently here.
let (stream, device_name) = match open_capture_stream(
sample_rate,
sync_tx,
Arc::clone(&stop),
Arc::clone(&dropped),
) {
Ok(v) => {
let _ = ready_tx.send(Ok(()));
v
}
Err(e) => {
tracing::warn!(error = %e, "voice capture failed to start");
let _ = ready_tx.send(Err(e));
return;
}
};
run_capture_poll_loop(stream, stop, dropped, device_name);
}
/// Open the input device, build, and start the cpal capture stream. All
/// device/config/permission failures surface here as a `VoiceError` so the
/// caller can report them before entering the steady-state loop.
fn open_capture_stream(
sample_rate: u32,
sync_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
stop: Arc<AtomicBool>,
dropped: Arc<AtomicUsize>,
) -> Result<(cpal::Stream, String), VoiceError> {
let host = cpal::default_host();
let device = host
.default_input_device()
.ok_or_else(|| VoiceError::Config("no default input audio device".into()))?;
let device_name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
let default_config = device.default_input_config().map_err(|e| {
VoiceError::Config(format!(
"default input config for {device_name}: {e} (grant mic permission in System Settings)"
))
})?;
// Prefer a device-native `sample_rate` (e.g. a mic that supports 16 kHz
// directly) so we can skip resampling entirely; fall back to the device
// default and the linear resampler when no native config matches.
let supported = native_rate_config(&device, sample_rate).unwrap_or(default_config);
let stream_rate = supported.sample_rate().0;
let in_channels = supported.channels();
let sample_format = supported.sample_format();
let stream_config: cpal::StreamConfig = supported.into();
tracing::info!(
device = %device_name,
stream_rate,
channels = in_channels,
?sample_format,
target_rate = sample_rate,
"voice capture stream"
);
let params = CaptureStreamParams {
device: &device,
stream_config,
in_channels,
stream_rate,
target_rate: sample_rate,
sync_tx,
stop,
dropped,
};
let stream = match sample_format {
SampleFormat::F32 => build_capture_stream::<f32>(params)?,
SampleFormat::F64 => build_capture_stream::<f64>(params)?,
SampleFormat::I8 => build_capture_stream::<i8>(params)?,
SampleFormat::I16 => build_capture_stream::<i16>(params)?,
SampleFormat::I32 => build_capture_stream::<i32>(params)?,
SampleFormat::I64 => build_capture_stream::<i64>(params)?,
SampleFormat::U8 => build_capture_stream::<u8>(params)?,
SampleFormat::U16 => build_capture_stream::<u16>(params)?,
SampleFormat::U32 => build_capture_stream::<u32>(params)?,
SampleFormat::U64 => build_capture_stream::<u64>(params)?,
other => {
return Err(VoiceError::Config(format!(
"unsupported input sample format {other:?} on {device_name} \
(supported: f32/f64/i8/i16/i32/i64/u8/u16/u32/u64)"
)));
}
};
stream
.play()
.map_err(|e| VoiceError::Config(format!("play input stream: {e}")))?;
Ok((stream, device_name))
}
/// Steady-state loop: wait for shutdown and report dropped frames off the
/// real-time audio thread (logging here keeps the callback allocation/lock-free).
fn run_capture_poll_loop(
stream: cpal::Stream,
stop: Arc<AtomicBool>,
dropped: Arc<AtomicUsize>,
device_name: String,
) {
let mut last_reported = 0usize;
let mut ticks: u32 = 0;
while !stop.load(Ordering::Acquire) {
thread::sleep(Duration::from_millis(50));
ticks += 1;
// ~once per second
if ticks.is_multiple_of(20) {
let total = dropped.load(Ordering::Relaxed);
if total > last_reported {
tracing::warn!(
device = %device_name,
dropped_total = total,
dropped_since_last = total - last_reported,
"voice capture dropping PCM chunks (consumer not keeping up)"
);
last_reported = total;
}
}
}
drop(stream);
let total = dropped.load(Ordering::Relaxed);
if total > 0 {
tracing::warn!(
device = %device_name,
dropped_total = total,
"voice capture finished with dropped PCM chunks"
);
}
}
/// Find a supported input config whose range includes `target_rate`, so capture
/// runs at the STT rate with no resampling. Prefers a config matching the
/// device's default sample format; returns `None` when nothing matches.
fn native_rate_config(
device: &cpal::Device,
target_rate: u32,
) -> Option<cpal::SupportedStreamConfig> {
let target = cpal::SampleRate(target_rate);
let preferred_format = device
.default_input_config()
.ok()
.map(|c| c.sample_format());
let configs: Vec<_> = device.supported_input_configs().ok()?.collect();
let contains_target = |c: &cpal::SupportedStreamConfigRange| {
c.min_sample_rate() <= target && target <= c.max_sample_rate()
};
configs
.iter()
.find(|c| contains_target(c) && Some(c.sample_format()) == preferred_format)
.or_else(|| configs.iter().find(|c| contains_target(c)))
.map(|c| c.with_sample_rate(target))
}
fn build_capture_stream<T>(params: CaptureStreamParams<'_>) -> Result<cpal::Stream, VoiceError>
where
T: Sample + SizedSample,
i16: FromSample<T>,
{
let CaptureStreamParams {
device,
stream_config,
in_channels,
stream_rate,
target_rate,
sync_tx,
stop,
dropped,
} = params;
let channels = in_channels as usize;
let stop_cb = Arc::clone(&stop);
let stream = device
.build_input_stream(
&stream_config,
move |data: &[T], _: &cpal::InputCallbackInfo| {
if stop_cb.load(Ordering::Acquire) {
return;
}
let mono = frames_to_mono_i16(data, channels);
let pcm = if stream_rate == target_rate {
mono
} else {
resample_mono_i16(&mono, stream_rate, target_rate)
};
if pcm.is_empty() {
return;
}
let bytes: Vec<u8> = pcm.iter().flat_map(|s| s.to_le_bytes()).collect();
// Never block the real-time audio thread: shed load if the
// consumer is behind. Dropped chunks are counted and logged by
// `run_capture_loop`.
match sync_tx.try_send(bytes) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
dropped.fetch_add(1, Ordering::Relaxed);
}
Err(TrySendError::Disconnected(_)) => {}
}
},
|err| {
tracing::warn!(error = %err, "voice capture stream error");
},
None,
)
.map_err(|e| VoiceError::Config(format!("build input stream: {e}")))?;
Ok(stream)
}
fn frames_to_mono_i16<T>(data: &[T], channels: usize) -> Vec<i16>
where
T: Sample,
i16: FromSample<T>,
{
if channels == 0 {
return Vec::new();
}
if channels == 1 {
return data.iter().map(|s| i16::from_sample(*s)).collect();
}
let mut mono = Vec::with_capacity(data.len() / channels);
for frame in data.chunks_exact(channels) {
let mut sum: i32 = 0;
for sample in frame {
sum += i16::from_sample(*sample) as i32;
}
let avg = (sum / channels as i32).clamp(i16::MIN as i32, i16::MAX as i32);
mono.push(avg as i16);
}
mono
}
fn resample_mono_i16(samples: &[i16], input_rate: u32, output_rate: u32) -> Vec<i16> {
if samples.is_empty() || input_rate == 0 || output_rate == 0 {
return Vec::new();
}
if input_rate == output_rate {
return samples.to_vec();
}
let output_len =
((samples.len() as u64 * output_rate as u64) / input_rate as u64).max(1) as usize;
let step = input_rate as f64 / output_rate as f64;
let mut output = Vec::with_capacity(output_len);
for i in 0..output_len {
let src_pos = i as f64 * step;
let idx = src_pos.floor() as usize;
let frac = src_pos - idx as f64;
let s0 = samples[idx] as f64;
let s1 = *samples.get(idx + 1).unwrap_or(&samples[idx]) as f64;
let sample = s0 + (s1 - s0) * frac;
let clamped = sample.round().max(i16::MIN as f64).min(i16::MAX as f64);
output.push(clamped as i16);
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resample_halves_rate() {
let input: Vec<i16> = (0..48).map(|i| (i * 100) as i16).collect();
let out = resample_mono_i16(&input, 48_000, 16_000);
assert_eq!(out.len(), 16);
}
#[test]
fn downmix_stereo_to_mono() {
let stereo = [i16::MAX, i16::MIN];
let mono = frames_to_mono_i16(&stereo, 2);
assert_eq!(mono.len(), 1);
assert_eq!(mono[0], 0);
}
}

View file

@ -0,0 +1,431 @@
//! Microphone capture on Linux via a subprocess recorder.
//!
//! The release CLI ships as a fully-static `*-unknown-linux-musl` binary, so it
//! cannot link `cpal` -> `alsa-sys` (a `NEEDED libasound.so.2`) without losing
//! the static guarantee enforced by the release build. Statically linking ALSA
//! is no help either: it reaches the user's real device (PulseAudio/PipeWire)
//! through plugins it loads via `dlopen`, which a static musl binary can't do.
//!
//! Instead, capture mic audio by spawning the system recorder (`pw-record`,
//! `parec`, or `arecord`) and reading raw PCM16 mono from its stdout — no native
//! audio library is linked into the binary at all. The recorders are asked for
//! signed 16-bit little-endian mono at the STT sample rate, which is exactly the
//! format the pipeline forwards, so there is no downmix/resample step.
//!
//! This module exposes the same interface as the `cpal` backend
//! (`spawn_pcm_capture`, `capture_pcm_for_duration`, `CaptureHandle`) so the
//! pipeline and probe are backend-agnostic.
use std::io::Read;
use std::process::{Child, ChildStdout, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use tokio::sync::mpsc as async_mpsc;
use crate::error::VoiceError;
/// PCM read size from the recorder's stdout (bytes) — ~64 ms at 16 kHz mono
/// PCM16. Small enough to stream responsively, large enough to avoid syscall
/// churn on the reader thread.
const READ_CHUNK: usize = 2048;
/// How long to wait after spawning before deciding the recorder started cleanly.
/// A missing device or a stopped audio server makes the recorder exit within a
/// few ms; this surfaces that as an error instead of a session that "listens"
/// but never produces audio (mirrors the `cpal` backend's open handshake).
const START_GRACE: Duration = Duration::from_millis(300);
/// A system audio recorder that can stream raw PCM16 mono to stdout.
#[derive(Clone, Copy)]
enum Recorder {
/// PipeWire's `pw-record`.
PwRecord,
/// PulseAudio's `parec`.
Parec,
/// ALSA's `arecord` (alsa-utils).
Arecord,
}
impl Recorder {
fn program(self) -> &'static str {
match self {
Recorder::PwRecord => "pw-record",
Recorder::Parec => "parec",
Recorder::Arecord => "arecord",
}
}
/// Args that emit signed 16-bit little-endian mono PCM at `rate` Hz to
/// stdout. (`pw-record`/`pw-cat` and `arecord` take an explicit `-` stdout
/// target; `parec` writes raw to stdout by default.)
fn args(self, rate: u32) -> Vec<String> {
let rate = rate.to_string();
match self {
Recorder::PwRecord => vec![
"--rate".into(),
rate,
"--channels".into(),
"1".into(),
"--format".into(),
"s16".into(),
"-".into(),
],
Recorder::Parec => vec![
"--raw".into(),
"--format=s16le".into(),
format!("--rate={rate}"),
"--channels=1".into(),
],
Recorder::Arecord => vec![
"-q".into(),
"-t".into(),
"raw".into(),
"-f".into(),
"S16_LE".into(),
"-c".into(),
"1".into(),
"-r".into(),
rate,
"-".into(),
],
}
}
}
/// First recorder found on `PATH`, preferring PipeWire > PulseAudio > ALSA so we
/// go through the user's configured audio server (and its default input device)
/// rather than grabbing a raw ALSA `hw:` device.
fn detect_recorder() -> Option<Recorder> {
detect_recorder_with(binary_on_path)
}
/// [`detect_recorder`] with the `PATH` probe injected, so the preference order
/// is unit-testable without process-global `PATH` mutation.
fn detect_recorder_with(available: impl Fn(&str) -> bool) -> Option<Recorder> {
[Recorder::PwRecord, Recorder::Parec, Recorder::Arecord]
.into_iter()
.find(|r| available(r.program()))
}
/// Whether `name` resolves to an executable regular file on any `PATH` entry
/// (so a stray non-executable file can't shadow a working recorder).
fn binary_on_path(name: &str) -> bool {
use std::os::unix::fs::PermissionsExt;
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| {
dir.join(name)
.metadata()
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
})
}
/// Spawn the chosen recorder with stdout/stderr piped, and confirm it didn't
/// exit immediately (no device, audio server down). On success the child is
/// running with `stdout` available for reading.
fn spawn_recorder(sample_rate: u32) -> Result<(Recorder, Child), VoiceError> {
let recorder = detect_recorder().ok_or_else(|| {
VoiceError::Config(
"no microphone recorder found on PATH: install pipewire (pw-record), \
pulseaudio-utils (parec), or alsa-utils (arecord)"
.into(),
)
})?;
let mut child = Command::new(recorder.program())
.args(recorder.args(sample_rate))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| VoiceError::Config(format!("failed to start {}: {e}", recorder.program())))?;
thread::sleep(START_GRACE);
match child.try_wait() {
Ok(Some(status)) => {
let mut stderr = String::new();
if let Some(mut err) = child.stderr.take() {
let _ = err.read_to_string(&mut stderr);
}
let stderr = stderr.trim();
Err(VoiceError::Config(format!(
"{} exited immediately ({status}){}",
recorder.program(),
if stderr.is_empty() {
String::new()
} else {
format!(": {stderr}")
},
)))
}
Ok(None) => Ok((recorder, child)),
Err(e) => Err(VoiceError::Config(format!(
"failed to poll {}: {e}",
recorder.program()
))),
}
}
/// Stop handle for the recorder subprocess (owns the child + reader thread).
pub struct CaptureHandle {
/// `Some` until `stop()` or `Drop` consumes it (kill + reap).
child: Option<Child>,
stop: Arc<AtomicBool>,
reader: Option<JoinHandle<()>>,
}
impl CaptureHandle {
/// Stop capture: kill the recorder, reap it, and join the reader thread so
/// the input device is released before returning.
///
/// Dropping a `CaptureHandle` also kills and reaps the recorder (see
/// `Drop`), but without joining the reader; call `stop()` when you must be
/// sure the device is freed before continuing.
pub fn stop(mut self) {
self.stop.store(true, Ordering::Release);
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
if let Some(reader) = self.reader.take() {
let _ = reader.join();
}
}
}
impl Drop for CaptureHandle {
fn drop(&mut self) {
// Always kill the recorder so the mic is released even when `stop()` was
// never called — e.g. the STT session ended on its own (server close /
// error). Killing closes the child's stdout, so the reader thread's
// blocking `read` returns 0 and it exits. `Drop` must never block (it
// may run on an async executor), so the reap happens on a detached
// thread — without it every drop-path teardown (session supersede, STT
// error, connect failure) would leave a zombie until the pager exits.
self.stop.store(true, Ordering::Release);
if let Some(mut child) = self.child.take() {
let _ = child.kill();
// `Builder::spawn` (not `thread::spawn`) so spawn failure under
// thread exhaustion degrades to kill-without-reap instead of a
// panic — a panicking `Drop` during unwind would abort.
let _ = thread::Builder::new()
.name("voice-capture-reap".into())
.spawn(move || {
let _ = child.wait();
});
}
}
}
/// Spawn subprocess capture; PCM16 LE chunks are forwarded to `pcm_tx`.
pub fn spawn_pcm_capture(
sample_rate: u32,
pcm_tx: async_mpsc::Sender<Vec<u8>>,
) -> Result<CaptureHandle, VoiceError> {
let (recorder, mut child) = spawn_recorder(sample_rate)?;
let stdout = child
.stdout
.take()
.ok_or_else(|| VoiceError::Config(format!("{} produced no stdout", recorder.program())))?;
drain_stderr(&mut child, recorder.program());
let stop = Arc::new(AtomicBool::new(false));
let stop_reader = Arc::clone(&stop);
let device = recorder.program();
let reader = thread::spawn(move || forward_pcm(stdout, pcm_tx, stop_reader, device));
tracing::info!(
recorder = recorder.program(),
sample_rate,
"voice capture stream (subprocess)"
);
Ok(CaptureHandle {
child: Some(child),
stop,
reader: Some(reader),
})
}
/// Drain the recorder's stderr to EOF on a detached thread so a chatty recorder
/// (xrun/underrun warnings, etc.) can't fill the pipe buffer and block its own
/// writes — which would stall capture, since the hot path never reads stderr.
/// Non-empty output is logged at debug for diagnostics. The thread ends on its
/// own when the child exits (EOF), so it is not joined.
fn drain_stderr(child: &mut Child, device: &'static str) {
let Some(mut stderr) = child.stderr.take() else {
return;
};
thread::spawn(move || {
let mut buf = String::new();
if stderr.read_to_string(&mut buf).is_ok() {
let msg = buf.trim();
if !msg.is_empty() {
tracing::debug!(device, stderr = msg, "voice recorder stderr");
}
}
});
}
/// Forward raw PCM from the recorder's stdout to the async STT sender until the
/// recorder stops (EOF on kill), the consumer goes away, or `stop` is set.
fn forward_pcm(
mut stdout: ChildStdout,
pcm_tx: async_mpsc::Sender<Vec<u8>>,
stop: Arc<AtomicBool>,
device: &'static str,
) {
let mut buf = vec![0u8; READ_CHUNK];
let mut dropped = 0u64;
loop {
if stop.load(Ordering::Acquire) {
break;
}
match stdout.read(&mut buf) {
// EOF: the recorder closed stdout (killed by teardown or exited).
Ok(0) => break,
Ok(n) => {
// Never park this thread on the channel: `stop()` joins it, so a
// send that waits on a stalled STT consumer would turn teardown
// into a hang. Shed load instead when the consumer is behind —
// the same strategy as the cpal backend's real-time callback.
// (`read` itself is unblocked by the kill-on-stop path: killing
// the recorder closes stdout, so a waiting `read` returns 0.)
match pcm_tx.try_send(buf[..n].to_vec()) {
Ok(()) => {}
Err(async_mpsc::error::TrySendError::Full(_)) => dropped += 1,
// Consumer is gone: the session ended; stop capturing.
Err(async_mpsc::error::TrySendError::Closed(_)) => break,
}
}
Err(e) => {
tracing::warn!(device, error = %e, "voice capture read error");
break;
}
}
}
if dropped > 0 {
tracing::warn!(
device,
dropped,
"voice capture dropped PCM chunks (slow consumer)"
);
}
}
/// Record mono PCM16 LE for a fixed duration (probe / diagnostics).
pub fn capture_pcm_for_duration(
sample_rate: u32,
seconds: u32,
) -> Result<(Vec<u8>, u32), VoiceError> {
let (recorder, mut child) = spawn_recorder(sample_rate)?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| VoiceError::Config(format!("{} produced no stdout", recorder.program())))?;
drain_stderr(&mut child, recorder.program());
let duration = Duration::from_secs(seconds.max(1) as u64);
let deadline = Instant::now() + duration;
// Watchdog: kill the recorder at the deadline so a `read` that is blocked
// waiting for PCM (recorder alive but idle / stalled pipe) gets EOF instead
// of running past the requested duration. Killing at the deadline also ends
// a healthy capture, so the read loop below needs no between-read deadline
// check beyond its backstop.
// Deliberately not joined: if the recorder dies early we return without
// waiting out the full duration, and the watchdog's late `kill` on an
// already-reaped `Child` is a harmless `InvalidInput` (std tracks the reap,
// so no PID-reuse hazard).
let child = Arc::new(Mutex::new(child));
let watchdog_child = Arc::clone(&child);
thread::spawn(move || {
thread::sleep(duration);
let mut child = watchdog_child.lock().expect("watchdog lock poisoned");
let _ = child.kill();
});
let mut pcm = Vec::new();
let mut chunks = 0u32;
let mut buf = vec![0u8; READ_CHUNK];
// Small slack past the deadline: the kill's EOF (`Ok(0)`) is the intended
// exit; the time check is a backstop against a pathological pipe.
while Instant::now() < deadline + Duration::from_secs(1) {
match stdout.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
chunks += 1;
pcm.extend_from_slice(&buf[..n]);
}
Err(_) => break,
}
}
{
let mut child = child.lock().expect("child lock poisoned");
let _ = child.kill();
let _ = child.wait();
}
Ok((pcm, chunks))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arecord_args_are_raw_s16_mono() {
let args = Recorder::Arecord.args(16_000);
assert!(args.contains(&"S16_LE".to_string()));
assert!(args.contains(&"raw".to_string()));
// mono
let c = args.iter().position(|a| a == "-c").unwrap();
assert_eq!(args[c + 1], "1");
// rate
let r = args.iter().position(|a| a == "-r").unwrap();
assert_eq!(args[r + 1], "16000");
// stdout target
assert_eq!(args.last().unwrap(), "-");
}
#[test]
fn parec_and_pw_args_carry_rate_format_and_mono() {
let parec = Recorder::Parec.args(24_000);
assert!(parec.contains(&"--raw".to_string()));
assert!(parec.contains(&"--format=s16le".to_string()));
assert!(parec.contains(&"--rate=24000".to_string()));
assert!(parec.contains(&"--channels=1".to_string()));
let pw = Recorder::PwRecord.args(48_000);
let r = pw.iter().position(|a| a == "--rate").unwrap();
assert_eq!(pw[r + 1], "48000");
let f = pw.iter().position(|a| a == "--format").unwrap();
assert_eq!(pw[f + 1], "s16");
let c = pw.iter().position(|a| a == "--channels").unwrap();
assert_eq!(pw[c + 1], "1");
assert_eq!(pw.last().unwrap(), "-"); // stdout target
}
#[test]
fn recorder_preference_is_pipewire_then_pulse_then_alsa() {
// All present: PipeWire wins (routes through the user's audio server).
let all = detect_recorder_with(|_| true);
assert!(matches!(all, Some(Recorder::PwRecord)));
// No PipeWire: PulseAudio next.
let no_pw = detect_recorder_with(|p| p != "pw-record");
assert!(matches!(no_pw, Some(Recorder::Parec)));
// alsa-utils only: arecord is the last resort.
let alsa_only = detect_recorder_with(|p| p == "arecord");
assert!(matches!(alsa_only, Some(Recorder::Arecord)));
assert!(detect_recorder_with(|_| false).is_none());
}
}

View file

@ -0,0 +1,18 @@
//! Microphone capture (optional `audio` feature).
//!
//! Two backends share one interface (`spawn_pcm_capture`,
//! `capture_pcm_for_duration`, `CaptureHandle`):
//! - non-Linux (macOS/Windows): `cpal` (coreaudio/wasapi), linked into the binary;
//! - Linux: a subprocess recorder (`pw-record`/`parec`/`arecord`), because the
//! static-musl release binary cannot link `cpal` -> `alsa-sys`. See
//! [`capture_linux`] for the full rationale.
#[cfg(not(target_os = "linux"))]
mod capture;
#[cfg(not(target_os = "linux"))]
pub use capture::{CaptureHandle, capture_pcm_for_duration, spawn_pcm_capture};
#[cfg(target_os = "linux")]
mod capture_linux;
#[cfg(target_os = "linux")]
pub use capture_linux::{CaptureHandle, capture_pcm_for_duration, spawn_pcm_capture};