use std::path::Path;
pub use crate::engine::{normalize_loudness, normalize_to_i16, Options, Progress, Stats};
pub use crate::error::{MidiError, 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 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(u32::MAX).with_tail(1.0);
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);
}
}