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,3 +1,4 @@
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
@@ -140,7 +141,18 @@ impl MicrophoneCapture {
/// Start capturing from the device whose name matches `device_name` exactly.
/// If no match is found, returns an error rather than silently falling back.
pub fn start_with_device(device_name: &str) -> Result<(Self, mpsc::Receiver<AudioChunk>)> {
///
/// The returned tuple is `(capture, replay_buffer, rx)`:
/// - `replay_buffer` holds chunks observed during the 350ms
/// validation pre-roll. Consumers MUST drain it before reading
/// from `rx` so the head of the recording isn't lost on hosts
/// whose cpal buffer is small enough to overflow the 32-slot
/// channel during validation (WASAPI exclusive, low-latency
/// ALSA at 256 frames).
/// - `rx` is the live cpal callback channel.
pub fn start_with_device(
device_name: &str,
) -> Result<(Self, VecDeque<AudioChunk>, mpsc::Receiver<AudioChunk>)> {
let host = cpal::default_host();
let devices = host
.input_devices()
@@ -169,7 +181,7 @@ impl MicrophoneCapture {
/// a short window — this is what defeats the "silent monitor source wins" bug.
/// 4. If no non-monitor device produces real audio, fall back to monitor sources
/// as a last resort (with a clear log line). Never accept dead silence.
pub fn start() -> Result<(Self, mpsc::Receiver<AudioChunk>)> {
pub fn start() -> Result<(Self, VecDeque<AudioChunk>, mpsc::Receiver<AudioChunk>)> {
let host = cpal::default_host();
let default_name = host
.default_input_device()
@@ -358,7 +370,7 @@ fn open_and_validate(
device: cpal::Device,
name: &str,
require_audio: bool,
) -> Result<(MicrophoneCapture, mpsc::Receiver<AudioChunk>)> {
) -> Result<(MicrophoneCapture, VecDeque<AudioChunk>, mpsc::Receiver<AudioChunk>)> {
let config = device
.default_input_config()
.map_err(|e| Error::AudioCaptureFailed(format!("default_input_config: {e}")))?;
@@ -376,7 +388,6 @@ fn open_and_validate(
);
let (tx, rx) = mpsc::sync_channel::<AudioChunk>(AUDIO_CHANNEL_CAPACITY);
let requeue_tx = tx.clone();
let dropped_chunks = Arc::new(AtomicU64::new(0));
// Bounded channel for runtime stream errors. Capacity 32 = plenty for
// the rare error case; if it ever fills, drops are reported via stderr
@@ -490,16 +501,21 @@ fn open_and_validate(
)));
}
// 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.
for chunk in collected {
if requeue_tx.try_send(chunk).is_err() {
dropped_chunks.fetch_add(1, Ordering::Relaxed);
}
}
// Hand the validation pre-roll back to the consumer as a separate
// VecDeque rather than try_send-requeuing into the 32-slot channel.
// On small-buffer audio hosts (WASAPI exclusive at ~256 frames /
// low-latency ALSA) the 350ms window collects ~65 chunks; the old
// requeue path silently dropped roughly half of them, losing ~150ms
// from the head of every recording. The consumer-side drain
// bypasses the channel cap entirely.
let replay_buffer: VecDeque<AudioChunk> = collected.into_iter().collect();
tracing::info!(target: "lumotia_audio", device = %name, "selected microphone");
tracing::info!(
target: "lumotia_audio",
device = %name,
replay_chunks = replay_buffer.len(),
"selected microphone"
);
Ok((
MicrophoneCapture {
stream: Some(stream),
@@ -507,6 +523,7 @@ fn open_and_validate(
dropped_chunks,
error_rx: Some(err_rx),
},
replay_buffer,
rx,
))
}