diff --git a/Cargo.lock b/Cargo.lock index af71d3de..06ccc317 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1064,7 +1064,7 @@ dependencies = [ [[package]] name = "craft-audio" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=75895c0321d8cb6e175a42466cba050352b117c5#75895c0321d8cb6e175a42466cba050352b117c5" +source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed" dependencies = [ "craft-media", "log", @@ -1109,7 +1109,16 @@ dependencies = [ [[package]] name = "craft-media" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=75895c0321d8cb6e175a42466cba050352b117c5#75895c0321d8cb6e175a42466cba050352b117c5" +source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed" +dependencies = [ + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "craft-midi" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed" dependencies = [ "serde", "thiserror 2.0.21", @@ -4459,10 +4468,8 @@ dependencies = [ name = "soundcraft-midi" version = "0.20.1" dependencies = [ + "craft-midi", "proptest", - "serde", - "serde_json", - "thiserror 2.0.21", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7d56d51a..cf25bce5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/*", "xtask"] version = "0.20.1" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/soundcraft" [workspace.dependencies] @@ -51,10 +51,12 @@ cpal = "0.18" craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } # Video containers and bitstreams, shared with the other craft apps. -craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "75895c0321d8cb6e175a42466cba050352b117c5" } +craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed" } # Audio file formats, codecs and waveform overviews, shared with the other craft apps. It shares # types with craft-media, so both stay on one rev. -craft-audio = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "75895c0321d8cb6e175a42466cba050352b117c5", features = ["decode", "lossless-encode", "peaks", "serde"] } +# Standard MIDI Files and the note/controller model, shared with the other craft apps. +craft-midi = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed", features = ["serde"] } +craft-audio = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed", features = ["decode", "lossless-encode", "peaks", "serde"] } # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } diff --git a/README.md b/README.md index d052ea3b..0b7dfdb8 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,7 @@ where things are. ## Getting started Download a build from the [releases page](https://github.com/storytold/soundcraft/releases), or build -from source with Rust 1.95 or later for the desktop/web UI (the core workspace's declared -minimum remains Rust 1.90): +from source with Rust 1.95 or later: ```sh git clone https://github.com/storytold/soundcraft diff --git a/apps/soundcraft-cli/src/convert.rs b/apps/soundcraft-cli/src/convert.rs index e81d9ec1..9f1a7987 100644 --- a/apps/soundcraft-cli/src/convert.rs +++ b/apps/soundcraft-cli/src/convert.rs @@ -15,6 +15,15 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli let bytes = std::fs::read(input).map_err(|source| CliError::File { path: input.clone(), source })?; let ext = input.extension().and_then(std::ffi::OsStr::to_str); let (info, mut buf) = craft_audio::decode(&bytes, ext).map_err(|source| CliError::Audio { path: input.clone(), source })?; + // The decoder replaces packets it cannot decode with silence of their length and counts them. + // Such a file is not a copy of what was recorded: never let it replace its source, and say so + // when it is written elsewhere. + if info.damaged > 0 { + if args.in_place { + return Err(ConvertError::Damaged { input: input.clone(), packets: info.damaged }.into()); + } + log::warn!("{}: {} damaged packet(s) decoded as silence", input.display(), info.damaged); + } let source_rate = buf.sample_rate; if let Some(rate) = rate { buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate); @@ -34,6 +43,11 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli // A BWF source keeps its `bext` (written for WAV output only). The time reference counts // samples, so it moves to the output rate. let mut opts = craft_audio::EncodeOptions::new(format).with_bit_depth(bit_depth).with_dither(dither); + // The source's speakers, not the usual ones for its channel count (5.1 with side surrounds + // stays that). Channels with no speaker assignment get the format's default. + if info.layout.is_ordered() { + opts = opts.with_layout(info.layout); + } opts.bwf = info.bwf.map(|mut b| { b.time_reference = at_rate(b.time_reference, source_rate, buf.sample_rate); b diff --git a/apps/soundcraft-cli/src/error.rs b/apps/soundcraft-cli/src/error.rs index 36e3ed5a..1d62652b 100644 --- a/apps/soundcraft-cli/src/error.rs +++ b/apps/soundcraft-cli/src/error.rs @@ -48,4 +48,6 @@ pub(crate) fn unicode(path: &Path) -> Result<&str, CliError> { pub(crate) enum ConvertError { #[error("{}: cannot write `.{ext}` files (supported: {})", output.display(), craft_audio::ENCODE_EXTENSIONS.join(", "))] OutputFormat { output: PathBuf, ext: String }, + #[error("{}: {packets} damaged packet(s) would be written as silence; not replacing the file (convert to another file to keep what decodes)", input.display())] + Damaged { input: PathBuf, packets: u64 }, } diff --git a/apps/soundcraft-cli/tests/convert.rs b/apps/soundcraft-cli/tests/convert.rs index 8666be9b..258ae0f8 100644 --- a/apps/soundcraft-cli/tests/convert.rs +++ b/apps/soundcraft-cli/tests/convert.rs @@ -118,3 +118,55 @@ fn convert_keeps_the_bwf_time_stamp() { assert_eq!(bext(&out), Some(bwf.with_time_reference(158_760_000))); let _ = std::fs::remove_dir_all(&dir); } + +/// Twelve MPEG-1 Layer I frames (mono, 32 kHz, 48 bytes each) of silence. The seventh has the +/// forbidden bit allocation 15 in every subband: the decoder writes silence for it and counts it. +fn mp1_with_a_damaged_frame() -> Vec { + let header = [0xFF, 0xFF, 0x18, 0xC0]; + let good = [&header[..], &[0; 44]].concat(); + let bad = [&header[..], &[0xFF; 16], &[0; 28]].concat(); + (0..12).flat_map(|i| if i == 6 { bad.clone() } else { good.clone() }).collect() +} + +#[test] +fn convert_warns_about_damaged_packets_and_never_replaces_the_source_with_them() { + let dir = tmpdir("damaged"); + let src = dir.join("take.mp1"); + let bytes = mp1_with_a_damaged_frame(); + std::fs::write(&src, &bytes).unwrap(); + let run = |args: &[&std::ffi::OsStr]| { + Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")).env("SOUNDCRAFT_NO_PREFS", "1").arg("convert").args(args).output().unwrap() + }; + // To another file: written, with a warning that says how much was lost. + let out = dir.join("take.wav"); + let o = run(&[src.as_os_str(), out.as_os_str()]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let warning = format!("warning: {}: 1 damaged packet(s) decoded as silence\n", src.display()); + assert_eq!(String::from_utf8_lossy(&o.stderr), warning); + assert_eq!(steps16(&out).len(), 12 * 384); + // Over the source: refused, and the source is untouched. + let o = run(&[src.as_os_str(), src.as_os_str(), "--in-place".as_ref()]); + assert_eq!(o.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(stderr.starts_with(&format!("error: {}: 1 damaged packet(s) would be written as silence", src.display())), "{stderr}"); + assert_eq!(std::fs::read(&src).unwrap(), bytes); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn convert_keeps_the_speaker_layout() { + use craft_audio::ChannelLayout; + let dir = tmpdir("layout"); + let (src, out) = (dir.join("side.wav"), dir.join("out.wav")); + // 5.1 with side surrounds: not the layout a reader assumes for six channels. + let opts = EncodeOptions::default().with_layout(ChannelLayout::SURROUND_5_1_SIDE); + std::fs::write(&src, craft_audio::encode(&AudioBuffer::new(48_000, 6, 480), &opts).unwrap()).unwrap(); + let layout = |p: &Path| craft_audio::probe(&std::fs::read(p).unwrap(), None).unwrap().layout; + assert_eq!(layout(&src), ChannelLayout::SURROUND_5_1_SIDE); + convert(&src, &out, &[]); + assert_eq!(layout(&out), ChannelLayout::SURROUND_5_1_SIDE); + let flac = dir.join("out.flac"); + convert(&src, &flac, &[]); + assert_eq!(layout(&flac), ChannelLayout::SURROUND_5_1_SIDE); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/midi/Cargo.toml b/crates/midi/Cargo.toml index c32093fc..315018ef 100644 --- a/crates/midi/Cargo.toml +++ b/crates/midi/Cargo.toml @@ -8,12 +8,10 @@ rust-version.workspace = true repository.workspace = true [dependencies] -serde.workspace = true -thiserror.workspace = true +craft-midi.workspace = true [dev-dependencies] proptest.workspace = true -serde_json.workspace = true [lints] workspace = true diff --git a/crates/midi/src/lib.rs b/crates/midi/src/lib.rs index 5ecb684c..8ab5243f 100644 --- a/crates/midi/src/lib.rs +++ b/crates/midi/src/lib.rs @@ -1,10 +1,9 @@ //! SoundCraft MIDI. //! //! - [`Note`], [`CtrlEvent`] and [`Sequence`]: the MIDI content of a region / track, in ticks at -//! [`PPQ`] (960) ticks per quarter note. -//! - [`read_smf`] / [`write_smf`]: Standard MIDI File import (format 0/1, format 2 best-effort) and -//! export (format 1, 960 PPQ). The reader treats its input as hostile: every length is checked, -//! variable-length quantities are bounded and event counts are capped. +//! [`PPQ`] (960) ticks per quarter note; [`read_smf`] / [`write_smf`]: Standard MIDI File import +//! and export. These come from `craft-midi` in craft-libs, shared with the other Craft apps, and +//! are re-exported so the rest of SoundCraft names them from here. //! - [`ops`]: MIDI editing operations (quantize, transpose, velocity, duration, split). //! - [`note_name`], [`gm_drum_name`], [`gm_program_name`]: display names. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -12,36 +11,7 @@ mod names; pub mod ops; mod rng; -mod sequence; -mod smf; +pub use craft_midi::{CtrlEvent, CtrlKind, MAX_EVENTS, MAX_TRACKS, MidiError, Note, PPQ, Sequence, Smf, SmfTrack, read_smf, write_smf}; pub use names::{gm_drum_name, gm_program_name, note_name}; pub use rng::XorShift; -pub use sequence::{CtrlEvent, CtrlKind, Note, Sequence}; -pub use smf::{MAX_EVENTS, MAX_TRACKS, Smf, SmfTrack, read_smf, write_smf}; - -/// Ticks per quarter note used throughout the model. -pub const PPQ: u32 = 960; - -/// Errors from reading or writing MIDI data. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum MidiError { - #[error("not a Standard MIDI File (missing MThd header)")] - NotSmf, - #[error("unexpected end of data at byte {0}")] - Truncated(usize), - #[error("variable-length quantity longer than 4 bytes at byte {0}")] - VlqTooLong(usize), - #[error("chunk length {len} at byte {offset} exceeds the remaining data")] - BadChunkLength { offset: usize, len: u64 }, - #[error("invalid MIDI data at byte {offset}: {reason}")] - Invalid { offset: usize, reason: &'static str }, - #[error("unsupported MIDI file: {0}")] - Unsupported(&'static str), - #[error("too many events (limit {0})")] - TooManyEvents(usize), - #[error("too many tracks (limit {0})")] - TooManyTracks(usize), - #[error("cannot write MIDI file: {0}")] - Write(String), -} diff --git a/crates/midi/src/sequence.rs b/crates/midi/src/sequence.rs deleted file mode 100644 index c5336f06..00000000 --- a/crates/midi/src/sequence.rs +++ /dev/null @@ -1,141 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::collections::hash_map::Entry; - -/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note). -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct Note { - /// MIDI pitch 0..=127 (60 = middle C, shown as "C3"). - pub pitch: u8, - /// Note-on velocity 1..=127. - pub velocity: u8, - /// Note-off (release) velocity 0..=127; 64 when the source had none. - pub release_velocity: u8, - /// MIDI channel 0..=15. - pub channel: u8, - /// Start in ticks. - pub start: i64, - /// Length in ticks; always > 0. - pub length: i64, -} - -impl Note { - /// Tick at which the note ends (`start + length`, saturating). - pub fn end(&self) -> i64 { - self.start.saturating_add(self.length) - } -} - -/// Kind of a continuous / channel controller event. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub enum CtrlKind { - /// Control change with the controller number 0..=127. Value 0..=127. - Cc(u8), - /// Pitch bend. Value is centred: -8192..=8191 (0 = no bend). - PitchBend, - /// Channel (mono) aftertouch. Value 0..=127. - ChannelPressure, - /// Polyphonic aftertouch for the given pitch. Value 0..=127. - PolyPressure(u8), - /// Program change. Value is the program number 0..=127. - Program, -} - -/// A controller / channel event at a tick. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct CtrlEvent { - pub tick: i64, - pub channel: u8, - pub kind: CtrlKind, - pub value: i32, -} - -/// The MIDI content of a region or track. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct Sequence { - /// Notes, sorted by `(start, pitch)` after [`Sequence::sort`]. - pub notes: Vec, - /// Controller events, sorted by tick after [`Sequence::sort`]. - pub ctrls: Vec, -} - -fn kind_key(k: CtrlKind) -> (u8, u8) { - match k { - CtrlKind::Cc(n) => (0, n), - CtrlKind::PitchBend => (1, 0), - CtrlKind::ChannelPressure => (2, 0), - CtrlKind::PolyPressure(p) => (3, p), - CtrlKind::Program => (4, 0), - } -} - -impl Sequence { - /// Sorts notes by `(start, pitch, channel, length, velocity)` and controller events by tick - /// (stable, so same-tick events keep their order). - pub fn sort(&mut self) { - self.notes.sort_by_key(|a| (a.start, a.pitch, a.channel, a.length, a.velocity)); - self.ctrls.sort_by_key(|c| c.tick); - } - - /// The last tick covered by any note end or controller event (0 when empty). - pub fn end_tick(&self) -> i64 { - let n = self.notes.iter().map(Note::end).max(); - let c = self.ctrls.iter().map(|c| c.tick).max(); - match (n, c) { - (Some(a), Some(b)) => a.max(b), - (Some(a), None) | (None, Some(a)) => a, - (None, None) => 0, - } - } - - /// Notes whose start lies in the half-open range `[start, end)`. - pub fn notes_in(&self, start: i64, end: i64) -> impl Iterator + '_ { - self.notes.iter().filter(move |n| n.start >= start && n.start < end) - } - - /// Notes that sound anywhere inside the half-open range `[start, end)`. - pub fn notes_overlapping(&self, start: i64, end: i64) -> impl Iterator + '_ { - self.notes.iter().filter(move |n| n.start < end && n.end() > start) - } - - /// Removes duplicate notes (same channel, pitch and start; the longest one is kept) and - /// duplicate controller events (same tick, channel and kind; the last one is kept, matching - /// what a synth would end up with). Returns how many events were removed. The sequence is - /// left sorted. - pub fn remove_duplicates(&mut self) -> usize { - let before = self.notes.len() + self.ctrls.len(); - // Longest first within each (start, pitch, channel) group, then keep the first. - self.notes.sort_by(|a, b| (a.start, a.pitch, a.channel, b.length).cmp(&(b.start, b.pitch, b.channel, a.length))); - self.notes.dedup_by(|later, kept| later.start == kept.start && later.pitch == kept.pitch && later.channel == kept.channel); - - self.ctrls.sort_by_key(|c| c.tick); - let mut kept: Vec = Vec::with_capacity(self.ctrls.len()); - // The latest tick and `kept` index of each channel+kind, so a same-tick repeat is found - // without rescanning the tick's events (a tick can hold many thousands of distinct keys). - // An entry from an earlier tick counts as absent; keeping the tick instead of clearing the - // map per tick keeps the work per event constant after a dense tick. - let mut last_at: HashMap<(u8, (u8, u8)), (i64, usize)> = HashMap::new(); - for c in self.ctrls.drain(..) { - // Within a tick, replace an earlier event of the same channel+kind. - match last_at.entry((c.channel, kind_key(c.kind))) { - Entry::Occupied(mut e) => { - let (tick, idx) = *e.get(); - match kept.get_mut(idx) { - Some(slot) if tick == c.tick => *slot = c, - _ => { - e.insert((c.tick, kept.len())); - kept.push(c); - } - } - } - Entry::Vacant(e) => { - e.insert((c.tick, kept.len())); - kept.push(c); - } - } - } - self.ctrls = kept; - self.sort(); - before - (self.notes.len() + self.ctrls.len()) - } -} diff --git a/crates/midi/src/smf.rs b/crates/midi/src/smf.rs deleted file mode 100644 index 7ec83801..00000000 --- a/crates/midi/src/smf.rs +++ /dev/null @@ -1,635 +0,0 @@ -//! Standard MIDI File reading and writing (from the public SMF 1.0 specification). - -use std::collections::VecDeque; - -use crate::{CtrlEvent, CtrlKind, MidiError, Note, PPQ, Sequence}; - -/// Maximum number of events (across all tracks) [`read_smf`] will parse. -pub const MAX_EVENTS: usize = 5_000_000; -/// Maximum number of track chunks [`read_smf`] will accept. -pub const MAX_TRACKS: usize = 4096; - -/// Release velocity used when a note ends with a note-on of velocity 0 (per the spec, a -/// note-on with velocity 0 is a note-off with velocity 64). -const DEFAULT_RELEASE: u8 = 64; - -/// One track of a Standard MIDI File. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct SmfTrack { - /// Name from the first Sequence/Track Name meta event (empty if none). - pub name: String, - pub sequence: Sequence, -} - -/// A Standard MIDI File in model terms. All ticks are at [`PPQ`] (960) per quarter note. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct Smf { - /// Format of the source file (0, 1 or 2). [`write_smf`] always writes format 1. - pub format: u16, - pub tracks: Vec, - /// `(tick, beats per minute)`. - pub tempos: Vec<(i64, f64)>, - /// `(tick, numerator, denominator)`, e.g. `(0, 6, 8)`. - pub meters: Vec<(i64, u8, u8)>, - /// `(tick, text)` from Marker meta events. - pub markers: Vec<(i64, String)>, - /// `(tick, sharps (+) / flats (-) -7..=7, is_minor)`. - pub key_sigs: Vec<(i64, i8, bool)>, -} - -// --------------------------------------------------------------------------------------------- -// Reading -// --------------------------------------------------------------------------------------------- - -struct Cursor<'a> { - data: &'a [u8], - pos: usize, - /// Absolute offset of `data[0]` in the file, for error messages. - base: usize, -} - -impl<'a> Cursor<'a> { - fn at(&self) -> usize { - self.base.saturating_add(self.pos) - } - - fn is_empty(&self) -> bool { - self.pos >= self.data.len() - } - - fn u8(&mut self) -> Result { - let b = *self.data.get(self.pos).ok_or(MidiError::Truncated(self.at()))?; - self.pos += 1; - Ok(b) - } - - fn peek(&self) -> Result { - self.data.get(self.pos).copied().ok_or(MidiError::Truncated(self.at())) - } - - fn bytes(&mut self, n: usize) -> Result<&'a [u8], MidiError> { - let end = self.pos.checked_add(n).ok_or(MidiError::Truncated(self.at()))?; - let s = self.data.get(self.pos..end).ok_or(MidiError::Truncated(self.at()))?; - self.pos = end; - Ok(s) - } - - fn u16(&mut self) -> Result { - let b = self.bytes(2)?; - Ok(u16::from_be_bytes([b.first().copied().unwrap_or(0), b.get(1).copied().unwrap_or(0)])) - } - - fn u32(&mut self) -> Result { - let b = self.bytes(4)?; - let mut a = [0u8; 4]; - a.copy_from_slice(b); - Ok(u32::from_be_bytes(a)) - } - - /// Variable-length quantity: at most 4 bytes (28 bits) per the spec. - fn vlq(&mut self) -> Result { - let start = self.at(); - let mut v: u32 = 0; - for _ in 0..4 { - let b = self.u8()?; - v = (v << 7) | u32::from(b & 0x7F); - if b & 0x80 == 0 { - return Ok(v); - } - } - Err(MidiError::VlqTooLong(start)) - } -} - -/// Converts file ticks to model ticks: `model = file * num / den` (rounded). -#[derive(Clone, Copy)] -struct TickConv { - num: i128, - den: i128, -} - -impl TickConv { - fn from_division(division: u16, offset: usize) -> Result { - if division & 0x8000 == 0 { - if division == 0 { - return Err(MidiError::Invalid { offset, reason: "division of 0 ticks per quarter note" }); - } - Ok(Self { num: i128::from(PPQ), den: i128::from(division) }) - } else { - // SMPTE: high byte is negative frames/sec (two's complement), low byte ticks/frame. - // Approximated at 120 bpm: one quarter note = 0.5 s, so - // model = file / ticks_per_sec * 2 * PPQ. - let [hi, lo] = division.to_be_bytes(); - let fps = -i128::from(hi as i8); - let tpf = i128::from(lo); - if !matches!(fps, 24 | 25 | 29 | 30) || tpf == 0 { - return Err(MidiError::Invalid { offset, reason: "invalid SMPTE division" }); - } - let two_ppq = 2 * i128::from(PPQ); - if fps == 29 { - // 29.97 drop-frame: ticks/sec = 30000/1001 * tpf - Ok(Self { num: two_ppq * 1001, den: 30000 * tpf }) - } else { - Ok(Self { num: two_ppq, den: fps * tpf }) - } - } - } - - fn convert(&self, file_tick: u64) -> i64 { - let v = (i128::from(file_tick) * self.num + self.den / 2) / self.den; - i64::try_from(v).unwrap_or(i64::MAX) - } -} - -struct Globals { - tempos: Vec<(i64, f64)>, - meters: Vec<(i64, u8, u8)>, - markers: Vec<(i64, String)>, - key_sigs: Vec<(i64, i8, bool)>, -} - -/// Parses a Standard MIDI File. -/// -/// - Formats 0 and 1 are fully supported; format 2 is read best-effort (all tracks share one -/// timeline). Each `MTrk` chunk becomes one [`SmfTrack`]; for format 1 a leading conductor -/// track without notes or controllers is folded into the global tempo/meter/marker lists. -/// - Ticks are converted to [`PPQ`]; an SMPTE division is approximated at 120 bpm. -/// - Running status, note-on velocity 0 as note-off, unmatched note-offs (ignored), overlapping -/// same-pitch notes (matched first-in first-out) and hanging notes (closed at the end of the -/// track) are handled. Zero-length notes get a length of 1 tick. -/// - Unknown chunks and meta events are skipped. Any malformed structure (bad header, truncated -/// chunk, VLQ longer than 4 bytes, data byte without running status, ...) returns an error. -pub fn read_smf(bytes: &[u8]) -> Result { - let mut cur = Cursor { data: bytes, pos: 0, base: 0 }; - if cur.bytes(4).map_err(|_| MidiError::NotSmf)? != b"MThd" { - return Err(MidiError::NotSmf); - } - let header_len = cur.u32()?; - if header_len < 6 { - return Err(MidiError::Invalid { offset: 4, reason: "header chunk shorter than 6 bytes" }); - } - let header_len = usize::try_from(header_len).map_err(|_| MidiError::BadChunkLength { offset: 4, len: u64::from(header_len) })?; - let header = cur.bytes(header_len).map_err(|_| MidiError::BadChunkLength { offset: 4, len: u64::try_from(header_len).unwrap_or(u64::MAX) })?; - let mut hc = Cursor { data: header, pos: 0, base: 8 }; - let format = hc.u16()?; - let _ntrks = hc.u16()?; - let division = hc.u16()?; - if format > 2 { - return Err(MidiError::Unsupported("SMF format greater than 2")); - } - let conv = TickConv::from_division(division, 12)?; - - let mut globals = Globals { tempos: Vec::new(), meters: Vec::new(), markers: Vec::new(), key_sigs: Vec::new() }; - let mut tracks = Vec::new(); - let mut event_budget = MAX_EVENTS; - - while !cur.is_empty() { - let chunk_at = cur.at(); - let id = cur.bytes(4)?; - let len = cur.u32()?; - let remaining = bytes.len().saturating_sub(cur.pos); - let len_usize = usize::try_from(len).unwrap_or(usize::MAX); - if len_usize > remaining { - return Err(MidiError::BadChunkLength { offset: chunk_at, len: u64::from(len) }); - } - let body_at = cur.at(); - let body = cur.bytes(len_usize)?; - if id != b"MTrk" { - continue; // unknown chunk types must be ignored - } - if tracks.len() >= MAX_TRACKS { - return Err(MidiError::TooManyTracks(MAX_TRACKS)); - } - let track = read_track(body, body_at, conv, &mut globals, &mut event_budget)?; - tracks.push(track); - } - - // A lone track is only a conductor track if it carried global data and has no name; otherwise it is kept. - let globals_present = !(globals.tempos.is_empty() && globals.meters.is_empty() && globals.markers.is_empty() && globals.key_sigs.is_empty()); - if format == 1 - && (tracks.len() > 1 || globals_present) - && tracks.first().is_some_and(|t| t.sequence.notes.is_empty() && t.sequence.ctrls.is_empty() && (tracks.len() > 1 || t.name.is_empty())) - { - tracks.remove(0); - } - - // Keep global lists ordered by tick (stable: file order within a tick). - globals.tempos.sort_by_key(|t| t.0); - globals.meters.sort_by_key(|t| t.0); - globals.markers.sort_by_key(|t| t.0); - globals.key_sigs.sort_by_key(|t| t.0); - - Ok(Smf { format, tracks, tempos: globals.tempos, meters: globals.meters, markers: globals.markers, key_sigs: globals.key_sigs }) -} - -/// Open note-ons for one (channel, pitch), oldest first: `(model tick, velocity)`. -type OpenNotes = Vec>; - -fn close_note(open: &mut OpenNotes, notes: &mut Vec, channel: u8, pitch: u8, tick: i64, release: u8) { - let key = usize::from(channel & 0x0F) * 128 + usize::from(pitch & 0x7F); - let Some(stack) = open.get_mut(key) else { return }; - if stack.is_empty() { - return; // unmatched note-off: ignored - } - let Some((start, velocity)) = stack.pop_front() else { return }; - let length = tick.saturating_sub(start).max(1); - notes.push(Note { pitch, velocity, release_velocity: release, channel, start, length }); -} - -fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget: &mut usize) -> Result { - let mut cur = Cursor { data: body, pos: 0, base }; - let mut name: Option = None; - let mut seq = Sequence::default(); - let mut open: OpenNotes = vec![VecDeque::new(); 16 * 128]; - let mut file_tick: u64 = 0; - let mut tick: i64 = 0; - let mut running: Option = None; - - while !cur.is_empty() { - if *budget == 0 { - return Err(MidiError::TooManyEvents(MAX_EVENTS)); - } - *budget -= 1; - - let delta = cur.vlq()?; - file_tick = file_tick.saturating_add(u64::from(delta)); - tick = conv.convert(file_tick); - - let event_at = cur.at(); - let first = cur.peek()?; - let status = if first & 0x80 != 0 { - cur.pos += 1; - first - } else { - running.ok_or(MidiError::Invalid { offset: event_at, reason: "data byte without running status" })? - }; - - match status { - 0xFF => { - running = None; - let kind = cur.u8()?; - let len = cur.vlq()?; - let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?; - match kind { - 0x2F => break, // End of Track - 0x03 if name.is_none() => { - name = Some(String::from_utf8_lossy(data).into_owned()); - } - 0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())), - 0x51 => { - if let [a, b, c] = data { - let us = (u32::from(*a) << 16) | (u32::from(*b) << 8) | u32::from(*c); - if us > 0 { - g.tempos.push((tick, 60_000_000.0 / f64::from(us))); - } - } - } - 0x58 => { - if let [nn, dd, ..] = data - && *nn > 0 - && *dd <= 7 - { - g.meters.push((tick, *nn, 1u8 << *dd)); - } - } - 0x59 => { - if let [sf, mi, ..] = data { - let sf = *sf as i8; - if (-7..=7).contains(&sf) { - g.key_sigs.push((tick, sf, *mi == 1)); - } - } - } - _ => {} - } - } - 0xF0 | 0xF7 => { - running = None; - let len = cur.vlq()?; - cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?; - } - 0x80..=0xEF => { - running = Some(status); - let channel = status & 0x0F; - let d1 = cur.u8()?; - if d1 & 0x80 != 0 { - return Err(MidiError::Invalid { offset: event_at, reason: "status byte where a data byte was expected" }); - } - let needs_two = !matches!(status & 0xF0, 0xC0 | 0xD0); - let d2 = if needs_two { - let d = cur.u8()?; - if d & 0x80 != 0 { - return Err(MidiError::Invalid { offset: event_at, reason: "status byte where a data byte was expected" }); - } - d - } else { - 0 - }; - match status & 0xF0 { - 0x80 => close_note(&mut open, &mut seq.notes, channel, d1, tick, d2), - 0x90 if d2 == 0 => close_note(&mut open, &mut seq.notes, channel, d1, tick, DEFAULT_RELEASE), - 0x90 => { - if let Some(stack) = open.get_mut(usize::from(channel) * 128 + usize::from(d1)) { - stack.push_back((tick, d2)); - } - } - 0xA0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PolyPressure(d1), value: i32::from(d2) }), - 0xB0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Cc(d1), value: i32::from(d2) }), - 0xC0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Program, value: i32::from(d1) }), - 0xD0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::ChannelPressure, value: i32::from(d1) }), - _ => { - // 0xE0 pitch bend: LSB then MSB, centred on 8192. - let v = (i32::from(d2) << 7 | i32::from(d1)) - 8192; - seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PitchBend, value: v }); - } - } - } - _ => return Err(MidiError::Invalid { offset: event_at, reason: "system real-time/common status in a track" }), - } - } - - // Close hanging notes at the end of the track. - for (key, stack) in open.iter_mut().enumerate() { - let channel = u8::try_from(key / 128).unwrap_or(0); - let pitch = u8::try_from(key % 128).unwrap_or(0); - for (start, velocity) in stack.drain(..) { - let length = tick.saturating_sub(start).max(1); - seq.notes.push(Note { pitch, velocity, release_velocity: DEFAULT_RELEASE, channel, start, length }); - } - } - seq.sort(); - Ok(SmfTrack { name: name.unwrap_or_default(), sequence: seq }) -} - -// --------------------------------------------------------------------------------------------- -// Writing -// --------------------------------------------------------------------------------------------- - -const MAX_VLQ: u64 = 0x0FFF_FFFF; - -fn push_vlq(out: &mut Vec, v: u64) -> Result<(), MidiError> { - if v > MAX_VLQ { - return Err(MidiError::Write(format!("value {v} does not fit in a MIDI variable-length quantity"))); - } - let mut buf = [0u8; 4]; - let mut n = 0; - let mut x = v; - loop { - if let Some(slot) = buf.get_mut(n) { - *slot = (x & 0x7F) as u8; - } - n += 1; - x >>= 7; - if x == 0 || n == 4 { - break; - } - } - for i in (0..n).rev() { - let b = buf.get(i).copied().unwrap_or(0); - out.push(if i > 0 { b | 0x80 } else { b }); - } - Ok(()) -} - -/// An event ready to be serialised: `(tick, order, bytes)`. `order` breaks ties at one tick. -struct WEvent { - tick: u64, - order: u8, - bytes: Vec, -} - -fn clamp_tick(t: i64) -> u64 { - u64::try_from(t.max(0)).unwrap_or(0) -} - -fn meta(kind: u8, data: &[u8]) -> Result, MidiError> { - let mut b = vec![0xFF, kind]; - push_vlq(&mut b, u64::try_from(data.len()).unwrap_or(u64::MAX))?; - b.extend_from_slice(data); - Ok(b) -} - -fn write_track(out: &mut Vec, mut events: Vec) -> Result<(), MidiError> { - events.sort_by_key(|e| (e.tick, e.order)); - let mut body = Vec::new(); - let mut last_tick = 0u64; - let mut running: Option = None; - for e in &events { - push_vlq(&mut body, e.tick - last_tick)?; - last_tick = e.tick; - let status = e.bytes.first().copied().unwrap_or(0); - if (0x80..=0xEF).contains(&status) { - if running == Some(status) { - body.extend_from_slice(e.bytes.get(1..).unwrap_or(&[])); - } else { - body.extend_from_slice(&e.bytes); - } - running = Some(status); - } else { - running = None; - body.extend_from_slice(&e.bytes); - } - } - // End of Track. - body.extend_from_slice(&[0x00, 0xFF, 0x2F, 0x00]); - let len = u32::try_from(body.len()).map_err(|_| MidiError::Write("track larger than 4 GiB".into()))?; - out.extend_from_slice(b"MTrk"); - out.extend_from_slice(&len.to_be_bytes()); - out.extend_from_slice(&body); - Ok(()) -} - -/// The conductor track: tempos, meters, key signatures and markers. -fn conductor_events(smf: &Smf) -> Result, MidiError> { - let mut ev = Vec::new(); - for &(t, bpm) in &smf.tempos { - if !bpm.is_finite() || bpm <= 0.0 { - return Err(MidiError::Write(format!("invalid tempo {bpm} bpm"))); - } - let us = (60_000_000.0 / bpm).round().clamp(1.0, f64::from(0x00FF_FFFFu32)) as u32; - let [_, a, b, c] = us.to_be_bytes(); - ev.push(WEvent { tick: clamp_tick(t), order: 0, bytes: meta(0x51, &[a, b, c])? }); - } - for &(t, num, den) in &smf.meters { - if den == 0 || !den.is_power_of_two() { - return Err(MidiError::Write(format!("meter denominator {den} is not a power of two"))); - } - let dd = den.trailing_zeros() as u8; - ev.push(WEvent { tick: clamp_tick(t), order: 1, bytes: meta(0x58, &[num, dd, 24, 8])? }); - } - for &(t, sf, minor) in &smf.key_sigs { - let sf = sf.clamp(-7, 7); - ev.push(WEvent { tick: clamp_tick(t), order: 2, bytes: meta(0x59, &[sf as u8, u8::from(minor)])? }); - } - for (t, text) in &smf.markers { - ev.push(WEvent { tick: clamp_tick(*t), order: 3, bytes: meta(0x06, text.as_bytes())? }); - } - Ok(ev) -} - -/// Trim notes still sounding when the next same-pitch note starts, and drop duplicates. -fn trim_overlaps(notes: &[Note], ends: &mut [u64], dropped: &mut [bool]) { - // The reader pairs offs with ons first-in first-out, so a note still sounding when the next - // same-pitch note starts would swap ends with it: trim it to that start. When two same-pitch - // notes start on the same tick they cannot be told apart, so only the longer one is written. - let mut order: Vec = (0..notes.len()).collect(); - order.sort_by_key(|&i| { - notes.get(i).map_or((0, 0, 0, std::cmp::Reverse(0)), |n| { - (n.channel & 0x0F, n.pitch.min(127), clamp_tick(n.start), std::cmp::Reverse(ends.get(i).copied().unwrap_or(0))) - }) - }); - let mut prev: Option = None; - for &b in &order { - let Some(nb) = notes.get(b) else { continue }; - if let Some(a) = prev - && let Some(na) = notes.get(a) - && na.channel & 0x0F == nb.channel & 0x0F - && na.pitch.min(127) == nb.pitch.min(127) - { - let (sa, sb) = (clamp_tick(na.start), clamp_tick(nb.start)); - if sa == sb { - if let Some(d) = dropped.get_mut(b) { - *d = true; - } - continue; - } - if let Some(ea) = ends.get_mut(a) - && sb < *ea - { - *ea = sb; - } - } - prev = Some(b); - } -} - -/// Writes a format-1 Standard MIDI File at [`PPQ`] (960) ticks per quarter note. -/// -/// Track 1 is a conductor track holding tempos, meters, key signatures and markers; every -/// [`SmfTrack`] follows as its own track. Negative ticks are clamped to 0, data values are -/// clamped to their MIDI ranges, and note-on velocity 0 is written as 1 (0 would mean -/// note-off). At each tick note-offs are written before controllers, then note-ons. -/// -/// Errors: invalid tempo (non-finite or <= 0 bpm), meter denominator that is not a power of two -/// (1..=128), more than 65535 tracks, or a gap between events beyond the 28-bit delta limit. -pub fn write_smf(smf: &Smf) -> Result, MidiError> { - // The conductor track is omitted when there is nothing to put in it and no content tracks, so an - // empty `Smf` is written as a file with zero tracks and reads back as zero tracks. - let has_conductor = - !(smf.tempos.is_empty() && smf.meters.is_empty() && smf.key_sigs.is_empty() && smf.markers.is_empty() && smf.tracks.is_empty()); - let ntrks = - u16::try_from(smf.tracks.len().saturating_add(usize::from(has_conductor))).map_err(|_| MidiError::Write("more than 65535 tracks".into()))?; - let mut out = Vec::new(); - out.extend_from_slice(b"MThd"); - out.extend_from_slice(&6u32.to_be_bytes()); - out.extend_from_slice(&1u16.to_be_bytes()); - out.extend_from_slice(&ntrks.to_be_bytes()); - out.extend_from_slice(&u16::try_from(PPQ).unwrap_or(960).to_be_bytes()); - - // Conductor track. - let ev = conductor_events(smf)?; - if has_conductor { - write_track(&mut out, ev)?; - } - - for track in &smf.tracks { - let mut ev = Vec::new(); - if !track.name.is_empty() { - ev.push(WEvent { tick: 0, order: 0, bytes: meta(0x03, track.name.as_bytes())? }); - } - let notes = &track.sequence.notes; - let mut ends: Vec = notes.iter().map(|n| clamp_tick(n.end()).max(clamp_tick(n.start).saturating_add(1))).collect(); - let mut dropped = vec![false; notes.len()]; - trim_overlaps(notes, &mut ends, &mut dropped); - for (i, n) in notes.iter().enumerate() { - if dropped.get(i).copied().unwrap_or(false) { - continue; - } - let ch = n.channel & 0x0F; - let pitch = n.pitch.min(127); - let start = clamp_tick(n.start); - let end = ends.get(i).copied().unwrap_or(start.saturating_add(1)); - ev.push(WEvent { tick: start, order: 3, bytes: vec![0x90 | ch, pitch, n.velocity.clamp(1, 127)] }); - ev.push(WEvent { tick: end, order: 1, bytes: vec![0x80 | ch, pitch, n.release_velocity.min(127)] }); - } - for c in &track.sequence.ctrls { - let ch = c.channel & 0x0F; - let v7 = c.value.clamp(0, 127) as u8; - let bytes = match c.kind { - CtrlKind::Cc(num) => vec![0xB0 | ch, num.min(127), v7], - CtrlKind::Program => vec![0xC0 | ch, v7], - CtrlKind::ChannelPressure => vec![0xD0 | ch, v7], - CtrlKind::PolyPressure(p) => vec![0xA0 | ch, p.min(127), v7], - CtrlKind::PitchBend => { - let v = (c.value.saturating_add(8192)).clamp(0, 16383); - vec![0xE0 | ch, (v & 0x7F) as u8, ((v >> 7) & 0x7F) as u8] - } - }; - ev.push(WEvent { tick: clamp_tick(c.tick), order: 2, bytes }); - } - write_track(&mut out, ev)?; - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn vlq_round_trip() { - for v in [0u64, 0x40, 0x7F, 0x80, 0x2000, 0x3FFF, 0x4000, 0x1F_FFFF, 0x20_0000, MAX_VLQ] { - let mut b = Vec::new(); - push_vlq(&mut b, v).unwrap(); - let mut c = Cursor { data: &b, pos: 0, base: 0 }; - assert_eq!(u64::from(c.vlq().unwrap()), v); - assert!(c.is_empty()); - } - assert!(push_vlq(&mut Vec::new(), MAX_VLQ + 1).is_err()); - } - - #[test] - fn empty_smf_round_trips_with_no_tracks() { - let r = read_smf(&write_smf(&Smf::default()).unwrap()).unwrap(); - assert_eq!(r.tracks.len(), 0); - let one = Smf { tracks: vec![SmfTrack::default()], ..Smf::default() }; - let r = read_smf(&write_smf(&one).unwrap()).unwrap(); - assert_eq!(r.tracks.len(), 1); - let cond = Smf { tempos: vec![(0, 90.0)], ..Smf::default() }; - let r = read_smf(&write_smf(&cond).unwrap()).unwrap(); - assert_eq!(r.tracks.len(), 0); - assert_eq!(r.tempos.len(), 1); - } - - #[test] - fn spec_vlq_examples() { - let mut b = Vec::new(); - push_vlq(&mut b, 0x0800_0000).unwrap(); - assert_eq!(b, [0xC0, 0x80, 0x80, 0x00]); - let mut b = Vec::new(); - push_vlq(&mut b, 0x2000).unwrap(); - assert_eq!(b, [0xC0, 0x00]); - } - - #[test] - fn same_pitch_overlaps_do_not_swap_ends() { - let mk = |start, length| Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start, length }; - let smf = Smf { - format: 1, - tracks: vec![SmfTrack { - name: String::new(), - sequence: Sequence { notes: vec![mk(0, 1000), mk(100, 200), mk(1000, 100), mk(2000, 500), mk(2000, 900)], ..Sequence::default() }, - }], - tempos: vec![], - meters: vec![], - markers: vec![], - key_sigs: vec![], - }; - let back = read_smf(&write_smf(&smf).unwrap()).unwrap(); - let mut got: Vec<(i64, i64)> = back.tracks.iter().flat_map(|t| t.sequence.notes.iter().map(|n| (n.start, n.length))).collect(); - got.sort_unstable(); - // The earlier note is trimmed to the start of the next same-pitch note; later notes keep their length; - // of two notes starting together only the longer survives. - assert_eq!(got, vec![(0, 100), (100, 200), (1000, 100), (2000, 900)]); - } -} diff --git a/crates/midi/tests/it/main.rs b/crates/midi/tests/it/main.rs index 26fb0ad9..c11e1bde 100644 --- a/crates/midi/tests/it/main.rs +++ b/crates/midi/tests/it/main.rs @@ -1,4 +1,3 @@ //! Integration tests, built as one test binary: each file is a module. mod ops; -mod smf; diff --git a/crates/midi/tests/it/ops.rs b/crates/midi/tests/it/ops.rs index 431790ff..d38812b9 100644 --- a/crates/midi/tests/it/ops.rs +++ b/crates/midi/tests/it/ops.rs @@ -187,107 +187,3 @@ fn split_by_pitch() { assert_eq!(inside.iter().map(|n| n.pitch).collect::>(), vec![60, 48]); assert_eq!(outside.iter().map(|n| n.pitch).collect::>(), vec![36, 72]); } - -#[test] -fn sequence_helpers() { - let mut seq = Sequence { - notes: vec![n(64, 100, 50), n(60, 100, 10), n(60, 100, 30), n(60, 0, 10), Note { channel: 2, ..n(60, 0, 10) }], - ctrls: vec![ - CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(1), value: 1 }, - CtrlEvent { tick: 5, channel: 0, kind: CtrlKind::Cc(1), value: 2 }, - CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(1), value: 3 }, - CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(2), value: 4 }, - ], - }; - assert_eq!(seq.end_tick(), 500); - seq.sort(); - assert_eq!(starts(&seq.notes), vec![0, 0, 100, 100, 100]); - assert_eq!(seq.notes[2].pitch, 60); - assert_eq!(seq.notes_in(0, 100).count(), 2); - assert_eq!(seq.notes_overlapping(105, 106).count(), 3); - - let removed = seq.remove_duplicates(); - assert_eq!(removed, 2); - assert_eq!(seq.notes.len(), 4); - let kept = seq.notes.iter().find(|n| n.start == 100 && n.pitch == 60).unwrap(); - assert_eq!(kept.length, 30); - assert_eq!(seq.ctrls.iter().map(|c| c.value).collect::>(), vec![2, 3, 4]); - assert_eq!(seq.remove_duplicates(), 0); - assert_eq!(Sequence::default().end_tick(), 0); -} - -#[test] -fn sequence_serde_round_trip() { - let seq = Sequence { notes: vec![n(60, 0, 1)], ctrls: vec![CtrlEvent { tick: 1, channel: 0, kind: CtrlKind::PolyPressure(60), value: 9 }] }; - let json = serde_json::to_string(&seq).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), seq); -} - -#[test] -fn remove_duplicates_handles_a_large_same_tick_controller_group() { - // Every channel x controller number at one tick, twice: the second copy of each wins. A - // rescan of the tick's kept events per incoming event would make this quadratic. - let keys: Vec<(u8, u8)> = (0..=255u8).flat_map(|ch| (0..=255u8).map(move |cc| (ch, cc))).collect(); - let ctrls = [0, 1] - .iter() - .flat_map(|&pass| keys.iter().map(move |&(channel, cc)| CtrlEvent { tick: 7, channel, kind: CtrlKind::Cc(cc), value: pass })) - .collect(); - let mut seq = Sequence { notes: Vec::new(), ctrls }; - let start = std::time::Instant::now(); - assert_eq!(seq.remove_duplicates(), keys.len()); - assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); - assert_eq!(seq.ctrls.len(), keys.len()); - assert!(seq.ctrls.iter().all(|c| c.value == 1)); -} - -#[test] -fn remove_duplicates_matches_a_per_tick_rescan_on_mixed_ticks() { - // The straightforward per-tick rescan the indexed version replaced, kept as an oracle. - fn reference(mut ctrls: Vec) -> Vec { - ctrls.sort_by_key(|c| c.tick); - let key = |k: CtrlKind| match k { - CtrlKind::Cc(n) => (0, n), - CtrlKind::PitchBend => (1, 0), - CtrlKind::ChannelPressure => (2, 0), - CtrlKind::PolyPressure(p) => (3, p), - CtrlKind::Program => (4, 0), - }; - let mut kept: Vec = Vec::new(); - for c in ctrls { - match kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && key(k.kind) == key(c.kind)) { - Some(slot) => *slot = c, - None => kept.push(c), - } - } - kept - } - - // Few ticks, channels and kinds, so the same key recurs both within a tick (interleaved with - // other keys) and across ticks; unsorted input from a fixed xorshift stream. - let mut state = 0x9E37_79B9_7F4A_7C15u64; - let mut next = move || { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - state - }; - let kinds = |r: u64| match r % 5 { - 0 => CtrlKind::Cc((r >> 8) as u8 % 4), - 1 => CtrlKind::PitchBend, - 2 => CtrlKind::ChannelPressure, - 3 => CtrlKind::PolyPressure((r >> 8) as u8 % 4), - _ => CtrlKind::Program, - }; - for round in 0..200 { - let mut ctrls = Vec::new(); - for i in 0..(next() % 64) { - let r = next(); - ctrls.push(CtrlEvent { tick: (r % 6) as i64 - 2, channel: (r >> 16) as u8 % 3, kind: kinds(r >> 24), value: (round * 100 + i) as i32 }); - } - let mut seq = Sequence { notes: Vec::new(), ctrls: ctrls.clone() }; - let removed = seq.remove_duplicates(); - let expected = reference(ctrls.clone()); - assert_eq!(seq.ctrls, expected, "round {round}"); - assert_eq!(removed, ctrls.len() - expected.len(), "round {round}"); - } -} diff --git a/crates/midi/tests/it/smf.rs b/crates/midi/tests/it/smf.rs deleted file mode 100644 index aa79ef2b..00000000 --- a/crates/midi/tests/it/smf.rs +++ /dev/null @@ -1,375 +0,0 @@ -use proptest::prelude::*; -use soundcraft_midi::*; - -fn vlq(mut v: u32) -> Vec { - let mut out = vec![(v & 0x7F) as u8]; - v >>= 7; - while v > 0 { - out.insert(0, (v & 0x7F) as u8 | 0x80); - v >>= 7; - } - out -} - -fn header(format: u16, ntrks: u16, division: u16) -> Vec { - let mut b = b"MThd".to_vec(); - b.extend_from_slice(&6u32.to_be_bytes()); - b.extend_from_slice(&format.to_be_bytes()); - b.extend_from_slice(&ntrks.to_be_bytes()); - b.extend_from_slice(&division.to_be_bytes()); - b -} - -fn track(body: &[u8]) -> Vec { - let mut b = b"MTrk".to_vec(); - b.extend_from_slice(&(body.len() as u32).to_be_bytes()); - b.extend_from_slice(body); - b -} - -fn file(format: u16, division: u16, bodies: &[Vec]) -> Vec { - let mut b = header(format, bodies.len() as u16, division); - for body in bodies { - b.extend(track(body)); - } - b -} - -fn note(pitch: u8, start: i64, length: i64) -> Note { - Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start, length } -} - -#[test] -fn round_trip() { - let mut seq = Sequence { - notes: vec![ - note(60, 0, 480), - Note { pitch: 64, velocity: 90, release_velocity: 30, channel: 3, start: 480, length: 960 }, - note(67, 960, 1), - note(60, 1920, 240), - ], - ctrls: vec![ - CtrlEvent { tick: 0, channel: 0, kind: CtrlKind::Program, value: 5 }, - CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 100 }, - CtrlEvent { tick: 20, channel: 1, kind: CtrlKind::PitchBend, value: -8192 }, - CtrlEvent { tick: 30, channel: 1, kind: CtrlKind::PitchBend, value: 8191 }, - CtrlEvent { tick: 40, channel: 1, kind: CtrlKind::PitchBend, value: 0 }, - CtrlEvent { tick: 50, channel: 2, kind: CtrlKind::ChannelPressure, value: 77 }, - CtrlEvent { tick: 60, channel: 2, kind: CtrlKind::PolyPressure(61), value: 12 }, - ], - }; - seq.sort(); - let smf = Smf { - format: 1, - tracks: vec![ - SmfTrack { name: "Piano".into(), sequence: seq.clone() }, - SmfTrack { name: "Bass ♪".into(), sequence: Sequence { notes: vec![note(36, 100, 50)], ctrls: vec![] } }, - ], - tempos: vec![(0, 120.0), (3840, 90.0)], - meters: vec![(0, 4, 4), (3840, 6, 8)], - markers: vec![(0, "Intro".into()), (7680, "Verse".into())], - key_sigs: vec![(0, -3, true), (3840, 2, false)], - }; - let bytes = write_smf(&smf).unwrap(); - let back = read_smf(&bytes).unwrap(); - assert_eq!(back.format, 1); - assert_eq!(back.tracks.len(), 2); - assert_eq!(back.tracks[0].name, "Piano"); - assert_eq!(back.tracks[0].sequence, seq); - assert_eq!(back.tracks[1].name, "Bass ♪"); - assert_eq!(back.tracks[1].sequence.notes, vec![note(36, 100, 50)]); - assert_eq!(back.tempos.len(), 2); - assert!((back.tempos[0].1 - 120.0).abs() < 1e-9); - assert!((back.tempos[1].1 - 90.0).abs() < 1e-3); - assert_eq!(back.tempos[1].0, 3840); - assert_eq!(back.meters, smf.meters); - assert_eq!(back.markers, smf.markers); - assert_eq!(back.key_sigs, smf.key_sigs); - // Writing again is byte-identical. - let mut again = back.clone(); - again.tempos = smf.tempos.clone(); - assert_eq!(write_smf(&again).unwrap(), bytes); -} - -#[test] -fn first_track_name_wins_and_ignored_meta_preserves_following_notes() { - let meta = |kind, data: &[u8]| [vec![0, 0xFF, kind], vlq(data.len() as u32), data.to_vec()].concat(); - for first in [&b"First"[..], &b""[..], &[0xFF][..]] { - let body = [ - meta(0x03, first), - meta(0x03, b"Second"), - meta(0x7F, &[0x03, 0xFF, 0x2F]), // Unhandled metadata must remain ignored. - vec![0, 0x90, 60, 100, 16, 0x80, 60, 64, 0, 0xFF, 0x2F, 0], - ] - .concat(); - let smf = read_smf(&file(0, 960, &[body])).unwrap(); - assert_eq!(smf.tracks.len(), 1); - assert_eq!(smf.tracks[0].name, String::from_utf8_lossy(first)); - assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, 16)]); - } -} - -#[test] -fn write_rejects_bad_values() { - let bad_tempo = Smf { tempos: vec![(0, 0.0)], ..Default::default() }; - assert!(write_smf(&bad_tempo).is_err()); - let nan_tempo = Smf { tempos: vec![(0, f64::NAN)], ..Default::default() }; - assert!(write_smf(&nan_tempo).is_err()); - let bad_meter = Smf { meters: vec![(0, 4, 3)], ..Default::default() }; - assert!(write_smf(&bad_meter).is_err()); - let huge_gap = Smf { - tracks: vec![SmfTrack { name: String::new(), sequence: Sequence { notes: vec![note(60, 0x1000_0000, 10)], ctrls: vec![] } }], - ..Default::default() - }; - assert!(write_smf(&huge_gap).is_err()); -} - -#[test] -fn division_conversion_480_to_960() { - // Note at tick 480 (one quarter at 480 PPQ), length 240. - let body = [vlq(480), vec![0x90, 60, 100], vlq(240), vec![0x80, 60, 0], vec![0, 0xFF, 0x2F, 0]].concat(); - let smf = read_smf(&file(0, 480, &[body])).unwrap(); - assert_eq!(smf.tracks.len(), 1); - assert_eq!(smf.tracks[0].sequence.notes, vec![Note { pitch: 60, velocity: 100, release_velocity: 0, channel: 0, start: 960, length: 480 }]); -} - -#[test] -fn division_conversion_96_and_smpte() { - let body = [vlq(96), vec![0x90, 60, 100], vlq(48), vec![0x80, 60, 64]].concat(); - let smf = read_smf(&file(0, 96, std::slice::from_ref(&body))).unwrap(); - assert_eq!(smf.tracks[0].sequence.notes[0].start, 960); - assert_eq!(smf.tracks[0].sequence.notes[0].length, 480); - - // SMPTE 25 fps, 40 ticks/frame = 1000 ticks/s; 96 ticks = 96 ms; at 120 bpm 0.5 s = 960 ticks. - let div = u16::from_be_bytes([(-25i8) as u8, 40]); - let body = [vlq(500), vec![0x90, 60, 100], vlq(250), vec![0x80, 60, 64]].concat(); - let smf = read_smf(&file(0, div, &[body])).unwrap(); - assert_eq!(smf.tracks[0].sequence.notes[0].start, 960); - assert_eq!(smf.tracks[0].sequence.notes[0].length, 480); -} - -#[test] -fn smpte_division_rejects_undefined_frame_rates() { - let body = vec![0, 0x90, 60, 100, 40, 0x80, 60, 64, 0, 0xFF, 0x2F, 0]; - for high in 0x80..=0xFF { - if [(-24i8) as u8, (-25i8) as u8, (-29i8) as u8, (-30i8) as u8].contains(&high) { - continue; - } - let division = u16::from_be_bytes([high, 40]); - assert!(matches!(read_smf(&file(0, division, std::slice::from_ref(&body))), Err(MidiError::Invalid { offset: 12, .. })), "{division:#06x}"); - } -} - -#[test] -fn smpte_division_keeps_all_four_standard_frame_rates() { - for (fps, file_ticks, model_ticks) in [(24i8, 960, 1920), (25, 1000, 1920), (29, 1200, 1922), (30, 1200, 1920)] { - let body = [vec![0, 0x90, 60, 100], vlq(file_ticks), vec![0x80, 60, 64, 0, 0xFF, 0x2F, 0]].concat(); - let division = u16::from_be_bytes([(-fps) as u8, 40]); - let smf = read_smf(&file(0, division, std::slice::from_ref(&body))).unwrap(); - assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, model_ticks)], "{fps} fps"); - assert!(read_smf(&file(0, u16::from_be_bytes([(-fps) as u8, 0]), std::slice::from_ref(&body))).is_err()); - } -} - -#[test] -fn running_status_and_velocity_zero_note_off() { - // Note-on with running status for both on and off (velocity 0). - let body = [ - vec![0x00, 0x91, 60, 100], // on C3 ch1 - vec![0x00, 64, 90], // running: on E3 - vlq(960), - vec![60, 0], // running: note-on vel 0 = off C3 - vec![0x00, 64, 0], - vec![0x00, 0xB1, 7, 100], // CC7 - vec![0x10, 10, 64], // running CC10 - ] - .concat(); - let smf = read_smf(&file(0, 960, &[body])).unwrap(); - let seq = &smf.tracks[0].sequence; - assert_eq!( - seq.notes, - vec![ - Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 1, start: 0, length: 960 }, - Note { pitch: 64, velocity: 90, release_velocity: 64, channel: 1, start: 0, length: 960 }, - ] - ); - assert_eq!( - seq.ctrls, - vec![ - CtrlEvent { tick: 960, channel: 1, kind: CtrlKind::Cc(7), value: 100 }, - CtrlEvent { tick: 976, channel: 1, kind: CtrlKind::Cc(10), value: 64 }, - ] - ); -} - -#[test] -fn meta_cancels_running_status() { - let body = [vec![0x00, 0x90, 60, 100], vec![0x00, 0xFF, 0x01, 0x01, b'x'], vec![0x00, 60, 0]].concat(); - assert!(read_smf(&file(0, 960, &[body])).is_err()); -} - -#[test] -fn unmatched_note_off_ignored_and_hanging_notes_closed() { - let body = [ - vec![0x00, 0x80, 50, 0], // unmatched off - vec![0x00, 0x90, 60, 100], // hanging - vlq(100), - vec![0x90, 62, 100], - vlq(100), - vec![0x80, 62, 10], - vlq(800), - vec![0xFF, 0x2F, 0x00], - ] - .concat(); - let smf = read_smf(&file(0, 960, &[body])).unwrap(); - let notes = &smf.tracks[0].sequence.notes; - assert_eq!(notes.len(), 2); - assert_eq!(notes[0], Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 1000 }); - assert_eq!(notes[1], Note { pitch: 62, velocity: 100, release_velocity: 10, channel: 0, start: 100, length: 100 }); -} - -#[test] -fn overlapping_same_pitch_fifo_and_zero_length() { - let body = [ - vec![0x00, 0x90, 60, 100], - vec![0x10, 0x90, 60, 80], - vec![0x10, 0x80, 60, 0], - vec![0x10, 0x80, 60, 0], - vec![0x00, 0x90, 70, 1], - vec![0x00, 0x80, 70, 0], - ] - .concat(); - let smf = read_smf(&file(0, 960, &[body])).unwrap(); - let notes = &smf.tracks[0].sequence.notes; - assert_eq!((notes[0].start, notes[0].length, notes[0].velocity), (0, 32, 100)); - assert_eq!((notes[1].start, notes[1].length, notes[1].velocity), (16, 32, 80)); - assert_eq!((notes[2].pitch, notes[2].length), (70, 1)); -} - -#[test] -fn format1_conductor_folded_and_unknown_chunks_skipped() { - let conductor = [ - vec![0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20], // 500000 us = 120 bpm - vec![0x00, 0xFF, 0x58, 0x04, 3, 2, 24, 8], // 3/4 - vec![0x00, 0xFF, 0x2F, 0x00], - ] - .concat(); - let t1 = [vec![0x00, 0xFF, 0x03, 0x03], b"Org".to_vec(), vec![0x00, 0x90, 60, 100, 0x60, 0x80, 60, 0]].concat(); - let mut bytes = header(1, 2, 960); - bytes.extend(track(&conductor)); - bytes.extend(b"XFIH\x00\x00\x00\x03abc"); - bytes.extend(track(&t1)); - let smf = read_smf(&bytes).unwrap(); - assert_eq!(smf.tracks.len(), 1); - assert_eq!(smf.tracks[0].name, "Org"); - assert_eq!(smf.tempos, vec![(0, 120.0)]); - assert_eq!(smf.meters, vec![(0, 3, 4)]); -} - -#[test] -fn sysex_skipped() { - let body = [vec![0x00, 0xF0, 0x03, 0x7E, 0x7F, 0xF7], vec![0x00, 0x90, 60, 100, 0x10, 0x80, 60, 0]].concat(); - let smf = read_smf(&file(0, 960, &[body])).unwrap(); - assert_eq!(smf.tracks[0].sequence.notes.len(), 1); -} - -#[test] -fn malformed_inputs_are_errors() { - assert!(matches!(read_smf(b""), Err(MidiError::NotSmf))); - assert!(matches!(read_smf(b"RIFF\x00\x00\x00\x06"), Err(MidiError::NotSmf))); - // Header truncated. - assert!(read_smf(b"MThd\x00\x00\x00\x06\x00\x01").is_err()); - // Header length < 6. - assert!(read_smf(b"MThd\x00\x00\x00\x02\x00\x01").is_err()); - // Division 0. - assert!(read_smf(&header(0, 0, 0)).is_err()); - // Format 3. - assert!(read_smf(&header(3, 0, 96)).is_err()); - // Chunk length larger than the data. - let mut b = header(0, 1, 96); - b.extend(b"MTrk\xFF\xFF\xFF\xF0\x00\x90"); - assert!(matches!(read_smf(&b), Err(MidiError::BadChunkLength { .. }))); - // Truncated chunk header. - let mut b = header(0, 1, 96); - b.extend(b"MTr"); - assert!(read_smf(&b).is_err()); - // Huge VLQ (5 continuation bytes). - let body = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x7F, 0x90, 60, 100]; - assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::VlqTooLong(_)))); - // Meta length past the end of the track. - let body = vec![0x00, 0xFF, 0x03, 0x7F, b'a']; - assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::Truncated(_)))); - // Truncated channel message. - let body = vec![0x00, 0x90, 60]; - assert!(read_smf(&file(0, 96, &[body])).is_err()); - // Data byte with no running status. - let body = vec![0x00, 60, 100]; - assert!(read_smf(&file(0, 96, &[body])).is_err()); - // System common inside a track. - let body = vec![0x00, 0xF2, 0, 0]; - assert!(read_smf(&file(0, 96, &[body])).is_err()); - // Header only (no tracks) is fine. - assert_eq!(read_smf(&header(1, 0, 96)).unwrap().tracks.len(), 0); -} - -#[test] -fn event_cap_reached_is_error() { - // MAX_EVENTS + 1 one-byte running-status program changes (2 bytes each with delta). - let mut body = vec![0x00, 0xC0, 0x00]; - body.reserve(MAX_EVENTS * 2); - for _ in 0..MAX_EVENTS { - body.extend_from_slice(&[0x00, 0x01]); - } - assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::TooManyEvents(_)))); -} - -proptest! { - #![proptest_config(ProptestConfig::with_cases(512))] - - #[test] - fn random_bytes_never_panic(data in proptest::collection::vec(any::(), 0..512)) { - let _ = read_smf(&data); - } - - #[test] - fn random_track_bodies_never_panic(body in proptest::collection::vec(any::(), 0..512), div in any::(), fmt in 0u16..4) { - let _ = read_smf(&file(fmt, div, &[body])); - } - - #[test] - fn written_files_read_back(notes in proptest::collection::vec((0u8..128, 1u8..128, 0u8..16, 0i64..100_000, 1i64..10_000), 0..64)) { - let mut seq = Sequence { - notes: notes.iter().map(|&(pitch, velocity, channel, start, length)| Note { pitch, velocity, release_velocity: 64, channel, start, length }).collect(), - ctrls: vec![], - }; - seq.remove_duplicates(); - let smf = Smf { format: 1, tracks: vec![SmfTrack { name: "t".into(), sequence: seq.clone() }], ..Default::default() }; - let back = read_smf(&write_smf(&smf).unwrap()).unwrap(); - // Overlapping same-pitch notes may re-pair, but count and starts survive. - let got = back.tracks.first().map(|t| t.sequence.notes.clone()).unwrap_or_default(); - prop_assert_eq!(got.len(), seq.notes.len()); - let mut a: Vec<_> = got.iter().map(|n| (n.start, n.pitch, n.channel)).collect(); - let mut b: Vec<_> = seq.notes.iter().map(|n| (n.start, n.pitch, n.channel)).collect(); - a.sort(); - b.sort(); - prop_assert_eq!(a, b); - } -} - -/// #472: out-of-range pitches, release velocities and controller numbers clamp to 127 instead of -/// wrapping into other notes and controllers (128 & 0x7F would be 0). -#[test] -fn writer_clamps_out_of_range_data_bytes() { - for raw in [127u8, 128, 255] { - let mut seq = Sequence::default(); - seq.notes.push(Note { pitch: raw, velocity: 100, release_velocity: raw, channel: 0, start: 0, length: 960 }); - seq.ctrls.push(CtrlEvent { tick: 0, channel: 0, kind: CtrlKind::Cc(raw), value: 64 }); - seq.ctrls.push(CtrlEvent { tick: 0, channel: 0, kind: CtrlKind::PolyPressure(raw), value: 64 }); - let smf = Smf { tracks: vec![SmfTrack { name: String::new(), sequence: seq }], ..Smf::default() }; - let back = read_smf(&write_smf(&smf).unwrap()).unwrap(); - let s = &back.tracks.last().unwrap().sequence; - assert_eq!((s.notes[0].pitch, s.notes[0].release_velocity), (127, 127), "input {raw}"); - let kinds: Vec = s.ctrls.iter().map(|c| c.kind).collect(); - assert!(kinds.contains(&CtrlKind::Cc(127)) && kinds.contains(&CtrlKind::PolyPressure(127)), "input {raw}: {kinds:?}"); - } -} diff --git a/crates/model/src/source.rs b/crates/model/src/source.rs index 8ce9a95b..7013d003 100644 --- a/crates/model/src/source.rs +++ b/crates/model/src/source.rs @@ -33,8 +33,13 @@ impl FileFormat { Container::MpegAudio => FileFormat::Mp3, Container::Ogg => FileFormat::Ogg, Container::Caf => FileFormat::Caf, - Container::Mp4 | Container::QuickTime if info.codec == CodecId::Alac => FileFormat::Alac, - Container::Mp4 | Container::QuickTime | Container::Adts => FileFormat::Aac, + Container::Adts => FileFormat::Aac, + // MP4 and QuickTime hold many codecs: the codec names the kind. + Container::Mp4 | Container::QuickTime => match info.codec { + CodecId::Alac => FileFormat::Alac, + CodecId::Aac | CodecId::HeAac => FileFormat::Aac, + _ => FileFormat::Other, + }, _ => FileFormat::Other, } } @@ -165,6 +170,27 @@ mod tests { } } + /// An MP4 is AAC only when its audio is: Opus, AC-3 or PCM in MP4 is not. + #[test] + fn an_mp4_is_named_by_its_codec() { + let wav = encode(&AudioBuffer::new(48_000, 1, 480), &EncodeOptions::default()).unwrap(); + let mut info = serde_json::to_value(probe(&wav, None).unwrap()).unwrap(); + for (container, codec, format) in [ + ("Mp4", "Aac", FileFormat::Aac), + ("Mp4", "HeAac", FileFormat::Aac), + ("QuickTime", "Alac", FileFormat::Alac), + ("Mp4", "Opus", FileFormat::Other), + ("Mp4", "Ac3", FileFormat::Other), + ("QuickTime", "Pcm", FileFormat::Other), + ("Adts", "Aac", FileFormat::Aac), + ] { + info["container"] = container.into(); + info["codec"] = codec.into(); + let info: craft_audio::AudioInfo = serde_json::from_value(info.clone()).unwrap(); + assert_eq!(FileFormat::of(&info), format, "{codec} in {container}"); + } + } + #[test] fn a_probed_file_maps_to_its_format() { let buf = AudioBuffer::new(48_000, 1, 480); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 507654f9..6ac446f9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -286,6 +286,15 @@ fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option< Some(text) } +/// The file a bounce in `format` writes for the path in the form: the path itself when its +/// extension already names the format (`Mix.aifc` and `Mix.AIFF` stay as picked, so the file +/// the save dialog confirmed is the one written), otherwise the path with the format's extension. +pub fn bounce_path(path: &str, format: EncodeFormat) -> std::path::PathBuf { + let p = std::path::Path::new(path); + let named = p.extension().and_then(std::ffi::OsStr::to_str).and_then(craft_audio::encode_format_for); + if named == Some(format) { p.to_path_buf() } else { p.with_extension(format.extension()) } +} + /// The Bounce form's file row: a path field and a Browse button that also picks the format. fn bounce_file_row(app: &mut SoundApp, ui: &mut egui::Ui, path: &mut String, format: &mut EncodeFormat) { ui.horizontal(|ui| { @@ -511,7 +520,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { .color(Tokens::current().text_dim), ); if buttons(ui, "Bounce", enter) { - let p = std::path::Path::new(path.as_str()).with_extension(format.extension()); + let p = bounce_path(path, *format); bounce_params = Some( json!({"path": p.to_string_lossy(), "format": format.extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}), ); diff --git a/crates/ui-egui/tests/it/bounce_dialog.rs b/crates/ui-egui/tests/it/bounce_dialog.rs index 6428fa68..df95281d 100644 --- a/crates/ui-egui/tests/it/bounce_dialog.rs +++ b/crates/ui-egui/tests/it/bounce_dialog.rs @@ -54,3 +54,20 @@ fn bounce_of_a_session_with_a_relative_path_starts_in_the_default_folder() { other => panic!("{other:?}"), } } + +/// A picked name that already says the format is written as picked: `Mix.aifc` is not turned +/// into `Mix.aif`, a file the save dialog never asked about. +#[test] +fn bounce_keeps_an_extension_that_names_the_format() { + use soundcraft_ui_egui::dialogs::bounce_path; + for (path, format, written) in [ + ("/Mixes/Mix.aifc", EncodeFormat::Aiff, "/Mixes/Mix.aifc"), + ("/Mixes/Mix.AIFF", EncodeFormat::Aiff, "/Mixes/Mix.AIFF"), + ("/Mixes/Mix.bwf", EncodeFormat::Wav, "/Mixes/Mix.bwf"), + ("/Mixes/Mix.aifc", EncodeFormat::Flac, "/Mixes/Mix.flac"), + ("/Mixes/Mix.wav", EncodeFormat::Aiff, "/Mixes/Mix.aif"), + ("/Mixes/Mix", EncodeFormat::Wav, "/Mixes/Mix.wav"), + ] { + assert_eq!(bounce_path(path, format), PathBuf::from(written), "{path} as {format:?}"); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index a9fac430..00f7ce72 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,7 @@ SoundCraft supplies each state's colors, border, radius and icon painter; select dispatch remain in the toolbar and track controls. The single-shape frame option preserves the existing fill/border blending when controls are disabled. Specialized transport Play/Stop/Record styling, nonlinear meters, faders, pan gestures, fonts and the live theme resolver remain local. Shared adapters read the current palette. The UI -requires Rust 1.95 or later without raising the core workspace's Rust 1.90 declaration. +requires Rust 1.95 or later, as does the workspace: the craft-libs crates the model and engine use declare it. ``` L0 time midi video (standalone: no workspace deps)