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
(commits 96980c7 + 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:
2026-04-17 13:02:51 +01:00
parent 41db162041
commit 19a6b83cb2
3 changed files with 126 additions and 39 deletions

View File

@@ -44,6 +44,17 @@ pub struct DeviceInfo {
pub is_default: bool, pub is_default: bool,
} }
/// A non-fatal capture-time error emitted by the cpal stream callback after
/// `start()` has already returned. The live session subscribes to these via
/// `error_rx()` so the frontend can show a toast when the mic vanishes
/// mid-recording.
/// (Codex review 2026/04/17 M2)
#[derive(Debug, Clone)]
pub struct CaptureRuntimeError {
pub device_name: String,
pub message: String,
}
/// Manages microphone capture via cpal. /// Manages microphone capture via cpal.
pub struct MicrophoneCapture { pub struct MicrophoneCapture {
stream: Option<cpal::Stream>, stream: Option<cpal::Stream>,
@@ -52,6 +63,9 @@ pub struct MicrophoneCapture {
/// Counter incremented every time the capture callback drops a chunk /// Counter incremented every time the capture callback drops a chunk
/// because the channel was full. Read via `dropped_chunks()`. /// because the channel was full. Read via `dropped_chunks()`.
dropped_chunks: Arc<AtomicU64>, dropped_chunks: Arc<AtomicU64>,
/// Receiver for runtime stream errors (device unplugged, audio server
/// crash, etc.). The live session calls `error_rx()` once and listens.
error_rx: Option<mpsc::Receiver<CaptureRuntimeError>>,
} }
impl MicrophoneCapture { impl MicrophoneCapture {
@@ -62,6 +76,14 @@ impl MicrophoneCapture {
self.dropped_chunks.load(Ordering::Relaxed) self.dropped_chunks.load(Ordering::Relaxed)
} }
/// Take the runtime-error receiver. Can be called once per capture; the
/// caller (live session manager) drains it on its own cadence and surfaces
/// errors to the frontend. Returns None on the second call.
/// (Codex review 2026/04/17 M2)
pub fn take_error_rx(&mut self) -> Option<mpsc::Receiver<CaptureRuntimeError>> {
self.error_rx.take()
}
/// Enumerate every input device the host knows about, with the metadata /// Enumerate every input device the host knows about, with the metadata
/// needed by the device-picker UI. /// needed by the device-picker UI.
pub fn list_devices() -> Result<Vec<DeviceInfo>> { pub fn list_devices() -> Result<Vec<DeviceInfo>> {
@@ -254,11 +276,16 @@ fn open_and_validate(
let (tx, rx) = mpsc::sync_channel::<AudioChunk>(AUDIO_CHANNEL_CAPACITY); let (tx, rx) = mpsc::sync_channel::<AudioChunk>(AUDIO_CHANNEL_CAPACITY);
let requeue_tx = tx.clone(); let requeue_tx = tx.clone();
let dropped_chunks = Arc::new(AtomicU64::new(0)); let dropped_chunks = Arc::new(AtomicU64::new(0));
// Bounded channel for runtime stream errors. Capacity 16 = plenty for
// the rare error case; if it ever fills, we drop newer errors silently
// because they would be redundant noise in a stream that is already
// failing. (Codex review 2026/04/17 M2)
let (err_tx, err_rx) = mpsc::sync_channel::<CaptureRuntimeError>(16);
let stream = match format { let stream = match format {
SampleFormat::F32 => build_input_stream::<f32>(&device, &config, sample_rate, channels, tx, dropped_chunks.clone()), SampleFormat::F32 => build_input_stream::<f32>(&device, &config, sample_rate, channels, tx, dropped_chunks.clone(), err_tx.clone(), name.to_string()),
SampleFormat::I16 => build_input_stream::<i16>(&device, &config, sample_rate, channels, tx, dropped_chunks.clone()), SampleFormat::I16 => build_input_stream::<i16>(&device, &config, sample_rate, channels, tx, dropped_chunks.clone(), err_tx.clone(), name.to_string()),
SampleFormat::U16 => build_input_stream::<u16>(&device, &config, sample_rate, channels, tx, dropped_chunks.clone()), SampleFormat::U16 => build_input_stream::<u16>(&device, &config, sample_rate, channels, tx, dropped_chunks.clone(), err_tx.clone(), name.to_string()),
other => { other => {
return Err(KonError::AudioCaptureFailed(format!( return Err(KonError::AudioCaptureFailed(format!(
"unsupported sample format {other:?}" "unsupported sample format {other:?}"
@@ -313,9 +340,25 @@ fn open_and_validate(
))); )));
} }
// Re-queue the collected chunks so downstream gets them. // Even in the fallback pass (require_audio=false), reject completely
// dead-zero audio. PulseAudio/PipeWire will sometimes happily emit a
// long stream of f32 zeros from a borked device — that is worse than
// failing fast. (Codex review 2026/04/17 D3)
const DEAD_SILENCE_FLOOR: f32 = 1e-7;
if rms < DEAD_SILENCE_FLOOR {
return Err(KonError::AudioCaptureFailed(format!(
"device produced dead silence (rms={rms:.6e} below absolute floor {DEAD_SILENCE_FLOOR:.6e})"
)));
}
// Re-queue the collected chunks so downstream gets them. Count any
// drops here against the same `dropped_chunks` counter so the live
// session sees them and can warn the user.
// (Codex review 2026/04/17 M1)
for chunk in collected { for chunk in collected {
let _ = requeue_tx.try_send(chunk); if requeue_tx.try_send(chunk).is_err() {
dropped_chunks.fetch_add(1, Ordering::Relaxed);
}
} }
eprintln!("[kon-audio] selected microphone: '{name}'"); eprintln!("[kon-audio] selected microphone: '{name}'");
@@ -324,11 +367,13 @@ fn open_and_validate(
stream: Some(stream), stream: Some(stream),
device_name: name.to_string(), device_name: name.to_string(),
dropped_chunks, dropped_chunks,
error_rx: Some(err_rx),
}, },
rx, rx,
)) ))
} }
#[allow(clippy::too_many_arguments)]
fn build_input_stream<T>( fn build_input_stream<T>(
device: &cpal::Device, device: &cpal::Device,
supported_config: &cpal::SupportedStreamConfig, supported_config: &cpal::SupportedStreamConfig,
@@ -336,12 +381,15 @@ fn build_input_stream<T>(
channels: u16, channels: u16,
tx: mpsc::SyncSender<AudioChunk>, tx: mpsc::SyncSender<AudioChunk>,
dropped_chunks: Arc<AtomicU64>, dropped_chunks: Arc<AtomicU64>,
err_tx: mpsc::SyncSender<CaptureRuntimeError>,
device_name: String,
) -> std::result::Result<cpal::Stream, cpal::BuildStreamError> ) -> std::result::Result<cpal::Stream, cpal::BuildStreamError>
where where
T: Sample + SizedSample, T: Sample + SizedSample,
f32: FromSample<T>, f32: FromSample<T>,
{ {
let config: cpal::StreamConfig = supported_config.clone().into(); let config: cpal::StreamConfig = supported_config.clone().into();
let err_device_name = device_name.clone();
device.build_input_stream( device.build_input_stream(
&config, &config,
move |data: &[T], _| { move |data: &[T], _| {
@@ -358,7 +406,16 @@ where
dropped_chunks.fetch_add(1, Ordering::Relaxed); dropped_chunks.fetch_add(1, Ordering::Relaxed);
} }
}, },
|err| eprintln!("[kon-audio] capture error: {err}"), move |err| {
// Surface stream errors to the live session via err_tx so the
// frontend can show a toast. Also keep the eprintln for ops
// logs. (Codex review 2026/04/17 M2)
eprintln!("[kon-audio] capture error: {err}");
let _ = err_tx.try_send(CaptureRuntimeError {
device_name: err_device_name.clone(),
message: err.to_string(),
});
},
None, None,
) )
} }

