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:
1 parent
60ae97c771
commit
6cea767dec
1 file changed
+37
-13
+37
-13
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user