Synced from monorepo

Synced from monorepo

Changes:
- Shell: accept target response id on rewind execute
- Shell: stamp response id on chat user message chunks
- Worktree: optional rebuild and stale git registration cleanup in auto-GC
- Worktree: kind-aware auto-GC TTLs and config knobs
- Worktree: macOS process CWD scan and Unix PID liveness for GC guards
- Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only)
- Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups
- Shell: stop overwriting user skills
- Tools: read markdown in `skills/` directories untruncated
- `/usage` shows per-session token and dollar usage in the TUI
- Security: prompt on environment-dumping `ps` variants
- Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission
- Tools: make scheduler deletion durable
- Shell: add relocation storage primitives
- Shell: give side model calls their own conversation ids
- Fix five workflow-runtime bugs (budget, pause, cancel, reconnect)
- Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask)
- Pager: expose doctor in the TUI
- Security: block unauthorized RCE via abused safe commands
- Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent"
- Security: block `rg --pre` arbitrary code execution in auto-mode
- Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section
- App builder deployer: `allow_forking` and `show_built_with_grok`
- Pager: stop stacking duplicate "Worked for" markers on parked turns
- Shell: support `max` as a distinct reasoning effort tier
- Tools: serialize background `/loop` fires on the whole work unit
- Shell: add working-directory relocation state primitives
- Proto: `ClientToolResult` and `ChatConfig` client-side tools
- Shell: model providers
- Chat: select App Builder product on the Build path
- Shell: attach author identity to feedback when the deployment opts in
- Doctor: fix for SSH wrap setup
- Workflow authoring skills: create-workflow and import-claude-workflow docs
- Add read-only grok doctor
- Sandbox: apply Landlock without a controlling TTY
- Pager: recover image paste over grok wrap on headless remotes
- Pager: make actions screen-mode aware
- Shell: resume sessions when the working directory moves
- Pager: centralize terminal diagnostics
- Workspace: gate inline shell file access
- Pager: centralize terminal probes
- Pager: edit minimal prompts in an external editor
- Pager: standardize backgrounding on Ctrl+B
- Shell: recap rides the parent turn's prompt cache
- Tools: add scheduler lifecycle version clock

Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
grokkybara[bot] 2026-07-21 18:10:23 +00:00
commit 3af4d5d398
556 changed files with 56609 additions and 21892 deletions

View file

