mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
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 <bombayapirat@gmail.com>
This commit is contained in:
1 parent
721b0868d7
commit
428541a512
7 files changed
+595
No files matched your search
Generated
+8
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -287,6 +287,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
for a small neutral model (paragraphs, character formatting, outline levels, tables).
|
||||
- [`craft-settings`](crates/settings/README.md): `config.toml` settings storage for every app: platform folders, portable mode and overrides, atomic saves while running, backup, damaged-file recovery and schema migrations. Policy in [docs/settings-storage.md](docs/settings-storage.md).
|
||||
- [`craft-sheet`](crates/sheet/README.md): spreadsheet vocabulary shared by `craft-xls`, `craft-xlsx` and `craft-ods`: cell positions and A1 text, cell values and error values, sheet-name quoting, Excel's built-in number formats and indexed colour palette. Not a file format; no dependencies.
|
||||
- [`craft-sf2`](crates/sf2/README.md): SoundFont 2 (`.sf2`) reader: the sample pool and, per preset, the key and velocity regions a note sounds, with tuning, loop points, pan, level and volume envelope resolved from the preset and instrument zones. Bounded on hostile input. From LoopCraft.
|
||||
- [`craft-silhouette-studio`](crates/silhouette-studio/src/lib.rs): bounded, fail-closed reader for Silhouette Studio `.studio` and `.studio3` documents: outlines, line colours and page size in millimetres. Protected designs are refused.
|
||||
- [`craft-sketch`](crates/sketch/README.md): clean-room reader for the `.sketch` container (Sketch 48 and newer): the ZIP archive, the version cut, `document.json`, the pages in document order and images, as the JSON the file holds. Bounded on hostile input. Carved from FigCraft.
|
||||
- [`craft-svg`](crates/svg/src/lib.rs): SVG. `sniff` recognises SVG and SVGZ data; `svgz` unpacks
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "craft-sf2"
|
||||
description = "SoundFont 2 (.sf2) reader: the sample pool and, for every preset, the key and velocity regions a note sounds, bounded on hostile input"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# `fixture`: a writer of small, valid SoundFont 2 files, for the tests of crates that read them.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
craft-sf2 = { path = ".", features = ["test-support"] }
|
||||
@@ -0,0 +1,54 @@
|
||||
# craft-sf2
|
||||
|
||||
SoundFont 2 (`.sf2`) files, read. Bytes in, a neutral model out; no file access, no
|
||||
dependencies beyond `thiserror`, builds for wasm32.
|
||||
|
||||
```rust
|
||||
let bytes = craft_sf2::fixture::build(&[], &[], 1); // any .sf2 file's bytes
|
||||
match craft_sf2::read(&bytes) {
|
||||
Ok(font) => 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.
|
||||
@@ -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<i16>,
|
||||
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<u8> {
|
||||
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<u8>]) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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::<Vec<u8>>()
|
||||
};
|
||||
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::<Vec<u8>>().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())
|
||||
}
|
||||
@@ -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<Region>,
|
||||
}
|
||||
|
||||
/// 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<f32>,
|
||||
/// Ordered by bank, then program.
|
||||
pub presets: Vec<Preset>,
|
||||
}
|
||||
|
||||
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<i16>; GENERATORS];
|
||||
|
||||
/// The `N` bytes at `at`; zeros when they are not there.
|
||||
fn le<const N: usize>(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<usize>,
|
||||
) -> impl Iterator<Item = (usize, Zone)> + '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::<GEN>().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<Region> {
|
||||
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<SoundFont, Error> {
|
||||
read_within(bytes, MAX_POOL_FRAMES)
|
||||
}
|
||||
|
||||
fn read_within(bytes: &[u8], max_frames: usize) -> Result<SoundFont, Error> {
|
||||
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::<SHDR>().0.iter().map(|h| &h[..]).collect();
|
||||
let instruments: Vec<&[u8]> = inst.as_chunks::<INST>().0.iter().map(|i| &i[..]).collect();
|
||||
let pool: Vec<f32> = 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::<PHDR>().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<u8> {
|
||||
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::<Vec<_>>()
|
||||
};
|
||||
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<_>>(), 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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user