mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-12 08:53:41 -06:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a166a5d00 | ||
|
|
220b42773d | ||
|
|
6a0949d9d0 | ||
|
|
4782cfa600 | ||
|
|
91952090ec |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,3 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod ops;
|
||||
mod smf;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"}),
|
||||
);
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/**
|
||||
|
||||
Reference in New Issue
Block a user