|
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.
|