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:
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user