Skip to main content

flexaudio_vad/
lib.rs

1//! flexaudio-vad — silero-VAD を ONNX でオフライン実行する VAD アドオン。
2//!
3//! `flexaudio-core` には依存せず、`&[f32]` のサンプル列だけを受け取る。silero-VAD
4//! モデル (MIT) をバイナリに埋め込むので、実行時のモデルファイルもネットワークも要らない。
5//!
6//! # 例 (ストリーミング)
7//! ```no_run
8//! use flexaudio_vad::{Vad, VadConfig, VadEvent};
9//! let mut vad = Vad::new(VadConfig::default()).unwrap();
10//! for chunk in some_audio_chunks() {
11//!     for ev in vad.process(chunk) {
12//!         match ev {
13//!             VadEvent::SpeechStart { at_sample } => println!("start @ {at_sample}"),
14//!             VadEvent::SpeechEnd { at_sample } => println!("end @ {at_sample}"),
15//!         }
16//!     }
17//! }
18//! # fn some_audio_chunks() -> Vec<&'static [f32]> { vec![] }
19//! ```
20//!
21//! # 例 (バッチ)
22//! ```no_run
23//! use flexaudio_vad::{get_speech_timestamps, VadConfig};
24//! let samples: Vec<f32> = vec![0.0; 16000];
25//! let segments = get_speech_timestamps(&samples, &VadConfig::default()).unwrap();
26//! for s in segments {
27//!     println!("{}..{}", s.start_sample, s.end_sample);
28//! }
29//! ```
30
31#![warn(missing_docs)]
32
33mod config;
34mod resample;
35mod segmenter;
36
37pub use config::VadConfig;
38pub use resample::PcmFormat;
39pub use segmenter::Segment;
40
41use ort::session::Session;
42use ort::value::Tensor;
43use resample::PcmConverter;
44use segmenter::Segmenter;
45
46/// silero-VAD モデル (v6, MIT)。実行時ファイルを要らなくするためバイナリへ埋め込む。
47static MODEL_BYTES: &[u8] = include_bytes!("../assets/silero_vad.onnx");
48
49/// state テンソルの要素数 (2 * 1 * 128)。
50const STATE_LEN: usize = 2 * 128;
51
52/// VAD が確定したイベント。サンプル位置はパディング適用後。
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum VadEvent {
55    /// 発話開始 (パディング適用後の絶対サンプル位置)。
56    SpeechStart {
57        /// 発話が始まった絶対サンプル位置 (パディング適用後・含む)。
58        at_sample: u64,
59    },
60    /// 発話終了 (パディング適用後の絶対サンプル位置・排他的)。
61    SpeechEnd {
62        /// 発話が終わった絶対サンプル位置 (パディング適用後・排他的)。
63        at_sample: u64,
64    },
65}
66
67/// VAD のエラー型。
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum VadError {
70    /// モデルのロードに失敗。
71    ModelLoad(String),
72    /// 推論実行中のエラー。
73    Inference(String),
74    /// 設定値が不正。
75    InvalidConfig(String),
76}
77
78impl std::fmt::Display for VadError {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            VadError::ModelLoad(m) => write!(f, "model load error: {m}"),
82            VadError::Inference(m) => write!(f, "inference error: {m}"),
83            VadError::InvalidConfig(m) => write!(f, "invalid config: {m}"),
84        }
85    }
86}
87
88impl std::error::Error for VadError {}
89
90/// ストリーミング VAD。1 インスタンスが ONNX `Session` を 1 つ持つ(共有しない)。
91///
92/// 任意長の `&[f32]` を [`Vad::process`] に流すと、内部で frame (16k=512) 単位に束ねて
93/// silero 推論し、セグメント状態機械を回して確定したイベントを返す。
94pub struct Vad {
95    session: Session,
96    config: VadConfig,
97    segmenter: Segmenter,
98
99    /// silero state テンソル `[2,1,128]`(フレーム間で継承)。
100    state: Vec<f32>,
101    /// 前回フレーム末尾の context (16k=64)。次フレームの前置に使う。
102    context: Vec<f32>,
103    /// frame_size に満たない端数サンプルの残り。
104    pending: Vec<f32>,
105
106    /// 直近 [`Vad::process`] で計算した各フレームの生発話確率。
107    last_probs: Vec<f32>,
108
109    /// [`Vad::process_pcm`] 用の前段変換器(任意フォーマット → VAD レートの mono)。
110    /// 変換が要らないフォーマット(VAD レートの mono)では作られない。入力フォーマットが
111    /// 変わったら作り直す。
112    converter: Option<PcmConverter>,
113}
114
115impl Vad {
116    /// 埋め込みモデルをロードして VAD を構築する。
117    pub fn new(config: VadConfig) -> Result<Vad, VadError> {
118        config.validate().map_err(VadError::InvalidConfig)?;
119
120        let session = Session::builder()
121            .map_err(|e| VadError::ModelLoad(e.to_string()))?
122            .commit_from_memory(MODEL_BYTES)
123            .map_err(|e| VadError::ModelLoad(e.to_string()))?;
124
125        let segmenter = Segmenter::new(&config);
126        let ctx_size = config.context_size();
127
128        Ok(Vad {
129            session,
130            config,
131            segmenter,
132            state: vec![0.0f32; STATE_LEN],
133            context: vec![0.0f32; ctx_size],
134            pending: Vec::new(),
135            last_probs: Vec::new(),
136            converter: None,
137        })
138    }
139
140    /// 任意長の f32 サンプルを処理し、確定した [`VadEvent`] を返す。
141    ///
142    /// 内部で frame_size 単位に束ね、満たないサンプルは次回まで保持する。サンプル位置は
143    /// 呼び出しをまたいで連続する(累積)。
144    pub fn process(&mut self, samples: &[f32]) -> Vec<VadEvent> {
145        let frame_size = self.config.frame_size();
146        self.last_probs.clear();
147
148        let mut segments_out = Vec::new();
149        let mut events = Vec::new();
150
151        // pending + 新規サンプルを連結して frame_size 単位に消費。
152        self.pending.extend_from_slice(samples);
153
154        let mut offset = 0;
155        // self.pending を借用したまま &mut self の infer_frame には渡せないので、
156        // 1 フレーム分を局所バッファへコピーしてから推論する。
157        let mut frame_buf = vec![0.0f32; frame_size];
158        while offset + frame_size <= self.pending.len() {
159            frame_buf.copy_from_slice(&self.pending[offset..offset + frame_size]);
160            // 推論失敗時は無音 (0.0) に倒して継続。
161            let prob = self.infer_frame(&frame_buf).unwrap_or(0.0);
162            self.last_probs.push(prob);
163            self.segmenter.feed(prob, &mut segments_out);
164            offset += frame_size;
165        }
166        // 消費済み分を捨てる。
167        self.pending.drain(0..offset);
168
169        for seg in segments_out {
170            events.push(VadEvent::SpeechStart {
171                at_sample: seg.start_sample,
172            });
173            events.push(VadEvent::SpeechEnd {
174                at_sample: seg.end_sample,
175            });
176        }
177        events
178    }
179
180    /// 録音チャンクをそのまま渡せる自己完結の入口。任意フォーマット(`input_sample_rate` /
181    /// `input_channels` の interleaved f32)を内部で mono 化・VAD レートへリサンプルしてから
182    /// [`Vad::process`] と同じ経路にかける。
183    ///
184    /// 各言語バインディングが録音チャンク(例 48k/stereo)を変換せずそのまま流せるようにする
185    /// のが狙い。`samples` は interleaved で、長さは `input_channels` の倍数を想定する
186    /// (端数フレームは次回まで内部に持ち越すので、任意の位置で分割して渡してよい)。
187    ///
188    /// 入力が既に VAD レート([`VadConfig::sample_rate`])の mono なら、mono 化もリサンプル
189    /// もせず [`Vad::process`] へそのまま渡す(追加コストなし)。それ以外は各チャンネルの
190    /// 平均で mono 化し、rubato でリサンプルする。リサンプラは呼び出しをまたいで状態を持つ
191    /// ので、連続ストリームでも継ぎ目は出ない。
192    ///
193    /// 返す [`VadEvent`] の `at_sample` は **VAD 内部レート(`config().sample_rate`=16000
194    /// か 8000)のサンプル基準**で、入力サンプル基準ではない(リサンプル後の内部位置の累積)。
195    /// [`Vad::process`] と揃えてあるので両者を混ぜても位置は連続する。秒に直すなら
196    /// `at_sample as f64 / config().sample_rate as f64`、入力サンプル位置の目安が要るなら
197    /// `at_sample * input_sample_rate / config().sample_rate` で近似できる。
198    ///
199    /// リサンプラの構築や実行に失敗した場合(極端なレート比など)は、その呼び出し分を捨てて
200    /// 空のイベント列を返す(取り込みを panic で止めない。[`Vad::process`] が推論失敗を無音に
201    /// 倒すのと同じ方針)。
202    pub fn process_pcm(
203        &mut self,
204        samples: &[f32],
205        input_sample_rate: u32,
206        input_channels: u16,
207    ) -> Vec<VadEvent> {
208        let target = self.config.sample_rate;
209        let format = PcmFormat {
210            sample_rate: input_sample_rate,
211            channels: input_channels,
212        };
213
214        // 入力フォーマットが前回と変わったら変換器を捨てる(必要なら下で作り直す)。
215        if let Some(c) = &self.converter {
216            if !c.matches(format) {
217                self.converter = None;
218            }
219        }
220
221        // 変換不要(既に VAD レートの mono)なら余計なコピーもリサンプルもせず既存経路へ。
222        if input_sample_rate == target && input_channels <= 1 {
223            return self.process(samples);
224        }
225
226        // 変換器を用意(初回・またはフォーマット変更時)。構築失敗はこの呼び出しを捨てて継続。
227        if self.converter.is_none() {
228            match PcmConverter::new(format, target) {
229                Ok(c) => self.converter = Some(c),
230                Err(_) => return Vec::new(),
231            }
232        }
233
234        // mono 化 + リサンプルして VAD レートの mono を得てから既存経路へ流す。
235        let mut converted = Vec::new();
236        {
237            let conv = self.converter.as_mut().expect("converter は直前に用意済み");
238            if conv.convert(samples, &mut converted).is_err() {
239                return Vec::new();
240            }
241        }
242        self.process(&converted)
243    }
244
245    /// 1 フレーム (frame_size サンプル) を silero に通し発話確率を返す。
246    ///
247    /// silero の入力は frame そのものではなく `concat(context, frame)` で、長さは
248    /// `context_size + frame_size` (16k=64+512=576)。推論後、context を今回入力末尾の
249    /// `context_size` サンプルで、state を出力 state で更新する。
250    fn infer_frame(&mut self, frame: &[f32]) -> Result<f32, VadError> {
251        let ctx_size = self.config.context_size();
252        let frame_size = self.config.frame_size();
253        debug_assert_eq!(frame.len(), frame_size);
254        debug_assert_eq!(self.context.len(), ctx_size);
255
256        // x = concat(context, frame)。長さ ctx_size + frame_size (16k=576)。
257        let input_len = ctx_size + frame_size;
258        let mut x = Vec::with_capacity(input_len);
259        x.extend_from_slice(&self.context);
260        x.extend_from_slice(frame);
261
262        // 次回 context = 今回入力末尾の ctx_size サンプル。x は run へ move されるので
263        // 先に控えておく。
264        let next_context: Vec<f32> = x[input_len - ctx_size..].to_vec();
265
266        // 入力テンソル: input f32 [1, input_len], state f32 [2,1,128], sr int64 scalar。
267        let input_tensor = Tensor::from_array(([1_i64, input_len as i64], x))
268            .map_err(|e| VadError::Inference(e.to_string()))?;
269        let state_tensor = Tensor::from_array(([2_i64, 1, 128], self.state.clone()))
270            .map_err(|e| VadError::Inference(e.to_string()))?;
271        // sr はランク0スカラー int64(モデル入力 shape は [])。
272        let sr_tensor =
273            Tensor::from_array((vec![] as Vec<i64>, vec![self.config.sample_rate as i64]))
274                .map_err(|e| VadError::Inference(e.to_string()))?;
275
276        let outputs = self
277            .session
278            .run(ort::inputs![
279                "input" => input_tensor,
280                "state" => state_tensor,
281                "sr" => sr_tensor,
282            ])
283            .map_err(|e| VadError::Inference(e.to_string()))?;
284
285        // 出力名: 確率 = "output", 更新後 state = "stateN"(実モデルで確認済み)。
286        let (_pshape, prob_slice) = outputs["output"]
287            .try_extract_tensor::<f32>()
288            .map_err(|e| VadError::Inference(e.to_string()))?;
289        let prob = *prob_slice
290            .first()
291            .ok_or_else(|| VadError::Inference("empty output tensor".to_string()))?;
292
293        let (_sshape, state_slice) = outputs["stateN"]
294            .try_extract_tensor::<f32>()
295            .map_err(|e| VadError::Inference(e.to_string()))?;
296        if state_slice.len() != STATE_LEN {
297            return Err(VadError::Inference(format!(
298                "unexpected state length {} (expected {STATE_LEN})",
299                state_slice.len()
300            )));
301        }
302        let new_state = state_slice.to_vec();
303
304        // 出力の借用が outputs/session を握っているので、コピーし終えてから更新する。
305        drop(outputs);
306        self.state = new_state;
307        self.context = next_context;
308
309        Ok(prob)
310    }
311
312    /// 直近 [`Vad::process`] で計算した各フレームの生発話確率を返す。
313    ///
314    /// セグメントイベントとは独立した第二の出力。
315    pub fn last_frame_probabilities(&self) -> &[f32] {
316        &self.last_probs
317    }
318
319    /// state / context / 状態機械 / サンプル位置 / 端数バッファ / リサンプラ状態を
320    /// すべて初期化する。
321    pub fn reset(&mut self) {
322        self.state = vec![0.0f32; STATE_LEN];
323        self.context = vec![0.0f32; self.config.context_size()];
324        self.pending.clear();
325        self.last_probs.clear();
326        self.segmenter.reset();
327        // 変換器を捨てる。次の process_pcm がフォーマットに応じて作り直すので、
328        // リサンプラの内部遅延・端数もまとめてリセットされる。
329        self.converter = None;
330    }
331
332    /// 現在の設定への参照。
333    pub fn config(&self) -> &VadConfig {
334        &self.config
335    }
336}
337
338/// バッチ処理用(silero `get_speech_timestamps` 相当)。
339///
340/// 全サンプルを一括処理して確定セグメント列を返す。末尾で発話中なら入力終端まで採る。
341pub fn get_speech_timestamps(
342    samples: &[f32],
343    config: &VadConfig,
344) -> Result<Vec<Segment>, VadError> {
345    let mut vad = Vad::new(config.clone())?;
346    let frame_size = config.frame_size();
347
348    let mut out = Vec::new();
349    let mut chunks = samples.chunks_exact(frame_size);
350    for frame in chunks.by_ref() {
351        let prob = vad.infer_frame(frame)?;
352        vad.last_probs.push(prob);
353        vad.segmenter.feed(prob, &mut out);
354    }
355    // 端数フレームは silero と同じく推論せず捨てる。入力終端で発話中なら確定。
356    vad.segmenter.flush(&mut out);
357
358    Ok(out)
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::config::VadConfig;
365    use crate::segmenter::{Segment, Segmenter};
366
367    /// frame_size=512 @16k 前提で、確率列をセグメンタに流しセグメント列を得るヘルパ。
368    fn run_probs(config: &VadConfig, probs: &[f32]) -> Vec<Segment> {
369        let mut seg = Segmenter::new(config);
370        let mut out = Vec::new();
371        for &p in probs {
372            seg.feed(p, &mut out);
373        }
374        seg.flush(&mut out);
375        out
376    }
377
378    fn base_config() -> VadConfig {
379        // pad=0 にして純粋な境界ロジックを検証しやすくする。512 サンプル/フレーム前提。
380        VadConfig {
381            threshold: 0.5,
382            neg_threshold: Some(0.35),
383            min_speech_ms: 0, // 破棄を切る (個別テストで上書き)
384            min_silence_ms: 0,
385            speech_pad_ms: 0,
386            max_speech_ms: 0,
387            sample_rate: 16000,
388        }
389    }
390
391    #[test]
392    fn neg_threshold_default_formula() {
393        let mut c = VadConfig::default();
394        assert_eq!(c.resolved_neg_threshold(), (0.5 - 0.15_f32).max(0.01));
395        c.threshold = 0.1;
396        assert_eq!(c.resolved_neg_threshold(), 0.01); // クランプ下限
397        c.neg_threshold = Some(0.2);
398        assert_eq!(c.resolved_neg_threshold(), 0.2); // 明示優先
399    }
400
401    #[test]
402    fn simple_speech_then_silence() {
403        // 512 サンプル/フレーム。min_silence=512 (=1フレーム), min_speech=0。
404        let mut c = base_config();
405        c.min_silence_ms = 32; // 32ms @16k = 512 samples = 1 frame
406                               // 20 フレーム発話 → 30 フレーム無音
407        let mut probs = vec![0.9f32; 20];
408        probs.extend(vec![0.1f32; 30]);
409        let segs = run_probs(&c, &probs);
410        assert_eq!(segs.len(), 1, "exactly one segment");
411        let s = segs[0];
412        // 開始 = フレーム0先頭 = 0。
413        assert_eq!(s.start_sample, 0);
414        // 終了 = 無音開始位置 (フレーム20先頭 = 20*512)。
415        assert_eq!(s.end_sample, 20 * 512);
416    }
417
418    #[test]
419    fn min_speech_discards_short_segment() {
420        let mut c = base_config();
421        c.min_silence_ms = 32; // 1 frame
422        c.min_speech_ms = 250; // 250ms @16k = 4000 samples ≈ 7.8 frames
423                               // 5 フレーム発話 (5*512=2560 < 4000) → 破棄されるべき
424        let mut probs = vec![0.9f32; 5];
425        probs.extend(vec![0.1f32; 10]);
426        let segs = run_probs(&c, &probs);
427        assert!(
428            segs.is_empty(),
429            "short segment must be discarded, got {segs:?}"
430        );
431
432        // 10 フレーム発話 (5120 >= 4000) → 採用
433        let mut probs2 = vec![0.9f32; 10];
434        probs2.extend(vec![0.1f32; 10]);
435        let segs2 = run_probs(&c, &probs2);
436        assert_eq!(segs2.len(), 1);
437        assert_eq!(segs2[0].len_samples(), 10 * 512);
438    }
439
440    #[test]
441    fn min_silence_boundary_keeps_segment_together() {
442        // 短い無音 (< min_silence) はセグメントを切らない。
443        let mut c = base_config();
444        c.min_silence_ms = 192; // 192ms @16k = 3072 samples = 6 frames
445                                // 10発話 → 3無音 (3*512=1536 < 3072 なので継続) → 10発話 → 長い無音
446        let mut probs = vec![0.9f32; 10];
447        probs.extend(vec![0.1f32; 3]);
448        probs.extend(vec![0.9f32; 10]);
449        probs.extend(vec![0.1f32; 10]); // 10*512=5120 >= 3072 で確定
450        let segs = run_probs(&c, &probs);
451        assert_eq!(segs.len(), 1, "short gap must NOT split, got {segs:?}");
452        assert_eq!(segs[0].start_sample, 0);
453        // 終端 = 2回目発話塊の後の無音開始 = フレーム23先頭。
454        assert_eq!(segs[0].end_sample, 23 * 512);
455    }
456
457    #[test]
458    fn min_silence_just_over_splits() {
459        // min_silence をちょうど超える無音は切る。
460        let mut c = base_config();
461        c.min_silence_ms = 64; // 64ms = 1024 samples = 2 frames
462                               // 5発話 → 3無音 (3*512=1536) → 5発話 → 無音
463                               // 無音3フレーム目で (frame_start - temp_end) を評価:
464                               //   temp_end は無音1フレーム目先頭。無音Nフレーム目先頭 - temp_end = (N-1)*512。
465                               //   >= 1024 となるのは N-1 >= 2 → N>=3 → 3フレーム目の feed で確定。
466        let mut probs = vec![0.9f32; 5];
467        probs.extend(vec![0.1f32; 3]);
468        probs.extend(vec![0.9f32; 5]);
469        probs.extend(vec![0.1f32; 5]);
470        let segs = run_probs(&c, &probs);
471        assert_eq!(segs.len(), 2, "long gap must split into two, got {segs:?}");
472        assert_eq!(segs[0].start_sample, 0);
473        assert_eq!(segs[0].end_sample, 5 * 512); // 無音開始位置
474    }
475
476    #[test]
477    fn speech_pad_extends_and_clamps() {
478        let mut c = base_config();
479        c.min_silence_ms = 32; // 1 frame
480        c.speech_pad_ms = 32; // 32ms = 512 samples
481                              // フレーム2..5 が発話 (先頭に無音2フレームを置き、開始 pad が 0 でクランプされない様に)
482        let mut probs = vec![0.1f32; 2]; // フレーム0,1 無音
483        probs.extend(vec![0.9f32; 4]); // フレーム2..5 発話 (開始=2*512=1024)
484        probs.extend(vec![0.1f32; 5]); // 無音
485        let segs = run_probs(&c, &probs);
486        assert_eq!(segs.len(), 1);
487        // 開始 = 1024 - 512 = 512。
488        assert_eq!(segs[0].start_sample, 1024 - 512);
489        // 終了 = 無音開始(6*512=3072) + 512 = 3584。
490        assert_eq!(segs[0].end_sample, 6 * 512 + 512);
491    }
492
493    #[test]
494    fn speech_pad_start_clamps_at_zero() {
495        // フレーム0から発話 → 開始 pad が underflow せず 0 になる。
496        let mut c = base_config();
497        c.min_silence_ms = 32;
498        c.speech_pad_ms = 64; // 1024 samples
499        let mut probs = vec![0.9f32; 5];
500        probs.extend(vec![0.1f32; 5]);
501        let segs = run_probs(&c, &probs);
502        assert_eq!(segs.len(), 1);
503        assert_eq!(segs[0].start_sample, 0, "start pad must clamp to 0");
504    }
505
506    #[test]
507    fn pad_does_not_overlap_previous_segment() {
508        // 2 セグメントで、後段の開始 pad が前段の終了 pad を侵さないようクランプ。
509        let mut c = base_config();
510        c.min_silence_ms = 64; // 2 frames
511        c.speech_pad_ms = 192; // 3072 samples = 6 frames (大きめに)
512                               // 5発話 → 3無音 (確定) → 5発話 → 無音
513        let mut probs = vec![0.9f32; 5];
514        probs.extend(vec![0.1f32; 3]);
515        probs.extend(vec![0.9f32; 5]);
516        probs.extend(vec![0.1f32; 5]);
517        let segs = run_probs(&c, &probs);
518        assert_eq!(segs.len(), 2);
519        // seg0 終端 pad と seg1 開始 pad が重ならない (seg1.start >= seg0.end)。
520        assert!(
521            segs[1].start_sample >= segs[0].end_sample,
522            "seg1.start ({}) must be >= seg0.end ({})",
523            segs[1].start_sample,
524            segs[0].end_sample
525        );
526    }
527
528    #[test]
529    fn max_speech_forces_split_when_no_silence() {
530        // 無音が一切無いまま max_speech を超えたら強制分割。
531        let mut c = base_config();
532        c.max_speech_ms = 192; // 3072 samples = 6 frames
533        c.min_silence_ms = 32;
534        // 連続発話 20 フレーム (無音なし)。max_speech=6フレームごとに分割。
535        let probs = vec![0.9f32; 20];
536        let segs = run_probs(&c, &probs);
537        assert!(
538            segs.len() >= 2,
539            "max_speech must force at least one split, got {segs:?}"
540        );
541        // 各セグメントが max_speech 程度で切られている。
542        for s in &segs {
543            assert!(
544                s.len_samples() <= c.ms_to_samples(c.max_speech_ms) + 512,
545                "segment {s:?} exceeds max_speech by more than one frame"
546            );
547        }
548    }
549
550    #[test]
551    fn gray_zone_keeps_triggered() {
552        // threshold > prob >= neg_threshold のグレーゾーンでは発話継続 (切れない)。
553        let mut c = base_config(); // threshold 0.5, neg 0.35
554        c.min_silence_ms = 32;
555        let mut probs = vec![0.9f32; 5];
556        probs.extend(vec![0.4f32; 5]); // グレー (0.35 <= 0.4 < 0.5)
557        probs.extend(vec![0.9f32; 5]);
558        probs.extend(vec![0.1f32; 5]); // 本当の無音
559        let segs = run_probs(&c, &probs);
560        assert_eq!(segs.len(), 1, "gray zone must not split, got {segs:?}");
561        assert_eq!(segs[0].end_sample, 15 * 512);
562    }
563
564    // ---- ONNX 経路スモークテスト ----
565
566    #[test]
567    fn vad_loads_model() {
568        let vad = Vad::new(VadConfig::default());
569        assert!(vad.is_ok(), "model load failed: {:?}", vad.err());
570    }
571
572    #[test]
573    fn zeros_produce_low_prob_no_speech() {
574        let mut vad = Vad::new(VadConfig::default()).unwrap();
575        let zeros = vec![0.0f32; 16000];
576        let events = vad.process(&zeros);
577        // 無音入力では SpeechStart は出ない。
578        assert!(
579            !events
580                .iter()
581                .any(|e| matches!(e, VadEvent::SpeechStart { .. })),
582            "silence must not trigger SpeechStart, got {events:?}"
583        );
584        let probs = vad.last_frame_probabilities();
585        assert_eq!(probs.len(), 16000 / 512, "expected one prob per frame");
586        for &p in probs {
587            assert!((0.0..=1.0).contains(&p), "prob {p} out of [0,1]");
588            assert!(p < 0.5, "silence prob {p} unexpectedly high");
589        }
590    }
591
592    #[test]
593    fn process_streams_across_calls() {
594        // 端数サンプルがコール境界をまたいでも全フレームが処理される。
595        let mut vad = Vad::new(VadConfig::default()).unwrap();
596        // 300 + 300 + ... で 512 境界をまたぐ。合計 512*4 = 2048 サンプルを小分け。
597        let total = 2048usize;
598        let chunk = vec![0.0f32; 300];
599        let mut fed = 0usize;
600        let mut total_probs = 0usize;
601        while fed < total {
602            let take = chunk.len().min(total - fed);
603            vad.process(&chunk[..take]);
604            total_probs += vad.last_frame_probabilities().len();
605            fed += take;
606        }
607        assert_eq!(
608            total_probs,
609            total / 512,
610            "all complete frames must be inferred"
611        );
612    }
613
614    #[test]
615    fn reset_clears_state() {
616        let mut vad = Vad::new(VadConfig::default()).unwrap();
617        vad.process(&vec![0.0f32; 1000]); // pending に端数を残す
618        vad.reset();
619        assert_eq!(vad.last_frame_probabilities().len(), 0);
620        assert_eq!(vad.config().sample_rate, 16000);
621    }
622
623    #[test]
624    fn invalid_sample_rate_rejected() {
625        let c = VadConfig {
626            sample_rate: 44100,
627            ..VadConfig::default()
628        };
629        let r = Vad::new(c);
630        assert!(matches!(r, Err(VadError::InvalidConfig(_))));
631    }
632
633    #[test]
634    fn batch_get_speech_timestamps_on_silence() {
635        let zeros = vec![0.0f32; 16000];
636        let segs = get_speech_timestamps(&zeros, &VadConfig::default()).unwrap();
637        assert!(segs.is_empty(), "silence yields no segments, got {segs:?}");
638    }
639
640    // ---- process_pcm(自己完結入口) ----
641
642    /// 決定的な帯域内(<8kHz)テスト信号。倍音を重ねた合成波でリサンプルの効きが分かる。
643    /// なお silero は合成波を発話とみなさない(既定設定では確率が低い)ので、イベント自体は
644    /// 主に低しきい値の設定で出す。ここでは確率列の一致でリサンプルの正しさを確かめる。
645    fn harmonics(rate: usize, n: usize) -> Vec<f32> {
646        (0..n)
647            .map(|i| {
648                let t = i as f32 / rate as f32;
649                let mut v = 0.0f32;
650                for (k, f) in [180.0f32, 540.0, 1200.0, 2600.0].iter().enumerate() {
651                    v += (0.4 / (k as f32 + 1.0)) * (2.0 * std::f32::consts::PI * f * t).sin();
652                }
653                v * 0.5
654            })
655            .collect()
656    }
657
658    /// mono を interleaved stereo(L=R)に複製する。
659    fn to_stereo(mono: &[f32]) -> Vec<f32> {
660        let mut s = Vec::with_capacity(mono.len() * 2);
661        for &v in mono {
662            s.push(v);
663            s.push(v);
664        }
665        s
666    }
667
668    /// 16k/mono を process_pcm に渡すと process と完全に同じ挙動(イベント列・確率列が
669    /// ビット一致)になる=パススルー経路。
670    #[test]
671    fn process_pcm_passthrough_matches_process() {
672        let sig = harmonics(16_000, 16_000);
673        let mut vad = Vad::new(VadConfig::default()).unwrap();
674
675        let e_pcm = vad.process_pcm(&sig, 16_000, 1);
676        let p_pcm = vad.last_frame_probabilities().to_vec();
677
678        vad.reset();
679        let e_proc = vad.process(&sig);
680        let p_proc = vad.last_frame_probabilities().to_vec();
681
682        assert_eq!(
683            e_pcm, e_proc,
684            "passthrough のイベント列が process と一致しない"
685        );
686        assert_eq!(
687            p_pcm, p_proc,
688            "passthrough の確率列が process とビット一致しない"
689        );
690        assert_eq!(p_pcm.len(), 16_000 / 512, "フレーム数が想定通りでない");
691    }
692
693    /// 48k/stereo を分割して process_pcm に渡しても、一括で渡したときとイベント列・確率列が
694    /// 一致する(リサンプラ状態が呼び出しをまたいで継続=継ぎ目なし)。奇数長で分割して
695    /// フレーム境界もまたがせる。
696    #[test]
697    fn process_pcm_split_matches_bulk() {
698        // 合成信号は silero が発話とみなさない(確率が低い)ので、確率値に依らず
699        // イベントが出る設定にする。threshold=0 で全フレームを発話扱いにし、max_speech で
700        // 一定長ごとに強制分割させる。イベント位置はリサンプル後のフレーム数で決まるので、
701        // 分割と一括が食い違えばイベント列がずれる=継ぎ目検出にもなる。
702        let cfg = VadConfig {
703            threshold: 0.0,
704            neg_threshold: Some(0.0),
705            min_speech_ms: 0,
706            min_silence_ms: 0,
707            speech_pad_ms: 0,
708            max_speech_ms: 200, // 200ms @16k = 3200 サンプルごとに強制分割。
709            sample_rate: 16_000,
710        };
711        let stereo = to_stereo(&harmonics(48_000, 48_000));
712
713        let mut vad = Vad::new(cfg).unwrap();
714
715        // 一括投入。
716        let bulk_events = vad.process_pcm(&stereo, 48_000, 2);
717        let bulk_probs = vad.last_frame_probabilities().to_vec();
718
719        vad.reset();
720
721        // 分割投入(777 サンプル=奇数長でフレーム境界をまたぐ)。
722        let mut split_events = Vec::new();
723        let mut split_probs = Vec::new();
724        for chunk in stereo.chunks(777) {
725            let evs = vad.process_pcm(chunk, 48_000, 2);
726            split_events.extend(evs);
727            split_probs.extend_from_slice(vad.last_frame_probabilities());
728        }
729
730        assert_eq!(
731            bulk_probs, split_probs,
732            "分割と一括で確率列が一致しない(継ぎ目が出ている)"
733        );
734        assert_eq!(
735            bulk_events, split_events,
736            "分割と一括でイベント列が一致しない"
737        );
738        assert!(
739            !bulk_events.is_empty(),
740            "この設定では発話イベントが出るはず(テストの実効性確認)"
741        );
742    }
743
744    /// reset 後に同一入力を process_pcm へ流すと、イベント列・確率列が完全に一致する
745    /// (リサンプラ状態も reset で初期化される)。
746    #[test]
747    fn process_pcm_reset_is_deterministic() {
748        let stereo = to_stereo(&harmonics(48_000, 24_000));
749
750        let mut vad = Vad::new(VadConfig::default()).unwrap();
751        let e1 = vad.process_pcm(&stereo, 48_000, 2);
752        let p1 = vad.last_frame_probabilities().to_vec();
753
754        vad.reset();
755        let e2 = vad.process_pcm(&stereo, 48_000, 2);
756        let p2 = vad.last_frame_probabilities().to_vec();
757
758        assert_eq!(e1, e2, "reset 後に同一入力で同一イベントにならない");
759        assert_eq!(p1, p2, "reset 後に同一入力で同一確率にならない");
760    }
761
762    /// 48k/stereo を process_pcm に流した結果が、同じ波形を直接 16k/mono で作って process に
763    /// 流した結果とほぼ一致する。過渡(先頭・末尾数フレーム)を除いた確率で突き合わせる。
764    #[test]
765    fn process_pcm_48k_stereo_matches_direct_16k() {
766        let n16 = 16_000usize;
767        let ref16 = harmonics(16_000, n16);
768        let stereo48 = to_stereo(&harmonics(48_000, n16 * 3));
769
770        let mut ref_vad = Vad::new(VadConfig::default()).unwrap();
771        let ref_events = ref_vad.process(&ref16);
772        let ref_probs = ref_vad.last_frame_probabilities().to_vec();
773
774        let mut pcm_vad = Vad::new(VadConfig::default()).unwrap();
775        let pcm_events = pcm_vad.process_pcm(&stereo48, 48_000, 2);
776        let pcm_probs = pcm_vad.last_frame_probabilities().to_vec();
777
778        // フレーム数はリサンプラ遅延で最大 1 フレーム差。共通部分の中央で確率を比べる。
779        let n = ref_probs.len().min(pcm_probs.len());
780        assert!(n >= 8, "十分なフレーム数が必要: {n}");
781        for k in 2..(n - 2) {
782            let d = (ref_probs[k] - pcm_probs[k]).abs();
783            assert!(
784                d < 0.05,
785                "frame {k}: 直接16kと変換経路の確率差が大きい: {d} (ref={} pcm={})",
786                ref_probs[k],
787                pcm_probs[k]
788            );
789        }
790        // 既定設定では合成信号は無音扱い=両経路とも発話イベントなし(等価)。
791        assert_eq!(
792            ref_events, pcm_events,
793            "変換経路のイベント列が直接16kと一致しない"
794        );
795    }
796}