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:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
63
crates/codegen/xai-grok-voice/src/pcm.rs
Normal file
63
crates/codegen/xai-grok-voice/src/pcm.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue