Synced from monorepo
Changes: - Non-blocking coding-data sharing upsell banner - Consolidate remediation in Doctor - Auto mode defers fail-closed gate asks to the classifier - Coalesce marketplace list fetches - Allow removing a marketplace source by name - Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal) - Label failed workspace RPCs with error_kind - Drop redundant explicit tonic/prost deps from xai-grok-shell - Report real exit codes for completed background shells - Narrow the date-rollover reminder to date-bearing templates - Wire toolOverrides through the session and agent - Security: Bash(git:*) allowlist matches whole command chain by prefix - Split prompt-trigger telemetry and record classifier provenance - Raise connectors-manager timeout to 60s - Auto classifier honors recorded approvals for repeat actions - Apply doctor fixes in the TUI - Auto-mode classifier timeouts prompt instead of silently denying - Scope subagent completion drains to the owning session - Add the toolOverrides wire types - Set client_identifier=grok-agent-sdk - Accept both spellings of the workspace-teleport kill switch - Persist one-shot occurrence journal - Stop turns that poll the exact same tool call 16x in a row - Copy compaction checkpoint files when forking sessions - Auto-focus permission prompt from scrollback - Esc cancels the running turn in non-vim and minimal modes - List Ctrl+Z undo and redo in keyboard shortcuts - Out-of-process macOS mic capture - Show active auth mode on session-info - Install the npm binary under $GROK_HOME - Remove hover/click dead zones between dashboard items - Route startup warnings to doctor - Document [feedback.user] author identity config - Extend bang command timeout - Close combine-queued edit-hold race - Integrate relocation recovery - Expose privacy notice rollout flag - Break harness discovery ref cycle so connections can idle-evict - Shift/Alt+Enter inserts newline when editing a queued prompt - Gate project Claude permissions on folder trust - Echo response.create.event_id on response.created - Toast when session creation fails from disk full - Add shared test process lifecycle - Enable dynamic workflows by default - Add relocation transaction state machine - Add shared test sandbox - Surface auth failures on model-switch compact - Persist durable scheduler expiry - Confirm before removing extensions-modal items - Re-run compact and prompt after login when compact hit expired auth - Recap sends hosted tools under backend search
This commit is contained in:
parent
3af4d5d398
commit
a5727c5960
482 changed files with 37627 additions and 13402 deletions
|
|
@ -5,9 +5,19 @@
|
|||
//! 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.
|
||||
//!
|
||||
//! # Two roles: in-process backend and `__mic-capture` child
|
||||
//!
|
||||
//! On Windows this module is the capture backend itself (WASAPI's in-process
|
||||
//! memory cost is modest). On macOS, opening CoreAudio in-process permanently
|
||||
//! dirties several MB that the OS never returns after the stream drops, so
|
||||
//! [`super::capture_subprocess`] re-execs the binary as a short-lived
|
||||
//! `__mic-capture` helper instead; this module provides that child
|
||||
//! ([`run_capture_child_cli`]) and the in-process fallback for when self-exec
|
||||
//! is unavailable (e.g. the on-disk binary was replaced by an update).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::TrySendError;
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
|
@ -23,15 +33,9 @@ pub struct CaptureHandle {
|
|||
stop: Arc<AtomicBool>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
bridge: tokio::task::JoinHandle<()>,
|
||||
peak: Arc<AtomicU16>,
|
||||
}
|
||||
|
||||
impl CaptureHandle {
|
||||
/// Session peak of device-delivered PCM (see [`meter_and_send`]).
|
||||
pub fn peak_meter(&self) -> Arc<AtomicU16> {
|
||||
Arc::clone(&self.peak)
|
||||
}
|
||||
|
||||
/// Stop capture and wait for the thread to exit.
|
||||
///
|
||||
/// Dropping a `CaptureHandle` also stops capture (see the `Drop` impl), but
|
||||
|
|
@ -82,11 +86,9 @@ pub fn spawn_pcm_capture(
|
|||
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_flag = Arc::clone(&stop);
|
||||
let peak = Arc::new(AtomicU16::new(0));
|
||||
let peak_cb = Arc::clone(&peak);
|
||||
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, peak_cb, ready_tx);
|
||||
run_capture_loop(sample_rate, sync_tx, stop_flag, ready_tx);
|
||||
});
|
||||
|
||||
// Wait briefly for the device to actually open (mirrors the STT
|
||||
|
|
@ -114,7 +116,6 @@ pub fn spawn_pcm_capture(
|
|||
stop,
|
||||
thread: Some(thread),
|
||||
bridge,
|
||||
peak,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -151,14 +152,7 @@ pub fn capture_pcm_for_duration(
|
|||
let stop_flag = Arc::clone(&stop);
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<Result<(), VoiceError>>(1);
|
||||
let thread = thread::spawn(move || {
|
||||
// Duration probe does not read the session peak.
|
||||
run_capture_loop(
|
||||
sample_rate,
|
||||
sync_tx,
|
||||
stop_flag,
|
||||
Arc::new(AtomicU16::new(0)),
|
||||
ready_tx,
|
||||
);
|
||||
run_capture_loop(sample_rate, sync_tx, stop_flag, ready_tx);
|
||||
});
|
||||
|
||||
// Surface device-open failures before recording instead of returning empty.
|
||||
|
|
@ -198,7 +192,6 @@ struct CaptureStreamParams<'a> {
|
|||
target_rate: u32,
|
||||
sync_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
peak: Arc<AtomicU16>,
|
||||
/// Count of PCM chunks dropped because the channel was full. Logged off the
|
||||
/// audio thread by `run_capture_loop`.
|
||||
dropped: Arc<AtomicUsize>,
|
||||
|
|
@ -208,7 +201,6 @@ fn run_capture_loop(
|
|||
sample_rate: u32,
|
||||
sync_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
peak: Arc<AtomicU16>,
|
||||
ready_tx: std::sync::mpsc::SyncSender<Result<(), VoiceError>>,
|
||||
) {
|
||||
let dropped = Arc::new(AtomicUsize::new(0));
|
||||
|
|
@ -220,7 +212,6 @@ fn run_capture_loop(
|
|||
sample_rate,
|
||||
sync_tx,
|
||||
Arc::clone(&stop),
|
||||
peak,
|
||||
Arc::clone(&dropped),
|
||||
) {
|
||||
Ok(v) => {
|
||||
|
|
@ -240,11 +231,10 @@ fn run_capture_loop(
|
|||
/// 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(
|
||||
pub(super) fn open_capture_stream(
|
||||
sample_rate: u32,
|
||||
sync_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
peak: Arc<AtomicU16>,
|
||||
dropped: Arc<AtomicUsize>,
|
||||
) -> Result<(cpal::Stream, String), VoiceError> {
|
||||
let device = default_input_device()?;
|
||||
|
|
@ -284,7 +274,6 @@ fn open_capture_stream(
|
|||
target_rate: sample_rate,
|
||||
sync_tx,
|
||||
stop,
|
||||
peak,
|
||||
dropped,
|
||||
};
|
||||
|
||||
|
|
@ -391,7 +380,6 @@ where
|
|||
target_rate,
|
||||
sync_tx,
|
||||
stop,
|
||||
peak,
|
||||
dropped,
|
||||
} = params;
|
||||
let channels = in_channels as usize;
|
||||
|
|
@ -413,7 +401,7 @@ where
|
|||
if pcm.is_empty() {
|
||||
return;
|
||||
}
|
||||
meter_and_send(&pcm, &peak, &sync_tx, &dropped);
|
||||
send_pcm(&pcm, &sync_tx, &dropped);
|
||||
},
|
||||
|err| {
|
||||
tracing::warn!(error = %err, "voice capture stream error");
|
||||
|
|
@ -425,15 +413,9 @@ where
|
|||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Meter then non-blocking send. Peak is updated **before** load-shed so the
|
||||
/// silence guard sees what the mic delivered, not what survived backpressure.
|
||||
fn meter_and_send(
|
||||
pcm: &[i16],
|
||||
peak: &AtomicU16,
|
||||
sync_tx: &std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
dropped: &AtomicUsize,
|
||||
) {
|
||||
peak.fetch_max(crate::pcm::peak_abs_i16(pcm), Ordering::Relaxed);
|
||||
/// Non-blocking send from the real-time audio callback: shed load (and count
|
||||
/// it) rather than ever blocking the device thread.
|
||||
fn send_pcm(pcm: &[i16], sync_tx: &std::sync::mpsc::SyncSender<Vec<u8>>, dropped: &AtomicUsize) {
|
||||
let bytes: Vec<u8> = pcm.iter().flat_map(|s| s.to_le_bytes()).collect();
|
||||
match sync_tx.try_send(bytes) {
|
||||
Ok(()) => {}
|
||||
|
|
@ -494,20 +476,184 @@ fn resample_mono_i16(samples: &[i16], input_rate: u32, output_rate: u32) -> Vec<
|
|||
output
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `__mic-capture` child mode (see the module docs and `capture_subprocess`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the `__mic-capture` helper child. `args` is argv after the subcommand:
|
||||
/// `--rate <N>` streams PCM16 mono LE at `N` Hz to stdout; `--device-info`
|
||||
/// prints the default input device instead (one line, no stream opened).
|
||||
///
|
||||
/// Wire protocol (stdout): one status header line, then raw PCM.
|
||||
/// - `READY <device>\n` followed by the PCM byte stream, or
|
||||
/// - `INFO <name>\t<detail>\n` for `--device-info`, or
|
||||
/// - `ERR <message>\n` and a non-zero exit on any failure.
|
||||
///
|
||||
/// The child exits when its stdout write fails (parent closed the pipe or
|
||||
/// died) or when the parent kills it — it never outlives the capture session.
|
||||
pub(crate) fn run_capture_child_cli(args: Vec<String>) -> i32 {
|
||||
// Route the child's tracing (device open info, cpal warnings) to stderr,
|
||||
// which the parent drains into its debug log — plain text, since the
|
||||
// reader is a pipe, not a terminal. Stdout is the protocol channel and
|
||||
// must stay clean.
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_ansi(false)
|
||||
.without_time()
|
||||
.try_init();
|
||||
|
||||
match parse_child_args(&args) {
|
||||
Ok(ChildMode::DeviceInfo) => run_device_info_child(),
|
||||
Ok(ChildMode::Capture { rate }) => run_capture_child(rate),
|
||||
Err(msg) => {
|
||||
emit_header(&super::protocol::err_line(&msg));
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a header line to stdout without panicking: `println!` aborts on
|
||||
/// EPIPE, and a helper whose parent died must exit quietly, not crash.
|
||||
fn emit_header(line: &str) {
|
||||
use std::io::Write;
|
||||
let mut out = std::io::stdout().lock();
|
||||
let _ = writeln!(out, "{line}");
|
||||
let _ = out.flush();
|
||||
}
|
||||
|
||||
/// What the helper child was asked to do (parsed from its argv).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ChildMode {
|
||||
Capture { rate: u32 },
|
||||
DeviceInfo,
|
||||
}
|
||||
|
||||
/// Parse the helper argv. Pure so the contract is unit-testable.
|
||||
fn parse_child_args(args: &[String]) -> Result<ChildMode, String> {
|
||||
let mut rate: u32 = crate::config::DEFAULT_SAMPLE_RATE;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--device-info" => return Ok(ChildMode::DeviceInfo),
|
||||
"--rate" => {
|
||||
i += 1;
|
||||
rate = args
|
||||
.get(i)
|
||||
.and_then(|v| v.parse().ok())
|
||||
.filter(|r| *r > 0)
|
||||
.ok_or_else(|| "bad --rate".to_string())?;
|
||||
}
|
||||
other => return Err(format!("unknown mic-capture arg: {other}")),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(ChildMode::Capture { rate })
|
||||
}
|
||||
|
||||
fn run_device_info_child() -> i32 {
|
||||
match input_device_info() {
|
||||
Ok(info) => {
|
||||
emit_header(&super::protocol::info_line(&info.name, &info.detail));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
emit_header(&super::protocol::err_line(&e.to_string()));
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_capture_child(rate: u32) -> i32 {
|
||||
use std::io::Write;
|
||||
|
||||
let (sync_tx, sync_rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(64);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stream = match open_capture_stream(
|
||||
rate,
|
||||
sync_tx,
|
||||
Arc::clone(&stop),
|
||||
Arc::new(AtomicUsize::new(0)),
|
||||
) {
|
||||
Ok((stream, device_name)) => {
|
||||
emit_header(&super::protocol::ready_line(&device_name));
|
||||
stream
|
||||
}
|
||||
Err(e) => {
|
||||
emit_header(&super::protocol::err_line(&e.to_string()));
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
let mut out = std::io::stdout().lock();
|
||||
// Flush per chunk: chunks are small (~10 ms of PCM) and streaming STT
|
||||
// wants them promptly, not batched by the stdout buffer.
|
||||
loop {
|
||||
match sync_rx.recv_timeout(Duration::from_secs(2)) {
|
||||
Ok(chunk) => {
|
||||
if out.write_all(&chunk).and_then(|()| out.flush()).is_err() {
|
||||
break; // parent closed the pipe / died → stop capturing
|
||||
}
|
||||
}
|
||||
// A silent device produces no writes, so parent death would go
|
||||
// unnoticed and orphan this child; poll for reparenting (the
|
||||
// parent normally kills us long before this fires).
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
#[cfg(unix)]
|
||||
if std::os::unix::process::parent_id() == 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
stop.store(true, Ordering::Release);
|
||||
drop(stream);
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn meter_and_send_meters_shed_chunks() {
|
||||
fn child_args_default_to_capture_at_default_rate() {
|
||||
assert_eq!(
|
||||
parse_child_args(&[]),
|
||||
Ok(ChildMode::Capture {
|
||||
rate: crate::config::DEFAULT_SAMPLE_RATE
|
||||
})
|
||||
);
|
||||
let args = vec!["--rate".to_string(), "24000".to_string()];
|
||||
assert_eq!(
|
||||
parse_child_args(&args),
|
||||
Ok(ChildMode::Capture { rate: 24000 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_args_reject_bad_rate_and_unknown_flags() {
|
||||
assert!(parse_child_args(&["--rate".to_string()]).is_err());
|
||||
assert!(parse_child_args(&["--rate".to_string(), "0".to_string()]).is_err());
|
||||
assert!(parse_child_args(&["--rate".to_string(), "x".to_string()]).is_err());
|
||||
assert!(parse_child_args(&["--bogus".to_string()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_args_device_info_wins() {
|
||||
assert_eq!(
|
||||
parse_child_args(&["--device-info".to_string()]),
|
||||
Ok(ChildMode::DeviceInfo)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_pcm_counts_shed_chunks() {
|
||||
let (tx, _rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(1);
|
||||
let peak = AtomicU16::new(0);
|
||||
let dropped = AtomicUsize::new(0);
|
||||
|
||||
meter_and_send(&[100], &peak, &tx, &dropped); // fills the channel
|
||||
meter_and_send(&[-9_000], &peak, &tx, &dropped); // shed, but metered
|
||||
send_pcm(&[100], &tx, &dropped); // fills the channel
|
||||
send_pcm(&[200], &tx, &dropped); // shed
|
||||
assert_eq!(dropped.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(peak.load(Ordering::Relaxed), 9_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -18,20 +18,16 @@
|
|||
|
||||
use std::io::Read;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::mpsc as async_mpsc;
|
||||
|
||||
use super::pipe::{self, READ_CHUNK};
|
||||
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"
|
||||
|
|
@ -142,11 +138,15 @@ fn require_recorder() -> Result<Recorder, VoiceError> {
|
|||
fn spawn_recorder(sample_rate: u32) -> Result<(Recorder, Child), VoiceError> {
|
||||
let recorder = require_recorder()?;
|
||||
|
||||
let mut child = Command::new(recorder.program())
|
||||
.args(recorder.args(sample_rate))
|
||||
let mut cmd = Command::new(recorder.program());
|
||||
cmd.args(recorder.args(sample_rate))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
// setsid detach via the sanctioned helper (workspace subprocess rule): the
|
||||
// recorder writes to a pipe and must not share the pager's controlling TTY.
|
||||
xai_tty_utils::detach_std_command(&mut cmd);
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| VoiceError::Config(format!("failed to start {}: {e}", recorder.program())))?;
|
||||
|
||||
|
|
@ -177,61 +177,7 @@ fn spawn_recorder(sample_rate: u32) -> Result<(Recorder, Child), VoiceError> {
|
|||
}
|
||||
|
||||
/// 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<()>>,
|
||||
peak: Arc<AtomicU16>,
|
||||
}
|
||||
|
||||
impl CaptureHandle {
|
||||
/// Session peak of recorder-delivered PCM (metered before load-shed).
|
||||
pub fn peak_meter(&self) -> Arc<AtomicU16> {
|
||||
Arc::clone(&self.peak)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use super::pipe::ChildCaptureHandle as CaptureHandle;
|
||||
|
||||
/// Spawn subprocess capture; PCM16 LE chunks are forwarded to `pcm_tx`.
|
||||
pub fn spawn_pcm_capture(
|
||||
|
|
@ -239,20 +185,21 @@ pub fn spawn_pcm_capture(
|
|||
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())))?;
|
||||
let Some(stdout) = child.stdout.take() else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(VoiceError::Config(format!(
|
||||
"{} produced no stdout",
|
||||
recorder.program()
|
||||
)));
|
||||
};
|
||||
|
||||
drain_stderr(&mut child, recorder.program());
|
||||
pipe::drain_stderr(&mut child, recorder.program());
|
||||
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_reader = Arc::clone(&stop);
|
||||
let peak = Arc::new(AtomicU16::new(0));
|
||||
let peak_reader = Arc::clone(&peak);
|
||||
let device = recorder.program();
|
||||
let reader =
|
||||
thread::spawn(move || forward_pcm(stdout, pcm_tx, stop_reader, peak_reader, device));
|
||||
let reader = thread::spawn(move || pipe::forward_pcm(stdout, pcm_tx, stop_reader, device));
|
||||
|
||||
tracing::info!(
|
||||
recorder = recorder.program(),
|
||||
|
|
@ -260,82 +207,7 @@ pub fn spawn_pcm_capture(
|
|||
"voice capture stream (subprocess)"
|
||||
);
|
||||
|
||||
Ok(CaptureHandle {
|
||||
child: Some(child),
|
||||
stop,
|
||||
reader: Some(reader),
|
||||
peak,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Generic over the reader for tests; production passes the child's stdout.
|
||||
fn forward_pcm(
|
||||
mut stdout: impl Read,
|
||||
pcm_tx: async_mpsc::Sender<Vec<u8>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
peak: Arc<AtomicU16>,
|
||||
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) => {
|
||||
// Before try_send: shed chunks must still move the peak meter.
|
||||
peak.fetch_max(crate::pcm::peak_abs_i16_le(&buf[..n]), Ordering::Relaxed);
|
||||
// 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)"
|
||||
);
|
||||
}
|
||||
Ok(CaptureHandle::new(child, stop, reader))
|
||||
}
|
||||
|
||||
/// Recorder that would be spawned, without recording ([`crate::probe::input_device_info`]).
|
||||
|
|
@ -353,11 +225,15 @@ pub fn capture_pcm_for_duration(
|
|||
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 Some(mut stdout) = child.stdout.take() else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(VoiceError::Config(format!(
|
||||
"{} produced no stdout",
|
||||
recorder.program()
|
||||
)));
|
||||
};
|
||||
pipe::drain_stderr(&mut child, recorder.program());
|
||||
|
||||
let duration = Duration::from_secs(seconds.max(1) as u64);
|
||||
let deadline = Instant::now() + duration;
|
||||
|
|
@ -407,30 +283,6 @@ pub fn capture_pcm_for_duration(
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn forward_pcm_meters_shed_chunks() {
|
||||
// One loud sample per READ_CHUNK read; capacity 1 forces the second
|
||||
// read to shed. Both must register in the peak meter.
|
||||
let mut pcm = vec![0u8; 2 * READ_CHUNK];
|
||||
pcm[..2].copy_from_slice(&5_000i16.to_le_bytes());
|
||||
pcm[READ_CHUNK..READ_CHUNK + 2].copy_from_slice(&(-9_000i16).to_le_bytes());
|
||||
|
||||
let (tx, mut rx) = async_mpsc::channel::<Vec<u8>>(1);
|
||||
let peak = Arc::new(AtomicU16::new(0));
|
||||
forward_pcm(
|
||||
std::io::Cursor::new(pcm),
|
||||
tx,
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
Arc::clone(&peak),
|
||||
"test",
|
||||
);
|
||||
|
||||
assert_eq!(peak.load(Ordering::Relaxed), 9_000);
|
||||
let first = rx.try_recv().expect("first chunk forwarded");
|
||||
assert_eq!(crate::pcm::peak_abs_i16_le(&first), 5_000);
|
||||
assert!(rx.try_recv().is_err(), "second chunk shed (channel full)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arecord_args_are_raw_s16_mono() {
|
||||
let args = Recorder::Arecord.args(16_000);
|
||||
|
|
|
|||
395
crates/codegen/xai-grok-voice/src/audio/capture_subprocess.rs
Normal file
395
crates/codegen/xai-grok-voice/src/audio/capture_subprocess.rs
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
//! Microphone capture on macOS via a short-lived self-exec helper process.
|
||||
//!
|
||||
//! Opening CoreAudio in-process permanently dirties the pager's memory
|
||||
//! footprint: several MB for the HAL plus device capture buffers (tens of MB
|
||||
//! with some input routes), none of it returned to the OS after the stream is
|
||||
//! dropped. Capture therefore runs out of process, like the Linux recorder
|
||||
//! backend: the pager spawns `current_exe __mic-capture --rate N`, the child
|
||||
//! streams raw PCM16 mono LE to stdout behind a one-line `READY`/`ERR` header
|
||||
//! (see [`super::capture::run_capture_child_cli`]), and all audio-stack
|
||||
//! memory is freed when the child exits with the utterance. The helper is the
|
||||
//! same executable, so the terminal's mic permission grant applies unchanged.
|
||||
//!
|
||||
//! In-process capture ([`super::capture`]) remains the fallback when the
|
||||
//! helper cannot run at all — self-exec unavailable, or the spawned binary
|
||||
//! doesn't speak the helper protocol (e.g. it was replaced by an update mid
|
||||
//! run) — and can be forced with `GROK_VOICE_CAPTURE=inprocess`.
|
||||
|
||||
use std::io::Read;
|
||||
use std::process::{Child, ChildStdout, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::mpsc as async_mpsc;
|
||||
|
||||
use super::pipe::{self, ChildCaptureHandle};
|
||||
use super::protocol;
|
||||
use crate::error::VoiceError;
|
||||
|
||||
/// Env escape hatch: `GROK_VOICE_CAPTURE=inprocess` forces the legacy
|
||||
/// in-process cpal backend (accepting its permanent footprint cost).
|
||||
const CAPTURE_BACKEND_ENV: &str = "GROK_VOICE_CAPTURE";
|
||||
|
||||
/// How long to wait for the helper's status header. Device open takes
|
||||
/// hundreds of ms; exec of the (usually page-cached) binary adds tens more.
|
||||
/// Matches the in-process backend's 5 s open handshake.
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Stop handle for a capture session: the helper child, or the in-process
|
||||
/// fallback stream.
|
||||
pub enum CaptureHandle {
|
||||
Child(ChildCaptureHandle),
|
||||
InProcess(super::capture::CaptureHandle),
|
||||
}
|
||||
|
||||
impl CaptureHandle {
|
||||
/// Stop capture and wait until the device is released.
|
||||
pub fn stop(self) {
|
||||
match self {
|
||||
CaptureHandle::Child(h) => h.stop(),
|
||||
CaptureHandle::InProcess(h) => h.stop(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the env escape hatch forces the in-process backend.
|
||||
fn force_inprocess() -> bool {
|
||||
std::env::var(CAPTURE_BACKEND_ENV).is_ok_and(|v| v.eq_ignore_ascii_case("inprocess"))
|
||||
}
|
||||
|
||||
/// Why the helper handshake produced no `READY`/`INFO` payload.
|
||||
#[derive(Debug)]
|
||||
enum HandshakeFailure {
|
||||
/// The helper ran and reported `ERR` (a real device/permission error), or
|
||||
/// timed out opening the device. Surfaced as-is; an in-process retry
|
||||
/// would fail identically.
|
||||
Reported(VoiceError),
|
||||
/// The helper could not run or doesn't speak the protocol (spawn failure,
|
||||
/// EOF/garbage/oversized header — e.g. the binary was replaced by an
|
||||
/// update mid-run). The caller falls back to in-process capture.
|
||||
Broken(VoiceError),
|
||||
}
|
||||
|
||||
/// Spawn the helper (detached from the TTY, stdin null, stdout/stderr piped)
|
||||
/// and hand back its stdout. Kills the child on the defensive missing-stdout
|
||||
/// path so it can never outlive the error.
|
||||
fn spawn_helper(args: &[&str]) -> Result<(Child, ChildStdout), VoiceError> {
|
||||
let exe = std::env::current_exe()
|
||||
.map_err(|e| VoiceError::Config(format!("current_exe for mic helper: {e}")))?;
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.arg(crate::MIC_CAPTURE_SUBCOMMAND)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
// The helper must not share the pager's controlling TTY.
|
||||
xai_tty_utils::detach_std_command(&mut cmd);
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| VoiceError::Config(format!("spawn mic helper: {e}")))?;
|
||||
let Some(stdout) = child.stdout.take() else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(VoiceError::Config("mic helper produced no stdout".into()));
|
||||
};
|
||||
pipe::drain_stderr(&mut child, "mic-helper");
|
||||
Ok((child, stdout))
|
||||
}
|
||||
|
||||
/// Read the helper's one-line status header. Byte-at-a-time so no PCM after
|
||||
/// the newline is consumed from the stream.
|
||||
fn read_header(stdout: &mut impl Read) -> Result<String, HandshakeFailure> {
|
||||
let mut line = Vec::with_capacity(64);
|
||||
let mut byte = [0u8; 1];
|
||||
// Cap far above any real header so a corrupt child can't feed us forever.
|
||||
while line.len() < 4096 {
|
||||
match stdout.read(&mut byte) {
|
||||
Ok(0) => {
|
||||
return Err(HandshakeFailure::Broken(VoiceError::Config(
|
||||
"mic helper exited before ready".into(),
|
||||
)));
|
||||
}
|
||||
Ok(_) if byte[0] == b'\n' => {
|
||||
let text = String::from_utf8_lossy(&line);
|
||||
let text = text.trim_end_matches('\r');
|
||||
return match text.split_once(' ') {
|
||||
Some((tag, payload)) if tag == protocol::READY || tag == protocol::INFO => {
|
||||
Ok(payload.to_string())
|
||||
}
|
||||
Some((tag, message)) if tag == protocol::ERR => Err(
|
||||
HandshakeFailure::Reported(VoiceError::Config(message.to_string())),
|
||||
),
|
||||
_ => Err(HandshakeFailure::Broken(VoiceError::Config(format!(
|
||||
"unexpected mic helper header: {text:?}"
|
||||
)))),
|
||||
};
|
||||
}
|
||||
Ok(_) => line.push(byte[0]),
|
||||
Err(e) => {
|
||||
return Err(HandshakeFailure::Broken(VoiceError::Config(format!(
|
||||
"read mic helper header: {e}"
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(HandshakeFailure::Broken(VoiceError::Config(
|
||||
"oversized mic helper header".into(),
|
||||
)))
|
||||
}
|
||||
|
||||
/// Kill + reap a handshake-failed child, then join its reader (the kill
|
||||
/// closes stdout, so a blocked header read returns EOF and the join
|
||||
/// completes).
|
||||
fn teardown(mut child: Child, reader: JoinHandle<()>) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = reader.join();
|
||||
}
|
||||
|
||||
/// Run the handshake with a deadline: a reader thread does the blocking read
|
||||
/// and sends the outcome (plus the stdout, for the PCM stream that follows)
|
||||
/// over a channel. On failure the child is killed, reaped, and joined.
|
||||
/// `timeout_what` names the operation in the timeout error (capture vs
|
||||
/// device-info).
|
||||
fn handshake(
|
||||
child: Child,
|
||||
mut stdout: ChildStdout,
|
||||
timeout_what: &str,
|
||||
) -> Result<(Child, String, ChildStdout), HandshakeFailure> {
|
||||
type Outcome = (Result<String, HandshakeFailure>, ChildStdout);
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<Outcome>(1);
|
||||
let reader = thread::spawn(move || {
|
||||
let outcome = read_header(&mut stdout);
|
||||
let _ = tx.send((outcome, stdout));
|
||||
});
|
||||
|
||||
// A result that lands just as the timeout fires must not be discarded as
|
||||
// a timeout, so the deadline arm re-checks the channel once before
|
||||
// tearing down.
|
||||
let outcome = rx
|
||||
.recv_timeout(READY_TIMEOUT)
|
||||
.or_else(|_| rx.try_recv())
|
||||
.map_err(|_| {
|
||||
HandshakeFailure::Reported(VoiceError::Config(format!(
|
||||
"{timeout_what} did not start within {}s",
|
||||
READY_TIMEOUT.as_secs()
|
||||
)))
|
||||
});
|
||||
match outcome {
|
||||
Ok((Ok(payload), stdout)) => {
|
||||
let _ = reader.join();
|
||||
Ok((child, payload, stdout))
|
||||
}
|
||||
Ok((Err(failure), _stdout)) => {
|
||||
teardown(child, reader);
|
||||
Err(failure)
|
||||
}
|
||||
Err(timeout) => {
|
||||
teardown(child, reader);
|
||||
Err(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn helper capture; PCM16 LE chunks are forwarded to `pcm_tx`.
|
||||
///
|
||||
/// Falls back to in-process cpal capture when the helper cannot run at all
|
||||
/// (spawn failure or broken protocol). Device/permission errors reported by a
|
||||
/// working helper — and handshake timeouts, which an in-process retry of the
|
||||
/// same stuck device would only double — surface as-is.
|
||||
pub fn spawn_pcm_capture(
|
||||
sample_rate: u32,
|
||||
pcm_tx: async_mpsc::Sender<Vec<u8>>,
|
||||
) -> Result<CaptureHandle, VoiceError> {
|
||||
if force_inprocess() {
|
||||
tracing::info!("voice capture forced in-process ({CAPTURE_BACKEND_ENV}=inprocess)");
|
||||
return super::capture::spawn_pcm_capture(sample_rate, pcm_tx)
|
||||
.map(CaptureHandle::InProcess);
|
||||
}
|
||||
|
||||
let rate = sample_rate.to_string();
|
||||
let handshaken = spawn_helper(&["--rate", &rate])
|
||||
.map_err(HandshakeFailure::Broken)
|
||||
.and_then(|(child, stdout)| handshake(child, stdout, "voice capture"));
|
||||
|
||||
let (child, device, stdout) = match handshaken {
|
||||
Ok(up) => up,
|
||||
Err(HandshakeFailure::Broken(e)) => {
|
||||
tracing::warn!(error = %e, "mic helper unavailable; falling back to in-process capture");
|
||||
return super::capture::spawn_pcm_capture(sample_rate, pcm_tx)
|
||||
.map(CaptureHandle::InProcess);
|
||||
}
|
||||
Err(HandshakeFailure::Reported(e)) => return Err(e),
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
device = %device,
|
||||
sample_rate,
|
||||
"voice capture stream (mic helper subprocess)"
|
||||
);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_reader = Arc::clone(&stop);
|
||||
let reader =
|
||||
thread::spawn(move || pipe::forward_pcm(stdout, pcm_tx, stop_reader, "mic-helper"));
|
||||
Ok(CaptureHandle::Child(ChildCaptureHandle::new(
|
||||
child, stop, reader,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Default input device via the helper (`--device-info`), so `/doctor` in the
|
||||
/// long-lived TUI doesn't pay the permanent in-process CoreAudio enumeration
|
||||
/// cost. Falls back to in-process enumeration when the helper cannot run.
|
||||
pub fn input_device_info() -> Result<crate::probe::InputDeviceInfo, VoiceError> {
|
||||
if force_inprocess() {
|
||||
return super::capture::input_device_info();
|
||||
}
|
||||
let handshaken = spawn_helper(&["--device-info"])
|
||||
.map_err(HandshakeFailure::Broken)
|
||||
.and_then(|(child, stdout)| handshake(child, stdout, "mic device lookup"));
|
||||
|
||||
let payload = match handshaken {
|
||||
Ok((mut child, payload, _stdout)) => {
|
||||
// Info mode: the child prints its one line and exits on its own.
|
||||
// Kill defensively before reaping (a no-op when already exited) so
|
||||
// a confused child that streams PCM can never wedge the `wait`.
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
payload
|
||||
}
|
||||
Err(HandshakeFailure::Broken(e)) => {
|
||||
tracing::debug!(error = %e, "mic helper unavailable; enumerating in-process");
|
||||
return super::capture::input_device_info();
|
||||
}
|
||||
Err(HandshakeFailure::Reported(e)) => return Err(e),
|
||||
};
|
||||
|
||||
let (name, detail) = payload
|
||||
.split_once(protocol::INFO_FIELD_SEPARATOR)
|
||||
.unwrap_or((payload.as_str(), ""));
|
||||
Ok(crate::probe::InputDeviceInfo {
|
||||
name: name.to_string(),
|
||||
detail: detail.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn header(bytes: &[u8]) -> Result<String, HandshakeFailure> {
|
||||
read_header(&mut std::io::Cursor::new(bytes.to_vec()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_parses_ready_info_and_err() {
|
||||
let mut ok = std::io::Cursor::new(b"READY Built-in Microphone\nPCM".to_vec());
|
||||
assert_eq!(read_header(&mut ok).unwrap(), "Built-in Microphone");
|
||||
// The PCM byte after the newline must remain unread.
|
||||
let mut rest = Vec::new();
|
||||
ok.read_to_end(&mut rest).unwrap();
|
||||
assert_eq!(rest, b"PCM");
|
||||
|
||||
assert_eq!(
|
||||
header(b"INFO Mic\t44100 Hz, 1 ch\n").unwrap(),
|
||||
"Mic\t44100 Hz, 1 ch"
|
||||
);
|
||||
|
||||
match header(b"ERR no default input audio device\n") {
|
||||
Err(HandshakeFailure::Reported(VoiceError::Config(msg))) => {
|
||||
assert_eq!(msg, "no default input audio device");
|
||||
}
|
||||
other => panic!("expected Reported, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_treats_eof_garbage_and_oversize_as_broken() {
|
||||
for bytes in [
|
||||
b"".as_slice(), // EOF before any header
|
||||
b"bogus header\n".as_slice(), // unknown tag
|
||||
&[b'x'; 5000], // no newline within the cap
|
||||
] {
|
||||
assert!(
|
||||
matches!(header(bytes), Err(HandshakeFailure::Broken(_))),
|
||||
"input {:?}... must be Broken",
|
||||
&bytes[..bytes.len().min(12)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive `handshake` against real scripted children, covering the
|
||||
/// concurrent recv/teardown paths that the pure header tests cannot:
|
||||
/// success (with the stdout handed back intact), a reported error, a
|
||||
/// protocol-broken child, and a child that never answers (timeout).
|
||||
#[test]
|
||||
fn handshake_resolves_scripted_children() {
|
||||
let spawn_sh = |script: &str| -> (Child, ChildStdout) {
|
||||
let mut cmd = std::process::Command::new("sh");
|
||||
cmd.arg("-c")
|
||||
.arg(script)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
xai_tty_utils::detach_std_command(&mut cmd);
|
||||
let mut child = cmd.spawn().expect("spawn sh");
|
||||
let stdout = child.stdout.take().expect("stdout");
|
||||
(child, stdout)
|
||||
};
|
||||
|
||||
// READY → payload plus the byte stream after the header, unconsumed.
|
||||
let (child, stdout) = spawn_sh("printf 'READY fake-mic\\nPCM'; sleep 5");
|
||||
let (mut child, payload, mut stdout) =
|
||||
handshake(child, stdout, "test").expect("ready handshake");
|
||||
assert_eq!(payload, "fake-mic");
|
||||
let mut pcm = [0u8; 3];
|
||||
stdout.read_exact(&mut pcm).expect("post-header bytes");
|
||||
assert_eq!(&pcm, b"PCM");
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
// ERR → Reported, child reaped by handshake.
|
||||
let (child, stdout) = spawn_sh("printf 'ERR no such device\\n'");
|
||||
match handshake(child, stdout, "test") {
|
||||
Err(HandshakeFailure::Reported(VoiceError::Config(msg))) => {
|
||||
assert_eq!(msg, "no such device");
|
||||
}
|
||||
Ok(_) => panic!("expected Reported, got READY"),
|
||||
Err(other) => panic!("expected Reported, got {other:?}"),
|
||||
}
|
||||
|
||||
// Garbage → Broken (the in-process fallback trigger).
|
||||
let (child, stdout) = spawn_sh("printf 'not-a-header\\n'");
|
||||
assert!(matches!(
|
||||
handshake(child, stdout, "test"),
|
||||
Err(HandshakeFailure::Broken(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// A child that produces no header within the deadline is killed and the
|
||||
/// timeout surfaces as `Reported`, naming the caller's operation. Costs a
|
||||
/// full `READY_TIMEOUT` (5 s), so it is ignored by default.
|
||||
#[test]
|
||||
#[ignore = "takes READY_TIMEOUT (5s); run with --ignored"]
|
||||
fn handshake_times_out_on_silent_child() {
|
||||
let mut cmd = std::process::Command::new("sh");
|
||||
cmd.arg("-c")
|
||||
.arg("sleep 30")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
xai_tty_utils::detach_std_command(&mut cmd);
|
||||
let mut child = cmd.spawn().expect("spawn sh");
|
||||
let stdout = child.stdout.take().expect("stdout");
|
||||
match handshake(child, stdout, "test capture") {
|
||||
Err(HandshakeFailure::Reported(VoiceError::Config(msg))) => {
|
||||
assert!(msg.contains("test capture"), "{msg}");
|
||||
assert!(msg.contains("did not start"), "{msg}");
|
||||
}
|
||||
Ok(_) => panic!("expected timeout, got READY"),
|
||||
Err(other) => panic!("expected timeout Reported, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,49 @@
|
|||
//! 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.
|
||||
//! Three backends share one interface (`spawn_pcm_capture`,
|
||||
//! `capture_pcm_for_duration`, `input_device_info`, `CaptureHandle`):
|
||||
//!
|
||||
//! - **Linux**: a subprocess recorder (`pw-record`/`parec`/`arecord`) — the
|
||||
//! static-musl release binary cannot link `cpal` → `alsa-sys`; see
|
||||
//! [`capture_linux`].
|
||||
//! - **macOS**: a subprocess too — the self-exec `__mic-capture` helper —
|
||||
//! because in-process CoreAudio memory is never returned after the stream
|
||||
//! drops; see [`capture_subprocess`].
|
||||
//! - **Windows**: `cpal` (WASAPI) in-process; its memory cost is modest.
|
||||
//!
|
||||
//! The fixed-duration probe capture stays in-process on macOS/Windows: it only
|
||||
//! runs in short-lived diagnostic processes, where the memory dies at exit.
|
||||
//!
|
||||
//! `CaptureHandle` is deliberately one name per platform, resolved by the
|
||||
//! re-exports below:
|
||||
//! - Linux → `pipe::ChildCaptureHandle` (recorder subprocess);
|
||||
//! - macOS → `capture_subprocess::CaptureHandle`, an enum over the helper
|
||||
//! subprocess and the in-process fallback;
|
||||
//! - Windows → `capture::CaptureHandle` (in-process cpal stream).
|
||||
|
||||
// cpal-based capture: the Windows backend, the macOS fallback, and the macOS
|
||||
// `__mic-capture` child implementation.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod capture;
|
||||
// Wire protocol shared by the `__mic-capture` child (writer, in `capture`)
|
||||
// and the macOS parent (parser, in `capture_subprocess`).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub use capture::{CaptureHandle, capture_pcm_for_duration, input_device_info, spawn_pcm_capture};
|
||||
mod protocol;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub use capture::capture_pcm_for_duration;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) use capture::run_capture_child_cli;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use capture::{CaptureHandle, input_device_info, spawn_pcm_capture};
|
||||
|
||||
// Shared PCM-over-pipe plumbing for the two subprocess backends.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
mod pipe;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod capture_subprocess;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use capture_subprocess::{CaptureHandle, input_device_info, spawn_pcm_capture};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod capture_linux;
|
||||
|
|
|
|||
169
crates/codegen/xai-grok-voice/src/audio/pipe.rs
Normal file
169
crates/codegen/xai-grok-voice/src/audio/pipe.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
//! Shared PCM-over-pipe plumbing for the subprocess capture backends
|
||||
//! (Linux system recorder, macOS `__mic-capture` helper): the capture child's
|
||||
//! stop handle, a reader-thread loop that forwards the child's stdout to the
|
||||
//! async STT sender, and a stderr drain.
|
||||
|
||||
use std::io::Read;
|
||||
use std::process::Child;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
use tokio::sync::mpsc as async_mpsc;
|
||||
|
||||
/// Stop handle for a capture child process (recorder or self-exec helper) and
|
||||
/// its PCM reader thread.
|
||||
pub struct ChildCaptureHandle {
|
||||
/// `Some` until `stop()` or `Drop` consumes it (kill + reap).
|
||||
child: Option<Child>,
|
||||
stop: Arc<AtomicBool>,
|
||||
reader: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ChildCaptureHandle {
|
||||
pub(super) fn new(child: Child, stop: Arc<AtomicBool>, reader: JoinHandle<()>) -> Self {
|
||||
Self {
|
||||
child: Some(child),
|
||||
stop,
|
||||
reader: Some(reader),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop capture: kill the child, reap it, and join the reader thread so
|
||||
/// the input device is released before returning.
|
||||
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 ChildCaptureHandle {
|
||||
fn drop(&mut self) {
|
||||
// Always kill the child so the mic is released even when `stop()` was
|
||||
// never called (e.g. the STT session ended on its own). 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,
|
||||
// drop-path teardowns would leave zombies until the pager exits.
|
||||
self.stop.store(true, Ordering::Release);
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.kill();
|
||||
// `Builder::spawn` so spawn failure under thread exhaustion
|
||||
// degrades to kill-without-reap instead of a panicking `Drop`.
|
||||
let _ = thread::Builder::new()
|
||||
.name("voice-capture-reap".into())
|
||||
.spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PCM read size from the child'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.
|
||||
pub(super) const READ_CHUNK: usize = 2048;
|
||||
|
||||
/// Forward raw PCM from the child's stdout to the async STT sender until the
|
||||
/// child stops (EOF on kill), the consumer goes away, or `stop` is set.
|
||||
/// Generic over the reader for tests; production passes the child's stdout.
|
||||
pub(super) fn forward_pcm(
|
||||
mut stdout: impl Read,
|
||||
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 child 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. (`read` itself is
|
||||
// unblocked by the kill-on-stop path: killing the child 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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the child's stderr to EOF on a detached thread so a chatty child
|
||||
/// can't fill the pipe buffer and block its own writes (the hot path never
|
||||
/// reads stderr). Non-empty output is logged at debug. The thread ends on its
|
||||
/// own when the child exits, so it is not joined.
|
||||
pub(super) 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 capture child stderr");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn forward_pcm_sheds_when_consumer_is_behind() {
|
||||
// Two reads into a capacity-1 channel: first forwarded, second shed.
|
||||
let pcm = vec![7u8; 2 * READ_CHUNK];
|
||||
let (tx, mut rx) = async_mpsc::channel::<Vec<u8>>(1);
|
||||
forward_pcm(
|
||||
std::io::Cursor::new(pcm),
|
||||
tx,
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
"test",
|
||||
);
|
||||
assert_eq!(rx.try_recv().expect("first chunk").len(), READ_CHUNK);
|
||||
assert!(rx.try_recv().is_err(), "second chunk shed (channel full)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_pcm_stops_when_consumer_closes() {
|
||||
let (tx, rx) = async_mpsc::channel::<Vec<u8>>(1);
|
||||
drop(rx);
|
||||
// Endless reader: must exit via the Closed arm, not spin forever.
|
||||
forward_pcm(
|
||||
std::io::repeat(0),
|
||||
tx,
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
"test",
|
||||
);
|
||||
}
|
||||
}
|
||||
62
crates/codegen/xai-grok-voice/src/audio/protocol.rs
Normal file
62
crates/codegen/xai-grok-voice/src/audio/protocol.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//! Wire protocol between the `__mic-capture` helper child and its parent.
|
||||
//!
|
||||
//! One status header line on stdout, then (in capture mode) raw PCM:
|
||||
//! - `READY <device>` — capture stream open, PCM follows;
|
||||
//! - `INFO <name>\t<detail>` — device lookup result (no stream);
|
||||
//! - `ERR <message>` — failure, non-zero exit.
|
||||
//!
|
||||
//! The child builds lines with the helpers here and the parent parses with
|
||||
//! the same tags, so the two sides cannot drift.
|
||||
|
||||
/// Capture stream is open; raw PCM follows this line.
|
||||
pub(super) const READY: &str = "READY";
|
||||
/// Device lookup result; fields separated by [`INFO_FIELD_SEPARATOR`].
|
||||
pub(super) const INFO: &str = "INFO";
|
||||
/// Failure; the payload is the error message.
|
||||
pub(super) const ERR: &str = "ERR";
|
||||
/// Separates the device name from its detail in an `INFO` payload.
|
||||
pub(super) const INFO_FIELD_SEPARATOR: char = '\t';
|
||||
|
||||
pub(super) fn ready_line(device: &str) -> String {
|
||||
format!("{READY} {}", sanitize(device))
|
||||
}
|
||||
|
||||
pub(super) fn info_line(name: &str, detail: &str) -> String {
|
||||
format!(
|
||||
"{INFO} {}{INFO_FIELD_SEPARATOR}{}",
|
||||
sanitize(name),
|
||||
sanitize(detail)
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn err_line(message: &str) -> String {
|
||||
format!("{ERR} {}", sanitize(message))
|
||||
}
|
||||
|
||||
/// Header payloads must stay single-line for the parent's line-oriented
|
||||
/// handshake, and must not contain the `INFO` field separator (a device name
|
||||
/// with a tab would otherwise bleed into the detail field).
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.replace(['\n', '\r', INFO_FIELD_SEPARATOR], " ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lines_carry_tag_and_sanitized_payload() {
|
||||
assert_eq!(ready_line("Mic\nName"), "READY Mic Name");
|
||||
assert_eq!(err_line("boom\r"), "ERR boom ");
|
||||
assert_eq!(info_line("USB Mic", "44100 Hz"), "INFO USB Mic\t44100 Hz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_the_info_field_separator() {
|
||||
// A tab inside a device name must not create a phantom third field.
|
||||
assert_eq!(
|
||||
info_line("Evil\tMic", "48000 Hz"),
|
||||
"INFO Evil Mic\t48000 Hz"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,8 +11,20 @@ use xai_grok_voice::{
|
|||
StaticVoiceAuth, VoiceConfig, VoiceProbeOptions, format_probe_report, run_streaming_probe,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// Hidden mic-capture helper intercept (macOS): the capture backend
|
||||
// re-execs the current binary — here, voice-probe itself. Runs before any
|
||||
// runtime/TLS init so the capture child stays minimal.
|
||||
if let Some(code) = xai_grok_voice::maybe_run_capture_subprocess() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(run())
|
||||
}
|
||||
|
||||
async fn run() -> anyhow::Result<()> {
|
||||
// Standalone binary: install the process-level rustls provider (the pager
|
||||
// does this in its own main), or the first TLS/WSS connect panics with
|
||||
// "Could not automatically determine the process-level CryptoProvider".
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::error::VoiceError;
|
||||
|
||||
/// Default STT capture rate (Hz). Shared with the `__mic-capture` helper's
|
||||
/// argv default so parent and child agree when `--rate` is omitted.
|
||||
pub const DEFAULT_SAMPLE_RATE: u32 = 16_000;
|
||||
|
||||
/// Voice settings for the STT transport.
|
||||
///
|
||||
/// Prefer **https** `api_base` (same shape as chat). [`Self::stt_ws_url`] derives
|
||||
|
|
@ -34,7 +38,7 @@ impl Default for VoiceConfig {
|
|||
api_base: "https://api.x.ai".into(),
|
||||
stt_ws_path: "/v1/stt".into(),
|
||||
language: "en".into(),
|
||||
sample_rate: 16_000,
|
||||
sample_rate: DEFAULT_SAMPLE_RATE,
|
||||
stt_endpointing_ms: 400,
|
||||
stt_interim_results: true,
|
||||
client_identifier: String::new(),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
//! [`run_voice_pipeline`] task that emits [`VoiceEvent`]s for the pager.
|
||||
//!
|
||||
//! Voice is dictation only: mic → streaming STT → transcript into the prompt box.
|
||||
//!
|
||||
//! On macOS and Linux the microphone is opened in a short-lived subprocess so
|
||||
//! the long-lived TUI never pays the platform audio stack's permanent memory
|
||||
//! cost (see [`audio`] and [`maybe_run_capture_subprocess`]).
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
pub mod audio;
|
||||
|
|
@ -10,7 +14,6 @@ pub mod config;
|
|||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod language;
|
||||
pub mod pcm;
|
||||
pub mod pipeline;
|
||||
pub mod probe;
|
||||
pub mod stt;
|
||||
|
|
@ -41,3 +44,78 @@ pub use probe::{
|
|||
/// is actually installed is reported when a session starts. Consumers gate voice
|
||||
/// on this so a no-audio build never advertises a mic it can't open.
|
||||
pub const AUDIO_SUPPORTED: bool = cfg!(feature = "audio");
|
||||
|
||||
/// Hidden subcommand consumers re-exec themselves with to capture microphone
|
||||
/// audio in a short-lived helper process (macOS; see
|
||||
/// [`audio::capture_subprocess`](audio) for why capture is out of process).
|
||||
/// Intercepted via [`maybe_run_capture_subprocess`] at the very top of `main`,
|
||||
/// before any TUI/agent/tokio init, so the child stays minimal.
|
||||
pub const MIC_CAPTURE_SUBCOMMAND: &str = "__mic-capture";
|
||||
|
||||
/// If this process was re-exec'd as the hidden mic-capture helper, run it and
|
||||
/// return `Some(exit_code)`; otherwise `None` (a normal invocation). Call at
|
||||
/// the very top of `main` in every binary that links this crate with `audio`
|
||||
/// (the pager composition root and `voice-probe`), mirroring the pager's
|
||||
/// mermaid render child intercept.
|
||||
pub fn maybe_run_capture_subprocess() -> Option<i32> {
|
||||
let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
|
||||
if !is_capture_subcommand(&argv) {
|
||||
return None;
|
||||
}
|
||||
#[cfg(all(feature = "audio", not(target_os = "linux")))]
|
||||
{
|
||||
// Skip argv[0] (binary) and argv[1] (subcommand); the rest are flags.
|
||||
let args: Vec<String> = argv
|
||||
.into_iter()
|
||||
.skip(2)
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
Some(audio::run_capture_child_cli(args))
|
||||
}
|
||||
#[cfg(not(all(feature = "audio", not(target_os = "linux"))))]
|
||||
{
|
||||
// Never spawned by this build's own parent backend (Linux uses system
|
||||
// recorders; no-audio builds have no capture). Reachable only by hand.
|
||||
// `write!` not `println!`: never panic on a closed pipe.
|
||||
use std::io::Write;
|
||||
let _ = writeln!(
|
||||
std::io::stdout(),
|
||||
"ERR mic-capture helper unavailable in this build"
|
||||
);
|
||||
Some(2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `argv` (the full process argv, incl. argv[0]) invokes the hidden
|
||||
/// mic-capture helper — i.e. argv[1] is [`MIC_CAPTURE_SUBCOMMAND`]. Pure so the
|
||||
/// dispatch decision is unit-testable without mutating the process's real args.
|
||||
fn is_capture_subcommand(argv: &[std::ffi::OsString]) -> bool {
|
||||
argv.get(1).and_then(|a| a.to_str()) == Some(MIC_CAPTURE_SUBCOMMAND)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod intercept_tests {
|
||||
use super::*;
|
||||
|
||||
fn argv(items: &[&str]) -> Vec<std::ffi::OsString> {
|
||||
items.iter().map(std::ffi::OsString::from).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_subcommand_matches_only_argv1() {
|
||||
assert!(is_capture_subcommand(&argv(&["grok", "__mic-capture"])));
|
||||
assert!(is_capture_subcommand(&argv(&[
|
||||
"grok",
|
||||
"__mic-capture",
|
||||
"--rate",
|
||||
"16000"
|
||||
])));
|
||||
assert!(!is_capture_subcommand(&argv(&["grok"])));
|
||||
assert!(!is_capture_subcommand(&argv(&["grok", "chat"])));
|
||||
assert!(!is_capture_subcommand(&argv(&[
|
||||
"grok",
|
||||
"chat",
|
||||
"__mic-capture"
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
//! PCM16 peak-level helpers for silence detection.
|
||||
//!
|
||||
//! Denied mic permission (macOS feeds unauthorized apps zeros), muted input, or
|
||||
//! a dead device yields ~zero peak. A working mic still has a noise floor, so
|
||||
//! peak separates "mic misconfigured" from "user didn't speak".
|
||||
|
||||
/// Peaks at or below this many PCM16 counts count as digital silence.
|
||||
/// Small allowance for dither on an otherwise dead input; far below a real
|
||||
/// mic's noise floor.
|
||||
pub const SILENCE_PEAK_MAX: u16 = 3;
|
||||
|
||||
/// Peak absolute sample of little-endian mono PCM16. Empty → 0; trailing odd
|
||||
/// byte ignored. `u16` because `i16::MIN.unsigned_abs()` is 32768.
|
||||
pub fn peak_abs_i16_le(pcm_le: &[u8]) -> u16 {
|
||||
pcm_le
|
||||
.chunks_exact(2)
|
||||
.map(|b| i16::from_le_bytes([b[0], b[1]]).unsigned_abs())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// [`peak_abs_i16_le`] for samples not yet encoded as bytes.
|
||||
pub fn peak_abs_i16(samples: &[i16]) -> u16 {
|
||||
samples.iter().map(|s| s.unsigned_abs()).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Whether a peak is digital silence (see [`SILENCE_PEAK_MAX`]).
|
||||
pub fn is_silence(peak: u16) -> bool {
|
||||
peak <= SILENCE_PEAK_MAX
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pcm(samples: &[i16]) -> Vec<u8> {
|
||||
samples.iter().flat_map(|s| s.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_abs_i16_le_edges() {
|
||||
assert_eq!(peak_abs_i16_le(&[]), 0);
|
||||
assert_eq!(peak_abs_i16_le(&pcm(&[10, -500, 300])), 500);
|
||||
assert_eq!(peak_abs_i16_le(&pcm(&[i16::MIN])), 32768);
|
||||
let mut bytes = pcm(&[7]);
|
||||
bytes.push(0xFF);
|
||||
assert_eq!(peak_abs_i16_le(&bytes), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_abs_i16_edges() {
|
||||
assert_eq!(peak_abs_i16(&[]), 0);
|
||||
assert_eq!(peak_abs_i16(&[10, -500, 300]), 500);
|
||||
assert_eq!(peak_abs_i16(&[i16::MIN]), 32768);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_threshold_boundary() {
|
||||
assert!(is_silence(0));
|
||||
assert!(is_silence(SILENCE_PEAK_MAX));
|
||||
assert!(!is_silence(SILENCE_PEAK_MAX + 1));
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,6 @@
|
|||
|
||||
#[cfg(feature = "audio")]
|
||||
use std::collections::VecDeque;
|
||||
#[cfg(feature = "audio")]
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
|
@ -96,7 +94,7 @@ pub async fn run_voice_pipeline(
|
|||
};
|
||||
// The reader task owns the capture handle; signalling it lets the
|
||||
// reader stop the mic and send `audio.done` in a single place,
|
||||
// matching the silence-guard teardown below.
|
||||
// matching the no-speech-watchdog teardown below.
|
||||
let _ = session.finish_tx.send(()).await;
|
||||
}
|
||||
}
|
||||
|
|
@ -193,30 +191,21 @@ async fn forward_pcm(
|
|||
}
|
||||
}
|
||||
|
||||
/// Silence → short toast (+ long OS hint); non-silence → "try again".
|
||||
/// Toast may be the only surface (dashboard / `--minimal`), so on macOS it
|
||||
/// includes grant + restart — the long hint has the full Settings path.
|
||||
/// How long a session may run without any transcript before it is torn down
|
||||
/// (instead of streaming a dead mic until the user gives up). Disarmed by the
|
||||
/// first transcript, so long dictation with pauses is unaffected.
|
||||
#[cfg(feature = "audio")]
|
||||
fn silence_guard_error(peak: u16) -> (String, Option<String>) {
|
||||
if crate::pcm::is_silence(peak) {
|
||||
let message = if cfg!(target_os = "macos") {
|
||||
"microphone delivered only silence — allow terminal mic access, then restart it"
|
||||
} else {
|
||||
"microphone delivered only silence — check mic permission"
|
||||
};
|
||||
(
|
||||
message.to_string(),
|
||||
Some(format!(
|
||||
"To fix voice dictation, {}",
|
||||
crate::probe::mic_silence_help()
|
||||
)),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"heard audio but no speech was detected — try again".to_string(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
const NO_SPEECH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Message and permission guidance for a session torn down by
|
||||
/// [`NO_SPEECH_TIMEOUT`]. A denied grant is indistinguishable from not speaking
|
||||
/// because macOS may return silence instead of an error.
|
||||
#[cfg(feature = "audio")]
|
||||
fn no_speech_error() -> (String, Option<String>) {
|
||||
(
|
||||
"No speech was detected. Voice stopped.".to_owned(),
|
||||
Some(crate::probe::mic_fix_help().to_owned()),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
|
|
@ -256,7 +245,6 @@ async fn start_capture_session(
|
|||
)));
|
||||
}
|
||||
};
|
||||
let peak = capture.peak_meter();
|
||||
let mut stt = connect_res?;
|
||||
|
||||
// Hand the live sender to the forwarder; it flushes the backlog then streams.
|
||||
|
|
@ -278,9 +266,9 @@ async fn start_capture_session(
|
|||
handle.stop();
|
||||
}
|
||||
};
|
||||
// No transcript in first 10s → diagnose from peak. Disarmed after speech.
|
||||
let silence_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let mut silence_check = true;
|
||||
// No transcript within the timeout → tear down. Disarmed after speech.
|
||||
let no_speech_deadline = tokio::time::Instant::now() + NO_SPEECH_TIMEOUT;
|
||||
let mut awaiting_speech = true;
|
||||
// Chunk-final (`is_final && !speech_final`) text is locked: the server
|
||||
// sends it as a delta of the turn. Stitch those deltas into the live
|
||||
// preview so a long pauseless utterance keeps accumulating on screen
|
||||
|
|
@ -293,19 +281,19 @@ async fn start_capture_session(
|
|||
tokio::select! {
|
||||
msg = finish_rx.recv() => {
|
||||
if msg.is_some() {
|
||||
// User ended the turn; stop watching for initial silence.
|
||||
silence_check = false;
|
||||
// User ended the turn; stop the no-speech watchdog.
|
||||
awaiting_speech = false;
|
||||
stop_capture(&mut capture);
|
||||
stt.finish_audio();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep_until(silence_deadline), if silence_check => {
|
||||
// Tear down rather than streaming silence until the user stops.
|
||||
_ = tokio::time::sleep_until(no_speech_deadline), if awaiting_speech => {
|
||||
// Tear down rather than streaming a dead mic until the user stops.
|
||||
stop_capture(&mut capture);
|
||||
stt.finish_audio();
|
||||
let (message, hint) = silence_guard_error(peak.load(Ordering::Relaxed));
|
||||
let (message, hint) = no_speech_error();
|
||||
let _ = out.send(VoiceEvent::Error { message, hint }).await;
|
||||
return;
|
||||
}
|
||||
|
|
@ -316,8 +304,8 @@ async fn start_capture_session(
|
|||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Real speech arrived: disarm the initial-silence guard.
|
||||
silence_check = false;
|
||||
// Real speech arrived: disarm the no-speech watchdog.
|
||||
awaiting_speech = false;
|
||||
|
||||
let event = if p.speech_final {
|
||||
locked_prefix.clear();
|
||||
|
|
@ -348,7 +336,7 @@ async fn start_capture_session(
|
|||
Some(StreamingSttEvent::Done { text }) => {
|
||||
locked_prefix.clear();
|
||||
if !text.trim().is_empty() {
|
||||
silence_check = false;
|
||||
awaiting_speech = false;
|
||||
let _ = out.send(VoiceEvent::UtteranceFinal { text }).await;
|
||||
}
|
||||
}
|
||||
|
|
@ -422,16 +410,9 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn silence_guard_error_matches_metered_level() {
|
||||
let (message, hint) = silence_guard_error(0);
|
||||
assert!(message.contains("only silence"));
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(message.contains("restart"), "{message}");
|
||||
}
|
||||
assert!(hint.is_some_and(|h| h.contains(crate::probe::mic_silence_help())));
|
||||
|
||||
let (message, hint) = silence_guard_error(2_000);
|
||||
assert!(message.contains("no speech was detected"));
|
||||
assert!(hint.is_none());
|
||||
fn no_speech_error_carries_permission_hint() {
|
||||
let (message, hint) = no_speech_error();
|
||||
assert_eq!(message, "No speech was detected. Voice stopped.");
|
||||
assert!(hint.is_some_and(|hint| hint.contains(crate::probe::mic_fix_help())));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,14 +152,13 @@ pub fn input_device_info() -> Result<InputDeviceInfo, VoiceError> {
|
|||
))
|
||||
}
|
||||
|
||||
/// Platform-specific fix text for a silent mic. On macOS the grant is for the
|
||||
/// terminal app and only applies after that app restarts.
|
||||
pub fn mic_silence_help() -> &'static str {
|
||||
/// Platform-specific fix text for a mic that isn't being picked up. On macOS
|
||||
/// the grant is for the terminal app and only applies after that app restarts.
|
||||
pub fn mic_fix_help() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"grant your terminal app microphone access in System Settings → \
|
||||
Privacy & Security → Microphone, then restart the terminal. If it's \
|
||||
already allowed, check the input device and level in System Settings \
|
||||
→ Sound → Input."
|
||||
"Allow microphone access for your terminal in System Settings → Privacy & Security → \
|
||||
Microphone, then restart the terminal. If access is already on, check the input device \
|
||||
and level in System Settings → Sound → Input."
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"allow microphone access in Settings → Privacy & security → \
|
||||
Microphone, and check the input device and level in Settings → \
|
||||
|
|
|
|||
Loading…
Reference in a new issue