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/// A streaming WAV writer that finalises a correct header.
129///
130/// Streaming synthesis does not know the sample count until the run ends, so a provisional header
131/// is written first and patched by [`WavWriter::finish`]. That seek-back is why the sink must be
132/// `Seek`: an unseekable sink cannot carry a correct length, and silently emitting a wrong one is
133/// the corruption this type exists to prevent.
134///
135/// If the run is cut short — cancellation, a full disk — calling `finish` still yields a valid
136/// file describing the samples that made it, which is the partial-output promise in plan §9.6.
137pub struct WavWriter<W: Write + Seek> {
138    /// `None` once [`WavWriter::finish`] has handed the sink back.
139    ///
140    /// An `Option` rather than a bare `W` because a type with a `Drop` impl cannot be moved out
141    /// of, and `ftts-core` forbids the `unsafe` that `ManuallyDrop` would need. The `None` state
142    /// also tells `Drop` that finalisation already happened, so it is not attempted twice.
143    sink: Option<W>,
144    sample_rate: u32,
145    samples_written: usize,
146}
147
148impl<W: Write + Seek> WavWriter<W> {
149    /// Begin a file, writing a provisional header.
150    ///
151    /// # Errors
152    ///
153    /// If the provisional header cannot be written.
154    pub fn new(mut sink: W, sample_rate: u32) -> io::Result<Self> {
155        sink.write_all(&wav_header(sample_rate, 0))?;
156        Ok(Self {
157            sink: Some(sink),
158            sample_rate,
159            samples_written: 0,
160        })
161    }
162
163    /// Append one packet of decoded `f32` samples.
164    ///
165    /// # Errors
166    ///
167    /// If the sink rejects the write. The count of samples already accepted stays accurate, so a
168    /// later [`WavWriter::finish`] still describes the file truthfully.
169    pub fn write_samples(&mut self, pcm: &[f32]) -> io::Result<()> {
170        // Buffer the packet so one short write cannot leave half a sample in the file, which
171        // would desynchronise every subsequent frame by one byte.
172        let mut bytes = Vec::with_capacity(pcm.len() * 2);
173        for sample in pcm {
174            bytes.extend_from_slice(&sample_to_i16(*sample).to_le_bytes());
175        }
176        let sink = self
177            .sink
178            .as_mut()
179            .ok_or_else(|| io::Error::other("WavWriter already finished"))?;
180        sink.write_all(&bytes)?;
181        self.samples_written += pcm.len();
182        Ok(())
183    }
184
185    /// Samples accepted so far.
186    #[must_use]
187    pub const fn samples_written(&self) -> usize {
188        self.samples_written
189    }
190
191    /// Duration of the audio written so far, in milliseconds.
192    #[must_use]
193    pub const fn duration_millis(&self) -> u64 {
194        if self.sample_rate == 0 {
195            return 0;
196        }
197        (self.samples_written as u64) * 1000 / (self.sample_rate as u64)
198    }
199
200    /// Patch the header to the real length and flush.
201    ///
202    /// # Errors
203    ///
204    /// If seeking back to the header, rewriting it, or flushing fails.
205    pub fn finish(mut self) -> io::Result<W> {
206        self.finalize_header()?;
207        self.sink
208            .take()
209            .ok_or_else(|| io::Error::other("WavWriter already finished"))
210    }
211
212    fn finalize_header(&mut self) -> io::Result<()> {
213        let header = wav_header(self.sample_rate, self.samples_written);
214        let Some(sink) = self.sink.as_mut() else {
215            return Ok(());
216        };
217        sink.seek(SeekFrom::Start(0))?;
218        sink.write_all(&header)?;
219        sink.seek(SeekFrom::End(0))?;
220        sink.flush()
221    }
222}
223
224impl<W: Write + Seek> Drop for WavWriter<W> {
225    /// Best-effort finalisation for a writer dropped without [`WavWriter::finish`].
226    ///
227    /// A dropped writer means an abnormal end — a panic, an early return, a cancelled run. Leaving
228    /// the provisional zero-length header would make the file claim it contains no audio while
229    /// holding a megabyte of it. The error is deliberately swallowed because `Drop` cannot report,
230    /// which is exactly why `finish` exists and should be called explicitly.
231    fn drop(&mut self) {
232        // `finish` takes the sink, so a still-present sink means an abnormal end.
233        if self.sink.is_some() {
234            let _ = self.finalize_header();
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use std::io::Cursor;
243
244    #[test]
245    fn frame_count_maps_to_the_codec_sample_rate() {
246        // 24 kHz at 12.5 frames/s is 1,920 samples per 80 ms frame; the two constants must agree
247        // or every duration we report is wrong.
248        assert_eq!(SAMPLES_PER_FRAME * 25, SAMPLE_RATE_HZ as usize * 2);
249        assert_eq!(samples_for_frames(1), 1_920);
250        assert_eq!(samples_for_frames(125), SAMPLE_RATE_HZ as usize * 10);
251    }
252
253    #[test]
254    fn conversion_clamps_before_scaling() {
255        // The bug this prevents: scaling 1.2 first gives 39_320, which wraps to a large negative
256        // i16 — a click at the loudest moment, which every byte-count check calls success.
257        assert_eq!(sample_to_i16(1.2), i16::MAX);
258        assert_eq!(sample_to_i16(-1.2), -i16::MAX);
259        assert_eq!(sample_to_i16(1.0), i16::MAX);
260        assert_eq!(sample_to_i16(-1.0), -i16::MAX);
261        assert_eq!(sample_to_i16(0.0), 0);
262    }
263
264    #[test]
265    fn conversion_rounds_rather_than_truncates() {
266        // Truncation biases every sample toward zero and quietly lowers the output level.
267        let half_step = 0.5 / 32_767.0;
268        assert_eq!(sample_to_i16(half_step), 1);
269        assert_eq!(sample_to_i16(-half_step), -1);
270    }
271
272    #[test]
273    fn non_finite_becomes_silence_not_noise() {
274        assert_eq!(sample_to_i16(f32::NAN), 0);
275        assert_eq!(sample_to_i16(f32::INFINITY), 0);
276        assert_eq!(sample_to_i16(f32::NEG_INFINITY), 0);
277    }
278
279    #[test]
280    fn energy_separates_silence_from_audio() {
281        assert_eq!(mean_square_energy(&[]), 0.0);
282        assert_eq!(mean_square_energy(&[0.0; 64]), 0.0);
283        assert!(mean_square_energy(&[0.5; 64]) > 0.2);
284    }
285
286    #[test]
287    fn the_header_describes_exactly_the_payload() {
288        let pcm = vec![0.25f32; 1_920];
289        let wav = encode_wav(&pcm, SAMPLE_RATE_HZ);
290        assert_eq!(wav.len(), WAV_HEADER_BYTES + pcm.len() * 2);
291        assert_eq!(&wav[0..4], b"RIFF");
292        assert_eq!(&wav[8..12], b"WAVE");
293        assert_eq!(&wav[36..40], b"data");
294
295        let declared_data = u32::from_le_bytes(wav[40..44].try_into().expect("data size"));
296        let actual_data = (wav.len() - WAV_HEADER_BYTES) as u32;
297        assert_eq!(
298            declared_data, actual_data,
299            "data size must match the payload"
300        );
301
302        let declared_riff = u32::from_le_bytes(wav[4..8].try_into().expect("riff size"));
303        assert_eq!(declared_riff, 36 + actual_data, "RIFF size must agree");
304
305        let rate = u32::from_le_bytes(wav[24..28].try_into().expect("rate"));
306        assert_eq!(rate, SAMPLE_RATE_HZ);
307        let channels = u16::from_le_bytes(wav[22..24].try_into().expect("channels"));
308        assert_eq!(channels, 1, "the model is mono");
309        let bits = u16::from_le_bytes(wav[34..36].try_into().expect("bits"));
310        assert_eq!(bits, 16);
311    }
312
313    #[test]
314    fn a_streamed_file_is_byte_identical_to_the_offline_encoding() {
315        // Streaming and offline must produce the same file for the same samples, or "streaming ==
316        // batch" fails at the very last stage of the pipeline.
317        let pcm: Vec<f32> = (0..1_920)
318            .map(|i| (i as f32 / 1_920.0 * std::f32::consts::TAU).sin() * 0.5)
319            .collect();
320
321        let mut writer = WavWriter::new(Cursor::new(Vec::new()), SAMPLE_RATE_HZ).expect("header");
322        for packet in pcm.chunks(480) {
323            writer.write_samples(packet).expect("packet");
324        }
325        assert_eq!(writer.samples_written(), pcm.len());
326        assert_eq!(writer.duration_millis(), 80);
327        let streamed = writer.finish().expect("finish").into_inner();
328
329        assert_eq!(streamed, encode_wav(&pcm, SAMPLE_RATE_HZ));
330    }
331
332    #[test]
333    fn a_truncated_run_still_finalises_a_valid_header() {
334        // The partial-output promise: a run cut short produces a file describing the samples that
335        // actually landed, not the zero-length provisional header.
336        let mut writer = WavWriter::new(Cursor::new(Vec::new()), SAMPLE_RATE_HZ).expect("header");
337        writer.write_samples(&[0.5f32; 960]).expect("packet");
338        let file = writer.finish().expect("finish").into_inner();
339
340        let declared = u32::from_le_bytes(file[40..44].try_into().expect("data size"));
341        assert_eq!(declared, 960 * 2);
342        assert_eq!(file.len(), WAV_HEADER_BYTES + 960 * 2);
343    }
344
345    #[test]
346    fn dropping_without_finish_still_patches_the_length() {
347        // A panic or early return must not leave a file claiming it holds no audio.
348        let mut sink = Cursor::new(Vec::new());
349        {
350            let mut writer = WavWriter::new(&mut sink, SAMPLE_RATE_HZ).expect("header");
351            writer.write_samples(&[0.25f32; 128]).expect("packet");
352            // dropped here without finish()
353        }
354        let file = sink.into_inner();
355        let declared = u32::from_le_bytes(file[40..44].try_into().expect("data size"));
356        assert_eq!(declared, 128 * 2, "Drop must finalise the length");
357    }
358
359    #[test]
360    fn an_empty_run_is_a_valid_zero_length_wav() {
361        let wav = encode_wav(&[], SAMPLE_RATE_HZ);
362        assert_eq!(wav.len(), WAV_HEADER_BYTES);
363        assert_eq!(u32::from_le_bytes(wav[40..44].try_into().expect("size")), 0);
364        assert_eq!(u32::from_le_bytes(wav[4..8].try_into().expect("riff")), 36);
365    }
366}