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#[derive(Debug, Clone, Copy, Default)]
36pub enum SampleFormat {
37 Int8,
39 #[default]
41 Int16,
42 Int32,
44 Float32,
46}
47
48#[derive(Default)]
50pub struct WriteConfig {
51 pub sample_format: SampleFormat,
53}
54
55pub 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 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 let layout = wav::Layout::new(
88 samples.len(),
89 num_channels,
90 sample_rate,
91 config.sample_format,
92 )?;
93
94 let path = path.as_ref();
99 let mut file = File::create(path)?;
100 let result = wav::write(&mut file, &layout, samples);
101 drop(file);
104
105 if let Err(err) = result {
106 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#[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 approx::assert_abs_diff_eq!(
165 audio1.samples_interleaved.as_slice(),
166 audio2.samples_interleaved.as_slice(),
167 epsilon = 2e-2
168 );
169
170 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 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 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 match write::<f32>(&path, &[0.0, 0.0], 2, 0, WriteConfig::default()) {
259 Err(WriteError::ZeroSampleRate) => (),
260 other => panic!("{other:?}"),
261 }
262
263 assert!(!path.exists());
265 }
266
267 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 std::fs::remove_file(&path).expect("Failed to remove temporary test file");
627 }
628}