Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 0a166a5d00 ci: verify the actions lockfile in CI
Fails the build when .github/workflows/actions.lock stops matching the
workflows, or when a locked pin no longer verifies upstream, so a stale or
tampered lockfile cannot go unnoticed between dependency updates.
2026-11-12 07:23:19 -05:00
Dylan M. Taylor 220b42773d ci: lock workflow actions through an actions.lock lockfile
Mutable action tags are a supply-chain risk: a moved tag runs different code
without a diff. Every workflow action is now pinned through
.github/workflows/actions.lock, and CI verifies the lockfile so an out-of-sync
or tampered one fails instead of running.
2026-11-12 07:18:10 -05:00
Dylan M. Taylor 6a0949d9d0 Review fixes after the craft-audio switch; MIDI files from craft-midi (#1513)
* Review fixes after the craft-audio switch; pin craft-audio at craft-libs main

Follow-up to #1506, for its review comments:

- convert: a decode that concealed damaged packets is reported as a warning,
  and --in-place refuses to replace the source with it.
- convert: the decoded speaker layout goes to the encoder, so 5.1 with side
  surrounds stays side surrounds in WAV and FLAC.
- FileFormat::of names an MP4 or QuickTime file by its codec: AAC or HE-AAC
  is Aac, ALAC is Alac, anything else (Opus, AC-3, PCM) is Other.
- Bounce keeps a picked extension that already names the format (Mix.aifc,
  Mix.AIFF), so the file written is the one the save dialog confirmed.
- rust-version is 1.95: craft-audio, craft-media and craft-ui declare it, so
  1.90 was not a version the workspace builds with.

craft-audio and craft-media move to craft-libs main 7db4645d.

* MIDI: take Standard MIDI Files and the sequence model from craft-midi

craft-midi in craft-libs was moved from this crate (storytold/craft-libs#884):
smf.rs, sequence.rs, the error type and their tests. soundcraft-midi now
re-exports them, so every caller keeps its imports, and keeps what is
SoundCraft's own: the editing operations, the display names and XorShift.

The SMF and Sequence tests are deleted here; they run in craft-midi, which
also adds one for the track limit. The serde names do not change (the same
derives behind craft-midi's serde feature), so sessions read as before.
2026-11-11 19:51:41 -05:00
Dylan M. Taylor 4782cfa600 chore: bump to 0.20.1 for the Flatpak updater patch release (#1512) 2026-11-11 19:16:01 -05:00
Dylan M. Taylor 91952090ec Take audio file formats from craft-audio; remove crates/audio-io (#1506)
craft-audio in craft-libs was built from audio-io and FilmCraft's codecs, so
each format has one implementation that every Craft app shares. Call sites use
its API directly: EncodeOptions::new(format).with_*(), EncodeFormat,
DECODE_EXTENSIONS, ENCODE_EXTENSIONS, encode_format_for, AudioInfo's container,
codec and layout.

soundcraft_model::FileFormat stays as the kind of file a session records for
each source: its variant names are part of the session format. It maps from
craft-audio's AudioInfo.

craft-audio and craft-media are pinned to the craft-libs branch of
storytold/craft-libs#824, which adds the extension tables SoundCraft had in
FileFormat and fixes the quadratic WAV chunk scan (#893).

Behaviour that changes: AAC, ALAC, Opus, AC-3/E-AC-3 and MP2 decode with
craft-audio's own decoders; MP4 and Matroska tracks import at their
presentation length; .opus, .ac3, .eac3, .ec3, .mov, .mka and .mpa are
importable names; .aifc is a writable name; FLAC files come from craft-audio's
encoder.
2026-11-11 18:46:56 -05:00
22 changed files with 244 additions and 1329 deletions
+32
View File
@@ -0,0 +1,32 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/ast-grep.yml': []
'.github/workflows/ci.yml': []
'.github/workflows/freebsd.yml': []
'.github/workflows/house-rules.yml': []
'.github/workflows/lockfile.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/nix.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/packaging-lint.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/release.yml': []
'.github/workflows/sonarcloud.yml': []
'.github/workflows/windows-arm64.yml': []
dependencies:
'actions/checkout@v7.0.1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'step-security/harden-runner@v2.22.4':
ref: 'v2.22.4'
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
owner_id: 88700172
repo_id: 422287306
+34
View File
@@ -0,0 +1,34 @@
name: Lockfile
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
# Docs: https://gh.io/actions-lockfile
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
env:
GH_TOKEN: ${{ github.token }}
jobs:
lockfile:
name: Lockfile is in sync
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Install gh-actions-lock
run: gh extension install github/gh-actions-lock --pin v0.4.1
- name: Verify the lockfile
run: gh actions-lock --verify
+2 -2
View File
@@ -28,10 +28,10 @@ jobs:
timeout-minutes: 5
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
+2 -2
View File
@@ -28,10 +28,10 @@ jobs:
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
Generated
+29 -22
View File
@@ -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",
@@ -4344,7 +4353,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"chrono",
"clap",
@@ -4368,7 +4377,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"log",
"serde",
@@ -4378,7 +4387,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"clap",
"craft-audio",
@@ -4391,7 +4400,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4404,7 +4413,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"clap",
"clap_complete",
@@ -4422,7 +4431,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"log",
"proptest",
@@ -4433,7 +4442,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"craft-docpath",
@@ -4457,17 +4466,15 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-midi",
"proptest",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "soundcraft-mix"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"log",
@@ -4483,7 +4490,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"proptest",
@@ -4496,7 +4503,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cpal",
"craft-audio",
@@ -4511,7 +4518,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"proptest",
"serde",
@@ -4521,7 +4528,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"craft-ui",
@@ -4551,7 +4558,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-media",
"image",
@@ -4563,7 +4570,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4579,7 +4586,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"eframe",
@@ -6227,7 +6234,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"serde_json",
]
+6 -4
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.20.0"
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"] }
+1 -2
View File
@@ -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
+14
View File
@@ -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
+2
View File
@@ -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 },
}
+52
View File
@@ -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<u8> {
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);
}
+1 -3
View File
@@ -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
+4 -34
View File
@@ -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),
}
-141
View File
@@ -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<Note>,
/// Controller events, sorted by tick after [`Sequence::sort`].
pub ctrls: Vec<CtrlEvent>,
}
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<Item = &Note> + '_ {
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<Item = &Note> + '_ {
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<CtrlEvent> = 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())
}
}
-635
View File
@@ -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<SmfTrack>,
/// `(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<u8, MidiError> {
let b = *self.data.get(self.pos).ok_or(MidiError::Truncated(self.at()))?;
self.pos += 1;
Ok(b)
}
fn peek(&self) -> Result<u8, MidiError> {
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<u16, MidiError> {
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<u32, MidiError> {
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<u32, MidiError> {
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<Self, MidiError> {
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<Smf, MidiError> {
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<VecDeque<(i64, u8)>>;
fn close_note(open: &mut OpenNotes, notes: &mut Vec<Note>, 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<SmfTrack, MidiError> {
let mut cur = Cursor { data: body, pos: 0, base };
let mut name: Option<String> = 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<u8> = 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<u8>, 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<u8>,
}
fn clamp_tick(t: i64) -> u64 {
u64::try_from(t.max(0)).unwrap_or(0)
}
fn meta(kind: u8, data: &[u8]) -> Result<Vec<u8>, 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<u8>, mut events: Vec<WEvent>) -> 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<u8> = 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<Vec<WEvent>, 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<usize> = (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<usize> = 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<Vec<u8>, 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<u64> = 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)]);
}
}
-1
View File
@@ -1,4 +1,3 @@
//! Integration tests, built as one test binary: each file is a module.
mod ops;
mod smf;
-104
View File
@@ -187,107 +187,3 @@ fn split_by_pitch() {
assert_eq!(inside.iter().map(|n| n.pitch).collect::<Vec<_>>(), vec![60, 48]);
assert_eq!(outside.iter().map(|n| n.pitch).collect::<Vec<_>>(), 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<_>>(), 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::<Sequence>(&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<CtrlEvent>) -> Vec<CtrlEvent> {
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<CtrlEvent> = 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}");
}
}
-375
View File
@@ -1,375 +0,0 @@
use proptest::prelude::*;
use soundcraft_midi::*;
fn vlq(mut v: u32) -> Vec<u8> {
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<u8> {
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<u8> {
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<u8>]) -> Vec<u8> {
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::<u8>(), 0..512)) {
let _ = read_smf(&data);
}
#[test]
fn random_track_bodies_never_panic(body in proptest::collection::vec(any::<u8>(), 0..512), div in any::<u16>(), 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<CtrlKind> = s.ctrls.iter().map(|c| c.kind).collect();
assert!(kinds.contains(&CtrlKind::Cc(127)) && kinds.contains(&CtrlKind::PolyPressure(127)), "input {raw}: {kinds:?}");
}
}
+28 -2
View File
@@ -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);
+10 -1
View File
@@ -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"}),
);
+17
View File
@@ -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:?}");
}
}
+1 -1
View File
@@ -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)
+9
View File
@@ -1,2 +1,11 @@
sonar.projectKey=storytold_soundcraft
sonar.organization=storytold
# githubactions:S7637 asks for every action to be pinned to a commit SHA. A workflow covered by
# .github/workflows/actions.lock runs the commit the lockfile records, so its tag refs are pinned
# and the lockfile covers the actions those actions use too. Sonar does not read lockfiles yet, so
# the rule flags the tag refs anyway:
# https://community.sonarsource.com/t/githubactions-s7637-false-positives-for-actions-pinned-through-native-actions-lock/190814
sonar.issue.ignore.multicriteria=gha-lockfile
sonar.issue.ignore.multicriteria.gha-lockfile.ruleKey=githubactions:S7637
sonar.issue.ignore.multicriteria.gha-lockfile.resourceKey=.github/workflows/**