use std::path::Path;
pub use crate::engine::{normalize_loudness, normalize_to_i16, Options, Progress, Stats};
pub use crate::error::{MidiError, MAX_MIDI_FILE_BYTES, MAX_SONG_SECONDS};
pub use crate::loudness::{integrated_lufs, limit_true_peak, momentary_lufs, true_peak_dbtp};
pub use crate::wav::write_wav;
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct Normalization {
kind: NormalizationKind,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum NormalizationKind {
Loudness { target_lufs: f32, ceiling_dbtp: f32 },
Peak { target: f32 },
}
impl Normalization {
pub fn loudness(target_lufs: f32, ceiling_dbtp: f32) -> Self {
Self {
kind: NormalizationKind::Loudness {
target_lufs,
ceiling_dbtp,
},
}
}
pub fn peak(target: f32) -> Self {
Self {
kind: NormalizationKind::Peak { target },
}
}
}
pub struct Song(crate::midi::Song);
impl Song {
pub fn seconds(&self) -> f64 {
self.0.seconds
}
pub fn events_len(&self) -> usize {
self.0.events.len()
}
pub fn markers_len(&self) -> usize {
self.0.markers.len()
}
pub fn title(&self) -> &str {
&self.0.title
}
pub fn initial_bpm(&self) -> f64 {
self.0.initial_bpm
}
}
pub fn load(path: &Path) -> Result<Song, MidiError> {
crate::midi::load(path).map(Song)
}
pub fn parse(data: &[u8]) -> Result<Song, MidiError> {
crate::midi::parse(data).map(Song)
}
pub fn render(song: &Song, opt: &Options) -> (Vec<f32>, Stats) {
crate::engine::render(&song.0, opt)
}
pub fn render_with_progress(
song: &Song,
opt: &Options,
on_progress: &mut dyn FnMut(Progress),
) -> (Vec<f32>, Stats) {
crate::engine::render_with_progress(&song.0, opt, on_progress)
}
pub fn render_to_wav(
song: &Song,
opt: &Options,
output: &Path,
normalization: Normalization,
) -> std::io::Result<Stats> {
render_to_wav_with_progress(song, opt, output, normalization, &mut |_| {})
}
pub fn render_to_wav_with_progress(
song: &Song,
opt: &Options,
output: &Path,
normalization: Normalization,
on_progress: &mut dyn FnMut(Progress),
) -> std::io::Result<Stats> {
crate::scratch::render_to_wav(&song.0, opt, output, normalization.kind, on_progress)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
struct TestDir(PathBuf);
impl TestDir {
fn new(label: &str) -> Self {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"ferrosintesis-offline-{label}-{}-{nonce}",
std::process::id()
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn join(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn file_from_track(events: &[u8]) -> Vec<u8> {
let mut data = Vec::new();
data.extend(b"MThd");
data.extend(6u32.to_be_bytes());
data.extend(0u16.to_be_bytes());
data.extend(1u16.to_be_bytes());
data.extend(480u16.to_be_bytes());
let mut track = Vec::from(events);
track.extend([0x00, 0xFF, 0x2F, 0x00]);
data.extend(b"MTrk");
data.extend((track.len() as u32).to_be_bytes());
data.extend(track);
data
}
fn one_note_song() -> Song {
parse(&file_from_track(&[
0x00, 0xC0, 46, 0x00, 0x90, 60, 100, 0x83, 0x60, 0x80, 60, 0, ]))
.unwrap()
}
#[test]
fn render_to_wav_matches_buffered_loudness_and_peak_paths() {
let dir = TestDir::new("differential");
let song = one_note_song();
let opt = Options::default()
.with_sample_rate(8_000)
.with_samples(false)
.with_tail(0.5);
let (samples, expected_stats) = render(&song, &opt);
let cases = [
(
"loudness",
Normalization::loudness(-18.0, -1.0),
normalize_loudness(&samples, opt.sample_rate(), -18.0, -1.0),
),
(
"peak",
Normalization::peak(0.891),
normalize_to_i16(&samples, expected_stats.peak, 0.891),
),
];
for (name, normalization, expected_pcm) in cases {
let expected = dir.join(&format!("{name}-expected.wav"));
let actual = dir.join(&format!("{name}-actual.wav"));
write_wav(&expected, opt.sample_rate(), &expected_pcm).unwrap();
fs::write(&actual, b"prior output").unwrap();
let actual_stats = render_to_wav(&song, &opt, &actual, normalization).unwrap();
assert_eq!(actual_stats, expected_stats);
assert_eq!(fs::read(&actual).unwrap(), fs::read(&expected).unwrap());
}
}
#[test]
fn load_rejects_an_oversized_file() {
let dir = TestDir::new("oversized");
let path = dir.join("huge.mid");
let file = fs::File::create(&path).unwrap();
file.set_len(MAX_MIDI_FILE_BYTES + 1).unwrap();
drop(file);
match load(&path).err() {
Some(MidiError::TooLarge { bytes, .. }) => {
assert_eq!(bytes, MAX_MIDI_FILE_BYTES + 1)
}
Some(MidiError::NotMidi) => {
panic!("`load` has no size limit: it read the whole file and parsed it")
}
other => panic!("expected TooLarge, got {other:?}"),
}
}
#[test]
fn load_accepts_a_file_exactly_at_the_limit() {
let dir = TestDir::new("at-limit");
let path = dir.join("limit.mid");
let file = fs::File::create(&path).unwrap();
file.set_len(MAX_MIDI_FILE_BYTES).unwrap();
drop(file);
assert!(
matches!(load(&path), Err(MidiError::NotMidi)),
"a file at the limit was refused"
);
}
#[test]
fn parse_rejects_a_track_declaring_almost_four_gibibytes() {
let mut data = Vec::new();
data.extend_from_slice(b"MThd");
data.extend_from_slice(&6u32.to_be_bytes());
data.extend_from_slice(&0u16.to_be_bytes()); data.extend_from_slice(&1u16.to_be_bytes()); data.extend_from_slice(&480u16.to_be_bytes()); data.extend_from_slice(b"MTrk");
data.extend_from_slice(&0xFFFF_FF00u32.to_be_bytes()); data.extend_from_slice(&[0x00, 0xFF, 0x2F, 0x00]);
assert!(
matches!(parse(&data), Err(MidiError::UnexpectedEof)),
"a track declaring 4 GiB was not rejected from its declared length"
);
}
#[test]
fn render_survives_extreme_option_requests() {
let song = one_note_song();
let opt = Options::default()
.with_sample_rate(u32::MAX)
.with_echo(f32::MAX)
.with_reverb(f32::NAN)
.with_samples(false)
.with_tail(0.5);
let (samples, _stats) = render(&song, &opt);
assert!(!samples.is_empty(), "extreme options rendered nothing");
assert!(
samples.iter().all(|x| x.is_finite()),
"extreme options produced non-finite audio"
);
}
#[test]
fn non_finite_normalization_is_rejected_before_anything_is_written() {
let dir = TestDir::new("bad-normalization");
let output = dir.join("song.wav");
fs::write(&output, b"prior output").unwrap();
let song = one_note_song();
let opt = Options::default()
.with_sample_rate(8_000)
.with_samples(false)
.with_tail(0.0);
let bad = [
(
"loudness target NaN",
Normalization::loudness(f32::NAN, -1.0),
),
(
"loudness target +Inf",
Normalization::loudness(f32::INFINITY, -1.0),
),
(
"loudness target -Inf",
Normalization::loudness(f32::NEG_INFINITY, -1.0),
),
("ceiling NaN", Normalization::loudness(-18.0, f32::NAN)),
(
"ceiling +Inf",
Normalization::loudness(-18.0, f32::INFINITY),
),
(
"ceiling -Inf",
Normalization::loudness(-18.0, f32::NEG_INFINITY),
),
("peak NaN", Normalization::peak(f32::NAN)),
("peak +Inf", Normalization::peak(f32::INFINITY)),
("peak -Inf", Normalization::peak(f32::NEG_INFINITY)),
(
"loudness target above 0",
Normalization::loudness(3.0, -1.0),
),
("ceiling above 0", Normalization::loudness(-18.0, 6.0)),
("peak above 1", Normalization::peak(2.0)),
("peak below 0", Normalization::peak(-0.5)),
];
for (label, normalization) in bad {
let error = render_to_wav(&song, &opt, &output, normalization)
.expect_err(&format!("{label} was accepted"));
assert_eq!(
error.kind(),
std::io::ErrorKind::InvalidInput,
"{label}: wrong error kind ({error})"
);
assert_eq!(
fs::read(&output).unwrap(),
b"prior output",
"{label}: the existing output was modified"
);
assert_eq!(
fs::read_dir(&dir.0).unwrap().count(),
1,
"{label}: scratch files were created before validation"
);
}
render_to_wav(&song, &opt, &output, Normalization::loudness(-18.0, -1.0))
.expect("a valid normalization must still render");
assert_ne!(fs::read(&output).unwrap(), b"prior output");
}
#[test]
fn buffered_normalization_passes_non_finite_settings_through_ungained() {
let sr = 44_100u32;
let samples: Vec<f32> = (0..sr as usize * 3)
.flat_map(|i| {
let s = 0.5 * (2.0 * std::f32::consts::PI * 440.0 * i as f32 / sr as f32).sin();
[s, s]
})
.collect();
assert!(
crate::loudness::integrated_lufs(&samples, sr).is_finite(),
"the fixture must be long enough to measure, or the NaN path is unreachable"
);
let reference = crate::engine::normalize_loudness(&samples, sr, -18.0, -1.0);
assert!(
reference.iter().any(|&s| s != 0),
"the reference render is silent, so this test cannot detect silence"
);
for (label, target, ceiling) in [
("target NaN", f32::NAN, -1.0f32),
("ceiling NaN", -18.0f32, f32::NAN),
("target +Inf", f32::INFINITY, -1.0),
("ceiling -Inf", -18.0, f32::NEG_INFINITY),
] {
let out = crate::engine::normalize_loudness(&samples, sr, target, ceiling);
assert!(
out.iter().any(|&s| s != 0),
"{label}: a non-finite setting produced silence"
);
}
}
#[test]
fn riff_preflight_preserves_existing_output_and_creates_no_scratch() {
let dir = TestDir::new("preflight");
let output = dir.join("song.wav");
fs::write(&output, b"prior output").unwrap();
let song = one_note_song();
let opt = Options::default()
.with_sample_rate(384_000)
.with_tail(2_800.0);
assert_eq!(opt.sample_rate(), 384_000, "the rate was clamped away");
assert_eq!(opt.tail(), 2_800.0, "the tail was clamped away");
let error =
render_to_wav(&song, &opt, &output, Normalization::loudness(-18.0, -1.0)).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(fs::read(&output).unwrap(), b"prior output");
assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 1);
}
#[test]
fn failed_final_rename_cleans_scratch_and_preserves_destination() {
let dir = TestDir::new("rename-failure");
let output = dir.join("song.wav");
fs::create_dir(&output).unwrap();
let song = parse(&file_from_track(&[])).unwrap();
let opt = Options::default()
.with_sample_rate(8_000)
.with_samples(false)
.with_tail(0.0);
render_to_wav(&song, &opt, &output, Normalization::loudness(-18.0, -1.0))
.expect_err("a completed WAV cannot rename over a directory");
assert!(output.is_dir(), "failed render replaced the destination");
assert_eq!(
fs::read_dir(&dir.0).unwrap().count(),
1,
"failed render leaked sibling scratch"
);
}
#[test]
fn malformed_note_keys_render_as_their_seven_bit_values() {
let opt = Options::default().with_samples(false).with_tail(0.2);
let cases: [(&str, &[u8], &[u8]); 2] = [
(
"melodic note and poly-aftertouch",
&[
0x00, 0x90, 200, 100, 0x00, 0xA0, 200, 64, 0x60, 0x80, 200, 0,
],
&[0x00, 0x90, 72, 100, 0x00, 0xA0, 72, 64, 0x60, 0x80, 72, 0],
),
(
"channel-10 drum note",
&[0x00, 0x99, 163, 100, 0x60, 0x89, 163, 0],
&[0x00, 0x99, 35, 100, 0x60, 0x89, 35, 0],
),
];
for (name, malformed, canonical) in cases {
let malformed = parse(&file_from_track(malformed)).unwrap();
let canonical = parse(&file_from_track(canonical)).unwrap();
let (malformed_audio, malformed_stats) = render(&malformed, &opt);
let (canonical_audio, canonical_stats) = render(&canonical, &opt);
assert!(
malformed_audio.iter().any(|sample| sample.abs() > 1e-6),
"{name} rendered silence"
);
assert_eq!(malformed_audio, canonical_audio, "{name}");
assert_eq!(
malformed_stats.voices_spawned, canonical_stats.voices_spawned,
"{name}"
);
}
}
#[test]
fn malformed_program_change_renders_as_its_seven_bit_value() {
let malformed = parse(&file_from_track(&[
0x00, 0xC0, 0xFF, 0x00, 0x90, 60, 100, 0x60, 0x80, 60, 0, ]))
.unwrap();
let canonical = parse(&file_from_track(&[
0x00, 0xC0, 0x7F, 0x00, 0x90, 60, 100, 0x60, 0x80, 60, 0, ]))
.unwrap();
let opt = Options::default().with_samples(false).with_tail(0.2);
let (malformed_audio, malformed_stats) = render(&malformed, &opt);
let (canonical_audio, canonical_stats) = render(&canonical, &opt);
assert!(malformed_audio.iter().any(|sample| sample.abs() > 1e-6));
assert_eq!(malformed_audio, canonical_audio);
assert_eq!(malformed_stats, canonical_stats);
}
#[test]
fn parsed_mid_file_gm_reset_preserves_whole_render_stats() {
let events = [
0x00, 0xC0, 30, 0x00, 0x90, 60, 110, 0x60, 0xF0, 0x05, 0x7E, 0x7F, 0x09, 0x01, 0xF7, 0x00, 0x90, 64, 100, 0x60, 0x80, 64, 0,
];
let song = parse(&file_from_track(&events)).unwrap();
let opt = Options::default().with_samples(false).with_tail(0.1);
let (audio, stats) = render(&song, &opt);
assert!(audio.iter().any(|sample| sample.abs() > 1e-6));
assert_eq!(
stats.voices_spawned, 2,
"pre-reset voice vanished from Stats"
);
assert_eq!(
stats.max_polyphony, 1,
"pre-reset voice overlapped the post-reset voice"
);
let measured_peak = audio.iter().fold(0.0f32, |peak, &x| peak.max(x.abs()));
assert_eq!(stats.peak, measured_peak);
}
}