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}