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:
2026-05-13 14:39:14 +01:00
parent 5725836f40
commit 094b533ef2
3 changed files with 128 additions and 21 deletions

View File

@@ -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;