Skip to main content

max / audiofiles

Score hardware export against the fourteen device specs A checker rather than a measurement, beside `layout`: `audiofiles-bench -- device-export` exports to each bundled target, reads every file it wrote back off disk, and scores it against that device's manifest. Per-target pass/fail, non-zero exit if any target fails. All fourteen conform today. The manifest is the ground truth, which is the rare case where the truth is written down rather than annotated, and it is why this is not unit tests. The existing export tests assert what a config the test wrote produces, so they cannot catch a constraint that never reaches the config or a config the pipeline ignores: both ends were written by the same hand. To make that real the profile-to-config mapping moves out of the browser backend into `DeviceProfile::apply_to`, so the harness and the app apply a device the same way. A harness that built its own config would be scoring the app against a second implementation of the rule under test. The header is the oracle. Rate, depth and channel count come from the WAV `fmt ` and AIFF `COMM` chunks, parsed here, because a device reads the header and a decoder's tolerance for an odd one would hide exactly what this looks for. Sources are fabricated, not corpus: what this checks hardest is a filename a device cannot take, and corpus files have ordinary names. Seven stems covering spaces, punctuation, combining accents, a stem longer than any declared limit, and a pair that collide once normalised. Five shapes across them, none of which any device accepts on every axis -- `no_device_can_pass_without_converting_something` asserts that, and it is what added the 96k source: MPC One/Live takes 44.1/48k, 16/24 bit and either channel count, so without it four of its five sources passed through untouched and its green cell said nothing. Every check was verified by breaking `apply_to` and watching the right devices fail: dropping the rate fails the five that are not 44.1k, dropping the depth fails the seven that are 16-bit only, dropping the mono clamp fails exactly the five mono devices, dropping the naming rules fails everything that declares them. Two manifest fields stay unexercised and the module says so rather than letting a green run imply otherwise: the Volca's 128 MiB file limit and the SP-404's 100-sample count, neither reachable with sources of a sane size.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 22:54 UTC
Signed with PGP, not checked
Commit: da6c3da449679aeeb7a7112b4ae24835d5003bde
Parent: 9dc5eed
6 files changed, +599 insertions, -67 deletions
M Cargo.lock +31 -30
@@ -429,6 +429,7 @@
429 429 version = "0.5.0"
430 430 dependencies = [
431 431 "audiofiles-core",
432 + "audiofiles-rhai",
432 433 "rayon",
433 434 "rusqlite",
434 435 "serde_json",
@@ -4244,7 +4245,7 @@
4244 4245
4245 4246 [[package]]
4246 4247 name = "quasi-immediate"
4247 - version = "0.20.0"
4248 + version = "0.20.1"
4248 4249 dependencies = [
4249 4250 "docengine",
4250 4251 "egui",
@@ -4254,7 +4255,7 @@
4254 4255
4255 4256 [[package]]
4256 4257 name = "quasi-router"
4257 - version = "0.20.0"
4258 + version = "0.20.1"
4258 4259 dependencies = [
4259 4260 "makeover-layout",
4260 4261 ]
@@ -7543,6 +7544,34 @@
7543 7544 "winnow 1.0.4",
7544 7545 ]
7545 7546
7547 + [[patch.unused]]
7548 + name = "quasi-axum"
7549 + version = "0.20.1"
7550 +
7551 + [[patch.unused]]
7552 + name = "quasi-basics"
7553 + version = "0.20.1"
7554 +
7555 + [[patch.unused]]
7556 + name = "quasi-http"
7557 + version = "0.20.1"
7558 +
7559 + [[patch.unused]]
7560 + name = "quasi-store"
7561 + version = "0.1.0"
7562 +
7563 + [[patch.unused]]
7564 + name = "quasi-tauri"
7565 + version = "0.20.1"
7566 +
7567 + [[patch.unused]]
7568 + name = "quasi-webview"
7569 + version = "0.20.1"
7570 +
7571 + [[patch.unused]]
7572 + name = "quasi-type"
7573 + version = "0.1.0"
7574 +
7546 7575 [[patch.unused]]
7547 7576 name = "kberg"
7548 7577 version = "0.1.0"
@@ -7554,31 +7583,3 @@
7554 7583 [[patch.unused]]
7555 7584 name = "painhours"
7556 7585 version = "0.1.0"
7557 -
7558 - [[patch.unused]]
7559 - name = "quasi-type"
7560 - version = "0.1.0"
7561 -
7562 - [[patch.unused]]
7563 - name = "quasi-axum"
7564 - version = "0.20.0"
7565 -
7566 - [[patch.unused]]
7567 - name = "quasi-basics"
7568 - version = "0.20.0"
7569 -
7570 - [[patch.unused]]
7571 - name = "quasi-http"
7572 - version = "0.20.0"
7573 -
7574 - [[patch.unused]]
7575 - name = "quasi-store"
7576 - version = "0.1.0"
7577 -
7578 - [[patch.unused]]
7579 - name = "quasi-tauri"
7580 - version = "0.20.0"
7581 -
7582 - [[patch.unused]]
7583 - name = "quasi-webview"
7584 - version = "0.20.0"
@@ -15,6 +15,10 @@
15 15 # The layout mode fabricates `samples` rows directly rather than importing audio,
16 16 # so it writes through the same connection the store does.
17 17 rusqlite = { workspace = true }
18 + # The bundled device manifests and their parser. The conformance harness scores
19 + # an export against the manifest it was exported for, so it needs the same
20 + # profiles the app loads rather than a copy of their numbers.
21 + audiofiles-rhai = { workspace = true }
18 22
19 23 [dev-dependencies]
20 24 tempfile = "3.25.0"
@@ -11,12 +11,15 @@
11 11 //! `cargo run --release -p audiofiles-bench -- ingest` vault ingest + queries
12 12 //! `cargo run --release -p audiofiles-bench -- accuracy` bpm/key vs ground truth
13 13 //! `cargo run --release -p audiofiles-bench -- layout` blob layout migration
14 + //! `cargo run --release -p audiofiles-bench -- device-export` export vs device specs
14 15 //! `cargo run --release -p audiofiles-bench -- layer-eval` cross-validate that layer
15 16 //! `cargo run --release -p audiofiles-bench -- layer-stability` does it answer twice
16 17 //!
17 - //! One mode is not a measurement: `layout` is a checker, fabricating flat
18 - //! vaults, sweeping them, and exiting non-zero if any scenario fails. It lives
18 + //! Two modes are not measurements. `layout` is a checker, fabricating flat
19 + //! vaults, sweeping them, and exiting non-zero if any scenario fails; it lives
19 20 //! here because corpus walking and scratch-vault fabrication do.
21 + //! `device-export` is the other, scoring a real export against each of the
22 + //! fourteen bundled device manifests, per target rather than as one number.
20 23 //!
21 24 //! `layer-eval` and `layer-stability` are the two meters for the exemplar k-NN,
22 25 //! and they measure independent properties: whether an answer is right, and
@@ -54,6 +57,7 @@
54 57
55 58 mod accuracy;
56 59 mod calibration;
60 + mod device_export;
57 61 mod families;
58 62 mod ingest;
59 63 mod labelled;
@@ -323,6 +327,16 @@
323 327 return;
324 328 }
325 329
330 + if args.first().map(String::as_str) == Some("device-export") {
331 + // No corpus and no vault: the sources are fabricated, because what this
332 + // checks hardest is a filename a device cannot take, and the corpus has
333 + // ordinary names. See the module for the rest of the reasoning.
334 + if !device_export::run() {
335 + std::process::exit(1);
336 + }
337 + return;
338 + }
339 +
326 340 if args.first().map(String::as_str) == Some("layer-eval") {
327 341 // Its own scratch vault, distinct from `layer-stability`'s: the two modes
328 342 // rebuild the same corpus and running one must not delete the other's
@@ -81,6 +81,50 @@
81 81 pub max_sample_count: Option<usize>,
82 82 }
83 83
84 + impl DeviceProfile {
85 + /// Fill in everything this device constrains that the caller left open.
86 + ///
87 + /// Only what is unset moves. A user who picked 24-bit on a device that
88 + /// accepts 16 and 24 has said something, and a profile that overwrote it
89 + /// would be a picker whose value did not survive being read.
90 + /// [`ChannelConstraint`] is the exception and has to be: `Mono` and
91 + /// `Stereo` are the device refusing the other one, so they overwrite, and
92 + /// `Both` leaves the choice alone because there is nothing to say.
93 + ///
94 + /// **This lives here rather than in the browser backend so that one
95 + /// mapping answers for the app and for the conformance harness.** The
96 + /// harness in `audiofiles-bench` exports through this and then reads the
97 + /// written file back against the same profile; if it built its own config
98 + /// it would be scoring the app against a second implementation of the rule
99 + /// under test, which is the imitation-oracle failure wiki
100 + /// `testing-posture` describes.
101 + ///
102 + /// The Rhai halves of applying a profile -- `validate_sample` filtering
103 + /// items, `transform_filename` rewriting names -- stay in the backend.
104 + /// They need the registry and an engine, and this crate is deliberately
105 + /// Rhai-independent.
106 + pub fn apply_to(&self, config: &mut super::ExportConfig) {
107 + if config.format == super::ExportFormat::Original
108 + && let Some(format) = self.audio.formats.first()
109 + {
110 + config.format = format.clone();
111 + }
112 + if config.sample_rate.is_none() {
113 + config.sample_rate = self.audio.sample_rates.first().copied();
114 + }
115 + if config.bit_depth.is_none() {
116 + config.bit_depth = self.audio.bit_depths.first().copied();
117 + }
118 + match self.audio.channels {
119 + ChannelConstraint::Mono => config.channels = super::ExportChannels::Mono,
120 + ChannelConstraint::Stereo => config.channels = super::ExportChannels::Stereo,
121 + ChannelConstraint::Both => {}
122 + }
123 + config.naming_rules.clone_from(&self.naming);
124 + config.max_file_size_bytes = self.limits.as_ref().and_then(|l| l.max_file_size_bytes);
125 + }
126 + }
127 +
84 128 /// Summary info for listing available profiles in the UI.
85 129 #[derive(Debug, Clone, Serialize, Deserialize)]
86 130 pub struct DeviceProfileSummary {
@@ -245,9 +245,6 @@
245 245 config: &mut audiofiles_core::export::ExportConfig,
246 246 items: &mut Vec<ExportItem>,
247 247 ) {
248 - use audiofiles_core::export::profile::ChannelConstraint;
249 - use audiofiles_core::export::{ExportChannels, ExportFormat};
250 -
251 248 let profile_name = match config.device_profile {
252 249 Some(ref name) => name.clone(),
253 250 None => return,
@@ -257,37 +254,9 @@
257 254 return;
258 255 };
259 256
260 - let profile = &plugin.profile;
261 -
262 - // Format: if Original, set to profile's first supported format
263 - if config.format == ExportFormat::Original
264 - && let Some(fmt) = profile.audio.formats.first()
265 - {
266 - config.format = fmt.clone();
267 - }
268 -
269 - // Sample rate: if not set, use profile's first rate
270 - if config.sample_rate.is_none() {
271 - config.sample_rate = profile.audio.sample_rates.first().copied();
272 - }
273 -
274 - // Bit depth: if not set, use profile's first depth
275 - if config.bit_depth.is_none() {
276 - config.bit_depth = profile.audio.bit_depths.first().copied();
277 - }
278 -
279 - // Channels
280 - match profile.audio.channels {
281 - ChannelConstraint::Mono => config.channels = ExportChannels::Mono,
282 - ChannelConstraint::Stereo => config.channels = ExportChannels::Stereo,
283 - ChannelConstraint::Both => {} // leave as-is
284 - }
285 -
286 - // Naming rules
287 - config.naming_rules.clone_from(&profile.naming);
288 -
289 - // File size limit
290 - config.max_file_size_bytes = profile.limits.as_ref().and_then(|l| l.max_file_size_bytes);
257 + // The declarative half of the profile, which `audiofiles-bench`'s
258 + // conformance harness applies through the same call.
259 + plugin.profile.apply_to(config);
291 260
292 261 // validate_sample hook: filter items through Rhai script
293 262 if let Some(ref ast) = plugin.hooks.validate_sample {
@@ -358,7 +327,7 @@
358 327 let mut names =
359 328 audiofiles_core::export::resolve_output_names(items, config, pattern.as_ref());
360 329
361 - let device_name = profile.name.clone();
330 + let device_name = plugin.profile.name.clone();
362 331 let destination = config.destination.display().to_string();
363 332 let total = names.len() as i64;
364 333
@@ -1,0 +1,730 @@
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.
Lines truncated