audio: Day 2 — Codex follow-up hardening (channel disconnect, spawn_blocking, fallback silence guard, requeue counting, runtime error propagation)
Closes the 6 Codex review findings on the Day 1 mic-capture work (commits96980c7+41db162). Detail in output/reports/kon-codex-mic-capture-followup-2026-04-17.md (CORBEL workspace). src-tauri/src/commands/audio.rs: - D1: Sending an explicit stop signal before dropping stop_tx, so the accumulator task wakes up immediately rather than waiting for the Disconnected detection added below. - D2: Wrapping MicrophoneCapture::start() in tokio::task::spawn_blocking. start() can spend up to 350ms × N_devices × 2 passes; running it on the async runtime froze other Tauri commands. - M3: Match on rx.try_recv() error variants. Empty -> sleep + continue. Disconnected -> exit accumulator task immediately. Previous behaviour was an infinite loop after the capture stream died. crates/audio/src/capture.rs: - D3: Added DEAD_SILENCE_FLOOR (1e-7) gate that applies even in the fallback pass. PulseAudio/PipeWire can stream zero-valued bytes from a borked device; that is worse than failing fast. - M1: Validation requeue (`for chunk in collected { try_send }`) now counts drops in the same dropped_chunks counter. - M2: New CaptureRuntimeError type. The cpal stream error callback now forwards errors via a separate sync_channel (capacity 16) that the live session can drain via take_error_rx() and surface as toasts. Re-exported from kon_audio crate root. cargo check -p kon-audio passes clean. NOT YET DONE (M4): JACK-specific monitor name patterns. Defers until testing on a JACK host. Current is_monitor_name() may miss JACK conventions. Wiring the new error_rx into the live session and surfacing as toasts lands with the Day 3 error-toast system commit.
This commit is contained in:
@@ -53,15 +53,28 @@ pub async fn start_native_capture(
|
||||
device_name.as_deref().unwrap_or("<auto>")
|
||||
);
|
||||
|
||||
// Stop any existing capture
|
||||
// Stop any existing capture: send an explicit stop signal first, then
|
||||
// drop the sender. The accumulator task watches for `Disconnected` too,
|
||||
// but signalling explicitly avoids the brief race window.
|
||||
// (Codex review 2026/04/17 D1)
|
||||
if let Some(tx) = state.stop_tx.lock().unwrap().take() {
|
||||
let _ = tx.try_send(());
|
||||
drop(tx);
|
||||
}
|
||||
|
||||
let (capture, rx) = match device_name.as_deref() {
|
||||
Some(name) if !name.is_empty() => MicrophoneCapture::start_with_device(name),
|
||||
_ => MicrophoneCapture::start(),
|
||||
}
|
||||
// `MicrophoneCapture::start()` is synchronous and may spend up to
|
||||
// DEVICE_VALIDATION_MS per device per pass (350ms × N devices × 2 passes
|
||||
// worst case). Run on a blocking thread so the async runtime stays
|
||||
// responsive to other Tauri commands. (Codex review 2026/04/17 D2)
|
||||
let device_name_for_blocking = device_name.clone();
|
||||
let (capture, rx) = tokio::task::spawn_blocking(move || {
|
||||
match device_name_for_blocking.as_deref() {
|
||||
Some(name) if !name.is_empty() => MicrophoneCapture::start_with_device(name),
|
||||
_ => MicrophoneCapture::start(),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("audio task join error: {e}"))?
|
||||
.map_err(|e| {
|
||||
eprintln!("[native-capture] MicrophoneCapture::start failed: {e}");
|
||||
e.to_string()
|
||||
@@ -95,38 +108,52 @@ pub async fn start_native_capture(
|
||||
break;
|
||||
}
|
||||
|
||||
// Drain available audio chunks from cpal (non-blocking)
|
||||
// Drain available audio chunks from cpal (non-blocking).
|
||||
// Distinguish Empty (try again) from Disconnected (capture stream
|
||||
// is dead — exit the loop, don't spin forever).
|
||||
// (Codex review 2026/04/17 M3)
|
||||
let mut got_data = false;
|
||||
while let Ok(chunk) = rx.try_recv() {
|
||||
got_data = true;
|
||||
let sample_rate = chunk.sample_rate;
|
||||
let channels = chunk.channels as usize;
|
||||
let mut capture_dead = false;
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(chunk) => {
|
||||
got_data = true;
|
||||
let sample_rate = chunk.sample_rate;
|
||||
let channels = chunk.channels as usize;
|
||||
|
||||
// Downmix to mono if stereo
|
||||
let mono: Vec<f32> = if channels > 1 {
|
||||
chunk
|
||||
.samples
|
||||
.chunks(channels)
|
||||
.map(|frame| frame.iter().sum::<f32>() / channels as f32)
|
||||
.collect()
|
||||
} else {
|
||||
chunk.samples
|
||||
};
|
||||
// Downmix to mono if stereo
|
||||
let mono: Vec<f32> = if channels > 1 {
|
||||
chunk
|
||||
.samples
|
||||
.chunks(channels)
|
||||
.map(|frame| frame.iter().sum::<f32>() / channels as f32)
|
||||
.collect()
|
||||
} else {
|
||||
chunk.samples
|
||||
};
|
||||
|
||||
// Downsample to 16kHz using simple decimation
|
||||
// (acceptable quality for speech — same approach as pcm-processor.js)
|
||||
let ratio = sample_rate as f64 / WHISPER_SAMPLE_RATE as f64;
|
||||
if (ratio - 1.0).abs() < 0.01 {
|
||||
pcm_buffer.extend_from_slice(&mono);
|
||||
} else {
|
||||
let mut pos: f64 = 0.0;
|
||||
for &s in &mono {
|
||||
pos += 1.0;
|
||||
if pos >= ratio {
|
||||
pcm_buffer.push(s);
|
||||
pos -= ratio;
|
||||
// Downsample to 16kHz using simple decimation
|
||||
// (acceptable quality for speech — same approach as pcm-processor.js)
|
||||
let ratio = sample_rate as f64 / WHISPER_SAMPLE_RATE as f64;
|
||||
if (ratio - 1.0).abs() < 0.01 {
|
||||
pcm_buffer.extend_from_slice(&mono);
|
||||
} else {
|
||||
let mut pos: f64 = 0.0;
|
||||
for &s in &mono {
|
||||
pos += 1.0;
|
||||
if pos >= ratio {
|
||||
pcm_buffer.push(s);
|
||||
pos -= ratio;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => break,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
eprintln!("[native-capture] capture stream disconnected; accumulator exiting");
|
||||
capture_dead = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +171,9 @@ pub async fn start_native_capture(
|
||||
}));
|
||||
}
|
||||
|
||||
if capture_dead {
|
||||
break;
|
||||
}
|
||||
if !got_data {
|
||||
// Avoid busy-spinning when no audio data is available
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
Reference in New Issue
Block a user