Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
464
crates/codegen/xai-grok-voice/src/audio/capture.rs
Normal file
464
crates/codegen/xai-grok-voice/src/audio/capture.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
431
crates/codegen/xai-grok-voice/src/audio/capture_linux.rs
Normal file
431
crates/codegen/xai-grok-voice/src/audio/capture_linux.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
18
crates/codegen/xai-grok-voice/src/audio/mod.rs
Normal file
18
crates/codegen/xai-grok-voice/src/audio/mod.rs
Normal 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};
|
||||
Loading…
Reference in a new issue