Skip to main content

firered_vad/
options.rs

1//! Configuration types for `firered-vad`.
2//!
3//! `VadOptions` controls postprocessor behavior; `SessionOptions` controls
4//! the underlying ONNX Runtime session. `GraphOptimizationLevel` is
5//! re-exported from `ort` so callers share vocabulary with everyone else
6//! using the runtime directly.
7
8use core::time::Duration;
9
10pub use ort::session::builder::GraphOptimizationLevel;
11#[cfg(feature = "serde")]
12use serde::{Deserialize, Serialize};
13
14#[cfg(feature = "serde")]
15mod graph_optimization_level {
16  use serde::*;
17
18  use super::GraphOptimizationLevel;
19
20  #[derive(
21    Debug, Default, Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize,
22  )]
23  #[serde(rename_all = "snake_case")]
24  enum OptimizationLevel {
25    Disable,
26    Level1,
27    Level2,
28    #[default]
29    Level3,
30    All,
31  }
32
33  impl From<GraphOptimizationLevel> for OptimizationLevel {
34    #[inline]
35    fn from(value: GraphOptimizationLevel) -> Self {
36      match value {
37        GraphOptimizationLevel::Disable => Self::Disable,
38        GraphOptimizationLevel::Level1 => Self::Level1,
39        GraphOptimizationLevel::Level2 => Self::Level2,
40        GraphOptimizationLevel::Level3 => Self::Level3,
41        GraphOptimizationLevel::All => Self::All,
42      }
43    }
44  }
45
46  impl From<OptimizationLevel> for GraphOptimizationLevel {
47    #[inline]
48    fn from(value: OptimizationLevel) -> Self {
49      match value {
50        OptimizationLevel::Disable => Self::Disable,
51        OptimizationLevel::Level1 => Self::Level1,
52        OptimizationLevel::Level2 => Self::Level2,
53        OptimizationLevel::Level3 => Self::Level3,
54        OptimizationLevel::All => Self::All,
55      }
56    }
57  }
58
59  #[cfg_attr(not(tarpaulin), inline(always))]
60  pub fn serialize<S>(level: &GraphOptimizationLevel, serializer: S) -> Result<S::Ok, S::Error>
61  where
62    S: Serializer,
63  {
64    OptimizationLevel::from(*level).serialize(serializer)
65  }
66
67  #[cfg_attr(not(tarpaulin), inline(always))]
68  pub fn deserialize<'de, D>(deserializer: D) -> Result<GraphOptimizationLevel, D::Error>
69  where
70    D: Deserializer<'de>,
71  {
72    OptimizationLevel::deserialize(deserializer).map(Into::into)
73  }
74
75  #[cfg_attr(not(tarpaulin), inline(always))]
76  pub const fn default() -> GraphOptimizationLevel {
77    GraphOptimizationLevel::Disable
78  }
79}
80
81/// Options for constructing the ONNX session.
82///
83/// This stays small: deployment-specific knobs (intra-thread count,
84/// inter-thread count, execution providers) belong one layer up and
85/// should be applied to a manually built `ort::Session` passed into
86/// [`crate::Vad::from_ort_session`].
87#[derive(Debug, Clone)]
88#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
89pub struct SessionOptions {
90  #[cfg_attr(
91    feature = "serde",
92    serde(
93      default = "graph_optimization_level::default",
94      with = "graph_optimization_level"
95    )
96  )]
97  optimization_level: GraphOptimizationLevel,
98}
99
100impl Default for SessionOptions {
101  #[inline]
102  fn default() -> Self {
103    Self::new()
104  }
105}
106
107impl SessionOptions {
108  /// Create a new `SessionOptions` with default values.
109  #[cfg_attr(not(tarpaulin), inline(always))]
110  pub const fn new() -> Self {
111    Self {
112      optimization_level: GraphOptimizationLevel::Level3,
113    }
114  }
115
116  /// Returns the graph optimization level used when constructing the ONNX session.
117  #[cfg_attr(not(tarpaulin), inline(always))]
118  pub const fn optimization_level(&self) -> GraphOptimizationLevel {
119    self.optimization_level
120  }
121
122  /// Set the graph optimization level (`&mut Self` for chaining).
123  #[cfg_attr(not(tarpaulin), inline(always))]
124  pub const fn set_optimization_level(&mut self, level: GraphOptimizationLevel) -> &mut Self {
125    self.optimization_level = level;
126    self
127  }
128
129  /// Builder variant of [`Self::set_optimization_level`].
130  #[cfg_attr(not(tarpaulin), inline(always))]
131  #[must_use]
132  pub const fn with_optimization_level(mut self, level: GraphOptimizationLevel) -> Self {
133    self.optimization_level = level;
134    self
135  }
136}
137
138/// Frame shift in milliseconds for the FireRedVAD model (10 ms).
139pub(crate) const FRAME_SHIFT_MS: u128 = 10;
140
141/// Convert a `Duration` to whole 10-ms frames. Saturates at `u32::MAX`.
142#[cfg_attr(not(tarpaulin), inline(always))]
143pub(crate) const fn duration_to_frames(d: Duration) -> u32 {
144  let frames = d.as_millis() / FRAME_SHIFT_MS;
145  if frames > u32::MAX as u128 {
146    u32::MAX
147  } else {
148    frames as u32
149  }
150}
151
152/// Clamp a probability into `[0, 1]`, mapping non-finite values to 0.
153#[cfg_attr(not(tarpaulin), inline(always))]
154pub(crate) const fn sanitize_probability(value: f32) -> f32 {
155  if value.is_finite() {
156    value.clamp(0.0, 1.0)
157  } else {
158    0.0
159  }
160}
161
162#[cfg_attr(not(tarpaulin), inline(always))]
163const fn default_smooth_window_size() -> u32 {
164  5
165}
166
167#[cfg_attr(not(tarpaulin), inline(always))]
168const fn default_speech_threshold() -> f32 {
169  0.5
170}
171
172#[cfg_attr(not(tarpaulin), inline(always))]
173const fn default_pad_start() -> Duration {
174  Duration::from_millis(50)
175}
176
177#[cfg_attr(not(tarpaulin), inline(always))]
178const fn default_min_speech_duration() -> Duration {
179  Duration::from_millis(80)
180}
181
182#[cfg_attr(not(tarpaulin), inline(always))]
183const fn default_min_silence_duration() -> Duration {
184  Duration::from_millis(200)
185}
186
187#[cfg_attr(not(tarpaulin), inline(always))]
188const fn default_max_speech_duration() -> Option<Duration> {
189  Some(Duration::from_secs(20))
190}
191
192/// Configuration for turning streaming probabilities into speech segments.
193///
194/// Defaults reproduce upstream Python's `FireRedStreamVadConfig` exactly.
195/// The four upstream "mode" presets are not exposed as an enum — see
196/// the crate-level docs for the recipe values you can apply via the
197/// `with_*` builders directly.
198#[derive(Debug, Clone)]
199#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
200pub struct VadOptions {
201  #[cfg_attr(feature = "serde", serde(default = "default_smooth_window_size"))]
202  smooth_window_size: u32,
203
204  #[cfg_attr(feature = "serde", serde(default = "default_speech_threshold"))]
205  speech_threshold: f32,
206
207  #[cfg_attr(
208    feature = "serde",
209    serde(default = "default_pad_start", with = "humantime_serde")
210  )]
211  pad_start: Duration,
212
213  #[cfg_attr(
214    feature = "serde",
215    serde(default = "default_min_speech_duration", with = "humantime_serde")
216  )]
217  min_speech_duration: Duration,
218
219  #[cfg_attr(
220    feature = "serde",
221    serde(default = "default_min_silence_duration", with = "humantime_serde")
222  )]
223  min_silence_duration: Duration,
224
225  #[cfg_attr(
226    feature = "serde",
227    serde(
228      default = "default_max_speech_duration",
229      with = "humantime_serde::option"
230    )
231  )]
232  max_speech_duration: Option<Duration>,
233
234  #[cfg_attr(feature = "serde", serde(default))]
235  session_options: SessionOptions,
236}
237
238impl Default for VadOptions {
239  #[cfg_attr(not(tarpaulin), inline(always))]
240  fn default() -> Self {
241    Self::new()
242  }
243}
244
245impl VadOptions {
246  /// Create `VadOptions` with upstream `FireRedStreamVadConfig` defaults.
247  #[cfg_attr(not(tarpaulin), inline(always))]
248  pub const fn new() -> Self {
249    Self {
250      smooth_window_size: default_smooth_window_size(),
251      speech_threshold: default_speech_threshold(),
252      pad_start: default_pad_start(),
253      min_speech_duration: default_min_speech_duration(),
254      min_silence_duration: default_min_silence_duration(),
255      max_speech_duration: default_max_speech_duration(),
256      session_options: SessionOptions::new(),
257    }
258  }
259
260  /// Smoothing-window size in frames (10 ms each).
261  #[cfg_attr(not(tarpaulin), inline(always))]
262  pub const fn smooth_window_size(&self) -> u32 {
263    self.smooth_window_size
264  }
265
266  /// Set the smoothing-window size; returns `&mut Self` for chaining.
267  #[cfg_attr(not(tarpaulin), inline(always))]
268  pub const fn set_smooth_window_size(&mut self, size: u32) -> &mut Self {
269    self.smooth_window_size = size;
270    self
271  }
272
273  /// Builder variant of [`Self::set_smooth_window_size`].
274  #[cfg_attr(not(tarpaulin), inline(always))]
275  #[must_use]
276  pub const fn with_smooth_window_size(mut self, size: u32) -> Self {
277    self.smooth_window_size = size;
278    self
279  }
280
281  /// Threshold above which a smoothed probability counts as speech.
282  #[cfg_attr(not(tarpaulin), inline(always))]
283  pub const fn speech_threshold(&self) -> f32 {
284    self.speech_threshold
285  }
286
287  /// Set the speech threshold; values are clamped into `[0, 1]`.
288  #[cfg_attr(not(tarpaulin), inline(always))]
289  pub const fn set_speech_threshold(&mut self, t: f32) -> &mut Self {
290    self.speech_threshold = sanitize_probability(t);
291    self
292  }
293
294  /// Builder variant of [`Self::set_speech_threshold`].
295  #[cfg_attr(not(tarpaulin), inline(always))]
296  #[must_use]
297  pub const fn with_speech_threshold(mut self, t: f32) -> Self {
298    self.speech_threshold = sanitize_probability(t);
299    self
300  }
301
302  /// Padding extending the start of every emitted speech segment backward.
303  #[cfg_attr(not(tarpaulin), inline(always))]
304  pub const fn pad_start(&self) -> Duration {
305    self.pad_start
306  }
307
308  /// Set `pad_start`; returns `&mut Self` for chaining.
309  #[cfg_attr(not(tarpaulin), inline(always))]
310  pub const fn set_pad_start(&mut self, d: Duration) -> &mut Self {
311    self.pad_start = d;
312    self
313  }
314
315  /// Builder variant of [`Self::set_pad_start`].
316  #[cfg_attr(not(tarpaulin), inline(always))]
317  #[must_use]
318  pub const fn with_pad_start(mut self, d: Duration) -> Self {
319    self.pad_start = d;
320    self
321  }
322
323  /// Minimum speech duration before a `POSSIBLE_SPEECH` run promotes to `SPEECH`.
324  #[cfg_attr(not(tarpaulin), inline(always))]
325  pub const fn min_speech_duration(&self) -> Duration {
326    self.min_speech_duration
327  }
328
329  /// Set the minimum speech duration; returns `&mut Self` for chaining.
330  #[cfg_attr(not(tarpaulin), inline(always))]
331  pub const fn set_min_speech_duration(&mut self, d: Duration) -> &mut Self {
332    self.min_speech_duration = d;
333    self
334  }
335
336  /// Builder variant of [`Self::set_min_speech_duration`].
337  #[cfg_attr(not(tarpaulin), inline(always))]
338  #[must_use]
339  pub const fn with_min_speech_duration(mut self, d: Duration) -> Self {
340    self.min_speech_duration = d;
341    self
342  }
343
344  /// Maximum speech duration before a force-split (None disables force-split).
345  #[cfg_attr(not(tarpaulin), inline(always))]
346  pub const fn max_speech_duration(&self) -> Option<Duration> {
347    self.max_speech_duration
348  }
349
350  /// Set the maximum speech duration; returns `&mut Self` for chaining.
351  #[cfg_attr(not(tarpaulin), inline(always))]
352  pub const fn set_max_speech_duration(&mut self, d: Duration) -> &mut Self {
353    self.max_speech_duration = Some(d);
354    self
355  }
356
357  /// Builder variant of [`Self::set_max_speech_duration`].
358  #[cfg_attr(not(tarpaulin), inline(always))]
359  #[must_use]
360  pub const fn with_max_speech_duration(mut self, d: Duration) -> Self {
361    self.max_speech_duration = Some(d);
362    self
363  }
364
365  /// Disable max-speech force-splitting.
366  #[cfg_attr(not(tarpaulin), inline(always))]
367  #[must_use]
368  pub const fn clear_max_speech_duration(mut self) -> Self {
369    self.max_speech_duration = None;
370    self
371  }
372
373  /// Minimum silence duration required to close an open speech segment.
374  #[cfg_attr(not(tarpaulin), inline(always))]
375  pub const fn min_silence_duration(&self) -> Duration {
376    self.min_silence_duration
377  }
378
379  /// Set the minimum silence duration; returns `&mut Self` for chaining.
380  #[cfg_attr(not(tarpaulin), inline(always))]
381  pub const fn set_min_silence_duration(&mut self, d: Duration) -> &mut Self {
382    self.min_silence_duration = d;
383    self
384  }
385
386  /// Builder variant of [`Self::set_min_silence_duration`].
387  #[cfg_attr(not(tarpaulin), inline(always))]
388  #[must_use]
389  pub const fn with_min_silence_duration(mut self, d: Duration) -> Self {
390    self.min_silence_duration = d;
391    self
392  }
393
394  /// The session options used when constructing the ONNX runtime.
395  #[cfg_attr(not(tarpaulin), inline(always))]
396  pub const fn session_options(&self) -> &SessionOptions {
397    &self.session_options
398  }
399
400  /// Set the `SessionOptions`; returns `&mut Self` for chaining.
401  #[cfg_attr(not(tarpaulin), inline(always))]
402  pub const fn set_session_options(&mut self, opts: SessionOptions) -> &mut Self {
403    self.session_options = opts;
404    self
405  }
406
407  /// Builder variant of [`Self::set_session_options`].
408  #[cfg_attr(not(tarpaulin), inline(always))]
409  #[must_use]
410  pub const fn with_session_options(mut self, opts: SessionOptions) -> Self {
411    self.session_options = opts;
412    self
413  }
414
415  // ── Sample-domain conversions used by the postprocessor ───────────
416  /// Smoothing-window size in frames.
417  #[cfg_attr(not(tarpaulin), inline(always))]
418  pub(crate) const fn smooth_window_size_frames(&self) -> u32 {
419    self.smooth_window_size
420  }
421
422  /// Pad-start in frames; clamped to be at least `smooth_window_size`,
423  /// matching upstream `__init__`.
424  #[cfg_attr(not(tarpaulin), inline(always))]
425  pub(crate) fn pad_start_frames(&self) -> u32 {
426    let raw = duration_to_frames(self.pad_start);
427    raw.max(self.smooth_window_size)
428  }
429
430  /// `min_speech_duration` in frames.
431  #[cfg_attr(not(tarpaulin), inline(always))]
432  pub(crate) fn min_speech_frames(&self) -> u32 {
433    duration_to_frames(self.min_speech_duration)
434  }
435
436  /// `min_silence_duration` in frames.
437  #[cfg_attr(not(tarpaulin), inline(always))]
438  pub(crate) fn min_silence_frames(&self) -> u32 {
439    duration_to_frames(self.min_silence_duration)
440  }
441
442  /// `max_speech_duration` in frames, if force-split is enabled.
443  #[cfg_attr(not(tarpaulin), inline(always))]
444  pub(crate) fn max_speech_frames(&self) -> Option<u32> {
445    self.max_speech_duration.map(duration_to_frames)
446  }
447}
448
449#[cfg(test)]
450mod tests {
451  use super::*;
452
453  #[test]
454  fn session_options_default_optimizes_at_level_3() {
455    let opts = SessionOptions::default();
456    assert!(matches!(
457      opts.optimization_level(),
458      GraphOptimizationLevel::Level3
459    ));
460  }
461
462  #[test]
463  fn session_options_with_optimization_level_overrides() {
464    let opts = SessionOptions::new().with_optimization_level(GraphOptimizationLevel::Level1);
465    assert!(matches!(
466      opts.optimization_level(),
467      GraphOptimizationLevel::Level1
468    ));
469  }
470
471  #[test]
472  fn vad_options_default_match_upstream_firered_stream_vad_config() {
473    let opts = VadOptions::default();
474    assert_eq!(opts.smooth_window_size(), 5);
475    assert!((opts.speech_threshold() - 0.5).abs() < f32::EPSILON);
476    assert_eq!(opts.pad_start(), Duration::from_millis(50));
477    assert_eq!(opts.min_speech_duration(), Duration::from_millis(80));
478    assert_eq!(opts.max_speech_duration(), Some(Duration::from_secs(20)));
479    assert_eq!(opts.min_silence_duration(), Duration::from_millis(200));
480  }
481
482  #[test]
483  fn vad_options_speech_threshold_clamps_into_unit_interval() {
484    let mut opts = VadOptions::new();
485    opts.set_speech_threshold(2.5);
486    assert!((opts.speech_threshold() - 1.0).abs() < f32::EPSILON);
487    opts.set_speech_threshold(-0.3);
488    assert!((opts.speech_threshold() - 0.0).abs() < f32::EPSILON);
489    opts.set_speech_threshold(f32::NAN);
490    assert!((opts.speech_threshold() - 0.0).abs() < f32::EPSILON);
491  }
492
493  #[test]
494  fn vad_options_clear_max_speech_duration_disables_force_split() {
495    let opts = VadOptions::new()
496      .with_max_speech_duration(Duration::from_secs(5))
497      .clear_max_speech_duration();
498    assert_eq!(opts.max_speech_duration(), None);
499    assert_eq!(opts.max_speech_frames(), None);
500  }
501
502  #[test]
503  fn pad_start_frames_is_clamped_to_smooth_window_size() {
504    let opts = VadOptions::new()
505      .with_smooth_window_size(8)
506      .with_pad_start(Duration::from_millis(30)); // 3 frames
507    assert_eq!(opts.pad_start_frames(), 8);
508  }
509
510  #[test]
511  fn duration_to_frames_truncates_partial_frames() {
512    assert_eq!(duration_to_frames(Duration::from_millis(15)), 1);
513    assert_eq!(duration_to_frames(Duration::from_millis(20)), 2);
514    assert_eq!(duration_to_frames(Duration::ZERO), 0);
515    // Sub-frame durations: anything < 10 ms → 0 frames.
516    assert_eq!(duration_to_frames(Duration::from_nanos(1)), 0);
517    assert_eq!(duration_to_frames(Duration::from_millis(9)), 0);
518    // Boundary at 10 ms = exactly 1 frame.
519    assert_eq!(duration_to_frames(Duration::from_millis(10)), 1);
520    // Saturates at u32::MAX rather than wrapping for very large
521    // inputs (Duration::MAX is ~5.85 × 10¹¹ years; the saturate
522    // branch in `duration_to_frames` covers it).
523    assert_eq!(duration_to_frames(Duration::MAX), u32::MAX);
524  }
525
526  #[cfg(feature = "serde")]
527  #[test]
528  fn vad_options_round_trip_through_humantime_serde() {
529    let opts = VadOptions::new()
530      .with_min_silence_duration(Duration::from_millis(250))
531      .with_max_speech_duration(Duration::from_secs(15));
532    let serialized = serde_json::to_string(&opts).expect("serialize");
533    assert!(serialized.contains("250ms"));
534    assert!(serialized.contains("15s"));
535    let restored: VadOptions = serde_json::from_str(&serialized).expect("deserialize");
536    assert_eq!(restored.min_silence_duration(), opts.min_silence_duration());
537    assert_eq!(restored.max_speech_duration(), opts.max_speech_duration());
538  }
539}