flexaudio-vad 0.2.0

Offline Voice Activity Detection (Silero VAD on ONNX Runtime, model embedded) for flexaudio.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
//! flexaudio-vad — silero-VAD を ONNX でオフライン実行する VAD アドオン。
//!
//! `flexaudio-core` には依存せず、`&[f32]` のサンプル列だけを受け取る。silero-VAD
//! モデル (MIT) をバイナリに埋め込むので、実行時のモデルファイルもネットワークも要らない。
//!
//! # 例 (ストリーミング)
//! ```no_run
//! use flexaudio_vad::{Vad, VadConfig, VadEvent};
//! let mut vad = Vad::new(VadConfig::default()).unwrap();
//! for chunk in some_audio_chunks() {
//!     for ev in vad.process(chunk) {
//!         match ev {
//!             VadEvent::SpeechStart { at_sample } => println!("start @ {at_sample}"),
//!             VadEvent::SpeechEnd { at_sample } => println!("end @ {at_sample}"),
//!         }
//!     }
//! }
//! # fn some_audio_chunks() -> Vec<&'static [f32]> { vec![] }
//! ```
//!
//! # 例 (バッチ)
//! ```no_run
//! use flexaudio_vad::{get_speech_timestamps, VadConfig};
//! let samples: Vec<f32> = vec![0.0; 16000];
//! let segments = get_speech_timestamps(&samples, &VadConfig::default()).unwrap();
//! for s in segments {
//!     println!("{}..{}", s.start_sample, s.end_sample);
//! }
//! ```

#![warn(missing_docs)]

mod config;
mod resample;
mod segmenter;

pub use config::VadConfig;
pub use resample::PcmFormat;
pub use segmenter::Segment;

use ort::session::Session;
use ort::value::Tensor;
use resample::PcmConverter;
use segmenter::Segmenter;

/// silero-VAD モデル (v6, MIT)。実行時ファイルを要らなくするためバイナリへ埋め込む。
static MODEL_BYTES: &[u8] = include_bytes!("../assets/silero_vad.onnx");

/// state テンソルの要素数 (2 * 1 * 128)。
const STATE_LEN: usize = 2 * 128;

/// VAD が確定したイベント。サンプル位置はパディング適用後。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VadEvent {
    /// 発話開始 (パディング適用後の絶対サンプル位置)。
    SpeechStart {
        /// 発話が始まった絶対サンプル位置 (パディング適用後・含む)。
        at_sample: u64,
    },
    /// 発話終了 (パディング適用後の絶対サンプル位置・排他的)。
    SpeechEnd {
        /// 発話が終わった絶対サンプル位置 (パディング適用後・排他的)。
        at_sample: u64,
    },
}

/// VAD のエラー型。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VadError {
    /// モデルのロードに失敗。
    ModelLoad(String),
    /// 推論実行中のエラー。
    Inference(String),
    /// 設定値が不正。
    InvalidConfig(String),
}

impl std::fmt::Display for VadError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            VadError::ModelLoad(m) => write!(f, "model load error: {m}"),
            VadError::Inference(m) => write!(f, "inference error: {m}"),
            VadError::InvalidConfig(m) => write!(f, "invalid config: {m}"),
        }
    }
}

impl std::error::Error for VadError {}

/// ストリーミング VAD。1 インスタンスが ONNX `Session` を 1 つ持つ(共有しない)。
///
/// 任意長の `&[f32]` を [`Vad::process`] に流すと、内部で frame (16k=512) 単位に束ねて
/// silero 推論し、セグメント状態機械を回して確定したイベントを返す。
pub struct Vad {
    session: Session,
    config: VadConfig,
    segmenter: Segmenter,

    /// silero state テンソル `[2,1,128]`(フレーム間で継承)。
    state: Vec<f32>,
    /// 前回フレーム末尾の context (16k=64)。次フレームの前置に使う。
    context: Vec<f32>,
    /// frame_size に満たない端数サンプルの残り。
    pending: Vec<f32>,

    /// 直近 [`Vad::process`] で計算した各フレームの生発話確率。
    last_probs: Vec<f32>,

