Skip to content

Commit 4d626bb

Browse files
committed
fix(vad): stop fragmenting live speech and correct the flush timestamp
Two defects in the VAD path that together produced short, silence-heavy audio clips and one corrupt segment per recording. 1. Live redemption time was 400ms `pipeline.rs` passed 400ms while `import.rs` and `retranscription.rs` both use 2000ms, so live recording and offline re-transcription of the same audio segmented completely differently. The platform ternary was also dead (`if macos { 400 } else { 400 }`), and `vad.rs` carried a comment claiming the pipeline already passed 2000ms. Redemption time decides how long a silence must be before a speech segment is closed, and every segment becomes its own ASR request. At 400ms a 26-minute meeting was split into 322 requests with a median length of 3.5s. Whisper is a fixed 30-second-window model: below that it zero-pads the window and falls back on its language-model prior, which was trained on web subtitles, so short clips return memorised boilerplate ("subscribe to the channel", "thank you") rather than speech. On a real recording 47% of segment boundaries sat in the 0.42-0.75s range, i.e. mid-sentence breaths a longer redemption simply bridges. Now uses a named constant kept equal to the offline paths. Costs up to ~1.6s of additional live-transcript latency at the end of each utterance. 2. `speech_start_sample` double-counted the session position It was computed as `processed_samples + timestamp_ms`, but silero's `timestamp_ms` is already session-absolute (`processed_duration()` minus `pre_speech_pad`), so the two absolute values were summed and the start position came out at roughly 2x the truth. The error grows with how late in the session the utterance begins. The only reader is the force-end branch in `flush()`, so it surfaced once per recording, on the final segment: a phantom row timestamped past the end of the audio, which sorted to the end of the stored transcript. Observed on a 1539.7s recording as a segment starting at 3083.1s. Adds two regression tests, both verified to fail before the change and pass after. They use a leading-silence-then-speech fixture because the force-end path only runs when recording stops mid-utterance, and the error is only large enough to violate the invariant when speech starts late. Also promotes VAD_SAMPLE_RATE to a module constant, since every sample count and timestamp in the module is expressed in it.
1 parent 7c94aa6 commit 4d626bb

2 files changed

Lines changed: 144 additions & 21 deletions

File tree

frontend/src-tauri/src/audio/pipeline.rs

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ use super::recording_state::{AudioChunk, AudioError, RecordingState, DeviceType}
1313
use super::audio_processing::{audio_to_mono, LoudnessNormalizer, NoiseSuppressionProcessor, HighPassFilter};
1414
use super::vad::{ContinuousVadProcessor};
1515

