Skip to main content

piper_plus/
streaming.rs

1//! Streaming synthesis pipeline
2//!
3//! テキストをセンテンス単位に分割し、逐次合成してAudioSinkに送出する。
4
5use std::io::{Seek, Write};
6use std::path::Path;
7
8use crate::error::PiperError;
9
10// ---------------------------------------------------------------------------
11// AudioSink trait
12// ---------------------------------------------------------------------------
13
14/// Audio output sink trait for receiving synthesized audio chunks.
15///
16/// Implementations include WAV file, in-memory buffer, rodio playback, etc.
17/// Object-safe: no generics in methods.
18pub trait AudioSink {
19    /// Called for each audio chunk produced by the synthesizer.
20    fn write_chunk(&mut self, samples: &[i16], sample_rate: u32) -> Result<(), PiperError>;
21
22    /// Called when synthesis is complete.
23    fn finalize(&mut self) -> Result<(), PiperError>;
24}
25
26// ---------------------------------------------------------------------------
27// StreamingResult
28// ---------------------------------------------------------------------------
29
30/// Streaming synthesis result summary
31#[derive(Debug, Clone)]
32pub struct StreamingResult {
33    /// Total audio duration in seconds across all chunks
34    pub total_audio_seconds: f64,
35    /// Total inference wall-clock time in seconds across all chunks
36    pub total_infer_seconds: f64,
37    /// Number of chunks synthesized
38    pub chunk_count: usize,
39}
40
41// ---------------------------------------------------------------------------
42// BufferSink
43// ---------------------------------------------------------------------------
44
45/// In-memory buffer sink that collects all audio chunks into a single Vec.
46pub struct BufferSink {
47    samples: Vec<i16>,
48    sample_rate: Option<u32>,
49}
50
51impl BufferSink {
52    /// Create a new empty buffer sink.
53    pub fn new() -> Self {
54        Self {
55            samples: Vec::new(),
56            sample_rate: None,
57        }
58    }
59
60    /// Return accumulated samples.
61    pub fn get_samples(&self) -> &[i16] {
62        &self.samples
63    }
64
65    /// Return the sample rate from the last written chunk, if any.
66    pub fn sample_rate(&self) -> Option<u32> {
67        self.sample_rate
68    }
69}
70
71impl Default for BufferSink {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl AudioSink for BufferSink {
78    fn write_chunk(&mut self, samples: &[i16], sample_rate: u32) -> Result<(), PiperError> {
79        self.sample_rate = Some(sample_rate);
80        self.samples.extend_from_slice(samples);
81        Ok(())
82    }
83
84    fn finalize(&mut self) -> Result<(), PiperError> {
85        Ok(())
86    }
87}
88
89// ---------------------------------------------------------------------------
90// WavFileSink
91// ---------------------------------------------------------------------------
92
93/// WAV file sink that writes audio incrementally.
94///
95/// Writes the WAV header on the first `write_chunk` call with a placeholder
96/// data size, then appends sample data on each chunk. On `finalize`, seeks
97/// back to update the RIFF file size and data chunk size fields.
98pub struct WavFileSink {
99    file: std::fs::File,
100    sample_rate: u32,
101    total_samples: usize,
102    header_written: bool,
103}
104
105impl WavFileSink {
106    /// Create a new WAV file sink.
107    ///
108    /// The file is created immediately but the WAV header is written on the
109    /// first call to `write_chunk` (so that we know the sample rate).
110    pub fn new(path: &Path) -> Result<Self, PiperError> {
111        let file = std::fs::File::create(path)?;
112        Ok(Self {
113            file,
114            sample_rate: 0,
115            total_samples: 0,
116            header_written: false,
117        })
118    }
119
120    /// Write the 44-byte WAV header with placeholder sizes.
121    fn write_header(&mut self, sample_rate: u32) -> Result<(), PiperError> {
122        let placeholder_data_size: u32 = 0;
123        let placeholder_file_size: u32 = 36; // 44 - 8
124
125        // RIFF header
126        self.file.write_all(b"RIFF")?;
127        self.file.write_all(&placeholder_file_size.to_le_bytes())?;
128        self.file.write_all(b"WAVE")?;
129
130        // fmt chunk
131        self.file.write_all(b"fmt ")?;
132        self.file.write_all(&16u32.to_le_bytes())?; // chunk size
133        self.file.write_all(&1u16.to_le_bytes())?; // PCM format
134        self.file.write_all(&1u16.to_le_bytes())?; // mono
135        self.file.write_all(&sample_rate.to_le_bytes())?;
136        self.file.write_all(&(sample_rate * 2).to_le_bytes())?; // byte rate
137        self.file.write_all(&2u16.to_le_bytes())?; // block align
138        self.file.write_all(&16u16.to_le_bytes())?; // bits per sample
139
140        // data chunk header
141        self.file.write_all(b"data")?;
142        self.file.write_all(&placeholder_data_size.to_le_bytes())?;
143
144        self.sample_rate = sample_rate;
145        self.header_written = true;
146        Ok(())
147    }
148
149    /// Update the RIFF and data chunk sizes in the WAV header.
150    fn update_sizes(&mut self) -> Result<(), PiperError> {
151        let data_size_u64 = (self.total_samples as u64) * 2;
152        if data_size_u64 > u32::MAX as u64 {
153            return Err(PiperError::Streaming(
154                "WAV file exceeds 4GB limit".to_string(),
155            ));
156        }
157        let data_size = data_size_u64 as u32;
158        let file_size = data_size + 36;
159
160        // Update RIFF chunk size at offset 4
161        self.file.seek(std::io::SeekFrom::Start(4))?;
162        self.file.write_all(&file_size.to_le_bytes())?;
163
164        // Update data chunk size at offset 40
165        self.file.seek(std::io::SeekFrom::Start(40))?;
166        self.file.write_all(&data_size.to_le_bytes())?;
167
168        // Flush
169        self.file.flush()?;
170        Ok(())
171    }
172}
173
174impl Drop for WavFileSink {
175    fn drop(&mut self) {
176        // Ensure the WAV header is updated even if the caller forgets to
177        // call finalize(). Errors are intentionally ignored during drop.
178        let _ = self.finalize();
179    }
180}
181
182impl AudioSink for WavFileSink {
183    fn write_chunk(&mut self, samples: &[i16], sample_rate: u32) -> Result<(), PiperError> {
184        if !self.header_written {
185            self.write_header(sample_rate)?;
186        }
187
188        // Reject mismatched sample rates after the header has been written
189        if self.sample_rate != sample_rate {
190            return Err(PiperError::Streaming(format!(
191                "sample rate mismatch: expected {}, got {}",
192                self.sample_rate, sample_rate
193            )));
194        }
195
196        // Write raw PCM sample data (batched to avoid per-sample syscalls)
197        let mut buf = Vec::with_capacity(samples.len() * 2);
198        for &sample in samples {
199            buf.extend_from_slice(&sample.to_le_bytes());
200        }
201        self.file.write_all(&buf)?;
202        self.total_samples += samples.len();
203        Ok(())
204    }
205
206    fn finalize(&mut self) -> Result<(), PiperError> {
207        if self.header_written {
208            self.update_sizes()?;
209        }
210        Ok(())
211    }
212}
213
214// ---------------------------------------------------------------------------
215// crossfade
216// ---------------------------------------------------------------------------
217
218/// Crossfade between two audio chunks using linear interpolation (overlap-add).
219///
220/// `prev_tail` is the end of the previous chunk, `next_head` is the start of
221/// the next chunk. `overlap_samples` controls how many samples are blended.
222///
223/// If `overlap_samples` exceeds the length of either slice, it is clamped to
224/// the shorter of the two.
225///
226/// Returns a Vec containing the blended overlap region.
227pub fn crossfade(prev_tail: &[i16], next_head: &[i16], overlap_samples: usize) -> Vec<i16> {
228    let actual_overlap = overlap_samples.min(prev_tail.len()).min(next_head.len());
229
230    if actual_overlap == 0 {
231        return Vec::new();
232    }
233
234    let mut blended = Vec::with_capacity(actual_overlap);
235    for i in 0..actual_overlap {
236        // Linear fade: prev fades out, next fades in
237        let alpha = if actual_overlap <= 1 {
238            1.0
239        } else {
240            (i as f64) / ((actual_overlap - 1) as f64)
241        };
242        let prev_sample = prev_tail[prev_tail.len() - actual_overlap + i] as f64;
243        let next_sample = next_head[i] as f64;
244        let mixed = prev_sample * (1.0 - alpha) + next_sample * alpha;
245        blended.push(mixed.clamp(-32768.0, 32767.0) as i16);
246    }
247    blended
248}
249
250// ---------------------------------------------------------------------------
251// split_sentences
252// ---------------------------------------------------------------------------
253
254/// Split text into sentence-sized chunks suitable for streaming synthesis.
255///
256/// Splits on sentence-ending punctuation while preserving the punctuation at
257/// the end of each chunk. Handles both Japanese (。!?) and Western (.!?)
258/// sentence terminators.
259///
260/// Consecutive whitespace between sentences is trimmed.
261/// Empty text returns an empty Vec.
262pub fn split_sentences(text: &str) -> Vec<String> {
263    if text.is_empty() {
264        return Vec::new();
265    }
266
267    let mut sentences = Vec::new();
268    let mut current = String::new();
269
270    let mut chars = text.chars().peekable();
271
272    while let Some(ch) = chars.next() {
273        current.push(ch);
274
275        // Check if this character is a sentence terminator
276        if is_sentence_terminator(ch) {
277            // Consume any trailing closing punctuation that belongs with this sentence
278            // (e.g., 」、), closing quotes)
279            while let Some(&next_ch) = chars.peek() {
280                if is_closing_punctuation(next_ch) {
281                    current.push(chars.next().unwrap());
282                } else {
283                    break;
284                }
285            }
286
287            // Push the completed sentence (trimmed)
288            let trimmed = current.trim().to_string();
289            if !trimmed.is_empty() {
290                sentences.push(trimmed);
291            }
292            current.clear();
293
294            // Skip leading whitespace before the next sentence
295            while let Some(&next_ch) = chars.peek() {
296                if next_ch.is_whitespace() {
297                    chars.next();
298                } else {
299                    break;
300                }
301            }
302        }
303    }
304
305    // Handle any remaining text (no trailing terminator)
306    let trimmed = current.trim().to_string();
307    if !trimmed.is_empty() {
308        sentences.push(trimmed);
309    }
310
311    sentences
312}
313
314/// Check whether a character is a sentence-ending terminator.
315fn is_sentence_terminator(ch: char) -> bool {
316    matches!(
317        ch,
318        '.' | '!' | '?' | '\u{3002}' // 。
319        | '\u{FF01}' // !
320        | '\u{FF1F}' // ?
321    )
322}
323
324/// Check whether a character is closing punctuation that follows a sentence
325/// terminator (e.g., closing brackets, quotation marks).
326fn is_closing_punctuation(ch: char) -> bool {
327    matches!(
328        ch,
329        ')' | ']'
330            | '}'
331            | '"'
332            | '\''
333            | '\u{300D}' // 」
334            | '\u{300F}' // 』
335            | '\u{FF09}' // )
336            | '\u{FF3D}' // ]
337            | '\u{3011}' // 】
338            | '\u{FF63}' // 」 (half-width)
339    )
340}
341
342// ---------------------------------------------------------------------------
343// テスト
344// ---------------------------------------------------------------------------
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    // ===================================================================
351    // AudioSink: BufferSink
352    // ===================================================================
353
354    #[test]
355    fn test_buffer_sink_collects_samples() {
356        let mut sink = BufferSink::new();
357        sink.write_chunk(&[1, 2, 3], 22050).unwrap();
358        sink.write_chunk(&[4, 5], 22050).unwrap();
359        sink.finalize().unwrap();
360        assert_eq!(sink.get_samples(), &[1, 2, 3, 4, 5]);
361    }
362
363    #[test]
364    fn test_buffer_sink_empty() {
365        let mut sink = BufferSink::new();
366        sink.finalize().unwrap();
367        assert!(sink.get_samples().is_empty());
368        assert_eq!(sink.sample_rate(), None);
369    }
370
371    #[test]
372    fn test_buffer_sink_sample_rate() {
373        let mut sink = BufferSink::new();
374        assert_eq!(sink.sample_rate(), None);
375        sink.write_chunk(&[100], 44100).unwrap();
376        assert_eq!(sink.sample_rate(), Some(44100));
377    }
378
379    #[test]
380    fn test_buffer_sink_default() {
381        let sink = BufferSink::default();
382        assert!(sink.get_samples().is_empty());
383    }
384
385    // ===================================================================
386    // AudioSink: WavFileSink
387    // ===================================================================
388
389    #[test]
390    fn test_wav_file_sink_writes_valid_wav() {
391        let dir = tempfile::tempdir().unwrap();
392        let wav_path = dir.path().join("test.wav");
393
394        {
395            let mut sink = WavFileSink::new(&wav_path).unwrap();
396            let samples: Vec<i16> = (0..100).collect();
397            sink.write_chunk(&samples, 22050).unwrap();
398            sink.finalize().unwrap();
399        }
400
401        // Verify with hound
402        let reader = hound::WavReader::open(&wav_path).unwrap();
403        let spec = reader.spec();
404        assert_eq!(spec.channels, 1);
405        assert_eq!(spec.sample_rate, 22050);
406        assert_eq!(spec.bits_per_sample, 16);
407        let read_samples: Vec<i16> = reader.into_samples::<i16>().map(|s| s.unwrap()).collect();
408        let expected: Vec<i16> = (0..100).collect();
409        assert_eq!(read_samples, expected);
410    }
411
412    #[test]
413    fn test_wav_file_sink_multiple_chunks() {
414        let dir = tempfile::tempdir().unwrap();
415        let wav_path = dir.path().join("multi.wav");
416
417        {
418            let mut sink = WavFileSink::new(&wav_path).unwrap();
419            sink.write_chunk(&[10, 20, 30], 16000).unwrap();
420            sink.write_chunk(&[40, 50], 16000).unwrap();
421            sink.write_chunk(&[60], 16000).unwrap();
422            sink.finalize().unwrap();
423        }
424
425        let reader = hound::WavReader::open(&wav_path).unwrap();
426        assert_eq!(reader.spec().sample_rate, 16000);
427        let read_samples: Vec<i16> = reader.into_samples::<i16>().map(|s| s.unwrap()).collect();
428        assert_eq!(read_samples, vec![10, 20, 30, 40, 50, 60]);
429    }
430
431    #[test]
432    fn test_wav_file_sink_finalize_without_write() {
433        let dir = tempfile::tempdir().unwrap();
434        let wav_path = dir.path().join("empty.wav");
435
436        let mut sink = WavFileSink::new(&wav_path).unwrap();
437        // Finalize without writing any chunks should not panic
438        sink.finalize().unwrap();
439    }
440
441    // ===================================================================
442    // crossfade
443    // ===================================================================
444
445    #[test]
446    fn test_crossfade_basic() {
447        // prev_tail fades out, next_head fades in
448        let prev = vec![1000i16; 10];
449        let next = vec![0i16; 10];
450        let result = crossfade(&prev, &next, 4);
451        assert_eq!(result.len(), 4);
452        // At i=0: alpha=0.0 -> 1000*(1.0) + 0*0.0 = 1000
453        assert_eq!(result[0], 1000);
454        // At i=3: alpha=1.0 -> 1000*0.0 + 0*1.0 = 0
455        assert_eq!(result[3], 0);
456    }
457
458    #[test]
459    fn test_crossfade_equal_blend() {
460        let prev = vec![100i16; 4];
461        let next = vec![200i16; 4];
462        let result = crossfade(&prev, &next, 4);
463        assert_eq!(result.len(), 4);
464        // i=0: alpha=0.0 -> 100
465        assert_eq!(result[0], 100);
466        // i=2: alpha=2/3 -> 100*(1/3) + 200*(2/3) = 166.67 -> 166
467        assert_eq!(result[2], 166);
468    }
469
470    #[test]
471    fn test_crossfade_zero_overlap() {
472        let prev = vec![100i16; 5];
473        let next = vec![200i16; 5];
474        let result = crossfade(&prev, &next, 0);
475        assert!(result.is_empty());
476    }
477
478    #[test]
479    fn test_crossfade_overlap_exceeds_prev() {
480        let prev = vec![500i16; 3];
481        let next = vec![0i16; 10];
482        let result = crossfade(&prev, &next, 100);
483        // Clamped to min(100, 3, 10) = 3
484        assert_eq!(result.len(), 3);
485    }
486
487    #[test]
488    fn test_crossfade_overlap_exceeds_next() {
489        let prev = vec![500i16; 10];
490        let next = vec![0i16; 2];
491        let result = crossfade(&prev, &next, 100);
492        // Clamped to min(100, 10, 2) = 2
493        assert_eq!(result.len(), 2);
494    }
495
496    #[test]
497    fn test_crossfade_empty_slices() {
498        let result = crossfade(&[], &[], 10);
499        assert!(result.is_empty());
500    }
501
502    #[test]
503    fn test_crossfade_one_sample() {
504        let prev = vec![1000i16];
505        let next = vec![0i16];
506        let result = crossfade(&prev, &next, 1);
507        assert_eq!(result.len(), 1);
508        // overlap=1: alpha=1.0 -> 1000*(0.0) + 0*(1.0) = 0
509        assert_eq!(result[0], 0);
510    }
511
512    // ===================================================================
513    // split_sentences
514    // ===================================================================
515
516    #[test]
517    fn test_split_sentences_japanese() {
518        let text = "こんにちは。今日は良い天気ですね。明日も晴れるでしょう。";
519        let result = split_sentences(text);
520        assert_eq!(result.len(), 3);
521        assert_eq!(result[0], "こんにちは。");
522        assert_eq!(result[1], "今日は良い天気ですね。");
523        assert_eq!(result[2], "明日も晴れるでしょう。");
524    }
525
526    #[test]
527    fn test_split_sentences_english() {
528        let text = "Hello world. How are you? I am fine!";
529        let result = split_sentences(text);
530        assert_eq!(result.len(), 3);
531        assert_eq!(result[0], "Hello world.");
532        assert_eq!(result[1], "How are you?");
533        assert_eq!(result[2], "I am fine!");
534    }
535
536    #[test]
537    fn test_split_sentences_mixed_punctuation() {
538        let text = "日本語のテスト。English test! 混合テスト?";
539        let result = split_sentences(text);
540        assert_eq!(result.len(), 3);
541        assert_eq!(result[0], "日本語のテスト。");
542        assert_eq!(result[1], "English test!");
543        assert_eq!(result[2], "混合テスト?");
544    }
545
546    #[test]
547    fn test_split_sentences_fullwidth_punctuation() {
548        let text = "すごい!本当ですか?はい。";
549        let result = split_sentences(text);
550        assert_eq!(result.len(), 3);
551        assert_eq!(result[0], "すごい!");
552        assert_eq!(result[1], "本当ですか?");
553        assert_eq!(result[2], "はい。");
554    }
555
556    #[test]
557    fn test_split_sentences_empty() {
558        let result = split_sentences("");
559        assert!(result.is_empty());
560    }
561
562    #[test]
563    fn test_split_sentences_no_terminator() {
564        let text = "This has no ending punctuation";
565        let result = split_sentences(text);
566        assert_eq!(result.len(), 1);
567        assert_eq!(result[0], "This has no ending punctuation");
568    }
569
570    #[test]
571    fn test_split_sentences_whitespace_only() {
572        let result = split_sentences("   ");
573        assert!(result.is_empty());
574    }
575
576    #[test]
577    fn test_split_sentences_with_closing_brackets() {
578        let text = "「こんにちは。」次の文。";
579        let result = split_sentences(text);
580        assert_eq!(result.len(), 2);
581        assert_eq!(result[0], "「こんにちは。」");
582        assert_eq!(result[1], "次の文。");
583    }
584
585    #[test]
586    fn test_split_sentences_single_sentence() {
587        let text = "一つだけ。";
588        let result = split_sentences(text);
589        assert_eq!(result.len(), 1);
590        assert_eq!(result[0], "一つだけ。");
591    }
592
593    // ===================================================================
594    // StreamingResult
595    // ===================================================================
596
597    #[test]
598    fn test_streaming_result_construction() {
599        let result = StreamingResult {
600            total_audio_seconds: 5.0,
601            total_infer_seconds: 1.5,
602            chunk_count: 3,
603        };
604        assert!((result.total_audio_seconds - 5.0).abs() < 1e-9);
605        assert!((result.total_infer_seconds - 1.5).abs() < 1e-9);
606        assert_eq!(result.chunk_count, 3);
607    }
608
609    #[test]
610    fn test_streaming_result_clone() {
611        let result = StreamingResult {
612            total_audio_seconds: 2.0,
613            total_infer_seconds: 0.8,
614            chunk_count: 1,
615        };
616        let cloned = result.clone();
617        assert_eq!(cloned.chunk_count, result.chunk_count);
618        assert!((cloned.total_audio_seconds - result.total_audio_seconds).abs() < 1e-9);
619    }
620
621    #[test]
622    fn test_streaming_result_debug() {
623        let result = StreamingResult {
624            total_audio_seconds: 3.14,
625            total_infer_seconds: 1.0,
626            chunk_count: 2,
627        };
628        let debug = format!("{:?}", result);
629        assert!(debug.contains("total_audio_seconds"));
630        assert!(debug.contains("chunk_count"));
631    }
632
633    // ===================================================================
634    // AudioSink object safety
635    // ===================================================================
636
637    #[test]
638    fn test_audio_sink_object_safety() {
639        // Verify AudioSink can be used as a trait object (dyn)
640        fn accept_sink(sink: &mut dyn AudioSink) -> Result<(), PiperError> {
641            sink.write_chunk(&[1, 2, 3], 22050)?;
642            sink.finalize()
643        }
644        let mut buffer = BufferSink::new();
645        accept_sink(&mut buffer).unwrap();
646        assert_eq!(buffer.get_samples(), &[1, 2, 3]);
647    }
648
649    // ===================================================================
650    // TDD追加テスト: WavFileSink error paths
651    // ===================================================================
652
653    #[test]
654    fn test_wav_file_sink_drop_finalizes() {
655        // Drop without calling finalize() should still produce a valid WAV.
656        let dir = tempfile::tempdir().unwrap();
657        let wav_path = dir.path().join("drop_test.wav");
658
659        {
660            let mut sink = WavFileSink::new(&wav_path).unwrap();
661            let samples: Vec<i16> = vec![100, 200, 300, -100, -200];
662            sink.write_chunk(&samples, 22050).unwrap();
663            // Intentionally NOT calling finalize(); drop should handle it.
664        }
665
666        // Read back with hound and verify the WAV is valid
667        let reader = hound::WavReader::open(&wav_path).unwrap();
668        let spec = reader.spec();
669        assert_eq!(spec.channels, 1);
670        assert_eq!(spec.sample_rate, 22050);
671        assert_eq!(spec.bits_per_sample, 16);
672        let read_samples: Vec<i16> = reader.into_samples::<i16>().map(|s| s.unwrap()).collect();
673        assert_eq!(read_samples, vec![100, 200, 300, -100, -200]);
674    }
675
676    #[test]
677    fn test_wav_file_sink_sample_rate_mismatch_rejected() {
678        // Writing chunks with different sample rates must return an error.
679        let dir = tempfile::tempdir().unwrap();
680        let wav_path = dir.path().join("rate_mismatch.wav");
681
682        let mut sink = WavFileSink::new(&wav_path).unwrap();
683        sink.write_chunk(&[10, 20], 16000).unwrap();
684        let err = sink.write_chunk(&[30, 40], 44100).unwrap_err();
685        let msg = err.to_string();
686        assert!(
687            msg.contains("sample rate mismatch"),
688            "expected sample rate mismatch error, got: {}",
689            msg
690        );
691    }
692
693    #[test]
694    fn test_wav_file_sink_same_sample_rate_ok() {
695        // Multiple chunks with the same sample rate should succeed.
696        let dir = tempfile::tempdir().unwrap();
697        let wav_path = dir.path().join("same_rate.wav");
698
699        {
700            let mut sink = WavFileSink::new(&wav_path).unwrap();
701            sink.write_chunk(&[10, 20], 16000).unwrap();
702            sink.write_chunk(&[30, 40], 16000).unwrap();
703            sink.finalize().unwrap();
704        }
705
706        let reader = hound::WavReader::open(&wav_path).unwrap();
707        assert_eq!(reader.spec().sample_rate, 16000);
708        let read_samples: Vec<i16> = reader.into_samples::<i16>().map(|s| s.unwrap()).collect();
709        assert_eq!(read_samples, vec![10, 20, 30, 40]);
710    }
711
712    #[test]
713    fn test_wav_file_sink_overflow_rejected() {
714        // Simulate a total_samples count that would overflow u32 when
715        // converted to byte size. We cannot actually write 2B+ samples in a
716        // test, so we poke the internal state via a helper.
717        let dir = tempfile::tempdir().unwrap();
718        let wav_path = dir.path().join("overflow.wav");
719
720        let mut sink = WavFileSink::new(&wav_path).unwrap();
721        sink.write_chunk(&[1], 22050).unwrap();
722        // Manually set total_samples to a value that overflows u32 * 2
723        sink.total_samples = (u32::MAX as usize) / 2 + 2;
724        let err = sink.finalize().unwrap_err();
725        let msg = err.to_string();
726        assert!(
727            msg.contains("4GB"),
728            "expected 4GB limit error, got: {}",
729            msg
730        );
731    }
732
733    // ===================================================================
734    // TDD追加テスト: crossfade edge cases
735    // ===================================================================
736
737    #[test]
738    fn test_crossfade_negative_samples() {
739        // Realistic negative audio values: linear blend between two negative/positive regions
740        let prev = vec![-10000i16, -5000];
741        let next = vec![5000i16, 10000];
742        let result = crossfade(&prev, &next, 2);
743        assert_eq!(result.len(), 2);
744        // i=0: alpha=0.0 -> -10000*(1.0) + 5000*(0.0) = -10000
745        assert_eq!(result[0], -10000);
746        // i=1: alpha=1.0 -> -5000*(0.0) + 10000*(1.0) = 10000
747        assert_eq!(result[1], 10000);
748    }
749
750    #[test]
751    fn test_crossfade_max_i16_values() {
752        // Verify no overflow when blending i16::MAX and i16::MIN.
753        // The computation is done in f64 and clamped to [-32768, 32767].
754        let prev = vec![i16::MAX, i16::MAX];
755        let next = vec![i16::MIN, i16::MIN];
756        let result = crossfade(&prev, &next, 2);
757        assert_eq!(result.len(), 2);
758        // i=0: alpha=0.0 -> 32767*(1.0) + (-32768)*(0.0) = 32767
759        assert_eq!(result[0], i16::MAX);
760        // i=1: alpha=1.0 -> 32767*0.0 + (-32768)*1.0 = -32768 = i16::MIN
761        assert_eq!(result[1], i16::MIN);
762    }
763
764    // ===================================================================
765    // TDD追加テスト: split_sentences edge cases
766    // ===================================================================
767
768    #[test]
769    fn test_split_sentences_consecutive_terminators() {
770        // "Really?! Yes." — '?' and '!' are each sentence terminators.
771        // '?' triggers the first split -> "Really?"
772        // '!' is consumed as a new char, immediately triggers a split -> "!"
773        // " Yes." is the third chunk -> "Yes."
774        let result = split_sentences("Really?! Yes.");
775        assert_eq!(result.len(), 3);
776        assert_eq!(result[0], "Really?");
777        assert_eq!(result[1], "!");
778        assert_eq!(result[2], "Yes.");
779    }
780
781    #[test]
782    fn test_split_sentences_single_char_sentence() {
783        // "A. B." should produce 2 chunks: "A." and "B."
784        let result = split_sentences("A. B.");
785        assert_eq!(result.len(), 2);
786        assert_eq!(result[0], "A.");
787        assert_eq!(result[1], "B.");
788    }
789
790    #[test]
791    fn test_split_sentences_newline_separator() {
792        // Newlines between sentences should be treated as whitespace and trimmed.
793        let result = split_sentences("Hello.\nWorld.");
794        assert_eq!(result.len(), 2);
795        assert_eq!(result[0], "Hello.");
796        assert_eq!(result[1], "World.");
797    }
798
799    // ===================================================================
800    // TDD追加テスト: BufferSink large data
801    // ===================================================================
802
803    #[test]
804    fn test_buffer_sink_large_chunks() {
805        // Write 1M samples and verify total count.
806        let mut sink = BufferSink::new();
807        let chunk: Vec<i16> = (0..10_000).map(|i| (i % 1000) as i16).collect();
808        for _ in 0..100 {
809            sink.write_chunk(&chunk, 22050).unwrap();
810        }
811        sink.finalize().unwrap();
812        assert_eq!(sink.get_samples().len(), 1_000_000);
813        assert_eq!(sink.sample_rate(), Some(22050));
814    }
815}