Skip to main content

firered_vad/
event.rs

1//! Public value types: [`SpeechSegment`] (the primary output) and
2//! [`FrameResult`] (per-frame inspection data, available via
3//! [`crate::Vad::recent_frames`]).
4
5use core::{fmt, ops::Range, time::Duration};
6
7/// One closed continuous human-speech window on the stream timeline.
8///
9/// Slice the original PCM with [`Self::range_usize`] to recover the
10/// audio that triggered this segment.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct SpeechSegment {
13  start_sample: u64,
14  end_sample: u64,
15}
16
17impl fmt::Display for SpeechSegment {
18  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19    write!(
20      f,
21      "SpeechSegment {{ start: {:.3}s, end: {:.3}s, duration: {:.3}s }}",
22      self.start().as_secs_f64(),
23      self.end().as_secs_f64(),
24      self.duration().as_secs_f64(),
25    )
26  }
27}
28
29impl SpeechSegment {
30  /// The sample rate every emitted segment is referenced against.
31  pub const SAMPLE_RATE_HZ: u32 = 16_000;
32
33  /// Construct a segment from absolute sample indices.
34  ///
35  /// `end_sample` is exclusive (the first sample *after* the segment).
36  /// Public so it's easy to construct in tests; the `Vad` engine is the
37  /// only producer in normal use.
38  #[cfg_attr(not(tarpaulin), inline(always))]
39  pub const fn new(start_sample: u64, end_sample: u64) -> Self {
40    Self {
41      start_sample,
42      end_sample,
43    }
44  }
45
46  /// Absolute sample index where the segment starts (inclusive).
47  #[cfg_attr(not(tarpaulin), inline(always))]
48  pub const fn start_sample(&self) -> u64 {
49    self.start_sample
50  }
51
52  /// Absolute sample index where the segment ends (exclusive).
53  #[cfg_attr(not(tarpaulin), inline(always))]
54  pub const fn end_sample(&self) -> u64 {
55    self.end_sample
56  }
57
58  /// Number of samples spanned by this segment.
59  #[cfg_attr(not(tarpaulin), inline(always))]
60  pub const fn sample_count(&self) -> u64 {
61    self.end_sample.saturating_sub(self.start_sample)
62  }
63
64  /// Start time of the segment as a `Duration`.
65  pub fn start(&self) -> Duration {
66    Duration::from_secs_f64(self.start_sample as f64 / Self::SAMPLE_RATE_HZ as f64)
67  }
68
69  /// End time of the segment as a `Duration`.
70  pub fn end(&self) -> Duration {
71    Duration::from_secs_f64(self.end_sample as f64 / Self::SAMPLE_RATE_HZ as f64)
72  }
73
74  /// Duration of the segment.
75  pub fn duration(&self) -> Duration {
76    Duration::from_secs_f64(self.sample_count() as f64 / Self::SAMPLE_RATE_HZ as f64)
77  }
78
79  /// `Range<u64>` covering the segment, useful for arithmetic on absolute timelines.
80  #[cfg_attr(not(tarpaulin), inline(always))]
81  pub const fn range(&self) -> Range<u64> {
82    self.start_sample..self.end_sample
83  }
84
85  /// `Range<usize>` for slicing a `&[f32]` PCM buffer.
86  ///
87  /// On 64-bit targets this is identity. On 32-bit targets where a
88  /// `u64` segment index would exceed `usize::MAX` (≈ 37 hours of audio
89  /// at 16 kHz), the bound is **saturated** to `usize::MAX` rather than
90  /// silently wrapped — passing the resulting range to a slice
91  /// indexing operation will panic with an out-of-bounds error rather
92  /// than produce a corrupt slice.
93  #[cfg_attr(not(tarpaulin), inline(always))]
94  pub fn range_usize(&self) -> Range<usize> {
95    let to_usize = |v: u64| -> usize {
96      if v > usize::MAX as u64 {
97        usize::MAX
98      } else {
99        v as usize
100      }
101    };
102    to_usize(self.start_sample)..to_usize(self.end_sample)
103  }
104}
105
106/// Per-frame view of the streaming detector's internal state.
107///
108/// One `FrameResult` is produced for every 10 ms frame consumed by
109/// [`crate::Vad::push_samples`]. The slice from the most recent
110/// non-empty `push_samples` call is accessible via
111/// [`crate::Vad::recent_frames`].
112///
113/// Frame indices are 0-based; upstream Python uses 1-based indices and
114/// we shift on construction. `speech_start_frame` and
115/// `speech_end_frame` are the 0-based frame indices of the most-recent
116/// segment opening and closing seen so far (`None` until a segment has
117/// actually opened).
118#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct FrameResult {
120  frame_index: u64,
121  raw_prob: f32,
122  smoothed_prob: f32,
123  is_speech: bool,
124  is_speech_start: bool,
125  is_speech_end: bool,
126  speech_start_frame: Option<u64>,
127  speech_end_frame: Option<u64>,
128}
129
130impl FrameResult {
131  /// Frame shift in samples (`160` at 16 kHz, i.e. 10 ms).
132  pub const FRAME_SHIFT_SAMPLES: u32 = 160;
133  /// Sample rate in Hz (always `16_000`).
134  pub const SAMPLE_RATE_HZ: u32 = 16_000;
135
136  /// Construct a `FrameResult`. Public so it is easy to assemble in
137  /// tests; the `Vad` engine is the normal producer.
138  #[cfg_attr(not(tarpaulin), inline(always))]
139  #[allow(clippy::too_many_arguments)]
140  pub const fn new(
141    frame_index: u64,
142    raw_prob: f32,
143    smoothed_prob: f32,
144    is_speech: bool,
145    is_speech_start: bool,
146    is_speech_end: bool,
147    speech_start_frame: Option<u64>,
148    speech_end_frame: Option<u64>,
149  ) -> Self {
150    Self {
151      frame_index,
152      raw_prob,
153      smoothed_prob,
154      is_speech,
155      is_speech_start,
156      is_speech_end,
157      speech_start_frame,
158      speech_end_frame,
159    }
160  }
161
162  /// 0-based frame index.
163  #[cfg_attr(not(tarpaulin), inline(always))]
164  pub const fn frame_index(&self) -> u64 {
165    self.frame_index
166  }
167
168  /// Raw sigmoid output from the model for this frame.
169  #[cfg_attr(not(tarpaulin), inline(always))]
170  pub const fn raw_prob(&self) -> f32 {
171    self.raw_prob
172  }
173
174  /// Trailing moving-average of `raw_prob` over the configured smooth window.
175  #[cfg_attr(not(tarpaulin), inline(always))]
176  pub const fn smoothed_prob(&self) -> f32 {
177    self.smoothed_prob
178  }
179
180  /// Whether `smoothed_prob` exceeds the speech threshold.
181  #[cfg_attr(not(tarpaulin), inline(always))]
182  pub const fn is_speech(&self) -> bool {
183    self.is_speech
184  }
185
186  /// Whether this frame opened a new segment.
187  #[cfg_attr(not(tarpaulin), inline(always))]
188  pub const fn is_speech_start(&self) -> bool {
189    self.is_speech_start
190  }
191
192  /// Whether this frame closed an open segment.
193  #[cfg_attr(not(tarpaulin), inline(always))]
194  pub const fn is_speech_end(&self) -> bool {
195    self.is_speech_end
196  }
197
198  /// 0-based frame index of the most recent segment opening, if any.
199  #[cfg_attr(not(tarpaulin), inline(always))]
200  pub const fn speech_start_frame(&self) -> Option<u64> {
201    self.speech_start_frame
202  }
203
204  /// 0-based frame index of the most recent segment closing, if any.
205  #[cfg_attr(not(tarpaulin), inline(always))]
206  pub const fn speech_end_frame(&self) -> Option<u64> {
207    self.speech_end_frame
208  }
209
210  /// Timestamp at the *start* of this frame.
211  pub fn timestamp(&self) -> Duration {
212    let samples = self.frame_index * Self::FRAME_SHIFT_SAMPLES as u64;
213    Duration::from_secs_f64(samples as f64 / Self::SAMPLE_RATE_HZ as f64)
214  }
215
216  /// If this frame closes a segment, return it; otherwise `None`.
217  pub fn closed_segment(&self) -> Option<SpeechSegment> {
218    if !self.is_speech_end {
219      return None;
220    }
221    let start = self.speech_start_frame? * Self::FRAME_SHIFT_SAMPLES as u64;
222    let end = self.speech_end_frame? * Self::FRAME_SHIFT_SAMPLES as u64;
223    Some(SpeechSegment::new(start, end))
224  }
225}
226
227#[cfg(test)]
228mod tests {
229  use super::*;
230
231  #[test]
232  fn sample_count_is_end_minus_start() {
233    let s = SpeechSegment::new(160, 1600);
234    assert_eq!(s.sample_count(), 1440);
235  }
236
237  #[test]
238  fn timestamps_round_trip_through_sample_rate() {
239    let s = SpeechSegment::new(16_000, 32_000);
240    assert_eq!(s.start(), Duration::from_secs(1));
241    assert_eq!(s.end(), Duration::from_secs(2));
242    assert_eq!(s.duration(), Duration::from_secs(1));
243  }
244
245  #[test]
246  fn range_usize_slices_pcm_directly() {
247    let pcm = [0.0f32; 2_000];
248    let s = SpeechSegment::new(160, 320);
249    let slice = &pcm[s.range_usize()];
250    assert_eq!(slice.len(), 160);
251  }
252
253  #[test]
254  fn empty_segment_has_zero_sample_count() {
255    let s = SpeechSegment::new(100, 100);
256    assert_eq!(s.sample_count(), 0);
257    assert!(s.range().is_empty());
258  }
259
260  #[test]
261  fn frame_result_closed_segment_is_some_only_when_is_speech_end() {
262    let result = FrameResult::new(20, 0.9, 0.85, true, false, true, Some(2), Some(20));
263    let segment = result.closed_segment().expect("segment closes");
264    assert_eq!(segment.start_sample(), 2 * 160);
265    assert_eq!(segment.end_sample(), 20 * 160);
266
267    let mid = FrameResult::new(15, 0.8, 0.75, true, false, false, Some(2), None);
268    assert!(mid.closed_segment().is_none());
269  }
270
271  #[test]
272  fn frame_result_timestamp_uses_frame_shift_samples() {
273    let result = FrameResult::new(100, 0.0, 0.0, false, false, false, None, None);
274    assert_eq!(result.timestamp(), Duration::from_millis(1_000));
275  }
276}