Skip to main content

ftts_core/
audio.rs

1//! The audio output tail: decoded PCM to a playable file.
2//!
3//! The codec hands back `f32` samples in `[-1, 1]`; everything downstream of that is this module.
4//! It is deliberately small and pure-Rust (AGENTS.md toolchain: no libsndfile, no FFmpeg FFI), and
5//! it owns exactly two conversions that are easy to get subtly wrong:
6//!
7//! 1. **f32 to 16-bit PCM.** Clamping happens *before* scaling. A sample of `1.2` scaled first and
8//!    clamped second wraps to a large negative value — an audible click that a duration check and a
9//!    byte-count check both call success.
10//! 2. **The RIFF header's two length fields.** They must describe the bytes actually present. A
11//!    header claiming more data than the file holds is corrupt, and players disagree about how to
12//!    fail on it, so [`WavWriter::finish`] patches both from the real sample count. The same rule
13//!    is what makes a disk-full partial file still playable.
14//!
15//! # Format
16//!
17//! 16-bit signed PCM, mono, 24 kHz — [`SAMPLE_RATE_HZ`], the codec's native rate (plan §2.7:
18//! 24 kHz, 1,920 samples per 80 ms frame). Nothing here resamples: emitting the codec's own rate
19//! keeps the output bit-faithful to what the model produced, and resampling is a separate,
20//! explicitly-requested operation.
21
22use std::io::{self, Seek, SeekFrom, Write};
23
24/// The codec's native output rate.
25pub const SAMPLE_RATE_HZ: u32 = 24_000;
26
27/// Samples the codec emits per 80 ms frame: `SAMPLE_RATE_HZ / 12.5`.
28pub const SAMPLES_PER_FRAME: usize = 1_920;
29
30/// Channel count. The model is mono; stereo would be a fabrication.
31pub const CHANNELS: u16 = 1;
32
33/// Bits per sample in the emitted WAV.
34pub const BITS_PER_SAMPLE: u16 = 16;
35
36/// Bytes in a canonical 44-byte RIFF/WAVE header for uncompressed PCM.
37pub const WAV_HEADER_BYTES: usize = 44;
38
39/// Samples produced by `frames` codec frames.
40#[must_use]
41pub const fn samples_for_frames(frames: usize) -> usize {
42    frames * SAMPLES_PER_FRAME
43}
44
45/// Convert one `f32` sample in `[-1, 1]` to signed 16-bit PCM.
46///
47/// Clamp first, then scale. The reverse order wraps on overshoot: `1.2 * 32767.0` is `39320`,
48/// which truncates to a large negative `i16` and produces a click exactly where the audio was
49/// loudest. Non-finite input becomes silence rather than an arbitrary bit pattern — a NaN reaching
50/// here is a bug upstream, and the runtime-health `NonFinite` seam is what reports it; this
51/// conversion's job is to not turn it into noise.
52///
53/// The scale is `32767.0` (not `32768.0`) so that `+1.0` maps to `i16::MAX` exactly and the
54/// mapping stays symmetric about zero.
55#[must_use]
56pub fn sample_to_i16(sample: f32) -> i16 {
57    if !sample.is_finite() {
58        return 0;
59    }
60    let clamped = sample.clamp(-1.0, 1.0);
61    // `round()` gives round-half-away-from-zero, matching the reference converters; truncation
62    // would bias every sample toward zero and quietly lower the output level.
63    (clamped * 32_767.0).round() as i16
64}
65
66/// Convert a decoded `f32` buffer to 16-bit PCM.
67#[must_use]
68pub fn pcm_f32_to_i16(pcm: &[f32]) -> Vec<i16> {
69    pcm.iter().copied().map(sample_to_i16).collect()
70}
71
72/// Mean square energy of a PCM buffer, in `[0, 1]`.
73///
74/// Used to answer "did we actually produce audio?". A silent result is a failure that every
75/// byte-count and duration check reports as success, so energy is the check that distinguishes
76/// them. Returns `0.0` for an empty buffer rather than dividing by zero.
77#[must_use]
78pub fn mean_square_energy(pcm: &[f32]) -> f64 {
79    if pcm.is_empty() {
80        return 0.0;
81    }
82    let total: f64 = pcm.iter().map(|s| f64::from(*s) * f64::from(*s)).sum();
83    total / pcm.len() as f64
84}
85
86/// Build a 44-byte RIFF/WAVE header for `sample_count` mono 16-bit samples.
87///
88/// Both length fields are derived from `sample_count` so they can never disagree with each other
89/// or with the payload.
90#[must_use]
91pub fn wav_header(sample_rate: u32, sample_count: usize) -> [u8; WAV_HEADER_BYTES] {
92    let data_bytes = (sample_count * usize::from(BITS_PER_SAMPLE / 8)) as u32;
93    let byte_rate = sample_rate * u32::from(CHANNELS) * u32::from(BITS_PER_SAMPLE / 8);
94    let block_align = CHANNELS * (BITS_PER_SAMPLE / 8);
95
96    let mut header = [0u8; WAV_HEADER_BYTES];
97    header[0..4].copy_from_slice(b"RIFF");
98    // RIFF size counts everything after this field: 36 header bytes plus the payload.
99    header[4..8].copy_from_slice(&(36u32.saturating_add(data_bytes)).to_le_bytes());
100    header[8..12].copy_from_slice(b"WAVE");
101    header[12..16].copy_from_slice(b"fmt ");
102    header[16..20].copy_from_slice(&16u32.to_le_bytes()); // PCM fmt chunk size
103    header[20..22].copy_from_slice(&1u16.to_le_bytes()); // format 1 = uncompressed PCM
104    header[22..24].copy_from_slice(&CHANNELS.to_le_bytes());
105    header[24..28].copy_from_slice(&sample_rate.to_le_bytes());
106    header[28..32].copy_from_slice(&byte_rate.to_le_bytes());
107    header[32..34].copy_from_slice(&block_align.to_le_bytes());
108    header[34..36].copy_from_slice(&BITS_PER_SAMPLE.to_le_bytes());
109    header[36..40].copy_from_slice(b"data");
110    header[40..44].copy_from_slice(&data_bytes.to_le_bytes());
111    header
112}
113
114/// Encode a whole PCM buffer as a WAV file in memory.
115///
116/// For the offline path, where the sample count is known before writing. Streaming synthesis uses
117/// [`WavWriter`], which does not need to know it up front.
118#[must_use]
119pub fn encode_wav(pcm: &[f32], sample_rate: u32) -> Vec<u8> {
120    let mut bytes = Vec::with_capacity(WAV_HEADER_BYTES + pcm.len() * 2);
121    bytes.extend_from_slice(&wav_header(sample_rate, pcm.len()));
122    for sample in pcm {
123        bytes.extend_from_slice(&sample_to_i16(*sample).to_le_bytes());
124    }
125    bytes
126}
127
128/// Samples of low-level, high-frequency junk at the very end of an utterance.
129///
130/// # The artifact
131///
132/// The model reliably ends an utterance with a short burst of broadband noise after speech has
133/// decayed and before its own trailing silence. Measured on real synthesis at 24 kHz, the burst
134/// sits around RMS 20-90 against speech at 500-1200, and its first-difference energy ratio (a cheap
135/// high-frequency proxy) runs 0.3-0.9 where speech runs 0.02-0.10. It is what a listener hears as
136/// "a little noise right at the end".
137///
138/// It is NOT a quantization artifact: an interleaved A/B against the f32 reference route on the
139/// same text and seed showed the reference producing MORE of it (sustained ~90 ms at ratio up to
140/// 0.94) than the int8 route. So it comes from the model's own final frames, and no kernel change
141/// removes it.
142///
143/// # What this does, and what it deliberately does not
144///
145/// Returns how many samples to drop from the end. The rule is conjunctive on purpose, because each
146/// condition alone has a false positive that would eat real audio:
147///
148/// * **quiet** relative to this utterance's own speech level, so a loud ending is never touched;
149/// * **high-frequency dominated**, so a soft voiced ending (a low hum, a sustained vowel) is never
150///   touched — those are the tonal opposite of this artifact;
151/// * **contiguous from the end**, so noise in the middle of a sentence is left alone entirely.
152///
153/// Trailing pure silence is skipped before analysis and kept afterwards: it is genuine model output
154/// (measured runs carry 0-71 ms of it), and trimming it would change utterance timing.
155///
156/// Returns 0 whenever the input is too short to judge, which keeps every caller total.
157#[must_use]
158pub fn trailing_noise_samples(pcm: &[f32], sample_rate: u32) -> usize {
159    let level = speech_level(pcm, sample_rate);
160    trailing_noise_samples_relative_to(pcm, sample_rate, level)
161}
162
163/// The loudest 10 ms window in `pcm`, the reference "speech level" the tail rule compares against.
164#[must_use]
165pub fn speech_level(pcm: &[f32], sample_rate: u32) -> f32 {
166    let window = (sample_rate as usize).saturating_mul(10) / 1000;
167    if window < 2 || pcm.is_empty() {
168        return 0.0;
169    }
170    pcm.chunks(window)
171        .map(|chunk| {
172            if chunk.is_empty() {
173                0.0
174            } else {
175                (chunk.iter().map(|s| s * s).sum::<f32>() / chunk.len() as f32).sqrt()
176            }
177        })
178        .fold(0.0_f32, f32::max)
179}
180
181/// [`trailing_noise_samples`] against a caller-supplied speech level.
182///
183/// Exists because the streaming writer only holds the last quarter second when it decides, and the
184/// "quiet" test is meaningless against a window that contains nothing but the artifact — the
185/// artifact would become its own reference and never look quiet. The writer therefore measures the
186/// level across the whole utterance as it goes and passes it in here.
187#[must_use]
188pub fn trailing_noise_samples_relative_to(
189    pcm: &[f32],
190    sample_rate: u32,
191    speech_level: f32,
192) -> usize {
193    trailing_noise_range_relative_to(pcm, sample_rate, speech_level).len()
194}
195
196/// As [`trailing_noise_samples_relative_to`], but returning WHERE the noise run sits.
197///
198/// The count alone is not enough to remove the artifact: the noise ends at the last
199/// audible sample, and any sub-epsilon silence after it is genuine model output to be
200/// kept. A caller that removes `count` samples from the buffer's literal end instead
201/// keeps the audible artifact and deletes the harmless silence — the exact inversion
202/// this range exists to prevent.
203#[must_use]
204pub fn trailing_noise_range_relative_to(
205    pcm: &[f32],
206    sample_rate: u32,
207    speech_level: f32,
208) -> std::ops::Range<usize> {
209    /// Analysis window. 10 ms is short enough to localize the burst and long enough that a
210    /// single glottal pulse does not dominate the statistic.
211    const WINDOW_MILLIS: usize = 10;
212    /// Never remove more than this. Measured artifact runs: 30 ms (nz5), 80 ms (f32 reference),
213    /// and just over 200 ms on the preset voice samples. 250 ms covers the observed range while
214    /// still bounding the damage if the rule ever misfires.
215    const MAX_TRIM_MILLIS: usize = 250;
216    /// A window must be under this fraction of the utterance's speech level to be a candidate.
217    /// Artifact windows measured at most 0.08 of the utterance peak, so 0.15 leaves headroom
218    /// without reaching the level a real final consonant occupies.
219    const QUIET_FRACTION: f32 = 0.15;
220    /// First-difference energy ratio above which a window is high-frequency dominated. Voiced
221    /// speech measured 0.005-0.10; the artifact measured 0.31-1.92. 0.25 sits in the empty gap
222    /// between those two populations.
223    const HF_RATIO: f32 = 0.25;
224    /// The whole trimmed run must also be this quiet on average. A sustained final fricative is
225    /// high-frequency too, and this is what separates it from the artifact: /s/ carries real
226    /// level, the artifact does not.
227    const RUN_MEAN_FRACTION: f32 = 0.10;
228
229    let window = (sample_rate as usize).saturating_mul(WINDOW_MILLIS) / 1000;
230    if window < 2 || pcm.len() < window * 4 {
231        return 0..0;
232    }
233
234    // Trailing silence is model output, not artifact; keep it, and analyze what precedes it.
235    //
236    // "Silence" is defined by AUDIBILITY, not by an exact zero bit pattern, and that distinction is
237    // load-bearing. This runs on the engine's raw f32, where the quiet tail is a run of tiny
238    // non-zero values that only become zeros when written as 16-bit PCM. Testing `!= 0.0` therefore
239    // found the last sample of the buffer, started the walk inside inaudible noise, and bailed
240    // immediately — the detector matched every WAV file it was validated against and did nothing at
241    // all in the live path. Half an i16 step is the honest threshold: below it, the sample rounds
242    // to zero in the file the listener actually gets.
243    const SILENCE_EPSILON: f32 = 0.5 / 32_767.0;
244    let voiced_end = pcm
245        .iter()
246        .rposition(|sample| sample.abs() >= SILENCE_EPSILON)
247        .map_or(0, |i| i + 1);
248    if voiced_end < window * 4 {
249        return 0..0;
250    }
251
252    let rms = |seg: &[f32]| -> f32 {
253        if seg.is_empty() {
254            return 0.0;
255        }
256        (seg.iter().map(|s| s * s).sum::<f32>() / seg.len() as f32).sqrt()
257    };
258    // First-difference energy over signal energy: high for broadband noise, low for voiced speech.
259    let hf = |seg: &[f32]| -> f32 {
260        let energy: f32 = seg.iter().map(|s| s * s).sum();
261        if energy <= f32::MIN_POSITIVE {
262            return 0.0;
263        }
264        let diff: f32 = seg.windows(2).map(|p| (p[1] - p[0]) * (p[1] - p[0])).sum();
265        diff / energy
266    };
267
268    // The CALLER'S speech level is authoritative, and must not be recomputed from `pcm` here.
269    //
270    // A local `let speech_level = ...` used to shadow the parameter, which silently defeated this
271    // function's entire reason to exist: the streaming writer holds only the last quarter second
272    // when it decides, so recomputing from `pcm` measures the artifact against ITSELF and nothing
273    // ever looks quiet. It compiled, it passed every whole-buffer test (where the two values
274    // coincide), and it made the live path behave differently from the tested one.
275    if speech_level <= 0.0 {
276        return 0..0;
277    }
278    let quiet_ceiling = speech_level * QUIET_FRACTION;
279
280    let max_trim = (sample_rate as usize).saturating_mul(MAX_TRIM_MILLIS) / 1000;
281    let mut trimmed = 0_usize;
282    let mut end = voiced_end;
283    while end >= window && trimmed + window <= max_trim {
284        let start = end - window;
285        let segment = &pcm[start..end];
286        if rms(segment) < quiet_ceiling && hf(segment) > HF_RATIO {
287            trimmed += window;
288            end = start;
289        } else {
290            break;
291        }
292    }
293
294    // Final guard on the run as a whole. Each window passing individually is not enough: a
295    // sustained final fricative is quiet-ish AND high-frequency window by window, and would walk
296    // the loop above backwards through real speech. The artifact's run mean sits far below a
297    // fricative's, so this is the condition that separates them.
298    if trimmed > 0 {
299        let run = &pcm[voiced_end - trimmed..voiced_end];
300        if rms(run) >= speech_level * RUN_MEAN_FRACTION {
301            return 0..0;
302        }
303    }
304    voiced_end - trimmed..voiced_end
305}
306
307/// A streaming WAV writer that finalises a correct header.
308///
309/// Streaming synthesis does not know the sample count until the run ends, so a provisional header
310/// is written first and patched by [`WavWriter::finish`]. That seek-back is why the sink must be
311/// `Seek`: an unseekable sink cannot carry a correct length, and silently emitting a wrong one is
312/// the corruption this type exists to prevent.
313///
314/// If the run is cut short — cancellation, a full disk — calling `finish` still yields a valid
315/// file describing the samples that made it, which is the partial-output promise in plan §9.6.
316/// Milliseconds of audio held back when tail trimming is armed: the detector's own
317/// ceiling, so the buffer always holds every sample the trim could possibly want. The
318/// sample count derives from the writer's OWN rate — a constant sized for 24 kHz would
319/// silently halve the trim window at 48 kHz.
320const TAIL_HOLDBACK_MILLIS: usize = 250;
321
322pub struct WavWriter<W: Write + Seek> {
323    /// `None` once [`WavWriter::finish`] has handed the sink back.
324    ///
325    /// An `Option` rather than a bare `W` because a type with a `Drop` impl cannot be moved out
326    /// of, and `ftts-core` forbids the `unsafe` that `ManuallyDrop` would need. The `None` state
327    /// also tells `Drop` that finalisation already happened, so it is not attempted twice.
328    sink: Option<W>,
329    sample_rate: u32,
330    samples_written: usize,
331    /// Samples withheld from the file so the end-of-utterance trim can still see them.
332    ///
333    /// Writing is delayed by at most the holdback window, which is why this is opt-in: for a
334    /// file that delay is invisible, but on `--stream raw` it would add latency to a path whose
335    /// whole contract is time-to-first-audio. `None` means trimming is off and every sample goes
336    /// straight through.
337    holdback: Option<Vec<f32>>,
338    /// Loudest 10 ms window seen across the WHOLE utterance.
339    ///
340    /// Tracked as samples arrive because the tail decision happens when only the last quarter
341    /// second is still in hand, and a "quiet relative to speech" rule needs the speech.
342    speech_level: f32,
343}
344
345impl<W: Write + Seek> WavWriter<W> {
346    /// Begin a file, writing a provisional header.
347    ///
348    /// # Errors
349    ///
350    /// If the provisional header cannot be written.
351    pub fn new(mut sink: W, sample_rate: u32) -> io::Result<Self> {
352        sink.write_all(&wav_header(sample_rate, 0))?;
353        Ok(Self {
354            sink: Some(sink),
355            sample_rate,
356            samples_written: 0,
357            holdback: None,
358            speech_level: 0.0,
359        })
360    }
361
362    /// As [`WavWriter::new`], but drops the model's end-of-utterance noise burst.
363    ///
364    /// See [`trailing_noise_samples`] for what is removed and why it is safe. The cost is that the
365    /// last quarter second is held in memory until [`WavWriter::finish`], so this is for file
366    /// output only; a raw PCM stream must keep its latency and stays untrimmed.
367    ///
368    /// # Errors
369    ///
370    /// If the provisional header cannot be written.
371    pub fn new_trimming_tail(sink: W, sample_rate: u32) -> io::Result<Self> {
372        let mut writer = Self::new(sink, sample_rate)?;
373        writer.holdback = Some(Vec::with_capacity(writer.holdback_samples() * 2));
374        Ok(writer)
375    }
376
377    /// Samples the trimming writer withholds, derived from this writer's own rate.
378    fn holdback_samples(&self) -> usize {
379        (self.sample_rate as usize).saturating_mul(TAIL_HOLDBACK_MILLIS) / 1000
380    }
381
382    /// Append one packet of decoded `f32` samples.
383    ///
384    /// # Errors
385    ///
386    /// If the sink rejects the write. The count of samples already accepted stays accurate, so a
387    /// later [`WavWriter::finish`] still describes the file truthfully.
388    pub fn write_samples(&mut self, pcm: &[f32]) -> io::Result<()> {
389        // With trimming armed, keep the newest holdback window back and emit only what has
390        // aged out. Those held samples are the only ones the trim can ever remove, so nothing that
391        // reaches the file here can need taking back.
392        if self.holdback.is_some() {
393            self.speech_level = self.speech_level.max(speech_level(pcm, self.sample_rate));
394            let mut pending = self.holdback.take().unwrap_or_default();
395            pending.extend_from_slice(pcm);
396            let releasable = pending.len().saturating_sub(self.holdback_samples());
397            let released: Vec<f32> = pending.drain(..releasable).collect();
398            self.holdback = Some(pending);
399            if released.is_empty() {
400                return Ok(());
401            }
402            return self.write_through(&released);
403        }
404        self.write_through(pcm)
405    }
406
407    /// Writes samples straight to the sink, bypassing the hold-back.
408    fn write_through(&mut self, pcm: &[f32]) -> io::Result<()> {
409        // Buffer the packet so one short write cannot leave half a sample in the file, which
410        // would desynchronise every subsequent frame by one byte.
411        let mut bytes = Vec::with_capacity(pcm.len() * 2);
412        for sample in pcm {
413            bytes.extend_from_slice(&sample_to_i16(*sample).to_le_bytes());
414        }
415        let sink = self
416            .sink
417            .as_mut()
418            .ok_or_else(|| io::Error::other("WavWriter already finished"))?;
419        sink.write_all(&bytes)?;
420        self.samples_written += pcm.len();
421        Ok(())
422    }
423
424    /// Samples accepted so far.
425    #[must_use]
426    pub const fn samples_written(&self) -> usize {
427        self.samples_written
428    }
429
430    /// Duration of the audio written so far, in milliseconds.
431    #[must_use]
432    pub const fn duration_millis(&self) -> u64 {
433        if self.sample_rate == 0 {
434            return 0;
435        }
436        (self.samples_written as u64) * 1000 / (self.sample_rate as u64)
437    }
438
439    /// Patch the header to the real length and flush.
440    ///
441    /// # Errors
442    ///
443    /// If seeking back to the header, rewriting it, or flushing fails.
444    pub fn finish(self) -> io::Result<W> {
445        self.finish_reporting().map(|(sink, _)| sink)
446    }
447
448    /// As [`WavWriter::finish`], also reporting how many samples the file actually contains.
449    ///
450    /// Callers that publish a sample count need this rather than their own tally: tail trimming
451    /// (and any short write) makes "samples handed to the writer" differ from "samples in the
452    /// file", and a reported count that describes audio the file does not hold is a false number
453    /// in a machine-readable stream.
454    ///
455    /// # Errors
456    ///
457    /// If flushing the held tail, seeking back to the header, rewriting it, or flushing fails.
458    pub fn finish_reporting(mut self) -> io::Result<(W, usize)> {
459        // Release the held tail, minus whatever the detector identifies as the model's end-of-
460        // utterance noise. Done before the header is patched so the length describes what landed.
461        if let Some(pending) = self.holdback.take() {
462            // The noise run ends at the last AUDIBLE sample; sub-epsilon silence after
463            // it is genuine model output and is kept, per the detector's contract.
464            // Removing `count` samples from the buffer's literal end instead kept the
465            // audible artifact and deleted the harmless silence whenever the burst was
466            // followed by a quiet tail — the live shape that motivated this feature.
467            let noise =
468                trailing_noise_range_relative_to(&pending, self.sample_rate, self.speech_level);
469            self.write_through(&pending[..noise.start])?;
470            if noise.end < pending.len() {
471                self.write_through(&pending[noise.end..])?;
472            }
473        }
474        self.finalize_header()?;
475        let written = self.samples_written;
476        let sink = self
477            .sink
478            .take()
479            .ok_or_else(|| io::Error::other("WavWriter already finished"))?;
480        Ok((sink, written))
481    }
482
483    fn finalize_header(&mut self) -> io::Result<()> {
484        let header = wav_header(self.sample_rate, self.samples_written);
485        let Some(sink) = self.sink.as_mut() else {
486            return Ok(());
487        };
488        sink.seek(SeekFrom::Start(0))?;
489        sink.write_all(&header)?;
490        sink.seek(SeekFrom::End(0))?;
491        sink.flush()
492    }
493}
494
495impl<W: Write + Seek> Drop for WavWriter<W> {
496    /// Best-effort finalisation for a writer dropped without [`WavWriter::finish`].
497    ///
498    /// A dropped writer means an abnormal end — a panic, an early return, a cancelled run. Leaving
499    /// the provisional zero-length header would make the file claim it contains no audio while
500    /// holding a megabyte of it. The error is deliberately swallowed because `Drop` cannot report,
501    /// which is exactly why `finish` exists and should be called explicitly.
502    fn drop(&mut self) {
503        // `finish` takes the sink, so a still-present sink means an abnormal end.
504        if self.sink.is_some() {
505            // Release the held tail FIRST, and deliberately without trimming.
506            //
507            // Two reasons. Losing it would silently shorten the file by up to a quarter second and
508            // break this type's partial-output promise: a run cut short must still describe every
509            // sample that made it. And an abnormal end means the tail is not an end-of-utterance
510            // artifact at all — it is wherever synthesis happened to stop, most likely mid-word —
511            // so the trim's premise does not hold and applying it would remove real audio.
512            if let Some(pending) = self.holdback.take()
513                && !pending.is_empty()
514            {
515                let _ = self.write_through(&pending);
516            }
517            let _ = self.finalize_header();
518        }
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use std::io::Cursor;
526
527    #[test]
528    fn frame_count_maps_to_the_codec_sample_rate() {
529        // 24 kHz at 12.5 frames/s is 1,920 samples per 80 ms frame; the two constants must agree
530        // or every duration we report is wrong.
531        assert_eq!(SAMPLES_PER_FRAME * 25, SAMPLE_RATE_HZ as usize * 2);
532        assert_eq!(samples_for_frames(1), 1_920);
533        assert_eq!(samples_for_frames(125), SAMPLE_RATE_HZ as usize * 10);
534    }
535
536    #[test]
537    fn conversion_clamps_before_scaling() {
538        // The bug this prevents: scaling 1.2 first gives 39_320, which wraps to a large negative
539        // i16 — a click at the loudest moment, which every byte-count check calls success.
540        assert_eq!(sample_to_i16(1.2), i16::MAX);
541        assert_eq!(sample_to_i16(-1.2), -i16::MAX);
542        assert_eq!(sample_to_i16(1.0), i16::MAX);
543        assert_eq!(sample_to_i16(-1.0), -i16::MAX);
544        assert_eq!(sample_to_i16(0.0), 0);
545    }
546
547    #[test]
548    fn conversion_rounds_rather_than_truncates() {
549        // Truncation biases every sample toward zero and quietly lowers the output level.
550        let half_step = 0.5 / 32_767.0;
551        assert_eq!(sample_to_i16(half_step), 1);
552        assert_eq!(sample_to_i16(-half_step), -1);
553    }
554
555    #[test]
556    fn non_finite_becomes_silence_not_noise() {
557        assert_eq!(sample_to_i16(f32::NAN), 0);
558        assert_eq!(sample_to_i16(f32::INFINITY), 0);
559        assert_eq!(sample_to_i16(f32::NEG_INFINITY), 0);
560    }
561
562    #[test]
563    fn energy_separates_silence_from_audio() {
564        assert_eq!(mean_square_energy(&[]), 0.0);
565        assert_eq!(mean_square_energy(&[0.0; 64]), 0.0);
566        assert!(mean_square_energy(&[0.5; 64]) > 0.2);
567    }
568
569    #[test]
570    fn the_header_describes_exactly_the_payload() {
571        let pcm = vec![0.25f32; 1_920];
572        let wav = encode_wav(&pcm, SAMPLE_RATE_HZ);
573        assert_eq!(wav.len(), WAV_HEADER_BYTES + pcm.len() * 2);
574        assert_eq!(&wav[0..4], b"RIFF");
575        assert_eq!(&wav[8..12], b"WAVE");
576        assert_eq!(&wav[36..40], b"data");
577
578        let declared_data = u32::from_le_bytes(wav[40..44].try_into().expect("data size"));
579        let actual_data = (wav.len() - WAV_HEADER_BYTES) as u32;
580        assert_eq!(
581            declared_data, actual_data,
582            "data size must match the payload"
583        );
584
585        let declared_riff = u32::from_le_bytes(wav[4..8].try_into().expect("riff size"));
586        assert_eq!(declared_riff, 36 + actual_data, "RIFF size must agree");
587
588        let rate = u32::from_le_bytes(wav[24..28].try_into().expect("rate"));
589        assert_eq!(rate, SAMPLE_RATE_HZ);
590        let channels = u16::from_le_bytes(wav[22..24].try_into().expect("channels"));
591        assert_eq!(channels, 1, "the model is mono");
592        let bits = u16::from_le_bytes(wav[34..36].try_into().expect("bits"));
593        assert_eq!(bits, 16);
594    }
595
596    #[test]
597    fn a_streamed_file_is_byte_identical_to_the_offline_encoding() {
598        // Streaming and offline must produce the same file for the same samples, or "streaming ==
599        // batch" fails at the very last stage of the pipeline.
600        let pcm: Vec<f32> = (0..1_920)
601            .map(|i| (i as f32 / 1_920.0 * std::f32::consts::TAU).sin() * 0.5)
602            .collect();
603
604        let mut writer = WavWriter::new(Cursor::new(Vec::new()), SAMPLE_RATE_HZ).expect("header");
605        for packet in pcm.chunks(480) {
606            writer.write_samples(packet).expect("packet");
607        }
608        assert_eq!(writer.samples_written(), pcm.len());
609        assert_eq!(writer.duration_millis(), 80);
610        let streamed = writer.finish().expect("finish").into_inner();
611
612        assert_eq!(streamed, encode_wav(&pcm, SAMPLE_RATE_HZ));
613    }
614
615    #[test]
616    fn a_truncated_run_still_finalises_a_valid_header() {
617        // The partial-output promise: a run cut short produces a file describing the samples that
618        // actually landed, not the zero-length provisional header.
619        let mut writer = WavWriter::new(Cursor::new(Vec::new()), SAMPLE_RATE_HZ).expect("header");
620        writer.write_samples(&[0.5f32; 960]).expect("packet");
621        let file = writer.finish().expect("finish").into_inner();
622
623        let declared = u32::from_le_bytes(file[40..44].try_into().expect("data size"));
624        assert_eq!(declared, 960 * 2);
625        assert_eq!(file.len(), WAV_HEADER_BYTES + 960 * 2);
626    }
627
628    #[test]
629    fn dropping_without_finish_still_patches_the_length() {
630        // A panic or early return must not leave a file claiming it holds no audio.
631        let mut sink = Cursor::new(Vec::new());
632        {
633            let mut writer = WavWriter::new(&mut sink, SAMPLE_RATE_HZ).expect("header");
634            writer.write_samples(&[0.25f32; 128]).expect("packet");
635            // dropped here without finish()
636        }
637        let file = sink.into_inner();
638        let declared = u32::from_le_bytes(file[40..44].try_into().expect("data size"));
639        assert_eq!(declared, 128 * 2, "Drop must finalise the length");
640    }
641
642    #[test]
643    fn an_empty_run_is_a_valid_zero_length_wav() {
644        let wav = encode_wav(&[], SAMPLE_RATE_HZ);
645        assert_eq!(wav.len(), WAV_HEADER_BYTES);
646        assert_eq!(u32::from_le_bytes(wav[40..44].try_into().expect("size")), 0);
647        assert_eq!(u32::from_le_bytes(wav[4..8].try_into().expect("riff")), 36);
648    }
649}
650
651#[cfg(test)]
652mod tail_tests {
653    use super::*;
654
655    const SR: u32 = 24_000;
656
657    fn noise(len: usize, amplitude: f32) -> Vec<f32> {
658        // Alternating sign: maximal first-difference energy, i.e. the broadband shape the
659        // artifact has.
660        (0..len)
661            .map(|i| if i % 2 == 0 { amplitude } else { -amplitude })
662            .collect()
663    }
664
665    fn tone(len: usize, amplitude: f32) -> Vec<f32> {
666        // 200 Hz: voiced, low first-difference energy.
667        (0..len)
668            .map(|i| amplitude * (i as f32 * 2.0 * std::f32::consts::PI * 200.0 / SR as f32).sin())
669            .collect()
670    }
671
672    #[test]
673    fn a_quiet_high_frequency_tail_is_trimmed() {
674        let mut pcm = tone(SR as usize / 2, 0.5);
675        pcm.extend(noise(SR as usize * 40 / 1000, 0.02));
676        let trimmed = trailing_noise_samples(&pcm, SR);
677        assert!(trimmed > 0, "the artifact shape must be detected");
678        assert!(
679            trimmed <= SR as usize * 40 / 1000 + SR as usize / 100,
680            "trim {trimmed} reached past the noise into speech"
681        );
682    }
683
684    #[test]
685    fn a_loud_ending_is_never_trimmed() {
686        // Speech that simply stops at full level: nothing to remove, however abrupt.
687        let pcm = tone(SR as usize / 2, 0.5);
688        assert_eq!(trailing_noise_samples(&pcm, SR), 0);
689    }
690
691    #[test]
692    fn a_quiet_voiced_ending_is_never_trimmed() {
693        // The dangerous false positive: a soft sustained vowel is quiet but TONAL, so the
694        // high-frequency condition must save it.
695        let mut pcm = tone(SR as usize / 2, 0.5);
696        pcm.extend(tone(SR as usize * 60 / 1000, 0.02));
697        assert_eq!(
698            trailing_noise_samples(&pcm, SR),
699            0,
700            "a soft voiced ending must survive"
701        );
702    }
703
704    #[test]
705    fn trailing_silence_is_preserved_and_the_noise_before_it_is_found() {
706        let mut pcm = tone(SR as usize / 2, 0.5);
707        pcm.extend(noise(SR as usize * 30 / 1000, 0.02));
708        let silence = SR as usize * 50 / 1000;
709        pcm.extend(std::iter::repeat_n(0.0_f32, silence));
710        let trimmed = trailing_noise_samples(&pcm, SR);
711        assert!(
712            trimmed > 0,
713            "silence after the burst must not hide the burst"
714        );
715        // The reported count covers only the noise; the caller keeps the silence.
716        assert!(trimmed <= SR as usize * 40 / 1000);
717    }
718
719    #[test]
720    fn noise_in_the_middle_is_left_alone() {
721        let mut pcm = tone(SR as usize / 4, 0.5);
722        pcm.extend(noise(SR as usize * 30 / 1000, 0.02));
723        pcm.extend(tone(SR as usize / 4, 0.5));
724        assert_eq!(
725            trailing_noise_samples(&pcm, SR),
726            0,
727            "only a tail contiguous with the end is in scope"
728        );
729    }
730
731    /// The caller's speech level must actually be honored.
732    ///
733    /// This is the test that was missing when a local binding shadowed the parameter: every
734    /// whole-buffer test still passed, because there the supplied level and the recomputed one are
735    /// the same number. The bug only appeared where it mattered — the streaming writer, which holds
736    /// just the tail. Feeding the same tail with the utterance's level versus the tail's own level
737    /// must give different answers, or the parameter is being ignored.
738    #[test]
739    fn the_supplied_speech_level_is_the_one_used() {
740        // The noise run must be at least as long as the writer's hold-back, or the 250 ms tail
741        // still contains loud speech and the two levels coincide — which is precisely the
742        // condition under which the shadowing bug stayed invisible.
743        let mut pcm = tone(SR as usize / 2, 0.5);
744        pcm.extend(noise(SR as usize * 300 / 1000, 0.02));
745
746        let utterance_level = speech_level(&pcm, SR);
747        let tail = &pcm[pcm.len() - SR as usize * 250 / 1000..];
748        let tail_level = speech_level(tail, SR);
749        assert!(
750            tail_level < utterance_level,
751            "the fixture must have a tail quieter than the utterance"
752        );
753
754        // Against the utterance's level the tail is quiet, so it is trimmed.
755        assert!(
756            trailing_noise_samples_relative_to(tail, SR, utterance_level) > 0,
757            "the supplied utterance level was ignored"
758        );
759        // Against the tail's OWN level nothing looks quiet, so nothing is trimmed. If the function
760        // recomputed internally it would always take this branch and never trim in the live path.
761        assert_eq!(
762            trailing_noise_samples_relative_to(tail, SR, tail_level),
763            0,
764            "a self-referential level must find nothing quiet"
765        );
766    }
767
768    #[test]
769    fn short_and_empty_inputs_are_total() {
770        assert_eq!(trailing_noise_samples(&[], SR), 0);
771        assert_eq!(trailing_noise_samples(&[0.1; 16], SR), 0);
772        assert_eq!(trailing_noise_samples(&[0.0; 4096], SR), 0);
773        assert_eq!(trailing_noise_samples(&tone(4096, 0.5), 0), 0);
774    }
775}
776
777#[cfg(test)]
778mod holdback_tests {
779    use super::*;
780    use std::io::Cursor;
781
782    fn tone(len: usize, amplitude: f32) -> Vec<f32> {
783        (0..len)
784            .map(|i| amplitude * (i as f32 * 2.0 * std::f32::consts::PI * 200.0 / 24_000.0).sin())
785            .collect()
786    }
787
788    /// The trim must remove the AUDIBLE noise burst and keep the inaudible tail after
789    /// it — not shave an equal count off the buffer's literal end. An earlier writer
790    /// did exactly that: identical sample COUNTS, completely inverted sample CHOICE,
791    /// so this asserts on content where the counts cannot distinguish right from wrong.
792    #[test]
793    fn the_trim_removes_the_burst_not_the_silence_after_it() {
794        let mut pcm = tone(24_000, 0.6);
795        // 100 ms audible broadband burst (alternating sign, quiet, high-frequency)...
796        for i in 0..2_400 {
797            pcm.push(if i % 2 == 0 { 0.02 } else { -0.02 });
798        }
799        // ...then 100 ms of sub-epsilon silence, the live f32 shape.
800        pcm.extend(std::iter::repeat_n(1.0e-6_f32, 2_400));
801
802        let mut writer =
803            WavWriter::new_trimming_tail(Cursor::new(Vec::new()), 24_000).expect("writer");
804        writer.write_samples(&pcm).expect("write");
805        let (sink, written) = writer.finish_reporting().expect("finish");
806        let bytes = sink.into_inner();
807
808        let payload: Vec<i16> = bytes[WAV_HEADER_BYTES..]
809            .as_chunks::<2>()
810            .0
811            .iter()
812            .map(|pair| i16::from_le_bytes(*pair))
813            .collect();
814        assert_eq!(written, payload.len(), "header count describes the payload");
815        assert_eq!(
816            written,
817            pcm.len() - 2_400,
818            "exactly the burst's length is gone"
819        );
820        // Content check the counts cannot fake: everything after the tone must be the
821        // (quantized-to-zero) silence, with no audible burst sample surviving.
822        let audible_after_tone = payload[24_000..].iter().filter(|s| s.abs() > 1).count();
823        assert_eq!(
824            audible_after_tone, 0,
825            "audible burst samples survived the trim: {audible_after_tone}"
826        );
827    }
828
829    fn write(pcm: &[f32], trimming: bool, chunk: usize) -> Vec<u8> {
830        let sink = Cursor::new(Vec::new());
831        let mut writer = if trimming {
832            WavWriter::new_trimming_tail(sink, SAMPLE_RATE_HZ).expect("header")
833        } else {
834            WavWriter::new(sink, SAMPLE_RATE_HZ).expect("header")
835        };
836        for packet in pcm.chunks(chunk) {
837            writer.write_samples(packet).expect("write");
838        }
839        writer.finish().expect("finish").into_inner()
840    }
841
842    /// Audio with no artifact must survive the hold-back path completely unchanged — same bytes,
843    /// same header — however it is packetized. The hold-back must be a delay, never a filter.
844    #[test]
845    fn clean_audio_is_byte_identical_through_the_holdback() {
846        let pcm = tone(24_000, 0.4);
847        let plain = write(&pcm, false, 1_920);
848        for chunk in [240, 1_920, 4_096, 24_000] {
849            assert_eq!(
850                write(&pcm, true, chunk),
851                plain,
852                "packet size {chunk} changed the bytes"
853            );
854        }
855    }
856
857    /// The header must describe what actually landed after a trim, or the file is corrupt in the
858    /// exact way this module exists to prevent.
859    #[test]
860    fn a_trimmed_file_has_a_header_matching_its_payload() {
861        let mut pcm = tone(24_000, 0.5);
862        let noise: Vec<f32> = (0..2_400)
863            .map(|i| if i % 2 == 0 { 0.01 } else { -0.01 })
864            .collect();
865        pcm.extend_from_slice(&noise);
866
867        let bytes = write(&pcm, true, 1_920);
868        let payload = bytes.len() - WAV_HEADER_BYTES;
869        let declared = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]) as usize;
870        assert_eq!(
871            declared, payload,
872            "data chunk size disagrees with the payload"
873        );
874        let riff = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
875        assert_eq!(riff, 36 + payload, "RIFF size disagrees with the payload");
876        assert!(
877            payload / 2 < pcm.len(),
878            "the artifact tail should have been removed"
879        );
880    }
881
882    /// A writer dropped without `finish` must still contain every sample it accepted.
883    ///
884    /// This is the partial-output promise, and the hold-back is exactly what threatens it: the last
885    /// quarter second lives in memory, so a `Drop` that only patched the header would silently
886    /// shorten a cancelled run's file by up to 250 ms. Nothing else in the type would notice —
887    /// the header would agree with the payload, and the file would play.
888    #[test]
889    fn a_dropped_writer_keeps_every_sample_it_accepted() {
890        let path = std::env::temp_dir().join(format!(
891            "ftts-drop-holdback-{}-{}.wav",
892            std::process::id(),
893            line!()
894        ));
895        let pcm = tone(24_000, 0.5);
896        {
897            let file = std::fs::File::create(&path).expect("create");
898            let mut writer = WavWriter::new_trimming_tail(file, SAMPLE_RATE_HZ).expect("header");
899            for packet in pcm.chunks(1_920) {
900                writer.write_samples(packet).expect("write");
901            }
902            // Deliberately NOT calling finish: this models a panic or a cancelled run.
903        }
904        let written = std::fs::read(&path).expect("read back");
905        let _ = std::fs::remove_file(&path);
906
907        let payload = written.len() - WAV_HEADER_BYTES;
908        assert_eq!(
909            payload / 2,
910            pcm.len(),
911            "the dropped writer lost {} held samples",
912            pcm.len() - payload / 2
913        );
914        let declared =
915            u32::from_le_bytes([written[40], written[41], written[42], written[43]]) as usize;
916        assert_eq!(declared, payload, "header disagrees with the payload");
917    }
918
919    /// An empty run must still produce a valid, playable, zero-length file.
920    #[test]
921    fn an_empty_run_still_finalizes() {
922        let bytes = write(&[], true, 1_920);
923        assert_eq!(bytes.len(), WAV_HEADER_BYTES);
924        assert_eq!(
925            u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]),
926            0
927        );
928    }
929}
930
931#[cfg(test)]
932mod f32_silence_tests {
933    use super::*;
934
935    /// Trailing silence in the ENGINE's f32 output is not exact zeros: it is tiny values that only
936    /// become zeros when written as 16-bit PCM. A detector that tests `!= 0.0` matches every WAV
937    /// file it is validated against and then does nothing at all in the live path, which is exactly
938    /// what happened. This pins the audibility-based definition instead.
939    #[test]
940    fn inaudible_f32_tail_counts_as_silence() {
941        let sr = 24_000;
942        let mut pcm: Vec<f32> = (0..sr / 2)
943            .map(|i| 0.5 * (i as f32 * 2.0 * std::f32::consts::PI * 200.0 / sr as f32).sin())
944            .collect();
945        // The artifact: quiet, alternating-sign, high-frequency.
946        pcm.extend((0..2_400).map(|i| if i % 2 == 0 { 0.01 } else { -0.01 }));
947        // A tail that rounds to zero in i16 but is nowhere near 0.0 in f32.
948        pcm.extend((0..2_400).map(|i| if i % 2 == 0 { 1.0e-6 } else { -1.0e-6 }));
949
950        let trimmed = trailing_noise_samples(&pcm, sr as u32);
951        assert!(
952            trimmed > 0,
953            "an inaudible f32 tail must not hide the artifact behind it"
954        );
955
956        // And the same buffer rounded through i16 must agree, so the WAV-file validation and the
957        // live f32 path can never diverge again.
958        let rounded: Vec<f32> = pcm
959            .iter()
960            .map(|s| f32::from(sample_to_i16(*s)) / 32_767.0)
961            .collect();
962        let trimmed_rounded = trailing_noise_samples(&rounded, sr as u32);
963        assert_eq!(
964            trimmed, trimmed_rounded,
965            "f32 and i16-rounded views of the same audio must trim identically"
966        );
967    }
968}