From 428541a512aed4c50d0ded35fdf830504628eb9b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 21:58:16 -0500 Subject: [PATCH] craft-sf2: SoundFont 2 reader from LoopCraft (#1076) The SoundFont 2 reader of LoopCraft's loopcraft-audiofile crate (sf2.rs at storytold/loopcraft@0d150c1), over a neutral model of its own: the sample pool, presets and resolved key/velocity regions with tuning, loop points, pan, gain and volume envelope. The fixture writer its tests use is the test-support feature, so readers in other crates can test with real files. Co-authored-by: bflatastic --- Cargo.lock | 8 + Cargo.toml | 1 + README.md | 1 + crates/sf2/Cargo.toml | 21 ++ crates/sf2/README.md | 54 +++++ crates/sf2/src/fixture.rs | 76 +++++++ crates/sf2/src/lib.rs | 434 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 595 insertions(+) create mode 100644 crates/sf2/Cargo.toml create mode 100644 crates/sf2/README.md create mode 100644 crates/sf2/src/fixture.rs create mode 100644 crates/sf2/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0d365b9ce..0404ebf61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1816,6 +1816,14 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "craft-sf2" +version = "0.1.0" +dependencies = [ + "craft-sf2", + "thiserror 2.0.21", +] + [[package]] name = "craft-sheet" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c9c524fe5..a51b5204a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,7 @@ craft-math = { path = "crates/math" } craft-media = { path = "crates/media", default-features = false } craft-media-testkit = { path = "crates/media-testkit" } craft-midi = { path = "crates/midi" } +craft-sf2 = { path = "crates/sf2" } craft-mime = { path = "crates/mime" } craft-odf = { path = "crates/odf" } craft-ods = { path = "crates/ods" } diff --git a/README.md b/README.md index 9477b8c0e..2dd2c06e9 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " println!("{}: {} presets, {} frames", font.name, font.presets.len(), font.pool.len()), + Err(e) => println!("{e}"), +} +``` + +(The example needs the `test-support` feature for `fixture`.) + +## The model + +- `SoundFont`: the file's name, the sample pool (every sample end to end, mono `f32` at full + scale ±1) and the presets, ordered by bank and program. +- `Preset`: name, bank, program and its `Region`s. +- `Region`: what one preset zone and one instrument zone sound together: key and velocity range, + the pool frames it plays and loops over, the recorded sample rate, the fractional root key + (with every tuning applied), pan, linear gain and a volume `Envelope`. + +Resolving zones is done here, as the specification describes: a first zone without an +instrument (or sample) is global, instrument generators are absolute, preset generators are +added to them, and key and velocity ranges intersect. + +Honoured: keyRange, velRange, instrument, sampleID, overridingRootKey, coarseTune, fineTune (with +the sample's pitch correction), sampleModes (loop or not), the start, end and loop address +offsets (fine and coarse), pan, initialAttenuation and the volume envelope's attack, decay, +sustain and release. Ignored: modulators, the filter, the modulation envelope, both LFOs, the +chorus and reverb sends, the volume envelope's delay, hold and key scaling, scaleTuning, +exclusiveClass, the fixed key and velocity, the 24-bit extension (`sm24`) and ROM samples. + +## Limits + +Input is hostile. Every offset is checked against the file; a file whose chunks run past its +end is an error, not played in part. The pool is read up to `MAX_POOL_FRAMES` (2^28) frames and +regions are clamped to it. Reading stops after 2^24 generators and a file may make at most 65,536 +regions (`Error::TooManyZones`), so a file that points every preset zone at the same long lists +cannot multiply itself out. A font with nothing that can sound is `Error::Empty`. + +## Features + +- `test-support`: `fixture::build`, a writer of small valid SoundFont 2 files, for the tests of + crates that read SoundFonts. Not a general SoundFont writer. + +## Sources and clean room + +Written from the SoundFont 2.04 Technical Specification (E-mu Systems / Creative Technology, +2006). No other product's code or data was used. The code comes from LoopCraft's +`loopcraft-audiofile` crate; the tests build every file they read in code, so the crate ships no +binary test data. diff --git a/crates/sf2/src/fixture.rs b/crates/sf2/src/fixture.rs new file mode 100644 index 000000000..6a49f47cf --- /dev/null +++ b/crates/sf2/src/fixture.rs @@ -0,0 +1,76 @@ +//! Test support (feature `test-support`): writes a small, valid SoundFont 2 file, so that crates +//! which read SoundFonts can test with a real file built in code instead of a binary fixture. + +/// One sample and the instrument zone that plays it. +pub struct Sample { + pub data: Vec, + pub rate: u32, + /// The key the sample was recorded at. + pub pitch: u8, + /// The zone's generators as (number, amount); the loop points are the whole sample. + pub gens: Vec<(u16, i16)>, +} + +/// The amount of a range generator. +pub fn range(low: u8, high: u8) -> i16 { + i16::from_le_bytes([low, high]) +} + +fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut out = [&id[..], &(body.len() as u32).to_le_bytes(), body].concat(); + out.resize(out.len() + body.len() % 2, 0); + out +} + +fn list(kind: &[u8; 4], chunks: &[Vec]) -> Vec { + chunk(b"LIST", &[&kind[..], &chunks.concat()].concat()) +} + +fn named(name: &str) -> [u8; 20] { + std::array::from_fn(|i| name.as_bytes().get(i).copied().unwrap_or(0)) +} + +/// A font named "Tiny" with one instrument (a zone per sample) and one preset, "Test", bank 0 +/// program 5, made of `copies` zones that each carry `preset_gens` and point at the instrument. +pub fn build(samples: &[Sample], preset_gens: &[(u16, i16)], copies: usize) -> Vec { + let generator = |(number, amount): &(u16, i16)| [number.to_le_bytes(), amount.to_le_bytes()].concat(); + // A bag is (first generator, first modulator); one more than the zones says where the last ends. + let bags = |zones: usize, each: &dyn Fn(usize) -> usize| { + let mut first = 0; + let mut bag = |zone: usize| { + first += each(zone); + [((first - each(zone)) as u16).to_le_bytes(), [0; 2]].concat() + }; + (0..=zones).flat_map(&mut bag).collect::>() + }; + let (mut smpl, mut shdr, mut igen) = (Vec::new(), Vec::new(), Vec::new()); + for (i, sample) in samples.iter().enumerate() { + let (start, end) = (smpl.len() as u32 / 2, (smpl.len() / 2 + sample.data.len()) as u32); + smpl.extend(sample.data.iter().flat_map(|s| s.to_le_bytes())); + // The specification asks for 46 frames of silence after every sample. + smpl.resize(smpl.len() + 92, 0); + shdr.extend(named(&format!("Sample {i}"))); + shdr.extend([start, end, start, end, sample.rate].iter().flat_map(|field| field.to_le_bytes())); + shdr.extend([sample.pitch, 0, 0, 0, 1, 0]); + igen.extend(sample.gens.iter().chain(&[(53, i as i16)]).flat_map(generator)); + } + shdr.extend(named("EOS").iter().chain(&[0; 26])); + igen.extend([0; 4]); + let pgen = [preset_gens.iter().chain(&[(41, 0)]).flat_map(generator).collect::>().repeat(copies), vec![0; 4]].concat(); + let header = + |name: &str, program: u16, bag: usize| [&named(name)[..], &program.to_le_bytes(), &[0; 2], &(bag as u16).to_le_bytes(), &[0; 12]].concat(); + let instrument = |name: &str, bag: usize| [&named(name)[..], &(bag as u16).to_le_bytes()].concat(); + let pdta = [ + chunk(b"phdr", &[header("Test", 5, 0), header("EOP", 0, copies)].concat()), + chunk(b"pbag", &bags(copies, &|_| preset_gens.len() + 1)), + chunk(b"pmod", &[0; 10]), + chunk(b"pgen", &pgen), + chunk(b"inst", &[instrument("Instrument", 0), instrument("EOI", samples.len())].concat()), + chunk(b"ibag", &bags(samples.len(), &|zone| samples.get(zone).map_or(0, |s| s.gens.len() + 1))), + chunk(b"imod", &[0; 10]), + chunk(b"igen", &igen), + chunk(b"shdr", &shdr), + ]; + let info = [chunk(b"ifil", &[2, 0, 1, 0]), chunk(b"isng", b"EMU8000\0"), chunk(b"INAM", b"Tiny\0")]; + chunk(b"RIFF", &[&b"sfbk"[..], &list(b"INFO", &info), &list(b"sdta", &[chunk(b"smpl", &smpl)]), &list(b"pdta", &pdta)].concat()) +} diff --git a/crates/sf2/src/lib.rs b/crates/sf2/src/lib.rs new file mode 100644 index 000000000..52c086c99 --- /dev/null +++ b/crates/sf2/src/lib.rs @@ -0,0 +1,434 @@ +//! SoundFont 2 (`.sf2`) reader, written from the SoundFont 2.04 Technical Specification (E-mu +//! Systems / Creative Technology, 2006). A file is a RIFF `sfbk` form of three lists: `INFO` (the +//! name is read from it), `sdta` (`smpl`: every sample end to end, 16-bit mono) and `pdta` (the +//! presets `phdr`/`pbag`/`pgen`, the instruments `inst`/`ibag`/`igen` and the sample headers +//! `shdr`). +//! +//! A preset is a list of zones that each point at an instrument; an instrument is a list of zones +//! that each point at a sample. A zone holds generators (numbered settings), and a first zone +//! that points at nothing is global: the default for the zones after it. Instrument generators +//! are absolute, preset generators are added to them, and key and velocity ranges intersect. +//! Every pair of a preset zone and an instrument zone that can sound becomes one [`Region`]. +//! +//! Honoured: keyRange, velRange, instrument, sampleID, overridingRootKey, coarseTune and fineTune +//! (with the sample's own pitch correction), sampleModes (loop or not), the start, end and loop +//! address offsets (fine and coarse), pan, initialAttenuation and the volume envelope's attack, +//! decay, sustain and release. Ignored: modulators (`pmod`, `imod`), the filter, the modulation +//! envelope and both LFOs, the chorus and reverb sends, the volume envelope's delay, hold and key +//! scaling, scaleTuning, exclusiveClass, the fixed key and velocity, the 24-bit extension +//! (`sm24`) and samples in ROM. A file whose chunks run past its end is rejected, not played in part. +//! +//! Input is hostile: every offset is checked against the file, the sample pool is capped at +//! [`MAX_POOL_FRAMES`], and a file that points many zones at the same long lists is refused +//! ([`Error::TooManyZones`]) rather than multiplied out. Malformed input is an error, never a panic. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use std::cell::Cell; + +#[cfg(any(test, feature = "test-support"))] +pub mod fixture; + +/// The sample pool is read up to this many frames (about 1.5 hours at 48 kHz); regions are +/// clamped to what was read. +pub const MAX_POOL_FRAMES: usize = 1 << 28; + +/// Why a file is not a SoundFont that can be played. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// Not a SoundFont 2 file, or a damaged one. + #[error("{0}")] + Malformed(String), + /// More zones than a font may have (a file that multiplies its zones). + #[error("the SoundFont has too many zones")] + TooManyZones, + /// Nothing in the file can sound. + #[error("the SoundFont holds no playable sample")] + Empty, +} + +/// A volume envelope: times in seconds, sustain as a linear level (0..=1). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Envelope { + pub attack: f32, + pub decay: f32, + pub sustain: f32, + pub release: f32, +} + +/// One stretch of the sample pool, as a preset plays it over a range of keys and velocities. +#[derive(Clone, Debug, PartialEq)] +pub struct Region { + /// The lowest and highest key, and the lowest and highest velocity (0..=127), that sound it. + pub keys: (u8, u8), + pub velocities: (u8, u8), + /// The pool frames it plays, end exclusive, and the frames it loops over while the note is held. + pub span: (usize, usize), + pub looped: Option<(usize, usize)>, + /// The rate the sample was recorded at, in Hz. + pub sample_rate: u32, + /// The (fractional) key that plays the sample at that rate. + pub root: f32, + /// -1 (left) to 1 (right). + pub pan: f32, + /// Linear gain, at most 1. + pub gain: f32, + pub envelope: Envelope, +} + +/// A preset (a patch): its bank and program number and the regions it sounds. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Preset { + pub name: String, + pub bank: u16, + pub program: u16, + pub regions: Vec, +} + +/// A SoundFont as a player needs it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SoundFont { + /// The `INAM` name of the file (empty when it has none). + pub name: String, + /// Every sample of the file end to end, mono, at full scale ±1; the regions say which frames + /// are whose and at what rate. + pub pool: Vec, + /// Ordered by bank, then program. + pub presets: Vec, +} + +fn db_to_gain(db: f32) -> f32 { + 10f32.powf(db / 20.0) +} + +/// Record sizes of the `pdta` chunks, in bytes. +const PHDR: usize = 38; +const BAG: usize = 4; +const GEN: usize = 4; +const INST: usize = 22; +const SHDR: usize = 46; + +/// Generator numbers (specification section 8.1.2); `GENERATORS` is one past the last defined. +const START: usize = 0; +const END: usize = 1; +const LOOP_START: usize = 2; +const LOOP_END: usize = 3; +const START_COARSE: usize = 4; +const END_COARSE: usize = 12; +const PAN: usize = 17; +const ATTACK: usize = 34; +const DECAY: usize = 36; +const SUSTAIN: usize = 37; +const RELEASE: usize = 38; +const INSTRUMENT: usize = 41; +const KEY_RANGE: usize = 43; +const VEL_RANGE: usize = 44; +const LOOP_START_COARSE: usize = 45; +const ATTENUATION: usize = 48; +const LOOP_END_COARSE: usize = 50; +const COARSE_TUNE: usize = 51; +const FINE_TUNE: usize = 52; +const SAMPLE_ID: usize = 53; +const SAMPLE_MODES: usize = 54; +const ROOT_KEY: usize = 58; +const GENERATORS: usize = 59; + +/// The `sfSampleType` bit of a sample that lives in ROM, not in the file. +const ROM: usize = 0x8000; +/// A hostile file can point every zone of every preset at the same long lists: reading stops +/// after this many generators, and a file may not make more regions than this. +const MAX_WORK: usize = 1 << 24; +const MAX_REGIONS: usize = 1 << 16; + +/// The generators of one zone, by number. +type Zone = [Option; GENERATORS]; + +/// The `N` bytes at `at`; zeros when they are not there. +fn le(bytes: &[u8], at: usize) -> [u8; N] { + bytes.get(at..).and_then(|b| b.first_chunk()).copied().unwrap_or([0; N]) +} + +fn u16_at(bytes: &[u8], at: usize) -> usize { + usize::from(u16::from_le_bytes(le(bytes, at))) +} + +fn u32_at(bytes: &[u8], at: usize) -> usize { + u32::from_le_bytes(le(bytes, at)) as usize +} + +/// A fixed-size, NUL-terminated name. +fn name(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes.split(|b| *b == 0).next().unwrap_or_default()).trim().to_string() +} + +/// The body of the first chunk called `id` in `data`, a run of RIFF chunks. With `form` it is the +/// first whose body starts with those four bytes (a `RIFF` or `LIST` of that type), without them. +fn chunk<'a>(mut data: &'a [u8], id: &[u8; 4], form: Option<&[u8; 4]>) -> Option<&'a [u8]> { + loop { + let size = u32_at(data.get(..8)?, 4); + let body = data.get(8..)?.get(..size)?; + match form { + None if data.starts_with(id) => return Some(body), + Some(form) if data.starts_with(id) && body.starts_with(form) => return body.get(4..), + // Chunks are padded to an even length. + _ => data = data.get(8..)?.get(size.checked_add(size & 1)?..)?, + } + } +} + +/// The zones `from..to` of a bag chunk that point at something (their generator `link` says at +/// what), each laid over the global zone before it. +fn zones<'a>( + bags: &'a [u8], + gens: &'a [u8], + (from, to): (usize, usize), + link: usize, + work: &'a Cell, +) -> impl Iterator + 'a { + // A zone's generators run from its bag's index to the next bag's. + let bag = move |i: usize| bags.get(i.checked_mul(BAG)?..).filter(|b| b.len() >= BAG).map(|b| u16_at(b, 0)); + let mut global: Zone = [None; GENERATORS]; + (from..to) + .map_while(move |i| { + let (first, end) = (bag(i)?, bag(i + 1)?); + let count = end.saturating_sub(first); + work.set(work.get().saturating_sub(count + 1)); + if work.get() == 0 { + return None; + } + let mut zone: Zone = [None; GENERATORS]; + for generator in gens.get(first * GEN..)?.as_chunks::().0.iter().take(count) { + if let Some(slot) = zone.get_mut(u16_at(generator, 0)) { + *slot = Some(i16::from_le_bytes(le(generator, 2))); + } + } + Some(zone) + }) + .enumerate() + .filter_map(move |(n, zone)| match zone.get(link).copied().flatten() { + Some(target) => Some((usize::from(target as u16), std::array::from_fn(|g| zone[g].or(global[g])))), + None => { + // Only a first zone is global; a later one that points at nothing means nothing. + if n == 0 { + global = zone; + } + None + } + }) +} + +/// The region an instrument zone makes inside a preset zone with the sample `header` describes, +/// if it can sound. `pool` is the length of the sample pool in frames. +fn region(preset: &Zone, instrument: &Zone, header: &[u8], pool: usize) -> Option { + let own = |g: usize| i64::from(instrument[g].unwrap_or(0)); + let sum = |g: usize, default: i16| i32::from(instrument[g].unwrap_or(default)) + i32::from(preset[g].unwrap_or(0)); + let range = |g: usize| { + let [(low, high), (from, to)] = [instrument, preset].map(|zone| zone[g].map_or((0, 127), |v| (v.to_le_bytes()[0], v.to_le_bytes()[1]))); + (low.max(from), high.min(to)) + }; + // An address in the pool: the header's, moved by the zone's fine and coarse (32768 frames) offsets. + let address = + |field: usize, fine: usize, coarse: usize| (u32_at(header, field) as i64 + own(fine) + 32768 * own(coarse)).clamp(0, pool as i64) as usize; + let (keys, velocities) = (range(KEY_RANGE), range(VEL_RANGE)); + let span = (address(20, START, START_COARSE), address(24, END, END_COARSE)); + let around = (address(28, LOOP_START, LOOP_START_COARSE), address(32, LOOP_END, LOOP_END_COARSE)); + let sample_rate = u32::from_le_bytes(le(header, 36)); + if keys.0 > keys.1 || velocities.0 > velocities.1 || span.0 >= span.1 || sample_rate == 0 || u16_at(header, 44) & ROM != 0 { + return None; + } + // Modes 1 and 3 loop; 3 would play on past the loop once released, which the envelope cuts short here. + let looped = (own(SAMPLE_MODES) & 1 == 1 && span.0 <= around.0 && around.0 < around.1 && around.1 <= span.1).then_some(around); + let [pitch, correction] = le(header, 40); + let root = match (instrument[ROOT_KEY], pitch) { + (Some(key @ 0..=127), _) => f32::from(key), + (_, key @ 0..=127) => f32::from(key), + // 255 marks an unpitched sample. + _ => 60.0, + }; + let cents = sum(COARSE_TUNE, 0) * 100 + sum(FINE_TUNE, 0) + i32::from(correction as i8); + // Times are in timecents (1200 per doubling of a second), levels in centibels of attenuation. + let secs = |g: usize| 2f32.powf(sum(g, -12000).clamp(-12000, 8000) as f32 / 1200.0); + let level = |centibels: i32| db_to_gain(-(centibels.clamp(0, 1440) as f32) / 10.0); + Some(Region { + keys, + velocities, + span, + looped, + sample_rate, + root: root - cents as f32 / 100.0, + pan: (sum(PAN, 0) as f32 / 500.0).clamp(-1.0, 1.0), + gain: level(sum(ATTENUATION, 0)), + envelope: Envelope { attack: secs(ATTACK), decay: secs(DECAY), sustain: level(sum(SUSTAIN, 0)), release: secs(RELEASE) }, + }) +} + +/// The chunk `id` of a list, which a SoundFont must have. +fn need<'a>(list: Option<&'a [u8]>, id: &[u8; 4]) -> Result<&'a [u8], Error> { + list.and_then(|l| chunk(l, id, None)).ok_or_else(|| Error::Malformed(format!("the SoundFont has no '{}' chunk", String::from_utf8_lossy(id)))) +} + +/// Reads a SoundFont 2 file. See the module documentation for what is honoured. +pub fn read(bytes: &[u8]) -> Result { + read_within(bytes, MAX_POOL_FRAMES) +} + +fn read_within(bytes: &[u8], max_frames: usize) -> Result { + let riff = chunk(bytes, b"RIFF", Some(b"sfbk")).ok_or_else(|| Error::Malformed("not a SoundFont 2 file, or one cut short".into()))?; + let list = |kind: &[u8; 4]| chunk(riff, b"LIST", Some(kind)); + let pdta = list(b"pdta"); + let smpl = need(list(b"sdta"), b"smpl")?; + let (phdr, pbag, pgen) = (need(pdta, b"phdr")?, need(pdta, b"pbag")?, need(pdta, b"pgen")?); + let (inst, ibag, igen) = (need(pdta, b"inst")?, need(pdta, b"ibag")?, need(pdta, b"igen")?); + let headers: Vec<&[u8]> = need(pdta, b"shdr")?.as_chunks::().0.iter().map(|h| &h[..]).collect(); + let instruments: Vec<&[u8]> = inst.as_chunks::().0.iter().map(|i| &i[..]).collect(); + let pool: Vec = smpl.as_chunks::<2>().0.iter().take(max_frames).map(|s| f32::from(i16::from_le_bytes(*s)) / 32768.0).collect(); + + let (work, mut made) = (Cell::new(MAX_WORK), 0usize); + let mut presets = Vec::new(); + // Every list ends with a terminal record, which is there to say where the last real one ends. + for pair in phdr.as_chunks::().0.windows(2) { + let [head, next] = pair else { continue }; + let (program, bank) = (u16::from_le_bytes(le(head, 20)), u16::from_le_bytes(le(head, 22))); + let mut preset = Preset { name: name(head.get(..20).unwrap_or_default()), bank, program, regions: Vec::new() }; + for (target, zone) in zones(pbag, pgen, (u16_at(head, 24), u16_at(next, 24)), INSTRUMENT, &work) { + let (Some(from), Some(to)) = (instruments.get(target), instruments.get(target + 1)) else { continue }; + for (sample, own) in zones(ibag, igen, (u16_at(from, 20), u16_at(to, 20)), SAMPLE_ID, &work) { + preset.regions.extend(headers.get(sample).and_then(|header| region(&zone, &own, header, pool.len()))); + } + if work.get() == 0 || made + preset.regions.len() > MAX_REGIONS { + return Err(Error::TooManyZones); + } + } + made += preset.regions.len(); + presets.push(preset); + } + if presets.iter().all(|p| p.regions.is_empty()) { + return Err(Error::Empty); + } + presets.sort_by_key(|p| (p.bank, p.program)); + let title = list(b"INFO").and_then(|info| chunk(info, b"INAM", None)).map(name).unwrap_or_default(); + Ok(SoundFont { name: title, pool, presets }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixture::{Sample, build, range}; + + /// xorshift32: the same bytes on every run. + struct Noise(u32); + + impl Noise { + fn below(&mut self, n: usize) -> usize { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 17; + self.0 ^= self.0 << 5; + self.0 as usize % n + } + } + + fn tiny() -> Vec { + let a = Sample { + data: (0..100).map(|i| i * 300).collect(), + rate: 22050, + pitch: 60, + gens: vec![(43, range(0, 63)), (54, 1), (2, 10), (3, -10), (17, -250), (48, 200), (37, 200), (38, 1200)], + }; + let b = Sample { data: vec![-32768; 80], rate: 44100, pitch: 72, gens: vec![(43, range(64, 127)), (58, 70), (51, 1), (52, -50)] }; + build(&[a, b], &[(52, 25), (44, range(0, 100))], 1) + } + + #[test] + fn a_tiny_font_becomes_its_regions() { + let font = read(&tiny()).unwrap(); + assert_eq!((font.name.as_str(), font.presets.len(), font.pool.len()), ("Tiny", 1, 100 + 46 + 80 + 46)); + assert_eq!((font.pool[1], font.pool[146]), (300.0 / 32768.0, -1.0)); + let preset = &font.presets[0]; + assert_eq!((preset.name.as_str(), preset.bank, preset.program, preset.regions.len()), ("Test", 0, 5, 2)); + let near = |a: f32, b: f32| (a - b).abs() < 1e-3 * b.abs().max(1.0); + + let low = &preset.regions[0]; + assert_eq!((low.keys, low.velocities, low.span, low.looped, low.sample_rate), ((0, 63), (0, 100), (0, 100), Some((10, 90)), 22050)); + // The preset's 25 cents are added to the instrument's none; 200 centibels are 20 dB. + assert!(near(low.root, 59.75) && near(low.pan, -0.5) && near(low.gain, 0.1), "{low:?}"); + assert!(near(low.envelope.release, 2.0) && near(low.envelope.sustain, 0.1) && near(low.envelope.attack, 2f32.powf(-10.0)), "{low:?}"); + + let high = &preset.regions[1]; + assert_eq!((high.keys, high.span, high.looped, high.sample_rate), ((64, 127), (146, 226), None, 44100)); + // Root key 70 overrides the sample's 72; a semitone up, 50 cents down and the preset's 25 up. + assert!(near(high.root, 70.0 - 0.75) && high.pan == 0.0 && high.gain == 1.0 && high.envelope.sustain == 1.0, "{high:?}"); + + let within = |(low, high): (u8, u8), x: u8| (low..=high).contains(&x); + let spans = |key: u8, velocity: u8| { + preset.regions.iter().filter(|r| within(r.keys, key) && within(r.velocities, velocity)).map(|r| r.span).collect::>() + }; + assert_eq!((spans(63, 100), spans(64, 1), spans(127, 100)), (vec![(0, 100)], vec![(146, 226)], vec![(146, 226)])); + assert!(spans(64, 101).is_empty(), "outside the preset's velocity range"); + } + + /// What every font that parses must keep, however damaged the file was. + fn sound(font: &SoundFont) { + let frames = font.pool.len(); + for r in font.presets.iter().flat_map(|p| &p.regions) { + assert!(r.span.0 < r.span.1 && r.span.1 <= frames && r.sample_rate > 0, "{r:?}"); + assert!(r.looped.is_none_or(|(a, b)| r.span.0 <= a && a < b && b <= r.span.1), "{r:?}"); + assert!(r.root.is_finite() && (-1.0..=1.0).contains(&r.pan) && (0.0..=1.0).contains(&r.gain), "{r:?}"); + let e = r.envelope; + assert!([e.attack, e.decay, e.release].iter().all(|t| t.is_finite() && *t > 0.0) && (0.0..=1.0).contains(&e.sustain), "{r:?}"); + } + } + + #[test] + fn truncated_files_are_errors() { + let file = tiny(); + for cut in 0..file.len() { + assert!(read(&file[..cut]).is_err(), "cut at {cut} of {}", file.len()); + } + assert!(read(&[file.clone(), vec![0; 7]].concat()).is_ok(), "junk after the form is not part of it"); + } + + #[test] + fn damaged_files_are_errors_or_sound_fonts_never_panics() { + let file = tiny(); + let check = |bytes: &[u8]| read(bytes).iter().for_each(sound); + for at in 0..file.len() { + for mask in [0xFF, 0x80, 0x01] { + let mut bytes = file.clone(); + bytes[at] ^= mask; + check(&bytes); + } + } + // A few bytes at a time, anywhere, set to anything. + let mut noise = Noise(0x9E37_79B9); + let mut pick = |below: usize| noise.below(below); + for _ in 0..4000 { + let mut bytes = file.clone(); + for _ in 0..1 + pick(4) { + bytes[pick(file.len())] = pick(256) as u8; + } + check(&bytes); + } + for junk in [&b""[..], b"RIFF", b"RIFF\xff\xff\xff\xffsfbkLIST", b"RIFF\x04\0\0\0sfbk", &[0xFF; 300]] { + assert!(read(junk).is_err()); + } + } + + #[test] + fn the_pool_is_read_up_to_its_limit_and_regions_stay_inside_it() { + let font = read_within(&tiny(), 120).unwrap(); + assert_eq!(font.pool.len(), 120); + sound(&font); + // The second sample starts past the limit: only the first can sound. + assert_eq!(font.presets[0].regions.iter().map(|r| r.span).collect::>(), vec![(0, 100)]); + } + + #[test] + fn a_file_that_multiplies_its_zones_is_refused() { + // Forty thousand preset zones, each pointing at the same two-zone instrument. + let sample = |keys| Sample { data: vec![1000; 8], rate: 8000, pitch: 60, gens: vec![(43, keys)] }; + let file = build(&[sample(range(0, 60)), sample(range(61, 127))], &[], 40_000); + assert_eq!(read(&file), Err(Error::TooManyZones)); + assert_eq!(read(&build(&[sample(range(0, 60))], &[], 3)).unwrap().presets[0].regions.len(), 3); + // A font nothing of which can sound holds no audio. + assert_eq!(read(&build(&[sample(range(70, 60))], &[], 1)), Err(Error::Empty)); + } +}