@ -7,7 +7,7 @@
//! 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::atomic::{AtomicBool, AtomicU16, AtomicUsize, Ordering};
use std::sync::mpsc::TrySendError;
use std::thread::{self, JoinHandle};
use std::time::Duration;
@ -23,9 +23,15 @@ 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
@ -76,9 +82,11 @@ 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, ready_tx);
run_capture_loop(sample_rate, sync_tx, stop_flag, peak_cb, ready_tx);
});
// Wait briefly for the device to actually open (mirrors the STT
@ -106,9 +114,33 @@ pub fn spawn_pcm_capture(
stop,
thread: Some(thread),
bridge,
peak,
})
}
/// Default cpal input device, or a config error when the host has none.
fn default_input_device() -> Result<cpal::Device, VoiceError> {
cpal::default_host()
.default_input_device()
.ok_or_else(|| VoiceError::Config("no default input audio device".into()))
}
/// Default input device without opening a stream ([`crate::probe::input_device_info`]).
pub fn input_device_info() -> Result<crate::probe::InputDeviceInfo, VoiceError> {
let device = default_input_device()?;
let name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
let detail = match device.default_input_config() {
Ok(c) => format!(
"{} Hz, {} ch, {:?}",
c.sample_rate().0,
c.channels(),
c.sample_format()
),
Err(e) => format!("default config unavailable: {e}"),
};
Ok(crate::probe::InputDeviceInfo { name, detail })
}
/// Record mono PCM16 LE for a fixed duration (probe / diagnostics).
pub fn capture_pcm_for_duration(
sample_rate: u32,
@ -119,7 +151,14 @@ 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 || {
run_capture_loop(sample_rate, sync_tx, stop_flag, ready_tx);
// Duration probe does not read the session peak.
run_capture_loop(
sample_rate,
sync_tx,
stop_flag,
Arc::new(AtomicU16::new(0)),
ready_tx,
);
});
// Surface device-open failures before recording instead of returning empty.
@ -159,6 +198,7 @@ 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>,
@ -168,6 +208,7 @@ 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));
@ -179,6 +220,7 @@ fn run_capture_loop(
sample_rate,
sync_tx,
Arc::clone(&stop),
peak,
Arc::clone(&dropped),
) {
Ok(v) => {
@ -202,12 +244,10 @@ 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 host = cpal::default_host();
let device = host
.default_input_device()
.ok_or_else(|| VoiceError::Config("no default input audio device".into()))?;
let device = default_input_device()?;
let device_name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
@ -244,6 +284,7 @@ fn open_capture_stream(
target_rate: sample_rate,
sync_tx,
stop,
peak,
dropped,
};
@ -350,6 +391,7 @@ where
target_rate,
sync_tx,
stop,
peak,
dropped,
} = params;
let channels = in_channels as usize;
@ -371,17 +413,7 @@ where
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(_)) => {}
}
meter_and_send(&pcm, &peak, &sync_tx, &dropped);
},
|err| {
tracing::warn!(error = %err, "voice capture stream error");
@ -393,6 +425,25 @@ 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);
let bytes: Vec<u8> = pcm.iter().flat_map(|s| s.to_le_bytes()).collect();
match sync_tx.try_send(bytes) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
dropped.fetch_add(1, Ordering::Relaxed);
}
Err(TrySendError::Disconnected(_)) => {}
}
}
fn frames_to_mono_i16<T>(data: &[T], channels: usize) -> Vec<i16>
where
T: Sample,
@ -447,6 +498,18 @@ fn resample_mono_i16(samples: &[i16], input_rate: u32, output_rate: u32) -> Vec<
mod tests {
use super::*;
#[test]
fn meter_and_send_meters_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
assert_eq!(dropped.load(Ordering::Relaxed), 1);
assert_eq!(peak.load(Ordering::Relaxed), 9_000);
}
#[test]
fn resample_halves_rate() {
let input: Vec<i16> = (0..48).map(|i| (i * 100) as i16).collect();

View file

@ -17,8 +17,8 @@
//! 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::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
@ -125,17 +125,22 @@ fn binary_on_path(name: &str) -> bool {
})
}
/// 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(|| {
/// The detected recorder, or a `VoiceError` naming the packages to install.
fn require_recorder() -> Result<Recorder, VoiceError> {
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(),
)
})?;
})
}
/// 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 = require_recorder()?;
let mut child = Command::new(recorder.program())
.args(recorder.args(sample_rate))
@ -177,9 +182,15 @@ pub struct CaptureHandle {
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.
///
@ -237,8 +248,11 @@ pub fn spawn_pcm_capture(
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, device));
let reader =
thread::spawn(move || forward_pcm(stdout, pcm_tx, stop_reader, peak_reader, device));
tracing::info!(
recorder = recorder.program(),
@ -250,6 +264,7 @@ pub fn spawn_pcm_capture(
child: Some(child),
stop,
reader: Some(reader),
peak,
})
}
@ -275,10 +290,12 @@ fn drain_stderr(child: &mut Child, device: &'static str) {
/// 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: ChildStdout,
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];
@ -291,6 +308,8 @@ fn forward_pcm(
// 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 —
@ -319,6 +338,15 @@ fn forward_pcm(
}
}
/// Recorder that would be spawned, without recording ([`crate::probe::input_device_info`]).
pub fn input_device_info() -> Result<crate::probe::InputDeviceInfo, VoiceError> {
let recorder = require_recorder()?;
Ok(crate::probe::InputDeviceInfo {
name: recorder.program().to_string(),
detail: "system recorder; uses the audio server's default input".to_string(),
})
}
/// Record mono PCM16 LE for a fixed duration (probe / diagnostics).
pub fn capture_pcm_for_duration(
sample_rate: u32,
@ -379,6 +407,30 @@ 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);

View file

@ -10,9 +10,11 @@
#[cfg(not(target_os = "linux"))]
mod capture;
#[cfg(not(target_os = "linux"))]
pub use capture::{CaptureHandle, capture_pcm_for_duration, spawn_pcm_capture};
pub use capture::{CaptureHandle, capture_pcm_for_duration, input_device_info, 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};
pub use capture_linux::{
CaptureHandle, capture_pcm_for_duration, input_device_info, spawn_pcm_capture,
};

View file

@ -7,6 +7,11 @@ pub enum VoiceEvent {
/// Utterance complete (`speech_final` on streaming STT, or batch result).
UtteranceFinal { text: String },
/// Non-fatal or fatal error from STT.
Error { message: String },
/// Non-fatal or fatal error from capture or STT.
Error {
/// Short description for a one-line toast.
message: String,
/// Optional longer fix steps, for surfaces that fit more than one line.
hint: Option<String>,
},
}

View file

@ -10,6 +10,7 @@ pub mod config;
pub mod error;
pub mod event;
pub mod language;
pub mod pcm;
pub mod pipeline;
pub mod probe;
pub mod stt;
@ -25,7 +26,10 @@ pub use language::{
pub use pipeline::{VoiceCommand, run_voice_pipeline};
#[cfg(feature = "audio")]
pub use probe::run_mic_only_probe;
pub use probe::{VoiceProbeOptions, VoiceProbeReport, format_probe_report, run_streaming_probe};
pub use probe::{
InputDeviceInfo, VoiceProbeOptions, VoiceProbeReport, format_probe_report, input_device_info,
run_streaming_probe,
};
/// Whether this build can capture microphone audio (the `audio` feature).
/// Production CLI builds enable it on every OS: macOS/Windows link `cpal`

View file

@ -0,0 +1,63 @@
//! 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));
}
}

