Skip to main content

audio_file/
writer.rs

1use std::fs::File;
2use std::path::Path;
3
4use num_traits::Float;
5use thiserror::Error;
6
7use crate::wav;
8
9#[derive(Debug, Error)]
10#[non_exhaustive]
11pub enum WriteError {
12    #[error("could not write file")]
13    Io(#[from] std::io::Error),
14
15    #[error("channel count must not be zero")]
16    ZeroChannels,
17
18    #[error("sample rate must not be zero")]
19    ZeroSampleRate,
20
21    #[error("sample count ({samples}) is not a multiple of the channel count ({channels})")]
22    UnalignedSamples { samples: usize, channels: u16 },
23
24    #[error("file size ({bytes} bytes) exceeds the 4 GiB limit of the wav format")]
25    FileTooLarge { bytes: u64 },
26
27    #[error("frame size ({bytes} bytes) exceeds the 65535 byte limit of the wav format")]
28    FrameTooLarge { bytes: u32 },
29
30    #[error("byte rate ({bytes_per_second} bytes/s) exceeds the limit of the wav format")]
31    ByteRateTooHigh { bytes_per_second: u64 },
32}
33
34/// Sample format for writing audio
35#[derive(Debug, Clone, Copy, Default)]
36pub enum SampleFormat {
37    /// 8-bit integer samples
38    Int8,
39    /// 16-bit integer samples
40    #[default]
41    Int16,
42    /// 32-bit integer samples
43    Int32,
44    /// 32-bit float samples
45    Float32,
46}
47
48/// Configuration for writing audio to WAV files
49#[derive(Default)]
50pub struct WriteConfig {
51    /// Sample format to use when writing
52    pub sample_format: SampleFormat,
53}
54
55/// Write interleaved audio samples to a WAV file.
56///
57/// Samples are taken with full scale at `±1.0`. No gain is applied, but the
58/// integer formats have nowhere to put a sample beyond full scale, so anything
59/// outside `±1.0` is clamped. `Float32` stores what it is given, out of range
60/// included.
61pub fn write<F: Float>(
62    path: impl AsRef<Path>,
63    samples: &[F],
64    num_channels: u16,
65    sample_rate: u32,
66    config: WriteConfig,
67) -> Result<(), WriteError> {
68    if num_channels == 0 {
69        return Err(WriteError::ZeroChannels);
70    }
71    // A zero `nSamplesPerSec` does not describe a timeline, so the file would
72    // not be playable and this crate's own wav decoder refuses to open it.
73    // Anything that derives a frame position or a resampling ratio from the rate
74    // divides by it, so the whole file is worth rejecting over.
75    if sample_rate == 0 {
76        return Err(WriteError::ZeroSampleRate);
77    }
78    if !samples.len().is_multiple_of(num_channels as usize) {
79        return Err(WriteError::UnalignedSamples {
80            samples: samples.len(),
81            channels: num_channels,
82        });
83    }
84
85    // Resolve the byte layout before touching the filesystem, so that input the
86    // wav format cannot describe does not leave a truncated file behind.
87    let layout = wav::Layout::new(
88        samples.len(),
89        num_channels,
90        sample_rate,
91        config.sample_format,
92    )?;
93
94    // The encoder writes the header in one call and the samples in large
95    // blocks, so there is nothing left for a BufWriter to coalesce. Writing
96    // straight to the file also means every error surfaces here rather than
97    // being discovered while a buffer is flushed on drop.
98    let path = path.as_ref();
99    let mut file = File::create(path)?;
100    let result = wav::write(&mut file, &layout, samples);
101    // Close the file before the cleanup below, since Windows cannot remove a
102    // path that is still open.
103    drop(file);
104
105    if let Err(err) = result {
106        // A write that failed halfway leaves a truncated file behind, which must
107        // not be mistaken for a finished one. Only a regular file is removed:
108        // the path may be a device or a pipe that was not created here and has
109        // to survive the failed write.
110        if std::fs::metadata(path).is_ok_and(|meta| meta.is_file()) {
111            let _ = std::fs::remove_file(path);
112        }
113        return Err(err);
114    }
115
116    Ok(())
117}
118
119/// Write audio from an AudioBlock to a WAV file
120#[cfg(feature = "audio-blocks")]
121pub fn write_block<P: AsRef<Path>, F: Float + 'static>(
122    path: P,
123    audio_block: impl audio_blocks::AudioBlock<F>,
124    sample_rate: u32,
125    config: WriteConfig,
126) -> Result<(), WriteError> {
127    let block = audio_blocks::Interleaved::from_block(&audio_block);
128    write(
129        path,
130        block.raw_data(),
131        audio_block.num_channels(),
132        sample_rate,
133        config,
134    )
135}
136
137#[cfg(test)]
138mod tests {
139
140    #[test]
141    fn test_round_trip_i8() {
142        use super::*;
143        use crate::reader::{ReadConfig, read};
144
145        let audio1 = read::<f32>("test_data/test_1ch.wav", ReadConfig::default()).unwrap();
146
147        let path = crate::tmp_path("round-trip-i8.wav");
148        write(
149            &path,
150            &audio1.samples_interleaved,
151            audio1.num_channels,
152            audio1.sample_rate,
153            WriteConfig {
154                sample_format: SampleFormat::Int8,
155            },
156        )
157        .unwrap();
158
159        let audio2 = read::<f32>(&path, ReadConfig::default()).unwrap();
160        assert_eq!(audio1.sample_rate, audio2.sample_rate);
161        // 8-bit PCM has low precision. Symphonia normalizes by dividing by 128
162        // (not 127), so max representable value is 127/128 ≈ 0.992, giving a
163        // worst-case error of ~0.016 near full scale.
164        approx::assert_abs_diff_eq!(
165            audio1.samples_interleaved.as_slice(),
166            audio2.samples_interleaved.as_slice(),
167            epsilon = 2e-2
168        );
169
170        // Clean up temporary file
171        std::fs::remove_file(&path).expect("Failed to remove temporary test file");
172    }
173
174    #[test]
175    fn test_round_trip_i16() {
176        use super::*;
177        use crate::reader::{ReadConfig, read};
178
179        let audio1 = read::<f32>("test_data/test_1ch.wav", ReadConfig::default()).unwrap();
180
181        let path = crate::tmp_path("round-trip-i16.wav");
182        write(
183            &path,
184            &audio1.samples_interleaved,
185            audio1.num_channels,
186            audio1.sample_rate,
187            WriteConfig {
188                sample_format: SampleFormat::Int16,
189            },
190        )
191        .unwrap();
192
193        let audio2 = read::<f32>(&path, ReadConfig::default()).unwrap();
194        assert_eq!(audio1.sample_rate, audio2.sample_rate);
195        approx::assert_abs_diff_eq!(
196            audio1.samples_interleaved.as_slice(),
197            audio2.samples_interleaved.as_slice(),
198            epsilon = 1e-4
199        );
200
201        // Clean up temporary file
202        std::fs::remove_file(&path).expect("Failed to remove temporary test file");
203    }
204
205    #[test]
206    fn test_round_trip_i32() {
207        use super::*;
208        use crate::reader::{ReadConfig, read};
209
210        let audio1 = read::<f32>("test_data/test_1ch.wav", ReadConfig::default()).unwrap();
211
212        let path = crate::tmp_path("round-trip-i32.wav");
213        write(
214            &path,
215            &audio1.samples_interleaved,
216            audio1.num_channels,
217            audio1.sample_rate,
218            WriteConfig {
219                sample_format: SampleFormat::Int32,
220            },
221        )
222        .unwrap();
223
224        let audio2 = read::<f32>(&path, ReadConfig::default()).unwrap();
225        assert_eq!(audio1.sample_rate, audio2.sample_rate);
226        approx::assert_abs_diff_eq!(
227            audio1.samples_interleaved.as_slice(),
228            audio2.samples_interleaved.as_slice(),
229            epsilon = 1e-4
230        );
231
232        // Clean up temporary file
233        std::fs::remove_file(&path).expect("Failed to remove temporary test file");
234    }
235
236    #[test]
237    fn test_invalid_input_is_rejected() {
238        use super::*;
239
240        let path = crate::tmp_path("invalid.wav");
241        match write::<f32>(&path, &[0.0], 0, 48000, WriteConfig::default()) {
242            Err(WriteError::ZeroChannels) => (),
243            other => panic!("{other:?}"),
244        }
245
246        match write::<f32>(&path, &[0.0, 0.0, 0.0], 2, 48000, WriteConfig::default()) {
247            Err(WriteError::UnalignedSamples {
248                samples: 3,
249                channels: 2,
250            }) => (),
251            other => panic!("{other:?}"),
252        }
253
254        // A file whose `nSamplesPerSec` is zero has no timeline, so it is not
255        // playable and this crate's own decoder refuses to open it. Writing it
256        // would produce a file that only the fallback decoder reads back, and
257        // then with a sample rate of zero.
258        match write::<f32>(&path, &[0.0, 0.0], 2, 0, WriteConfig::default()) {
259            Err(WriteError::ZeroSampleRate) => (),
260            other => panic!("{other:?}"),
261        }
262
263        // All three are rejected before the file is created
264        assert!(!path.exists());
265    }
266
267    /// Input the wav format cannot describe is rejected up front too, so no
268    /// half written file is left on disk.
269    #[test]
270    fn test_unrepresentable_input_leaves_no_file() {
271        use super::*;
272
273        let path = crate::tmp_path("unrepresentable.wav");
274        match write::<f32>(
275            &path,
276            &[],
277            u16::MAX,
278            48000,
279            WriteConfig {
280                sample_format: SampleFormat::Int32,
281            },
282        ) {
283            Err(WriteError::FrameTooLarge { .. }) => (),
284            other => panic!("{other:?}"),
285        }
286
287        assert!(!path.exists());
288    }
289
290    /// The reason this crate encodes wav itself. A mono float file must not be
291    /// tagged `WAVEFORMATEXTENSIBLE`: its `dwChannelMask` can only name a
292    /// physical speaker, and naming `SPEAKER_FRONT_LEFT` makes players route
293    /// the audio to the left speaker alone.
294    #[test]
295    fn test_mono_files_are_not_speaker_assigned() {
296        use super::*;
297
298        let path = crate::tmp_path("mono-mask.wav");
299        for sample_format in [
300            SampleFormat::Int8,
301            SampleFormat::Int16,
302            SampleFormat::Int32,
303            SampleFormat::Float32,
304        ] {
305            write(&path, &[0.0f32; 8], 1, 48000, WriteConfig { sample_format }).unwrap();
306            let bytes = std::fs::read(&path).unwrap();
307
308            // wFormatTag sits at the start of the fmt chunk body.
309            let tag = u16::from_le_bytes(bytes[20..22].try_into().unwrap());
310            assert_ne!(tag, 0xfffe, "{sample_format:?} is extensible");
311        }
312
313        std::fs::remove_file(&path).unwrap();
314    }
315
316    /// Multichannel output takes the extensible path, so check a decoder can
317    /// still read it back.
318    #[test]
319    fn test_round_trip_multichannel() {
320        use super::*;
321        use crate::reader::{ReadConfig, read};
322
323        let audio1 = read::<f32>("test_data/test_4ch.wav", ReadConfig::default()).unwrap();
324        assert_eq!(audio1.num_channels, 4);
325
326        let path = crate::tmp_path("round-trip-4ch.wav");
327        // Every sample format, since each pairs the extensible layout with a
328        // different SubFormat GUID and sample width. 8-bit needs the loose
329        // epsilon its precision allows.
330        for (sample_format, epsilon) in [
331            (SampleFormat::Int8, 2e-2),
332            (SampleFormat::Int16, 1e-4),
333            (SampleFormat::Int32, 1e-4),
334            (SampleFormat::Float32, 1e-4),
335        ] {
336            write(
337                &path,
338                &audio1.samples_interleaved,
339                audio1.num_channels,
340                audio1.sample_rate,
341                WriteConfig { sample_format },
342            )
343            .unwrap();
344
345            let audio2 = read::<f32>(&path, ReadConfig::default()).unwrap();
346            assert_eq!(audio2.num_channels, 4, "{sample_format:?}");
347            assert_eq!(audio1.sample_rate, audio2.sample_rate);
348            approx::assert_abs_diff_eq!(
349                audio1.samples_interleaved.as_slice(),
350                audio2.samples_interleaved.as_slice(),
351                epsilon = epsilon
352            );
353        }
354
355        std::fs::remove_file(&path).unwrap();
356    }
357
358    /// A channel count wide enough to need the extensible layout, round
359    /// tripped through the native WAV decoder.
360    #[test]
361    fn test_round_trip_eighteen_channels() {
362        use super::*;
363        use crate::reader::{ReadConfig, read};
364
365        let path = crate::tmp_path("round-trip-18ch.wav");
366        let num_channels = 18u16;
367        let samples: Vec<f32> = (0..usize::from(num_channels) * 5)
368            .map(|i| (i as f32 * 0.01) - 0.5)
369            .collect();
370
371        write(
372            &path,
373            &samples,
374            num_channels,
375            48000,
376            WriteConfig {
377                sample_format: SampleFormat::Int32,
378            },
379        )
380        .unwrap();
381
382        let audio = read::<f32>(&path, ReadConfig::default()).unwrap();
383        assert_eq!(audio.num_channels, num_channels);
384        approx::assert_abs_diff_eq!(
385            samples.as_slice(),
386            audio.samples_interleaved.as_slice(),
387            epsilon = 1e-6
388        );
389
390        std::fs::remove_file(&path).unwrap();
391    }
392
393    /// Symphonia's WAV reader derives the channel layout from the extensible
394    /// `dwChannelMask` and only knows 18 standard speaker positions, so it
395    /// rejects a mask naming more than that. The native decoder in
396    /// [`crate::wav`] reads `nChannels` directly and never looks at the mask,
397    /// so a file with more channels than that ceiling now round trips too.
398    #[test]
399    fn test_many_channels_can_be_read_back() {
400        use super::*;
401        use crate::reader::{ReadConfig, read};
402
403        let path = crate::tmp_path("64ch.wav");
404        let num_channels = 64u16;
405        let total = usize::from(num_channels) * 5;
406        let samples: Vec<f32> = (0..total)
407            .map(|i| (i as f32 / total as f32) - 0.5)
408            .collect();
409
410        write(
411            &path,
412            &samples,
413            num_channels,
414            48000,
415            WriteConfig {
416                sample_format: SampleFormat::Int32,
417            },
418        )
419        .unwrap();
420
421        let audio = read::<f32>(&path, ReadConfig::default()).unwrap();
422        assert_eq!(audio.num_channels, num_channels);
423        approx::assert_abs_diff_eq!(
424            samples.as_slice(),
425            audio.samples_interleaved.as_slice(),
426            epsilon = 1e-6
427        );
428
429        std::fs::remove_file(&path).unwrap();
430    }
431
432    /// A path that cannot be created has to surface as an error instead of a
433    /// panic, since the encoder writes straight to the file.
434    #[test]
435    fn test_unwritable_path_is_reported() {
436        use super::*;
437
438        match write::<f32>(
439            crate::tmp_path("missing-dir").join("nested/out.wav"),
440            &[0.0; 4],
441            1,
442            48000,
443            WriteConfig::default(),
444        ) {
445            Err(WriteError::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound),
446            other => panic!("{other:?}"),
447        }
448    }
449
450    /// A write that fails halfway, here because the device is full, has to
451    /// surface the error. `/dev/full` also covers the other half of the cleanup
452    /// that follows such a failure: it removes a file it truncated, but not a
453    /// path it did not create.
454    #[cfg(target_os = "linux")]
455    #[test]
456    fn test_failed_write_is_reported() {
457        use super::*;
458
459        match write::<f32>("/dev/full", &[0.0; 1024], 2, 48000, WriteConfig::default()) {
460            Err(WriteError::Io(_)) => (),
461            other => panic!("{other:?}"),
462        }
463
464        assert!(
465            std::path::Path::new("/dev/full").exists(),
466            "the failed write removed the device"
467        );
468    }
469
470    /// `write_block` reinterleaves before handing the samples to the encoder, so
471    /// a channel-major block has to come back in the same order it went in.
472    #[cfg(feature = "audio-blocks")]
473    #[test]
474    fn test_round_trip_block() {
475        use super::*;
476        use crate::reader::{ReadConfig, read};
477
478        let path = crate::tmp_path("round-trip-block.wav");
479        // Two channels of three frames, laid out channel after channel.
480        let block = audio_blocks::Sequential::from_slice(&[0.1f32, 0.2, 0.3, -0.1, -0.2, -0.3], 2);
481
482        write_block(
483            &path,
484            block,
485            48000,
486            WriteConfig {
487                sample_format: SampleFormat::Float32,
488            },
489        )
490        .unwrap();
491
492        let audio = read::<f32>(&path, ReadConfig::default()).unwrap();
493        assert_eq!(audio.num_channels, 2);
494        assert_eq!(audio.sample_rate, 48000);
495        approx::assert_abs_diff_eq!(
496            [0.1f32, -0.1, 0.2, -0.2, 0.3, -0.3].as_slice(),
497            audio.samples_interleaved.as_slice(),
498            epsilon = 1e-6
499        );
500
501        std::fs::remove_file(&path).unwrap();
502    }
503
504    /// The odd frame counts that need a pad byte have to survive a round trip,
505    /// since a decoder that trusts the chunk size would otherwise read into it.
506    #[test]
507    fn test_round_trip_odd_length() {
508        use super::*;
509        use crate::reader::{ReadConfig, read};
510
511        let path = crate::tmp_path("round-trip-odd.wav");
512        for num_frames in 0..6 {
513            let samples: Vec<f32> = (0..num_frames).map(|i| i as f32 / 10.0).collect();
514
515            write(
516                &path,
517                &samples,
518                1,
519                48000,
520                WriteConfig {
521                    sample_format: SampleFormat::Int8,
522                },
523            )
524            .unwrap();
525
526            let audio = read::<f32>(&path, ReadConfig::default()).unwrap();
527            assert_eq!(
528                audio.samples_interleaved.len(),
529                num_frames,
530                "{num_frames} frames"
531            );
532        }
533
534        std::fs::remove_file(&path).unwrap();
535    }
536
537    /// The public API is generic over the float type, so f64 input has to work
538    /// end to end and not just in the encoder.
539    #[test]
540    fn test_round_trip_f64_input() {
541        use super::*;
542        use crate::reader::{ReadConfig, read};
543
544        let path = crate::tmp_path("round-trip-f64.wav");
545        let samples: Vec<f64> = (0..64).map(|i| (i as f64 / 32.0) - 1.0).collect();
546
547        write(
548            &path,
549            &samples,
550            2,
551            48000,
552            WriteConfig {
553                sample_format: SampleFormat::Float32,
554            },
555        )
556        .unwrap();
557
558        let audio = read::<f64>(&path, ReadConfig::default()).unwrap();
559        assert_eq!(audio.num_channels, 2);
560        approx::assert_abs_diff_eq!(
561            samples.as_slice(),
562            audio.samples_interleaved.as_slice(),
563            epsilon = 1e-6
564        );
565
566        std::fs::remove_file(&path).unwrap();
567    }
568
569    /// Full scale samples must not wrap around or drop out, which happens when
570    /// the integer range is not exactly representable in the sample type.
571    #[test]
572    fn test_full_scale_round_trip() {
573        use super::*;
574        use crate::reader::{ReadConfig, read};
575
576        let path = crate::tmp_path("full-scale.wav");
577        let samples = [1.0f32, -1.0, 0.5, -0.5, 0.0];
578
579        for sample_format in [
580            SampleFormat::Int8,
581            SampleFormat::Int16,
582            SampleFormat::Int32,
583            SampleFormat::Float32,
584        ] {
585            write(&path, &samples, 1, 48000, WriteConfig { sample_format }).unwrap();
586            let audio = read::<f32>(&path, ReadConfig::default()).unwrap();
587
588            approx::assert_abs_diff_eq!(
589                samples.as_slice(),
590                audio.samples_interleaved.as_slice(),
591                epsilon = 1e-2
592            );
593        }
594
595        std::fs::remove_file(&path).unwrap();
596    }
597
598    #[test]
599    fn test_round_trip_f32() {
600        use super::*;
601        use crate::reader::{ReadConfig, read};
602
603        let audio1 = read::<f32>("test_data/test_1ch.wav", ReadConfig::default()).unwrap();
604
605        let path = crate::tmp_path("round-trip-f32.wav");
606        write(
607            &path,
608            &audio1.samples_interleaved,
609            audio1.num_channels,
610            audio1.sample_rate,
611            WriteConfig {
612                sample_format: SampleFormat::Float32,
613            },
614        )
615        .unwrap();
616
617        let audio2 = read::<f32>(&path, ReadConfig::default()).unwrap();
618        assert_eq!(audio1.sample_rate, audio2.sample_rate);
619        approx::assert_abs_diff_eq!(
620            audio1.samples_interleaved.as_slice(),
621            audio2.samples_interleaved.as_slice(),
622            epsilon = 1e-6
623        );
624
625        // Clean up temporary file
626        std::fs::remove_file(&path).expect("Failed to remove temporary test file");
627    }
628}