    /// [`Vad::process_pcm`] 用の前段変換器(任意フォーマット → VAD レートの mono)。
    /// 変換が要らないフォーマット(VAD レートの mono)では作られない。入力フォーマットが
    /// 変わったら作り直す。
    converter: Option<PcmConverter>,
}

impl Vad {
    /// 埋め込みモデルをロードして VAD を構築する。
    pub fn new(config: VadConfig) -> Result<Vad, VadError> {
        config.validate().map_err(VadError::InvalidConfig)?;

        let session = Session::builder()
            .map_err(|e| VadError::ModelLoad(e.to_string()))?
            .commit_from_memory(MODEL_BYTES)
            .map_err(|e| VadError::ModelLoad(e.to_string()))?;

        let segmenter = Segmenter::new(&config);
        let ctx_size = config.context_size();

        Ok(Vad {
            session,
            config,
            segmenter,
            state: vec![0.0f32; STATE_LEN],
            context: vec![0.0f32; ctx_size],
            pending: Vec::new(),
            last_probs: Vec::new(),
            converter: None,
        })
    }

    /// 任意長の f32 サンプルを処理し、確定した [`VadEvent`] を返す。
    ///
    /// 内部で frame_size 単位に束ね、満たないサンプルは次回まで保持する。サンプル位置は
    /// 呼び出しをまたいで連続する(累積)。
    pub fn process(&mut self, samples: &[f32]) -> Vec<VadEvent> {
        let frame_size = self.config.frame_size();
        self.last_probs.clear();

        let mut segments_out = Vec::new();
        let mut events = Vec::new();

        // pending + 新規サンプルを連結して frame_size 単位に消費。
        self.pending.extend_from_slice(samples);

        let mut offset = 0;
        // self.pending を借用したまま &mut self の infer_frame には渡せないので、
        // 1 フレーム分を局所バッファへコピーしてから推論する。
        let mut frame_buf = vec![0.0f32; frame_size];
        while offset + frame_size <= self.pending.len() {
            frame_buf.copy_from_slice(&self.pending[offset..offset + frame_size]);
            // 推論失敗時は無音 (0.0) に倒して継続。
            let prob = self.infer_frame(&frame_buf).unwrap_or(0.0);
            self.last_probs.push(prob);
            self.segmenter.feed(prob, &mut segments_out);
            offset += frame_size;
        }
        // 消費済み分を捨てる。
        self.pending.drain(0..offset);

        for seg in segments_out {
            events.push(VadEvent::SpeechStart {
                at_sample: seg.start_sample,
            });
            events.push(VadEvent::SpeechEnd {
                at_sample: seg.end_sample,
            });
        }
        events
    }

    /// 録音チャンクをそのまま渡せる自己完結の入口。任意フォーマット(`input_sample_rate` /
    /// `input_channels` の interleaved f32)を内部で mono 化・VAD レートへリサンプルしてから
    /// [`Vad::process`] と同じ経路にかける。
    ///
    /// 各言語バインディングが録音チャンク(例 48k/stereo)を変換せずそのまま流せるようにする
    /// のが狙い。`samples` は interleaved で、長さは `input_channels` の倍数を想定する
    /// (端数フレームは次回まで内部に持ち越すので、任意の位置で分割して渡してよい)。
    ///
    /// 入力が既に VAD レート([`VadConfig::sample_rate`])の mono なら、mono 化もリサンプル
    /// もせず [`Vad::process`] へそのまま渡す(追加コストなし)。それ以外は各チャンネルの
    /// 平均で mono 化し、rubato でリサンプルする。リサンプラは呼び出しをまたいで状態を持つ
    /// ので、連続ストリームでも継ぎ目は出ない。
    ///
    /// 返す [`VadEvent`] の `at_sample` は **VAD 内部レート(`config().sample_rate`=16000
    /// か 8000)のサンプル基準**で、入力サンプル基準ではない(リサンプル後の内部位置の累積)。
    /// [`Vad::process`] と揃えてあるので両者を混ぜても位置は連続する。秒に直すなら
    /// `at_sample as f64 / config().sample_rate as f64`、入力サンプル位置の目安が要るなら
    /// `at_sample * input_sample_rate / config().sample_rate` で近似できる。
    ///
    /// リサンプラの構築や実行に失敗した場合(極端なレート比など)は、その呼び出し分を捨てて
    /// 空のイベント列を返す(取り込みを panic で止めない。[`Vad::process`] が推論失敗を無音に
    /// 倒すのと同じ方針)。
    pub fn process_pcm(
        &mut self,
        samples: &[f32],
        input_sample_rate: u32,
        input_channels: u16,
    ) -> Vec<VadEvent> {
        let target = self.config.sample_rate;
        let format = PcmFormat {
            sample_rate: input_sample_rate,
            channels: input_channels,
        };

        // 入力フォーマットが前回と変わったら変換器を捨てる(必要なら下で作り直す)。
        if let Some(c) = &self.converter {
            if !c.matches(format) {
                self.converter = None;
            }
        }

        // 変換不要(既に VAD レートの mono)なら余計なコピーもリサンプルもせず既存経路へ。
        if input_sample_rate == target && input_channels <= 1 {
            return self.process(samples);
        }

        // 変換器を用意(初回・またはフォーマット変更時)。構築失敗はこの呼び出しを捨てて継続。
        if self.converter.is_none() {
            match PcmConverter::new(format, target) {
                Ok(c) => self.converter = Some(c),
                Err(_) => return Vec::new(),
            }
        }

        // mono 化 + リサンプルして VAD レートの mono を得てから既存経路へ流す。
        let mut converted = Vec::new();
        {
            let conv = self.converter.as_mut().expect("converter は直前に用意済み");
            if conv.convert(samples, &mut converted).is_err() {
                return Vec::new();
            }
        }
        self.process(&converted)
    }

    /// 1 フレーム (frame_size サンプル) を silero に通し発話確率を返す。
    ///
    /// silero の入力は frame そのものではなく `concat(context, frame)` で、長さは
    /// `context_size + frame_size` (16k=64+512=576)。推論後、context を今回入力末尾の
    /// `context_size` サンプルで、state を出力 state で更新する。
    fn infer_frame(&mut self, frame: &[f32]) -> Result<f32, VadError> {
        let ctx_size = self.config.context_size();
        let frame_size = self.config.frame_size();
        debug_assert_eq!(frame.len(), frame_size);
        debug_assert_eq!(self.context.len(), ctx_size);

        // x = concat(context, frame)。長さ ctx_size + frame_size (16k=576)。
        let input_len = ctx_size + frame_size;
        let mut x = Vec::with_capacity(input_len);
        x.extend_from_slice(&self.context);
        x.extend_from_slice(frame);

        // 次回 context = 今回入力末尾の ctx_size サンプル。x は run へ move されるので
        // 先に控えておく。
        let next_context: Vec<f32> = x[input_len - ctx_size..].to_vec();

        // 入力テンソル: input f32 [1, input_len], state f32 [2,1,128], sr int64 scalar。
        let input_tensor = Tensor::from_array(([1_i64, input_len as i64], x))
            .map_err(|e| VadError::Inference(e.to_string()))?;
        let state_tensor = Tensor::from_array(([2_i64, 1, 128], self.state.clone()))
            .map_err(|e| VadError::Inference(e.to_string()))?;
        // sr はランク0スカラー int64(モデル入力 shape は [])。
        let sr_tensor =
            Tensor::from_array((vec![] as Vec<i64>, vec![self.config.sample_rate as i64]))
                .map_err(|e| VadError::Inference(e.to_string()))?;

        let outputs = self
            .session
            .run(ort::inputs![
                "input" => input_tensor,
                "state" => state_tensor,
                "sr" => sr_tensor,
            ])
            .map_err(|e| VadError::Inference(e.to_string()))?;

        // 出力名: 確率 = "output", 更新後 state = "stateN"(実モデルで確認済み)。
        let (_pshape, prob_slice) = outputs["output"]
            .try_extract_tensor::<f32>()
            .map_err(|e| VadError::Inference(e.to_string()))?;
        let prob = *prob_slice
            .first()
            .ok_or_else(|| VadError::Inference("empty output tensor".to_string()))?;

        let (_sshape, state_slice) = outputs["stateN"]
            .try_extract_tensor::<f32>()
            .map_err(|e| VadError::Inference(e.to_string()))?;
        if state_slice.len() != STATE_LEN {
            return Err(VadError::Inference(format!(
                "unexpected state length {} (expected {STATE_LEN})",
                state_slice.len()
            )));
        }
        let new_state = state_slice.to_vec();

        // 出力の借用が outputs/session を握っているので、コピーし終えてから更新する。
        drop(outputs);
        self.state = new_state;
        self.context = next_context;

        Ok(prob)
    }