View file

@ -7,6 +7,8 @@
#[cfg(feature = "audio")]
use std::collections::VecDeque;
#[cfg(feature = "audio")]
use std::sync::atomic::Ordering;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
@ -119,6 +121,7 @@ async fn open_session(
let _ = event_tx
.send(VoiceEvent::Error {
message: e.to_string(),
hint: None,
})
.await;
None
@ -190,6 +193,32 @@ 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.
#[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,
)
}
}
#[cfg(feature = "audio")]
async fn start_capture_session(
config: &VoiceConfig,
@ -205,10 +234,8 @@ async fn start_capture_session(
let capture_task =
tokio::task::spawn_blocking(move || crate::audio::spawn_pcm_capture(sample_rate, mic_tx));
// Start draining the mic *now* — before connect resolves — so the capture
// chain never backpressures (and cpal never drops chunks) while the socket
// comes up. `forward_pcm` buffers until the STT sender arrives, then flushes
// and streams live.
// Drain mic before connect resolves so capture never backpressures while
// the socket comes up.
let (audio_tx_tx, audio_tx_rx) = tokio::sync::oneshot::channel::<mpsc::Sender<Vec<u8>>>();
tokio::spawn(forward_pcm(mic_rx, audio_tx_rx));
@ -229,6 +256,7 @@ 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.
@ -250,12 +278,7 @@ async fn start_capture_session(
handle.stop();
}
};
// Surface a hint if nothing is transcribed within the first 10s of a
// session — the usual sign of a denied mic permission, a muted mic, or
// the wrong input device (on macOS a "healthy" stream still delivers
// silence until access is granted). The check applies only before the
// first transcript: once the user starts talking, pauses can be as long
// as they like.
// 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;
// Chunk-final (`is_final && !speech_final`) text is locked: the server
@ -279,18 +302,11 @@ async fn start_capture_session(
}
}
_ = tokio::time::sleep_until(silence_deadline), if silence_check => {
// Give up on this turn: stop the mic and flush `audio.done`
// so the session tears down instead of streaming silence
// until the pager sends stop (toggle off).
// Tear down rather than streaming silence until the user stops.
stop_capture(&mut capture);
stt.finish_audio();
let _ = out
.send(VoiceEvent::Error {
message: "no audio detected in 10s: check that your \
terminal has microphone permission"
.into(),
})
.await;
let (message, hint) = silence_guard_error(peak.load(Ordering::Relaxed));
let _ = out.send(VoiceEvent::Error { message, hint }).await;
return;
}
ev = stt.recv() => {
@ -337,7 +353,7 @@ async fn start_capture_session(
}
}
Some(StreamingSttEvent::Error { message }) => {
let _ = out.send(VoiceEvent::Error { message }).await;
let _ = out.send(VoiceEvent::Error { message, hint: None }).await;
return;
}
Some(StreamingSttEvent::Ready) | None => return,
@ -404,4 +420,18 @@ mod tests {
drop(tx_tx);
task.await.unwrap();
}
#[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());
}
}

View file

@ -1,4 +1,5 @@
//! End-to-end voice probe: mic → streaming STT → transcript (for local debugging).
//! Voice diagnostics: input-device lookup, silent-mic fix text, and an
//! end-to-end probe (mic → streaming STT → transcript).
#[cfg(feature = "audio")]
use std::sync::Arc;
@ -129,6 +130,46 @@ pub async fn run_streaming_probe(_opts: VoiceProbeOptions) -> Result<VoiceProbeR
))
}
/// Input device capture would use (cpal default, or Linux recorder name).
/// Available without `audio` so `/terminal-setup` compiles in no-audio builds.
#[derive(Debug, Clone)]
pub struct InputDeviceInfo {
pub name: String,
pub detail: String,
}
/// Look up the input device without opening a stream (does not trigger the
/// macOS mic-permission prompt).
#[cfg(feature = "audio")]
pub fn input_device_info() -> Result<InputDeviceInfo, VoiceError> {
crate::audio::input_device_info()
}
#[cfg(not(feature = "audio"))]
pub fn input_device_info() -> Result<InputDeviceInfo, VoiceError> {
Err(VoiceError::Config(
"voice audio capture disabled (build without `audio` feature)".into(),
))
}
/// 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 {
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."
} else if cfg!(target_os = "windows") {
"allow microphone access in Settings → Privacy & security → \
Microphone, and check the input device and level in Settings \
System Sound."
} else {
"check the default input device and its volume in your sound settings \
(e.g. `pavucontrol`, or `wpctl status` on PipeWire)."
}
}
/// Human-readable multi-line report for terminal output.
pub fn format_probe_report(report: &VoiceProbeReport) -> String {
let mut out = String::from("=== xai-grok-voice probe ===\n\n");