Skip to main content

max / audiofiles

26.2 KB · 731 lines History Blame Raw
1 //! Does an export actually satisfy the device it was exported for?
2 //!
3 //! A checker rather than a measurement, like [`layout`](crate::layout): it
4 //! exports to each of the fourteen bundled targets, reads every file it wrote
5 //! back off disk, and scores it against that device's manifest. Per-target
6 //! pass/fail, and a non-zero exit if any target fails.
7 //!
8 //! **Why not unit tests.** The export module has plenty, and they assert what a
9 //! config the test wrote produces. That cannot catch a profile whose constraints
10 //! never reach the config, or a config the pipeline quietly ignores, because
11 //! both ends of the comparison were written by the same hand. This runs the real
12 //! resolution ([`DeviceProfile::apply_to`]) over the real manifest, exports
13 //! through the real pipeline, and compares against the manifest again. The
14 //! manifest is the ground truth, and this is the rare corner where the truth is
15 //! written down rather than annotated by somebody.
16 //!
17 //! **The header is the oracle, not a decoder.** Sample rate, bit depth and
18 //! channel count are read out of the WAV `fmt ` and AIFF `COMM` chunks by the
19 //! parser below. A device reads the file's header, so the header is the thing
20 //! that has to be right, and going through symphonia would let a decoder's
21 //! tolerance for an odd header hide exactly the defect this is looking for.
22 //!
23 //! **Sources are fabricated, not drawn from the corpus.** The corpus is real
24 //! audio with ordinary names, and what this checks hardest is what happens to a
25 //! name a device cannot take: spaces, punctuation, case, accents, and a stem
26 //! four times the longest limit any of the fourteen declare. Those have to be
27 //! constructed. It also means the mode runs anywhere, with no `AF_BENCH_CORPUS`.
28 //!
29 //! **What it does not check, stated so a green run is not read as more than it
30 //! is.** Two manifest fields go unexercised because only one device declares
31 //! each and neither can be reached with sources of a sane size: the Volca's
32 //! `max_file_size_bytes` (128 MiB, against sources of a tenth of a second) and
33 //! the SP-404's `max_sample_count` (100, against seven). `check_size` is written
34 //! and will fire if a device ever declares a limit a real sample can cross;
35 //! `max_sample_count` has no check at all, because nothing in the pipeline
36 //! enforces it yet. Audio content is not compared either: this asks whether the
37 //! file is one the device can read, not whether it still sounds like the source.
38 //!
39 //! Usage: `cargo run --release -p audiofiles-bench -- device-export`
40
41 use std::collections::BTreeSet;
42 use std::path::{Path, PathBuf};
43 use std::sync::atomic::AtomicBool;
44
45 use audiofiles_core::export::profile::{ChannelConstraint, DeviceProfile, NamingCase};
46 use audiofiles_core::export::{ExportChannels, ExportConfig, ExportFormat, ExportItem, run_export};
47 use audiofiles_core::store::SampleStore;
48
49 /// The names every device is asked to cope with.
50 ///
51 /// Each is a stem, and each is here for a rule in the manifests. Nothing in the
52 /// list is exotic for a sample library: these are the names a pack ships with.
53 const AWKWARD_STEMS: &[&str] = &[
54 // Ordinary, so a device that mangles the easy case shows up too.
55 "kick",
56 // Spaces and mixed case, the two most common things in a sample pack.
57 "Deep House Kick 01",
58 // Punctuation a filesystem allows and a sampler often does not.
59 "snare (bright) [wet] #2!",
60 // Non-ASCII, which `strip_special` has to answer for one way or the other.
61 "cafe\u{301} cra\u{300}sh",
62 // Longer than the longest `max_length` in the bundled set (128) by enough
63 // that a truncation off by a character is visible.
64 "a_very_long_stem_that_keeps_going_and_going_and_going_and_going_and_going_and_going_and_going_and_going_and_going_and_going_and_going_and_going_and_going_and_going_and_going",
65 // Two stems that collide once case and separators are normalised, so the
66 // deduplication has to hold the names apart.
67 "Clap A",
68 "clap_a",
69 ];
70
71 /// What one device's run came to.
72 struct DeviceReport {
73 device: String,
74 slug: String,
75 exported: usize,
76 violations: Vec<String>,
77 }
78
79 impl DeviceReport {
80 fn passed(&self) -> bool {
81 self.violations.is_empty()
82 }
83 }
84
85 /// What a container's header says it holds.
86 #[derive(Debug, Clone, Copy)]
87 struct Header {
88 sample_rate: u32,
89 bit_depth: u16,
90 channels: u16,
91 }
92
93 /// Run the whole matrix. Returns false if any target failed.
94 pub(crate) fn run() -> bool {
95 let mut registry = audiofiles_rhai::registry::PluginRegistry::new();
96 if let Err(e) = audiofiles_rhai::bundled::load_bundled(&mut registry) {
97 eprintln!("could not load the bundled plugins: {e}");
98 return false;
99 }
100
101 let scratch = match tempdir() {
102 Ok(dir) => dir,
103 Err(e) => {
104 eprintln!("could not make a scratch directory: {e}");
105 return false;
106 }
107 };
108 let sources = match fabricate_sources(&scratch.join("sources")) {
109 Ok(sources) => sources,
110 Err(e) => {
111 eprintln!("could not write the source files: {e}");
112 return false;
113 }
114 };
115
116 println!("Device export conformance");
117 println!(" {} sources, {} targets", sources.len(), registry.len());
118 println!();
119
120 let mut reports: Vec<DeviceReport> = registry
121 .list()
122 .iter()
123 .filter_map(|summary| registry.get(&summary.name))
124 .map(|plugin| score(&plugin.profile, &sources, &scratch))
125 .collect();
126 reports.sort_by(|a, b| a.slug.cmp(&b.slug));
127
128 for report in &reports {
129 let mark = if report.passed() { "pass" } else { "FAIL" };
130 println!(
131 " {mark} {:<20} {} exported",
132 report.device, report.exported
133 );
134 for violation in &report.violations {
135 println!(" {violation}");
136 }
137 }
138
139 let failed = reports.iter().filter(|r| !r.passed()).count();
140 println!();
141 println!(
142 " {} of {} targets conform",
143 reports.len() - failed,
144 reports.len()
145 );
146
147 let _ = std::fs::remove_dir_all(&scratch);
148 failed == 0
149 }
150
151 /// Export every source for one device and check what came out.
152 fn score(profile: &DeviceProfile, sources: &[PathBuf], scratch: &Path) -> DeviceReport {
153 let slug = slug(&profile.name);
154 let destination = scratch.join("out").join(&slug);
155 let mut violations = Vec::new();
156
157 let items: Vec<ExportItem> = sources
158 .iter()
159 .map(|path| ExportItem {
160 // Never read: `source_path` is set, so the runner takes the file
161 // from disk rather than from the store.
162 hash: audiofiles_core::SampleHash::from_trusted("0".repeat(64)),
163 ext: "wav".to_string(),
164 relative_path: PathBuf::from(file_stem(path)),
165 name: file_stem(path),
166 bpm: None,
167 musical_key: None,
168 duration: None,
169 tags: Vec::new(),
170 source_path: Some(path.clone()),
171 })
172 .collect();
173
174 // Everything left open, so the profile is what decides. This is the state
175 // the picker hands the backend when a user chooses a device and touches
176 // nothing else, which is the case worth checking.
177 let mut config = ExportConfig {
178 format: ExportFormat::Original,
179 sample_rate: None,
180 bit_depth: None,
181 channels: ExportChannels::Original,
182 naming_pattern: None,
183 flatten: true,
184 metadata_sidecar: false,
185 destination: destination.clone(),
186 device_profile: Some(profile.name.clone()),
187 naming_rules: None,
188 max_file_size_bytes: None,
189 name_overrides: None,
190 };
191 profile.apply_to(&mut config);
192
193 let store = match SampleStore::new(scratch.join("store")) {
194 Ok(store) => store,
195 Err(e) => {
196 violations.push(format!("could not open a store: {e}"));
197 return DeviceReport {
198 device: profile.name.clone(),
199 slug,
200 exported: 0,
201 violations,
202 };
203 }
204 };
205
206 let summary = match run_export(
207 &items,
208 &config,
209 &store,
210 &AtomicBool::new(false),
211 |_, _, _| true,
212 ) {
213 Ok(summary) => summary,
214 Err(e) => {
215 violations.push(format!("the export failed outright: {e}"));
216 return DeviceReport {
217 device: profile.name.clone(),
218 slug,
219 exported: 0,
220 violations,
221 };
222 }
223 };
224 for (name, error) in &summary.errors {
225 violations.push(format!("{name}: the pipeline reported {error}"));
226 }
227
228 let written = match list_files(&destination) {
229 Ok(written) => written,
230 Err(e) => {
231 violations.push(format!("nothing to read back: {e}"));
232 Vec::new()
233 }
234 };
235
236 // A name that collided and was silently overwritten is a lost sample, and
237 // the count is the only place it shows.
238 let expected = items.len() - summary.errors.len();
239 if written.len() != expected {
240 violations.push(format!(
241 "{} files written for {expected} samples that did not error: names collided, \
242 or something was dropped without saying so",
243 written.len()
244 ));
245 }
246
247 let mut stems = BTreeSet::new();
248 for path in &written {
249 check_format(profile, path, &mut violations);
250 check_header(profile, path, &mut violations);
251 check_name(profile, path, &mut violations);
252 check_size(profile, path, &mut violations);
253 stems.insert(file_stem(path));
254 }
255 if stems.len() != written.len() {
256 violations.push("two files share a stem after normalisation".to_string());
257 }
258
259 DeviceReport {
260 device: profile.name.clone(),
261 slug,
262 exported: written.len(),
263 violations,
264 }
265 }
266
267 /// The extension has to be one the device reads.
268 fn check_format(profile: &DeviceProfile, path: &Path, violations: &mut Vec<String>) {
269 let ext = path
270 .extension()
271 .and_then(|e| e.to_str())
272 .unwrap_or_default()
273 .to_ascii_lowercase();
274 let allowed: Vec<&str> = profile
275 .audio
276 .formats
277 .iter()
278 .filter_map(|f| match f {
279 ExportFormat::Wav => Some("wav"),
280 ExportFormat::Aiff => Some("aiff"),
281 // "whatever came in" is not a format a device declares support for.
282 ExportFormat::Original => None,
283 })
284 .collect();
285 if !allowed.is_empty() && !allowed.contains(&ext.as_str()) {
286 violations.push(format!(
287 "{}: extension .{ext}, device reads {}",
288 name(path),
289 allowed.join("/")
290 ));
291 }
292 }
293
294 /// Rate, depth and channel count, read off the container.
295 fn check_header(profile: &DeviceProfile, path: &Path, violations: &mut Vec<String>) {
296 let header = match read_header(path) {
297 Ok(header) => header,
298 Err(e) => {
299 violations.push(format!("{}: unreadable header ({e})", name(path)));
300 return;
301 }
302 };
303 if !profile.audio.sample_rates.contains(&header.sample_rate) {
304 violations.push(format!(
305 "{}: {} Hz, device takes {:?}",
306 name(path),
307 header.sample_rate,
308 profile.audio.sample_rates
309 ));
310 }
311 if !profile.audio.bit_depths.contains(&header.bit_depth) {
312 violations.push(format!(
313 "{}: {}-bit, device takes {:?}",
314 name(path),
315 header.bit_depth,
316 profile.audio.bit_depths
317 ));
318 }
319 let channels_ok = match profile.audio.channels {
320 ChannelConstraint::Mono => header.channels == 1,
321 ChannelConstraint::Stereo => header.channels == 2,
322 ChannelConstraint::Both => header.channels == 1 || header.channels == 2,
323 };
324 if !channels_ok {
325 violations.push(format!(
326 "{}: {} channels, device is {:?}",
327 name(path),
328 header.channels,
329 profile.audio.channels
330 ));
331 }
332 }
333
334 /// Case, separator, length and the character set.
335 fn check_name(profile: &DeviceProfile, path: &Path, violations: &mut Vec<String>) {
336 let Some(rules) = &profile.naming else {
337 return;
338 };
339 let stem = file_stem(path);
340
341 match rules.case {
342 NamingCase::Lower if stem != stem.to_lowercase() => {
343 violations.push(format!("{stem}: not lowercased"));
344 }
345 NamingCase::Upper if stem != stem.to_uppercase() => {
346 violations.push(format!("{stem}: not uppercased"));
347 }
348 _ => {}
349 }
350
351 if stem.chars().count() > rules.max_length {
352 violations.push(format!(
353 "{stem}: {} characters, device takes {}",
354 stem.chars().count(),
355 rules.max_length
356 ));
357 }
358
359 if rules.strip_special {
360 let bad: String = stem
361 .chars()
362 .filter(|c| !c.is_ascii_alphanumeric() && *c != rules.separator)
363 .collect();
364 if !bad.is_empty() {
365 violations.push(format!(
366 "{stem}: keeps {bad:?}, which strip_special forbids"
367 ));
368 }
369 }
370 }
371
372 /// The written file has to fit.
373 fn check_size(profile: &DeviceProfile, path: &Path, violations: &mut Vec<String>) {
374 let Some(limit) = profile.limits.as_ref().and_then(|l| l.max_file_size_bytes) else {
375 return;
376 };
377 let Ok(meta) = std::fs::metadata(path) else {
378 return;
379 };
380 if meta.len() > limit {
381 violations.push(format!(
382 "{}: {} bytes, device takes {limit}",
383 name(path),
384 meta.len()
385 ));
386 }
387 }
388
389 // ── Reading a container's header ──
390
391 /// Sample rate, bit depth and channel count, from the file itself.
392 fn read_header(path: &Path) -> Result<Header, String> {
393 let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
394 if bytes.len() < 12 {
395 return Err("shorter than a container header".to_string());
396 }
397 match &bytes[0..4] {
398 b"RIFF" => wav_header(&bytes),
399 b"FORM" => aiff_header(&bytes),
400 other => Err(format!(
401 "unknown container {:?}",
402 String::from_utf8_lossy(other)
403 )),
404 }
405 }
406
407 /// The `fmt ` chunk of a RIFF/WAVE file.
408 fn wav_header(bytes: &[u8]) -> Result<Header, String> {
409 if &bytes[8..12] != b"WAVE" {
410 return Err("RIFF but not WAVE".to_string());
411 }
412 let mut at = 12;
413 while at + 8 <= bytes.len() {
414 let id = &bytes[at..at + 4];
415 let size = u32::from_le_bytes(take4(bytes, at + 4)?) as usize;
416 let body = at + 8;
417 if id == b"fmt " {
418 if body + 16 > bytes.len() {
419 return Err("truncated fmt chunk".to_string());
420 }
421 return Ok(Header {
422 channels: u16::from_le_bytes([bytes[body + 2], bytes[body + 3]]),
423 sample_rate: u32::from_le_bytes(take4(bytes, body + 4)?),
424 bit_depth: u16::from_le_bytes([bytes[body + 14], bytes[body + 15]]),
425 });
426 }
427 // Chunks are word-aligned: an odd size carries a pad byte.
428 at = body + size + (size % 2);
429 }
430 Err("no fmt chunk".to_string())
431 }
432
433 /// The `COMM` chunk of an AIFF file.
434 ///
435 /// The sample rate is an 80-bit IEEE 754 extended float, which is why this
436 /// reads longer than the WAV case. Only the integral part is wanted and every
437 /// rate in the manifests is a small positive integer, so the mantissa's
438 /// fractional bits are dropped rather than accumulated.
439 fn aiff_header(bytes: &[u8]) -> Result<Header, String> {
440 if &bytes[8..12] != b"AIFF" && &bytes[8..12] != b"AIFC" {
441 return Err("FORM but not AIFF".to_string());
442 }
443 let mut at = 12;
444 while at + 8 <= bytes.len() {
445 let id = &bytes[at..at + 4];
446 let size = u32::from_be_bytes(take4(bytes, at + 4)?) as usize;
447 let body = at + 8;
448 if id == b"COMM" {
449 if body + 18 > bytes.len() {
450 return Err("truncated COMM chunk".to_string());
451 }
452 let exponent = u16::from_be_bytes([bytes[body + 8], bytes[body + 9]]);
453 let mantissa = u64::from_be_bytes([
454 bytes[body + 10],
455 bytes[body + 11],
456 bytes[body + 12],
457 bytes[body + 13],
458 bytes[body + 14],
459 bytes[body + 15],
460 bytes[body + 16],
461 bytes[body + 17],
462 ]);
463 let shift = i32::from(exponent & 0x7fff) - 16383 - 63;
464 let rate = if shift >= 0 {
465 mantissa << shift.min(63)
466 } else {
467 mantissa >> (-shift).min(63)
468 };
469 return Ok(Header {
470 channels: u16::from_be_bytes([bytes[body], bytes[body + 1]]),
471 sample_rate: u32::try_from(rate).map_err(|_| "absurd sample rate".to_string())?,
472 bit_depth: u16::from_be_bytes([bytes[body + 6], bytes[body + 7]]),
473 });
474 }
475 at = body + size + (size % 2);
476 }
477 Err("no COMM chunk".to_string())
478 }
479
480 fn take4(bytes: &[u8], at: usize) -> Result<[u8; 4], String> {
481 bytes
482 .get(at..at + 4)
483 .and_then(|s| s.try_into().ok())
484 .ok_or_else(|| "truncated chunk header".to_string())
485 }
486
487 // ── Fabricating the sources ──
488
489 /// One source per awkward stem, cycling through four shapes.
490 ///
491 /// **Every shape is one at least one device has to convert.** A source that
492 /// already conformed would let a pipeline that did nothing at all pass. The
493 /// four together cover conversion in both directions on each axis: 44.1k up to
494 /// the 48k-only devices and 48k down to the 44.1k-only ones, 24-bit down to the
495 /// seven that take only 16, and stereo down to the five that are mono.
496 ///
497 /// 96k is here for the widest device rather than the narrowest. MPC One/Live
498 /// takes 44.1k and 48k, 16 and 24 bit, mono and stereo, so without a rate none
499 /// of the fourteen accept it would be handed four sources it could pass through
500 /// untouched, and its green cell would say nothing at all.
501 /// `no_device_can_pass_without_converting_something` is what keeps that true.
502 ///
503 /// 31250 Hz is deliberately not among them. It is the Volca's only rate, so
504 /// resampling to it is exercised from every source rather than needing one of
505 /// its own.
506 const SOURCE_SHAPES: &[(u32, u16, u16)] = &[
507 (44100, 16, 2),
508 (48000, 24, 2),
509 (44100, 24, 1),
510 (48000, 16, 1),
511 (96000, 24, 2),
512 ];
513
514 /// Write one WAV per awkward stem, rotating through [`SOURCE_SHAPES`].
515 fn fabricate_sources(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
516 std::fs::create_dir_all(dir)?;
517 let mut written = Vec::new();
518 for (i, stem) in AWKWARD_STEMS.iter().enumerate() {
519 let (rate, bits, channels) = SOURCE_SHAPES[i % SOURCE_SHAPES.len()];
520 let path = dir.join(format!("{stem}.wav"));
521 std::fs::write(
522 &path,
523 wav(&tone(i, rate, bits, channels), rate, bits, channels),
524 )?;
525 written.push(path);
526 }
527 Ok(written)
528 }
529
530 /// A tenth of a second of a sine, at the given shape.
531 ///
532 /// The frequency varies with `i` so two sources are never byte-identical; a
533 /// pipeline that wrote the same file twice would otherwise be invisible to the
534 /// name checks, which are all that read the output today.
535 fn tone(i: usize, rate: u32, bits: u16, channels: u16) -> Vec<u8> {
536 let frames = rate as usize / 10;
537 let mut pcm = Vec::with_capacity(frames * usize::from(channels) * usize::from(bits / 8));
538 for frame in 0..frames {
539 #[allow(clippy::cast_precision_loss)]
540 let t = frame as f64 / f64::from(rate);
541 #[allow(clippy::cast_precision_loss)]
542 let hz = 220.0 * (i + 1) as f64;
543 let value = (t * hz * std::f64::consts::TAU).sin() * 0.25;
544 for _ in 0..channels {
545 match bits {
546 24 => {
547 #[allow(clippy::cast_possible_truncation)]
548 let scaled = (value * f64::from(1 << 23)) as i32;
549 pcm.extend_from_slice(&scaled.to_le_bytes()[0..3]);
550 }
551 _ => {
552 #[allow(clippy::cast_possible_truncation)]
553 let scaled = (value * f64::from(i16::MAX)) as i16;
554 pcm.extend_from_slice(&scaled.to_le_bytes());
555 }
556 }
557 }
558 }
559 pcm
560 }
561
562 /// A canonical 44-byte-header RIFF/WAVE file around `pcm`.
563 fn wav(pcm: &[u8], rate: u32, bits: u16, channels: u16) -> Vec<u8> {
564 let block_align = channels * bits / 8;
565 let byte_rate = rate * u32::from(block_align);
566 let mut out = Vec::with_capacity(44 + pcm.len());
567 out.extend_from_slice(b"RIFF");
568 out.extend_from_slice(
569 &u32::try_from(36 + pcm.len())
570 .unwrap_or(u32::MAX)
571 .to_le_bytes(),
572 );
573 out.extend_from_slice(b"WAVEfmt ");
574 out.extend_from_slice(&16u32.to_le_bytes());
575 out.extend_from_slice(&1u16.to_le_bytes()); // PCM
576 out.extend_from_slice(&channels.to_le_bytes());
577 out.extend_from_slice(&rate.to_le_bytes());
578 out.extend_from_slice(&byte_rate.to_le_bytes());
579 out.extend_from_slice(&block_align.to_le_bytes());
580 out.extend_from_slice(&bits.to_le_bytes());
581 out.extend_from_slice(b"data");
582 out.extend_from_slice(&u32::try_from(pcm.len()).unwrap_or(u32::MAX).to_le_bytes());
583 out.extend_from_slice(pcm);
584 out
585 }
586
587 // ── Small helpers ──
588
589 fn tempdir() -> std::io::Result<PathBuf> {
590 let dir = std::env::temp_dir().join(format!("af-device-export-{}", std::process::id()));
591 std::fs::create_dir_all(&dir)?;
592 Ok(dir)
593 }
594
595 fn list_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
596 let mut out = Vec::new();
597 let mut stack = vec![dir.to_path_buf()];
598 while let Some(at) = stack.pop() {
599 for entry in std::fs::read_dir(&at)? {
600 let path = entry?.path();
601 if path.is_dir() {
602 stack.push(path);
603 } else {
604 out.push(path);
605 }
606 }
607 }
608 out.sort();
609 Ok(out)
610 }
611
612 fn name(path: &Path) -> String {
613 path.file_name()
614 .map(|n| n.to_string_lossy().into_owned())
615 .unwrap_or_default()
616 }
617
618 fn file_stem(path: &Path) -> String {
619 path.file_stem()
620 .map(|n| n.to_string_lossy().into_owned())
621 .unwrap_or_default()
622 }
623
624 fn slug(device: &str) -> String {
625 device
626 .chars()
627 .map(|c| {
628 if c.is_ascii_alphanumeric() {
629 c.to_ascii_lowercase()
630 } else {
631 '_'
632 }
633 })
634 .collect()
635 }
636
637 #[cfg(test)]
638 mod tests {
639 use super::*;
640
641 /// The header parser is the oracle here, so it is the one thing that cannot
642 /// be checked by the harness it serves. Round-trip it against the writer.
643 #[test]
644 fn the_wav_parser_reads_back_what_the_writer_wrote() {
645 for (rate, bits, channels) in [
646 (44100, 16, 1),
647 (48000, 16, 2),
648 (44100, 24, 2),
649 (48000, 24, 1),
650 ] {
651 let bytes = wav(&[0u8; 64], rate, bits, channels);
652 let dir = tempdir().unwrap();
653 let path = dir.join(format!("probe-{rate}-{bits}-{channels}.wav"));
654 std::fs::write(&path, &bytes).unwrap();
655 let header = read_header(&path).unwrap();
656 assert_eq!(header.sample_rate, rate);
657 assert_eq!(header.bit_depth, bits);
658 assert_eq!(header.channels, channels);
659 std::fs::remove_file(&path).unwrap();
660 }
661 }
662
663 /// A chunk before `fmt ` must not throw the walk off, and an odd-sized one
664 /// carries a pad byte the walk has to skip or every later offset is wrong.
665 #[test]
666 fn the_wav_parser_walks_past_an_odd_sized_chunk() {
667 let mut bytes = Vec::new();
668 bytes.extend_from_slice(b"RIFF");
669 bytes.extend_from_slice(&0u32.to_le_bytes());
670 bytes.extend_from_slice(b"WAVE");
671 // A 3-byte LIST chunk, so the pad byte matters.
672 bytes.extend_from_slice(b"LIST");
673 bytes.extend_from_slice(&3u32.to_le_bytes());
674 bytes.extend_from_slice(&[1, 2, 3, 0]);
675 let canonical = wav(&[0u8; 8], 48000, 24, 2);
676 bytes.extend_from_slice(&canonical[12..]);
677
678 let dir = tempdir().unwrap();
679 let path = dir.join("odd-chunk.wav");
680 std::fs::write(&path, &bytes).unwrap();
681 let header = read_header(&path).unwrap();
682 assert_eq!(header.sample_rate, 48000);
683 assert_eq!(header.bit_depth, 24);
684 assert_eq!(header.channels, 2);
685 std::fs::remove_file(&path).unwrap();
686 }
687
688 /// Every stem in the list has to reach the harness as a real file, or a
689 /// case silently stops being covered.
690 #[test]
691 fn every_awkward_stem_becomes_a_source_of_the_shape_it_was_meant_to_have() {
692 let dir = tempdir().unwrap().join("fabricate-test");
693 let sources = fabricate_sources(&dir).unwrap();
694 assert_eq!(sources.len(), AWKWARD_STEMS.len());
695 for (i, path) in sources.iter().enumerate() {
696 let (rate, bits, channels) = SOURCE_SHAPES[i % SOURCE_SHAPES.len()];
697 let header = read_header(path).unwrap();
698 assert_eq!(header.sample_rate, rate);
699 assert_eq!(header.bit_depth, bits);
700 assert_eq!(header.channels, channels);
701 }
702 std::fs::remove_dir_all(&dir).unwrap();
703 }
704
705 /// The point of the four shapes is that no device is handed a source it can
706 /// take as-is on every axis. If that ever stops being true the harness goes
707 /// quietly weaker, so it is asserted rather than left to the comment.
708 #[test]
709 fn no_device_can_pass_without_converting_something() {
710 let mut registry = audiofiles_rhai::registry::PluginRegistry::new();
711 audiofiles_rhai::bundled::load_bundled(&mut registry).unwrap();
712 for summary in registry.list() {
713 let profile = &registry.get(&summary.name).unwrap().profile;
714 let conforming = SOURCE_SHAPES.iter().filter(|(rate, bits, channels)| {
715 profile.audio.sample_rates.contains(rate)
716 && profile.audio.bit_depths.contains(bits)
717 && match profile.audio.channels {
718 ChannelConstraint::Mono => *channels == 1,
719 ChannelConstraint::Stereo => *channels == 2,
720 ChannelConstraint::Both => true,
721 }
722 });
723 assert!(
724 conforming.count() < SOURCE_SHAPES.len(),
725 "{} takes every source shape unchanged, so its pass says nothing",
726 profile.name
727 );
728 }
729 }
730 }
731