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
207 lines
7 KiB
Rust
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
|
|
}
|