16+
/// How long a silence must last before the VAD closes a speech segment, and
17+
/// therefore how long the audio clips handed to the ASR engine are.
18+
///
19+
/// Kept equal to `import.rs::VAD_REDEMPTION_TIME_MS` and
20+
/// `retranscription.rs::VAD_REDEMPTION_TIME_MS` so that live recording and
21+
/// offline re-transcription of the same audio segment identically. Raising this
22+
/// trades live-transcript latency for longer, more accurate ASR requests.
23+
const VAD_REDEMPTION_TIME_MS: u32 = 2000;
24+
1625
/// Ring buffer for synchronized audio mixing
1726
/// Accumulates samples from mic and system streams until we have aligned windows
1827
struct AudioMixerRingBuffer {
@@ -719,16 +728,34 @@ impl AudioPipeline {
719728
// For now, we log it for monitoring and potential optimization
720729
let _ = (mic_device_name, mic_device_kind, system_device_name, system_device_kind);
721730

722-
// Create VAD processor with balanced redemption time for speech accumulation
723-
// The VAD processor now handles 48kHz->16kHz resampling internally
724-
// This bridges natural pauses without excessive fragmentation
725-
// For mac os core audio, 900ms, for windows 400ms seems good
726-
727-
let redemption_time = if cfg!(target_os = "macos") { 400 } else { 400 };
728-
729-
let vad_processor = match ContinuousVadProcessor::new(sample_rate, redemption_time) {
731+
// Create VAD processor. The VAD processor handles 48kHz->16kHz resampling
732+
// internally.
733+
//
734+
// Redemption time is how long a silence must last before the VAD closes a
735+
// speech segment, so it decides how long the audio clips handed to the ASR
736+
// engine are. Conversational speech pauses constantly for breath and
737+
// mid-sentence thought, and every pause longer than this becomes a segment
738+
// boundary and therefore a separate transcription request.
739+
//
740+
// This was 400ms, which fragmented a 26-minute meeting into 322 requests with
741+
// a median length of 3.5s. Whisper is a fixed 30-second-window model: below
742+
// that it zero-pads the window and leans on its language-model prior, which
743+
// was trained on web subtitles, so short clips come back as memorised
744+
// boilerplate ("subscribe to the channel", "thank you") instead of speech.
745+
// Measured on a real recording, 47% of segment boundaries sat in the
746+
// 0.42-0.75s range that a longer redemption simply bridges.
747+
//
748+
// 2000ms matches what the offline paths already use (see
749+
// `import.rs::VAD_REDEMPTION_TIME_MS` and
750+
// `retranscription.rs::VAD_REDEMPTION_TIME_MS`), so live and batch
751+
// transcription now segment identically. It costs up to ~1.6s of extra
752+
// live-transcript latency at the end of each utterance.
753+
let vad_processor = match ContinuousVadProcessor::new(sample_rate, VAD_REDEMPTION_TIME_MS) {
730754
Ok(processor) => {
731-
info!("VAD-driven pipeline: VAD segments will be sent directly to Whisper (no time-based accumulation)");
755+
info!(
756+
"VAD-driven pipeline: segments dispatched per speech burst (redemption_time={}ms)",
757+
VAD_REDEMPTION_TIME_MS
758+
);
732759
processor
733760
}
734761
Err(e) => {

frontend/src-tauri/src/audio/vad.rs

Lines changed: 108 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ use log::{debug, info, warn};
44
use std::collections::VecDeque;
55
use std::time::Duration;
66

7+
/// Silero VAD only operates at 16kHz; input is resampled to this rate, and every
8+
/// sample count and timestamp inside this module is expressed in it.
9+
const VAD_SAMPLE_RATE: u32 = 16000;
10+
711
/// Represents a complete speech segment detected by VAD
812
#[derive(Debug, Clone)]
913
pub struct SpeechSegment {
@@ -30,9 +34,6 @@ pub struct ContinuousVadProcessor {
3034

3135
impl ContinuousVadProcessor {
3236
pub fn new(input_sample_rate: u32, redemption_time_ms: u32) -> Result<Self> {
33-
// Silero VAD MUST use 16kHz - this is hardcoded requirement
34-
const VAD_SAMPLE_RATE: u32 = 16000;
35-
3637
// Use STRICT settings to prevent silence from reaching Whisper
3738
let mut config = VadConfig::default();
3839
config.sample_rate = VAD_SAMPLE_RATE as usize;
@@ -43,9 +44,10 @@ impl ContinuousVadProcessor {
4344
config.positive_speech_threshold = 0.50; // Silero default - good for continuous speech
4445
config.negative_speech_threshold = 0.35; // Silero default - allows natural pauses
4546

46-
// CRITICAL FIX: Removed redemption_time capping to support long continuous speech
47-
// Previous: capped at 400ms, causing VAD to fragment 5-second speech into 40ms segments
48-
// New: Use full redemption_time from pipeline (2000ms) to bridge natural pauses
47+
// Use the caller's redemption_time uncapped, so long continuous speech stays
48+
// in one segment. Callers pass 2000ms (see `pipeline.rs`, `import.rs` and
49+
// `retranscription.rs`); shorter values fragment natural speech at every
50+
// mid-sentence breath.
4951
config.redemption_time = Duration::from_millis(redemption_time_ms as u64);
5052
config.pre_speech_pad = Duration::from_millis(300); // Pre-speech padding for context
5153
config.post_speech_pad = Duration::from_millis(400); // Increased: more context at end
@@ -236,11 +238,16 @@ impl ContinuousVadProcessor {
236238
self.last_logged_state = true;
237239
}
238240
self.in_speech = true;
239-
// Silero's timestamp_ms is already session-absolute (derived from its own
240-
// processed_duration), so it must NOT be offset by processed_samples —
241-
// adding them double-counts and yields start times past the end of the audio.
242-
// Convert ms to samples at the 16kHz VAD processing rate.
243-
self.speech_start_sample = timestamp_ms * 16000 / 1000;
241+
// `timestamp_ms` is ALREADY session-absolute: silero computes it as
242+
// `processed_duration() - pre_speech_pad`, where `processed_duration()`
243+
// is every sample the network has seen this session. Adding our own
244+
// session-absolute `processed_samples` to it double-counted the
245+
// position, producing a start timestamp of roughly 2x the true one.
246+
//
247+
// The only reader is the end-of-recording flush below, so the bug
248+
// surfaced once per recording, on the final segment — which landed at
249+
// ~2x the file duration and sorted to the end of the transcript.
250+
self.speech_start_sample = timestamp_ms * VAD_SAMPLE_RATE as usize / 1000;
244251
self.current_speech.clear();
245252
}
246253
VadTransition::SpeechEnd { start_timestamp_ms, end_timestamp_ms, samples } => {
@@ -594,5 +601,94 @@ mod tests {
594601
assert!(duration_ms >= 200.0, "Segment {} too short: {:.0}ms", i, duration_ms);
595602
}
596603
}
597-
}
604+
/// Leading silence, then speech that runs to the end of the buffer.
605+
///
606+
/// This is the shape that matters for the flush path: an utterance that begins
607+
/// late in a long session and is still in progress when recording stops.
608+
fn generate_late_speech_audio(
609+
silence_seconds: f32,
610+
speech_seconds: f32,
611+
sample_rate: u32,
612+
) -> Vec<f32> {
613+
let silence_samples = (silence_seconds * sample_rate as f32) as usize;
614+
let speech = generate_test_audio_with_speech(speech_seconds, sample_rate);
615+
616+
let mut samples = vec![0.0f32; silence_samples];
617+
samples.extend_from_slice(&speech);
618+
samples
619+
}
620+
621+
/// `speech_start_sample` records where the current utterance began, so it can
622+
/// never point past the number of samples the VAD has actually seen.
623+
///
624+
/// It used to, because it was computed as `processed_samples + timestamp_ms` where
625+
/// silero's `timestamp_ms` is ALREADY session-absolute
626+
/// (`processed_duration() - pre_speech_pad`), which doubled the position. The only
627+
/// reader is the force-end branch in `flush()`, so in production the corruption
628+
/// escaped as one phantom segment per recording, timestamped past the end of the
629+
/// audio. The error grows with how late the utterance starts, which is why it took
630+
/// a long recording to surface.
631+
#[test]
632+
fn test_speech_start_sample_never_exceeds_processed_samples() {
633+
// 20s of silence, then 3s of speech still running when the buffer ends.
634+
let audio = generate_late_speech_audio(20.0, 3.0, 16000);
635+
636+
let mut processor =
637+
ContinuousVadProcessor::new(16000, 2000).expect("Failed to create processor");
638+
processor
639+
.process_audio(&audio)
640+
.expect("process_audio failed");
641+
642+
assert!(
643+
processor.in_speech,
644+
"expected to still be mid-speech at the end of the buffer; the invariant \
645+
below would not be exercised otherwise"
646+
);
598647

648+
assert!(
649+
processor.speech_start_sample <= processor.processed_samples,
650+
"speech_start_sample ({}) is past processed_samples ({}) - \
651+
session-absolute timestamp double-count regression. \
652+
In seconds: start={:.2}s vs processed={:.2}s",
653+
processor.speech_start_sample,
654+
processor.processed_samples,
655+
processor.speech_start_sample as f64 / VAD_SAMPLE_RATE as f64,
656+
processor.processed_samples as f64 / VAD_SAMPLE_RATE as f64,
657+
);
658+
}
659+
660+
/// Whatever `flush()` emits must also lie inside the audio that was supplied.
661+
#[test]
662+
fn test_flush_segment_timestamps_stay_within_audio_duration() {
663+
let audio = generate_late_speech_audio(20.0, 3.0, 16000);
664+
let audio_duration_ms = (audio.len() as f64 / 16000.0) * 1000.0;
665+
666+
let mut processor =
667+
ContinuousVadProcessor::new(16000, 2000).expect("Failed to create processor");
668+
669+
let mut segments = processor
670+
.process_audio(&audio)
671+
.expect("process_audio failed");
672+
let flushed = processor.flush().expect("flush failed");
673+
assert!(
674+
!flushed.is_empty(),
675+
"flush() emitted nothing, so the force-end path under test never ran"
676+
);
677+
segments.extend(flushed);
678+
679+
for (i, seg) in segments.iter().enumerate() {
680+
assert!(
681+
seg.start_timestamp_ms <= audio_duration_ms,
682+
"Segment {i} starts at {:.0}ms, beyond the {:.0}ms of audio supplied",
683+
seg.start_timestamp_ms,
684+
audio_duration_ms
685+
);
686+
assert!(
687+
seg.end_timestamp_ms >= seg.start_timestamp_ms,
688+
"Segment {i} ends before it starts: {:.0}ms -> {:.0}ms",
689+
seg.start_timestamp_ms,
690+
seg.end_timestamp_ms
691+
);
692+
}
693+
}
694+
}

0 commit comments

Comments
 (0)