fix(audio): drain mic device-paced during silence to stop playback skips/ticks (FR-CAP-7)

The capture-silence fix metered the mic pull by wall-clock, but the mic fills
the bridge at its own device clock. Over a long silent stretch they drifted, the
bridge backed up to its 0.5s cap and dropped oldest samples -> the recording
skipped/'fast-forwarded' late in the gap. And a 10ms idle threshold punched
silence into the loopback on brief inter-packet stalls -> audible ticks. Now:
drain the whole bridge (device-paced, no overflow) and only synthesize after a
sustained 150ms gap.
This commit is contained in:
iamdoubz committed 2026-07-14 11:10:07 -05:00
1 parent 60ae97c771
commit 6cea767dec
1 file changed
+37 -13
+37 -13
View File
@@ -201,6 +201,18 @@ impl MicBridge {
}
}
}
/// Loopback idle path: take everything buffered at once, device-paced. The
/// idle writer must NOT meter the mic by wall-clock — that lets the buffer
/// back up to `cap` and drop the oldest samples, which sounds like the audio
/// skipping/fast-forwarding late in a long silent stretch. Draining fully
/// keeps the mic flowing at its own clock.
fn drain(&self) -> Vec<f32> {
self.buf
.lock()
.map(|mut buf| buf.drain(..).collect())
.unwrap_or_default()
}
}
/// Below this per-chunk RMS the mic is treated as silence/room noise and not
@@ -653,10 +665,12 @@ const LEVEL_EMIT_INTERVAL: Duration = Duration::from_millis(50);
/// the recording would stall and the bridged mic — the user talking over system
/// silence — would be lost as the MicBridge overflows. The loopback loop then
/// synthesizes silence for the elapsed gap and folds the mic in, keeping
/// `audio.wav` continuous and complete (FR-CAP-7). These bound one synthesized
/// chunk: skip gaps below MIN to avoid churn, and cap a chunk at MAX (≈ the
/// MicBridge's own ~0.5s depth, so we never request more mic than it holds).
const SILENCE_TICK_MIN: Duration = Duration::from_millis(10);
/// `audio.wav` continuous and complete (FR-CAP-7). MIN gates on a *sustained*
/// gap so brief inter-packet stalls during active playback don't punch silence
/// into the loopback (audible clicks); it stays well under the MicBridge's
/// ~0.5s cap so the mic can't back up and drop samples between drains. MAX caps
/// the silence padding written in one chunk.
const SILENCE_TICK_MIN: Duration = Duration::from_millis(150);
const SILENCE_TICK_MAX: Duration = Duration::from_millis(500);
/// Runs on a dedicated OS thread for the lifetime of a `CaptureHandle`. Owns the
@@ -766,21 +780,31 @@ fn capture_loop(
continue;
}
if queue.is_empty() {
// Loopback idle (nothing playing): keep audio.wav advancing with
// silence so the bridged mic (user talking over system silence) is
// preserved instead of lost to MicBridge overflow. Loopback +
// mic-enabled only; the mic capture stream has no writer, and a
// mic-off recording has nothing to preserve during silence.
// Loopback idle (nothing playing): keep audio.wav advancing so the
// bridged mic (user talking over system silence) is preserved
// instead of lost to MicBridge overflow. Only after a *sustained*
// gap, so brief inter-packet stalls during active playback don't
// punch silence into the loopback (clicks). Loopback + mic-enabled
// only; the mic capture stream has no writer.
if is_loopback && !paused.load(Ordering::Relaxed) {
if let (Some(w), Some(b)) = (writer.as_mut(), bridge) {
let elapsed = last_write.elapsed().min(SILENCE_TICK_MAX);
let elapsed = last_write.elapsed();
if elapsed >= SILENCE_TICK_MIN {
let rate = session.format.get_samplespersec() as u128;
let gap_frames = (elapsed.as_micros() * rate / 1_000_000) as usize;
// Drain the whole mic buffer (device-paced). Metering the
// pull by wall-clock let the buffer back up to its cap and
// drop the oldest samples — the audio skipped/fast-
// forwarded late in a long gap. Draining fully avoids that.
let mic = b.drain();
let rate = session.format.get_samplespersec() as usize;
// Advance the timeline by the wall-clock gap (capped), but
// never fewer frames than the mic we must write.
let gap_frames = (elapsed.min(SILENCE_TICK_MAX).as_micros() as usize
* rate
/ 1_000_000)
.max(mic.len());
let bpf = session.format.get_nchannels() as usize
* (session.format.get_bitspersample() as usize / 8);
let silence = vec![0u8; gap_frames * bpf];
let mic = b.pull(gap_frames);
let before = frames_written;
frames_written += write_wav_bytes(w, &silence, &session.format, &mic)?;
if let Some(ma) = mic_activity {