agent: code-atomiser-fix — surface capture-thread drops + bypass validation requeue cap
dropped_chunks was incremented on cpal-callback channel-full and validation requeue overflow but never read by the live session, so the UI's dropped_audio_ms missed callback-level losses entirely. Architecture doc had flagged this as a TODO. Also: the 350ms validation buffer was requeued via try_send into the same 32-slot channel, silently dropping past the cap on small-buffer audio hosts (WASAPI exclusive, low-latency ALSA at 256 frames -> ~65 chunks). Fix: live runtime reads MicrophoneCapture::dropped_chunks() on each recv_audio tick (LiveSessionRuntime::poll_capture_drops) and converts the per-chunk-duration delta into the dropped_audio_ms surfaced to the UI overload status. Per-chunk duration is derived from the most recent AudioChunk's sample_rate + samples-per-channel so it adapts to whatever rate cpal is delivering at. Validation requeue moved from try_send into the bounded channel onto a VecDeque<AudioChunk> returned alongside the Receiver; ActiveCapture drains the replay buffer before reading rx in recv_audio, bypassing the 32-slot cap entirely. Architecture doc updated to remove the TODO and document the new pre-roll path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
@@ -165,26 +165,40 @@ struct LiveSessionSummary {
|
||||
/// explicit and locally structured.
|
||||
struct ActiveCapture {
|
||||
/// Keeping the capture handle alive keeps the underlying cpal stream alive.
|
||||
_capture: MicrophoneCapture,
|
||||
capture: MicrophoneCapture,
|
||||
rx: std::sync::mpsc::Receiver<AudioChunk>,
|
||||
mic_error_rx: Option<std::sync::mpsc::Receiver<CaptureRuntimeError>>,
|
||||
/// Pre-roll chunks collected during the 350ms device validation
|
||||
/// window. Drained BEFORE reading from `rx` so the head of the
|
||||
/// recording survives small-buffer hosts that would otherwise
|
||||
/// overflow the 32-slot capture channel during validation.
|
||||
replay_buffer: VecDeque<AudioChunk>,
|
||||
}
|
||||
|
||||
impl ActiveCapture {
|
||||
fn start(config: &StartLiveTranscriptionConfig) -> Result<Self, String> {
|
||||
let (mut capture, rx) = match config.microphone_device.as_deref() {
|
||||
let (mut capture, replay_buffer, rx) = match config.microphone_device.as_deref() {
|
||||
Some(name) if !name.is_empty() => MicrophoneCapture::start_with_device(name),
|
||||
_ => MicrophoneCapture::start(),
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mic_error_rx = capture.take_error_rx();
|
||||
Ok(Self {
|
||||
_capture: capture,
|
||||
capture,
|
||||
rx,
|
||||
mic_error_rx,
|
||||
replay_buffer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Cumulative chunks the cpal callback (and the validation
|
||||
/// pre-roll, when it overflows the channel cap) has dropped.
|
||||
/// The live runtime samples this on each recv tick to convert
|
||||
/// the delta into `dropped_audio_ms`.
|
||||
fn dropped_chunks(&self) -> u64 {
|
||||
self.capture.dropped_chunks()
|
||||
}
|
||||
|
||||
fn drain_runtime_errors(
|
||||
&mut self,
|
||||
session_id: u64,
|
||||
@@ -217,6 +231,19 @@ struct LiveLoopState {
|
||||
resampler_flushed: bool,
|
||||
result_listener_lost: bool,
|
||||
recent_segments: Vec<RecentTranscriptSegment>,
|
||||
/// Cumulative value of `MicrophoneCapture::dropped_chunks()` last
|
||||
/// observed by `poll_capture_drops`. Subtracted from the live
|
||||
/// reading to compute the per-tick delta added to
|
||||
/// `dropped_audio_ms`. Counts callback-level losses that the
|
||||
/// session's own overflow path can't see.
|
||||
last_dropped_chunks: u64,
|
||||
/// Most-recent chunk dimensions used to convert a `dropped_chunks`
|
||||
/// delta into milliseconds. Populated on the first received chunk
|
||||
/// and refreshed thereafter. Zeroed sentinel means "no chunk yet";
|
||||
/// drops observed before any chunk arrives are deferred until we
|
||||
/// know the device's native rate.
|
||||
last_chunk_samples_per_chan: u32,
|
||||
last_chunk_sample_rate: u32,
|
||||
}
|
||||
|
||||
impl LiveLoopState {
|
||||
@@ -276,6 +303,11 @@ impl LiveSessionRuntime {
|
||||
if let Some(chunk) = self.recv_audio()? {
|
||||
self.process_audio_chunk(chunk)?;
|
||||
}
|
||||
// Pick up cpal-callback drops AND validation-window
|
||||
// requeue drops that the live session's own buffer
|
||||
// overflow path can't see. Called after recv_audio so the
|
||||
// most recent chunk dimensions inform the ms conversion.
|
||||
self.poll_capture_drops();
|
||||
self.drop_pending_overflow();
|
||||
self.flush_tail_if_stopping()?;
|
||||
if self.dispatch_inference_if_ready() {
|
||||
@@ -306,6 +338,14 @@ impl LiveSessionRuntime {
|
||||
}
|
||||
|
||||
fn recv_audio(&mut self) -> Result<Option<AudioChunk>, String> {
|
||||
// Drain the validation pre-roll first. This is the head-of-the
|
||||
// -recording audio that was collected during the 350ms device
|
||||
// validation window; replaying it from a consumer-side VecDeque
|
||||
// bypasses the 32-slot channel cap that previously dropped half
|
||||
// of it silently on small-buffer hosts.
|
||||
if let Some(chunk) = self.capture.replay_buffer.pop_front() {
|
||||
return Ok(Some(chunk));
|
||||
}
|
||||
match self.capture.rx.recv_timeout(Duration::from_millis(25)) {
|
||||
Ok(chunk) => Ok(Some(chunk)),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Ok(None),
|
||||
@@ -321,6 +361,14 @@ impl LiveSessionRuntime {
|
||||
}
|
||||
|
||||
fn process_audio_chunk(&mut self, chunk: AudioChunk) -> Result<(), String> {
|
||||
// Remember native-rate dimensions so `poll_capture_drops` can
|
||||
// turn a chunks-dropped delta into milliseconds without
|
||||
// hard-coding host-buffer assumptions.
|
||||
let channels = chunk.channels.max(1) as u32;
|
||||
let total_samples = chunk.samples.len() as u32;
|
||||
self.state.last_chunk_sample_rate = chunk.sample_rate;
|
||||
self.state.last_chunk_samples_per_chan = total_samples / channels;
|
||||
|
||||
let mono = downmix_chunk(chunk.samples, chunk.channels as usize);
|
||||
let resampler = match &mut self.state.resampler {
|
||||
Some(resampler) => resampler,
|
||||
@@ -341,6 +389,48 @@ impl LiveSessionRuntime {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reconcile the live session's `dropped_audio_ms` against the
|
||||
/// capture thread's cpal-callback + validation-requeue drop
|
||||
/// counter. Without this the UI's reported drops only ever
|
||||
/// reflected the live runtime's own buffer overflow path, missing
|
||||
/// every callback-level loss caused by transient back-pressure or
|
||||
/// the 350ms validation pre-roll overflowing the channel cap on
|
||||
/// small-buffer audio hosts.
|
||||
fn poll_capture_drops(&mut self) {
|
||||
let now = self.capture.dropped_chunks();
|
||||
if now == self.state.last_dropped_chunks {
|
||||
return;
|
||||
}
|
||||
let delta = now.saturating_sub(self.state.last_dropped_chunks);
|
||||
self.state.last_dropped_chunks = now;
|
||||
|
||||
// Defer the conversion until we've seen at least one chunk so
|
||||
// the dimensions are real. Validation-window drops can fire
|
||||
// before any chunk reaches `process_audio_chunk`; they get
|
||||
// attributed at the next reconciliation once we know the rate.
|
||||
if self.state.last_chunk_sample_rate == 0
|
||||
|| self.state.last_chunk_samples_per_chan == 0
|
||||
{
|
||||
// Roll back the consumed delta so we re-observe it once
|
||||
// we have dimensions to convert it with.
|
||||
self.state.last_dropped_chunks = self.state.last_dropped_chunks.saturating_sub(delta);
|
||||
return;
|
||||
}
|
||||
|
||||
let per_chunk_ms = (self.state.last_chunk_samples_per_chan as u64 * 1000)
|
||||
/ self.state.last_chunk_sample_rate.max(1) as u64;
|
||||
let added_ms = delta.saturating_mul(per_chunk_ms);
|
||||
if added_ms == 0 {
|
||||
return;
|
||||
}
|
||||
self.state.dropped_audio_ms = self.state.dropped_audio_ms.saturating_add(added_ms);
|
||||
let _ = self.status_channel.send(LiveStatusMessage::Overload {
|
||||
session_id: self.session_id,
|
||||
dropped_audio_ms: self.state.dropped_audio_ms,
|
||||
message: "Microphone capture dropped audio chunks (downstream back-pressure)".into(),
|
||||
});
|
||||
}
|
||||
|
||||
fn drop_pending_overflow(&mut self) {
|
||||
if self.state.inflight.is_none() || self.state.capture_buffer.len() <= MAX_PENDING_SAMPLES {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user