View File

@@ -6,7 +6,7 @@ pub mod streaming_resample;
pub mod vad; pub mod vad;
pub mod wav; pub mod wav;
pub use capture::{AudioChunk, DeviceInfo, MicrophoneCapture}; pub use capture::{AudioChunk, CaptureRuntimeError, DeviceInfo, MicrophoneCapture};
pub use concurrency::decode_and_resample; pub use concurrency::decode_and_resample;
pub use decode::decode_audio_file; pub use decode::decode_audio_file;
pub use resample::resample_to_16khz; pub use resample::resample_to_16khz;

View File

@@ -53,15 +53,28 @@ pub async fn start_native_capture(
device_name.as_deref().unwrap_or("<auto>") 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() { if let Some(tx) = state.stop_tx.lock().unwrap().take() {
let _ = tx.try_send(());
drop(tx); drop(tx);
} }
let (capture, rx) = match device_name.as_deref() { // `MicrophoneCapture::start()` is synchronous and may spend up to
Some(name) if !name.is_empty() => MicrophoneCapture::start_with_device(name), // DEVICE_VALIDATION_MS per device per pass (350ms × N devices × 2 passes
_ => MicrophoneCapture::start(), // 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| { .map_err(|e| {
eprintln!("[native-capture] MicrophoneCapture::start failed: {e}"); eprintln!("[native-capture] MicrophoneCapture::start failed: {e}");
e.to_string() e.to_string()
@@ -95,38 +108,52 @@ pub async fn start_native_capture(
break; 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; let mut got_data = false;
while let Ok(chunk) = rx.try_recv() { let mut capture_dead = false;
got_data = true; loop {
let sample_rate = chunk.sample_rate; match rx.try_recv() {
let channels = chunk.channels as usize; Ok(chunk) => {
got_data = true;
let sample_rate = chunk.sample_rate;
let channels = chunk.channels as usize;
// Downmix to mono if stereo // Downmix to mono if stereo
let mono: Vec<f32> = if channels > 1 { let mono: Vec<f32> = if channels > 1 {
chunk chunk
.samples .samples
.chunks(channels) .chunks(channels)
.map(|frame| frame.iter().sum::<f32>() / channels as f32) .map(|frame| frame.iter().sum::<f32>() / channels as f32)
.collect() .collect()
} else { } else {
chunk.samples chunk.samples
}; };
// Downsample to 16kHz using simple decimation // Downsample to 16kHz using simple decimation
// (acceptable quality for speech — same approach as pcm-processor.js) // (acceptable quality for speech — same approach as pcm-processor.js)
let ratio = sample_rate as f64 / WHISPER_SAMPLE_RATE as f64; let ratio = sample_rate as f64 / WHISPER_SAMPLE_RATE as f64;
if (ratio - 1.0).abs() < 0.01 { if (ratio - 1.0).abs() < 0.01 {
pcm_buffer.extend_from_slice(&mono); pcm_buffer.extend_from_slice(&mono);
} else { } else {
let mut pos: f64 = 0.0; let mut pos: f64 = 0.0;
for &s in &mono { for &s in &mono {
pos += 1.0; pos += 1.0;
if pos >= ratio { if pos >= ratio {
pcm_buffer.push(s); pcm_buffer.push(s);
pos -= ratio; 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 { if !got_data {
// Avoid busy-spinning when no audio data is available // Avoid busy-spinning when no audio data is available
tokio::time::sleep(std::time::Duration::from_millis(10)).await; tokio::time::sleep(std::time::Duration::from_millis(10)).await;