    /// 直近 [`Vad::process`] で計算した各フレームの生発話確率を返す。
    ///
    /// セグメントイベントとは独立した第二の出力。
    pub fn last_frame_probabilities(&self) -> &[f32] {
        &self.last_probs
    }

    /// state / context / 状態機械 / サンプル位置 / 端数バッファ / リサンプラ状態を
    /// すべて初期化する。
    pub fn reset(&mut self) {
        self.state = vec![0.0f32; STATE_LEN];
        self.context = vec![0.0f32; self.config.context_size()];
        self.pending.clear();
        self.last_probs.clear();
        self.segmenter.reset();
        // 変換器を捨てる。次の process_pcm がフォーマットに応じて作り直すので、
        // リサンプラの内部遅延・端数もまとめてリセットされる。
        self.converter = None;
    }

    /// 現在の設定への参照。
    pub fn config(&self) -> &VadConfig {
        &self.config
    }
}

/// バッチ処理用(silero `get_speech_timestamps` 相当)。
///
/// 全サンプルを一括処理して確定セグメント列を返す。末尾で発話中なら入力終端まで採る。
pub fn get_speech_timestamps(
    samples: &[f32],
    config: &VadConfig,
) -> Result<Vec<Segment>, VadError> {
    let mut vad = Vad::new(config.clone())?;
    let frame_size = config.frame_size();

    let mut out = Vec::new();
    let mut chunks = samples.chunks_exact(frame_size);
    for frame in chunks.by_ref() {
        let prob = vad.infer_frame(frame)?;
        vad.last_probs.push(prob);
        vad.segmenter.feed(prob, &mut out);
    }
    // 端数フレームは silero と同じく推論せず捨てる。入力終端で発話中なら確定。
    vad.segmenter.flush(&mut out);

    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::VadConfig;
    use crate::segmenter::{Segment, Segmenter};

    /// frame_size=512 @16k 前提で、確率列をセグメンタに流しセグメント列を得るヘルパ。
    fn run_probs(config: &VadConfig, probs: &[f32]) -> Vec<Segment> {
        let mut seg = Segmenter::new(config);
        let mut out = Vec::new();
        for &p in probs {
            seg.feed(p, &mut out);
        }
        seg.flush(&mut out);
        out
    }

    fn base_config() -> VadConfig {
        // pad=0 にして純粋な境界ロジックを検証しやすくする。512 サンプル/フレーム前提。
        VadConfig {
            threshold: 0.5,
            neg_threshold: Some(0.35),
            min_speech_ms: 0, // 破棄を切る (個別テストで上書き)
            min_silence_ms: 0,
            speech_pad_ms: 0,
            max_speech_ms: 0,
            sample_rate: 16000,
        }
    }

    #[test]
    fn neg_threshold_default_formula() {
        let mut c = VadConfig::default();
        assert_eq!(c.resolved_neg_threshold(), (0.5 - 0.15_f32).max(0.01));
        c.threshold = 0.1;
        assert_eq!(c.resolved_neg_threshold(), 0.01); // クランプ下限
        c.neg_threshold = Some(0.2);
        assert_eq!(c.resolved_neg_threshold(), 0.2); // 明示優先
    }

    #[test]
    fn simple_speech_then_silence() {
        // 512 サンプル/フレーム。min_silence=512 (=1フレーム), min_speech=0。
        let mut c = base_config();
        c.min_silence_ms = 32; // 32ms @16k = 512 samples = 1 frame
                               // 20 フレーム発話 → 30 フレーム無音
        let mut probs = vec![0.9f32; 20];
        probs.extend(vec![0.1f32; 30]);
        let segs = run_probs(&c, &probs);
        assert_eq!(segs.len(), 1, "exactly one segment");
        let s = segs[0];
        // 開始 = フレーム0先頭 = 0。
        assert_eq!(s.start_sample, 0);
        // 終了 = 無音開始位置 (フレーム20先頭 = 20*512)。
        assert_eq!(s.end_sample, 20 * 512);
    }

    #[test]
    fn min_speech_discards_short_segment() {
        let mut c = base_config();
        c.min_silence_ms = 32; // 1 frame
        c.min_speech_ms = 250; // 250ms @16k = 4000 samples ≈ 7.8 frames
                               // 5 フレーム発話 (5*512=2560 < 4000) → 破棄されるべき
        let mut probs = vec![0.9f32; 5];
        probs.extend(vec![0.1f32; 10]);
        let segs = run_probs(&c, &probs);
        assert!(
            segs.is_empty(),
            "short segment must be discarded, got {segs:?}"
        );

        // 10 フレーム発話 (5120 >= 4000) → 採用
        let mut probs2 = vec![0.9f32; 10];
        probs2.extend(vec![0.1f32; 10]);
        let segs2 = run_probs(&c, &probs2);
        assert_eq!(segs2.len(), 1);
        assert_eq!(segs2[0].len_samples(), 10 * 512);
    }

    #[test]
    fn min_silence_boundary_keeps_segment_together() {
        // 短い無音 (< min_silence) はセグメントを切らない。
        let mut c = base_config();
        c.min_silence_ms = 192; // 192ms @16k = 3072 samples = 6 frames
                                // 10発話 → 3無音 (3*512=1536 < 3072 なので継続) → 10発話 → 長い無音
        let mut probs = vec![0.9f32; 10];
        probs.extend(vec![0.1f32; 3]);
        probs.extend(vec![0.9f32; 10]);
        probs.extend(vec![0.1f32; 10]); // 10*512=5120 >= 3072 で確定
        let segs = run_probs(&c, &probs);
        assert_eq!(segs.len(), 1, "short gap must NOT split, got {segs:?}");
        assert_eq!(segs[0].start_sample, 0);
        // 終端 = 2回目発話塊の後の無音開始 = フレーム23先頭。
        assert_eq!(segs[0].end_sample, 23 * 512);
    }

    #[test]
    fn min_silence_just_over_splits() {
        // min_silence をちょうど超える無音は切る。
        let mut c = base_config();
        c.min_silence_ms = 64; // 64ms = 1024 samples = 2 frames
                               // 5発話 → 3無音 (3*512=1536) → 5発話 → 無音
                               // 無音3フレーム目で (frame_start - temp_end) を評価:
                               //   temp_end は無音1フレーム目先頭。無音Nフレーム目先頭 - temp_end = (N-1)*512。
                               //   >= 1024 となるのは N-1 >= 2 → N>=3 → 3フレーム目の feed で確定。
        let mut probs = vec![0.9f32; 5];
        probs.extend(vec![0.1f32; 3]);
        probs.extend(vec![0.9f32; 5]);
        probs.extend(vec![0.1f32; 5]);
        let segs = run_probs(&c, &probs);
        assert_eq!(segs.len(), 2, "long gap must split into two, got {segs:?}");
        assert_eq!(segs[0].start_sample, 0);
        assert_eq!(segs[0].end_sample, 5 * 512); // 無音開始位置
    }

    #[test]
    fn speech_pad_extends_and_clamps() {
        let mut c = base_config();
        c.min_silence_ms = 32; // 1 frame
        c.speech_pad_ms = 32; // 32ms = 512 samples
                              // フレーム2..5 が発話 (先頭に無音2フレームを置き、開始 pad が 0 でクランプされない様に)
        let mut probs = vec![0.1f32; 2]; // フレーム0,1 無音
        probs.extend(vec![0.9f32; 4]); // フレーム2..5 発話 (開始=2*512=1024)
        probs.extend(vec![0.1f32; 5]); // 無音
        let segs = run_probs(&c, &probs);
        assert_eq!(segs.len(), 1);
        // 開始 = 1024 - 512 = 512。
        assert_eq!(segs[0].start_sample, 1024 - 512);
        // 終了 = 無音開始(6*512=3072) + 512 = 3584。
        assert_eq!(segs[0].end_sample, 6 * 512 + 512);
    }

    #[test]
    fn speech_pad_start_clamps_at_zero() {
        // フレーム0から発話 → 開始 pad が underflow せず 0 になる。
        let mut c = base_config();
        c.min_silence_ms = 32;
        c.speech_pad_ms = 64; // 1024 samples
        let mut probs = vec![0.9f32; 5];
        probs.extend(vec![0.1f32; 5]);
        let segs = run_probs(&c, &probs);
        assert_eq!(segs.len(), 1);
        assert_eq!(segs[0].start_sample, 0, "start pad must clamp to 0");
    }

    #[test]
    fn pad_does_not_overlap_previous_segment() {
        // 2 セグメントで、後段の開始 pad が前段の終了 pad を侵さないようクランプ。
        let mut c = base_config();
        c.min_silence_ms = 64; // 2 frames
        c.speech_pad_ms = 192; // 3072 samples = 6 frames (大きめに)
                               // 5発話 → 3無音 (確定) → 5発話 → 無音
        let mut probs = vec![0.9f32; 5];
        probs.extend(vec![0.1f32; 3]);
        probs.extend(vec![0.9f32; 5]);
        probs.extend(vec![0.1f32; 5]);
        let segs = run_probs(&c, &probs);
        assert_eq!(segs.len(), 2);
        // seg0 終端 pad と seg1 開始 pad が重ならない (seg1.start >= seg0.end)。
        assert!(
            segs[1].start_sample >= segs[0].end_sample,
            "seg1.start ({}) must be >= seg0.end ({})",
            segs[1].start_sample,
            segs[0].end_sample
        );
    }

    #[test]
    fn max_speech_forces_split_when_no_silence() {
        // 無音が一切無いまま max_speech を超えたら強制分割。
        let mut c = base_config();
        c.max_speech_ms = 192; // 3072 samples = 6 frames
        c.min_silence_ms = 32;
        // 連続発話 20 フレーム (無音なし)。max_speech=6フレームごとに分割。
        let probs = vec![0.9f32; 20];
        let segs = run_probs(&c, &probs);
        assert!(
            segs.len() >= 2,
            "max_speech must force at least one split, got {segs:?}"
        );
        // 各セグメントが max_speech 程度で切られている。
        for s in &segs {
            assert!(
                s.len_samples() <= c.ms_to_samples(c.max_speech_ms) + 512,
                "segment {s:?} exceeds max_speech by more than one frame"
            );
        }
    }

    #[test]
    fn gray_zone_keeps_triggered() {
        // threshold > prob >= neg_threshold のグレーゾーンでは発話継続 (切れない)。
        let mut c = base_config(); // threshold 0.5, neg 0.35
        c.min_silence_ms = 32;
        let mut probs = vec![0.9f32; 5];
        probs.extend(vec![0.4f32; 5]); // グレー (0.35 <= 0.4 < 0.5)
        probs.extend(vec![0.9f32; 5]);
        probs.extend(vec![0.1f32; 5]); // 本当の無音
        let segs = run_probs(&c, &probs);
        assert_eq!(segs.len(), 1, "gray zone must not split, got {segs:?}");
        assert_eq!(segs[0].end_sample, 15 * 512);
    }

    // ---- ONNX 経路スモークテスト ----

    #[test]
    fn vad_loads_model() {
        let vad = Vad::new(VadConfig::default());
        assert!(vad.is_ok(), "model load failed: {:?}", vad.err());
    }

    #[test]
    fn zeros_produce_low_prob_no_speech() {
        let mut vad = Vad::new(VadConfig::default()).unwrap();
        let zeros = vec![0.0f32; 16000];
        let events = vad.process(&zeros);
        // 無音入力では SpeechStart は出ない。
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, VadEvent::SpeechStart { .. })),
            "silence must not trigger SpeechStart, got {events:?}"
        );
        let probs = vad.last_frame_probabilities();
        assert_eq!(probs.len(), 16000 / 512, "expected one prob per frame");
        for &p in probs {
            assert!((0.0..=1.0).contains(&p), "prob {p} out of [0,1]");
            assert!(p < 0.5, "silence prob {p} unexpectedly high");
        }
    }

    #[test]
    fn process_streams_across_calls() {
        // 端数サンプルがコール境界をまたいでも全フレームが処理される。
        let mut vad = Vad::new(VadConfig::default()).unwrap();
        // 300 + 300 + ... で 512 境界をまたぐ。合計 512*4 = 2048 サンプルを小分け。
        let total = 2048usize;
        let chunk = vec![0.0f32; 300];
        let mut fed = 0usize;
        let mut total_probs = 0usize;
        while fed < total {
            let take = chunk.len().min(total - fed);
            vad.process(&chunk[..take]);
            total_probs += vad.last_frame_probabilities().len();
            fed += take;
        }
        assert_eq!(
            total_probs,
            total / 512,
            "all complete frames must be inferred"
        );
    }

    #[test]
    fn reset_clears_state() {
        let mut vad = Vad::new(VadConfig::default()).unwrap();
        vad.process(&vec![0.0f32; 1000]); // pending に端数を残す
        vad.reset();
        assert_eq!(vad.last_frame_probabilities().len(), 0);
        assert_eq!(vad.config().sample_rate, 16000);
    }

    #[test]
    fn invalid_sample_rate_rejected() {
        let c = VadConfig {
            sample_rate: 44100,
            ..VadConfig::default()
        };
        let r = Vad::new(c);
        assert!(matches!(r, Err(VadError::InvalidConfig(_))));
    }

    #[test]
    fn batch_get_speech_timestamps_on_silence() {
        let zeros = vec![0.0f32; 16000];
        let segs = get_speech_timestamps(&zeros, &VadConfig::default()).unwrap();
        assert!(segs.is_empty(), "silence yields no segments, got {segs:?}");
    }

    // ---- process_pcm(自己完結入口) ----

    /// 決定的な帯域内(<8kHz)テスト信号。倍音を重ねた合成波でリサンプルの効きが分かる。
    /// なお silero は合成波を発話とみなさない(既定設定では確率が低い)ので、イベント自体は
    /// 主に低しきい値の設定で出す。ここでは確率列の一致でリサンプルの正しさを確かめる。
    fn harmonics(rate: usize, n: usize) -> Vec<f32> {
        (0..n)
            .map(|i| {
                let t = i as f32 / rate as f32;
                let mut v = 0.0f32;
                for (k, f) in [180.0f32, 540.0, 1200.0, 2600.0].iter().enumerate() {
                    v += (0.4 / (k as f32 + 1.0)) * (2.0 * std::f32::consts::PI * f * t).sin();
                }
                v * 0.5
            })
            .collect()
    }

    /// mono を interleaved stereo(L=R)に複製する。
    fn to_stereo(mono: &[f32]) -> Vec<f32> {
        let mut s = Vec::with_capacity(mono.len() * 2);
        for &v in mono {
            s.push(v);
            s.push(v);
        }
        s
    }

    /// 16k/mono を process_pcm に渡すと process と完全に同じ挙動(イベント列・確率列が
    /// ビット一致)になる=パススルー経路。
    #[test]
    fn process_pcm_passthrough_matches_process() {
        let sig = harmonics(16_000, 16_000);
        let mut vad = Vad::new(VadConfig::default()).unwrap();

        let e_pcm = vad.process_pcm(&sig, 16_000, 1);
        let p_pcm = vad.last_frame_probabilities().to_vec();

        vad.reset();
        let e_proc = vad.process(&sig);
        let p_proc = vad.last_frame_probabilities().to_vec();

        assert_eq!(
            e_pcm, e_proc,
            "passthrough のイベント列が process と一致しない"
        );
        assert_eq!(
            p_pcm, p_proc,
            "passthrough の確率列が process とビット一致しない"
        );
        assert_eq!(p_pcm.len(), 16_000 / 512, "フレーム数が想定通りでない");
    }

    /// 48k/stereo を分割して process_pcm に渡しても、一括で渡したときとイベント列・確率列が
    /// 一致する(リサンプラ状態が呼び出しをまたいで継続=継ぎ目なし)。奇数長で分割して
    /// フレーム境界もまたがせる。
    #[test]
    fn process_pcm_split_matches_bulk() {
        // 合成信号は silero が発話とみなさない(確率が低い)ので、確率値に依らず
        // イベントが出る設定にする。threshold=0 で全フレームを発話扱いにし、max_speech で
        // 一定長ごとに強制分割させる。イベント位置はリサンプル後のフレーム数で決まるので、
        // 分割と一括が食い違えばイベント列がずれる=継ぎ目検出にもなる。
        let cfg = VadConfig {
            threshold: 0.0,
            neg_threshold: Some(0.0),
            min_speech_ms: 0,
            min_silence_ms: 0,
            speech_pad_ms: 0,
            max_speech_ms: 200, // 200ms @16k = 3200 サンプルごとに強制分割。
            sample_rate: 16_000,
        };
        let stereo = to_stereo(&harmonics(48_000, 48_000));

        let mut vad = Vad::new(cfg).unwrap();

        // 一括投入。
        let bulk_events = vad.process_pcm(&stereo, 48_000, 2);
        let bulk_probs = vad.last_frame_probabilities().to_vec();

        vad.reset();

        // 分割投入(777 サンプル=奇数長でフレーム境界をまたぐ)。
        let mut split_events = Vec::new();
        let mut split_probs = Vec::new();
        for chunk in stereo.chunks(777) {
            let evs = vad.process_pcm(chunk, 48_000, 2);
            split_events.extend(evs);
            split_probs.extend_from_slice(vad.last_frame_probabilities());
        }

        assert_eq!(
            bulk_probs, split_probs,
            "分割と一括で確率列が一致しない(継ぎ目が出ている)"
        );
        assert_eq!(
            bulk_events, split_events,
            "分割と一括でイベント列が一致しない"
        );
        assert!(
            !bulk_events.is_empty(),
            "この設定では発話イベントが出るはず(テストの実効性確認)"
        );
    }

    /// reset 後に同一入力を process_pcm へ流すと、イベント列・確率列が完全に一致する
    /// (リサンプラ状態も reset で初期化される)。
    #[test]
    fn process_pcm_reset_is_deterministic() {
        let stereo = to_stereo(&harmonics(48_000, 24_000));

        let mut vad = Vad::new(VadConfig::default()).unwrap();
        let e1 = vad.process_pcm(&stereo, 48_000, 2);
        let p1 = vad.last_frame_probabilities().to_vec();

        vad.reset();
        let e2 = vad.process_pcm(&stereo, 48_000, 2);
        let p2 = vad.last_frame_probabilities().to_vec();

        assert_eq!(e1, e2, "reset 後に同一入力で同一イベントにならない");
        assert_eq!(p1, p2, "reset 後に同一入力で同一確率にならない");
    }

    /// 48k/stereo を process_pcm に流した結果が、同じ波形を直接 16k/mono で作って process に
    /// 流した結果とほぼ一致する。過渡(先頭・末尾数フレーム)を除いた確率で突き合わせる。
    #[test]
    fn process_pcm_48k_stereo_matches_direct_16k() {
        let n16 = 16_000usize;
        let ref16 = harmonics(16_000, n16);
        let stereo48 = to_stereo(&harmonics(48_000, n16 * 3));

        let mut ref_vad = Vad::new(VadConfig::default()).unwrap();
        let ref_events = ref_vad.process(&ref16);
        let ref_probs = ref_vad.last_frame_probabilities().to_vec();

        let mut pcm_vad = Vad::new(VadConfig::default()).unwrap();
        let pcm_events = pcm_vad.process_pcm(&stereo48, 48_000, 2);
        let pcm_probs = pcm_vad.last_frame_probabilities().to_vec();

        // フレーム数はリサンプラ遅延で最大 1 フレーム差。共通部分の中央で確率を比べる。
        let n = ref_probs.len().min(pcm_probs.len());
        assert!(n >= 8, "十分なフレーム数が必要: {n}");
        for k in 2..(n - 2) {
            let d = (ref_probs[k] - pcm_probs[k]).abs();
            assert!(
                d < 0.05,
                "frame {k}: 直接16kと変換経路の確率差が大きい: {d} (ref={} pcm={})",
                ref_probs[k],
                pcm_probs[k]
            );
        }
        // 既定設定では合成信号は無音扱い=両経路とも発話イベントなし(等価)。
        assert_eq!(
            ref_events, pcm_events,
            "変換経路のイベント列が直接16kと一致しない"
        );
    }
}