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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue