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