grok-build-upstream-mirror/crates/codegen/xai-grok-voice/src/probe.rs
grokkybara[bot] 3af4d5d398 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
2026-07-21 18:10:23 +00:00

207 lines
7 KiB
Rust

//! 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;
#[cfg(feature = "audio")]
use std::sync::atomic::{AtomicUsize, Ordering};
#[cfg(feature = "audio")]
use std::time::Duration;
#[cfg(feature = "audio")]
use tokio::time::timeout;
use crate::auth::SharedVoiceAuth;
use crate::config::VoiceConfig;
use crate::error::VoiceError;
#[cfg(feature = "audio")]
use crate::stt::{StreamingSttEvent, StreamingSttSession};
/// Options for [`run_streaming_probe`].
#[derive(Debug, Clone)]
pub struct VoiceProbeOptions {
pub config: VoiceConfig,
pub auth: SharedVoiceAuth,
/// How long to capture microphone audio before `audio.done`.
pub capture_secs: u32,
}
/// Collected probe output.
#[derive(Debug)]
pub struct VoiceProbeReport {
pub pcm_bytes: usize,
pub stt_log: Vec<String>,
pub transcript: Option<String>,
}
/// Capture mic audio and stream it to xAI STT, reporting the transcript.
#[cfg(feature = "audio")]
pub async fn run_streaming_probe(opts: VoiceProbeOptions) -> Result<VoiceProbeReport, VoiceError> {
let bearer = crate::auth::require_bearer(&opts.auth).await?;
let mut stt = StreamingSttSession::connect(&opts.config, &bearer).await?;
let stt_tx = stt
.audio_sender()
.ok_or_else(|| VoiceError::Stt("STT audio sender unavailable".into()))?;
let byte_count = Arc::new(AtomicUsize::new(0));
let byte_count_cb = Arc::clone(&byte_count);
let (pcm_tx, pcm_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
let forward = tokio::spawn(async move {
let mut pcm_rx = pcm_rx;
while let Some(chunk) = pcm_rx.recv().await {
byte_count_cb.fetch_add(chunk.len(), Ordering::Relaxed);
if stt_tx.send(chunk).await.is_err() {
break;
}
}
});
let sample_rate = opts.config.sample_rate;
let secs = opts.capture_secs.max(1);
let capture = crate::audio::spawn_pcm_capture(sample_rate, pcm_tx)?;
tracing::info!(secs, "speak now — probe is listening");
tokio::time::sleep(Duration::from_secs(secs as u64)).await;
capture.stop();
let _ = forward.await;
stt.finish_audio();
let mut stt_log = Vec::new();
let mut transcript = None;
let deadline = Duration::from_secs(30);
loop {
let ev = match timeout(deadline, stt.recv()).await {
Ok(Some(ev)) => ev,
Ok(None) => {
stt_log.push("STT channel closed".into());
break;
}
Err(_) => {
stt_log.push("STT recv timed out (30s)".into());
break;
}
};
match &ev {
StreamingSttEvent::Ready => stt_log.push("STT: ready (transcript.created)".into()),
StreamingSttEvent::Partial(p) => {
stt_log.push(format!(
"STT: partial is_final={} speech_final={} text={:?}",
p.is_final, p.speech_final, p.text
));
if !p.text.trim().is_empty() && (p.speech_final || p.is_final) {
transcript = Some(p.text.clone());
}
}
StreamingSttEvent::Done { text } => {
stt_log.push(format!("STT: done text={:?}", text));
if !text.trim().is_empty() {
transcript = Some(text.clone());
}
break;
}
StreamingSttEvent::Error { message } => {
stt_log.push(format!("STT: error {message}"));
break;
}
}
}
let pcm_bytes = byte_count.load(Ordering::Relaxed);
Ok(VoiceProbeReport {
pcm_bytes,
stt_log,
transcript,
})
}
/// Record mic only (no STT) — quick hardware check.
#[cfg(feature = "audio")]
pub fn run_mic_only_probe(sample_rate: u32, seconds: u32) -> Result<(usize, u32), VoiceError> {
let (pcm, chunks) = crate::audio::capture_pcm_for_duration(sample_rate, seconds)?;
Ok((pcm.len(), chunks))
}
#[cfg(not(feature = "audio"))]
pub async fn run_streaming_probe(_opts: VoiceProbeOptions) -> Result<VoiceProbeReport, VoiceError> {
Err(VoiceError::Config(
"voice probe requires the `audio` feature (cpal)".into(),
))
}
/// 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");
out.push_str(&format!(
"Mic capture (streamed)\n pcm_bytes: {}\n",
report.pcm_bytes
));
if report.pcm_bytes == 0 {
out.push_str(" WARNING: no PCM captured — check mic permission / default input device\n");
} else {
let secs_approx = report.pcm_bytes as f64 / (16000.0 * 2.0);
out.push_str(&format!(
" approx duration: {secs_approx:.2}s @ 16kHz mono PCM16\n"
));
}
out.push_str("\nSTT events\n");
if report.stt_log.is_empty() {
out.push_str(" (none)\n");
} else {
for line in &report.stt_log {
out.push_str(&format!(" {line}\n"));
}
}
out.push_str("\nTranscript\n");
match &report.transcript {
Some(t) if !t.trim().is_empty() => out.push_str(&format!(" {t}\n")),
Some(_) => out.push_str(" (empty string)\n"),
None => out.push_str(" (none — STT returned no text)\n"),
}
out
}