Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 04878f8fda Release 0.19.0 2026-11-09 06:33:34 -05:00
32 changed files with 146 additions and 1418 deletions
-4
View File
@@ -27,10 +27,6 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
-4
View File
@@ -27,10 +27,6 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
Generated
+24 -26
View File
@@ -1064,17 +1064,17 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1097,7 +1097,7 @@ dependencies = [
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"thiserror 2.0.21",
]
@@ -1105,7 +1105,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1126,7 +1126,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"chrono",
"dunce",
@@ -1136,7 +1136,6 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -4331,7 +4330,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"chrono",
"clap",
@@ -4355,7 +4354,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"log",
"serde",
@@ -4365,7 +4364,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"log",
"proptest",
@@ -4377,7 +4376,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"clap",
"log",
@@ -4390,7 +4389,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4403,7 +4402,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"clap",
"clap_complete",
@@ -4421,7 +4420,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"log",
"proptest",
@@ -4432,7 +4431,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"craft-docpath",
"log",
@@ -4450,13 +4449,12 @@ dependencies = [
"soundcraft-video",
"soundcraft-vst3-host",
"thiserror 2.0.21",
"winsafe",
"zip",
]
[[package]]
name = "soundcraft-midi"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"proptest",
"serde",
@@ -4466,7 +4464,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"log",
"rayon",
@@ -4482,7 +4480,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"proptest",
"serde",
@@ -4495,7 +4493,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"cpal",
"log",
@@ -4510,7 +4508,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"proptest",
"serde",
@@ -4520,7 +4518,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -4550,7 +4548,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"craft-media",
"image",
@@ -4562,7 +4560,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4578,7 +4576,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"eframe",
"egui",
@@ -6226,7 +6224,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.19.1"
version = "0.19.0"
dependencies = [
"serde_json",
]
+5 -5
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.19.1"
version = "0.19.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -50,15 +50,15 @@ zip = { version = "9", default-features = false }
cpal = "0.18"
# Which file paths stored in a session may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
# 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"] }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["serde"] }
egui = { version = "0.37" }
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
egui_extras = { version = "0.37", default-features = false }
@@ -310,36 +310,6 @@ fn links_out_of_the_roots_are_refused() {
ok(&mut via_alias, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
}
#[cfg(any(unix, windows))]
#[test]
fn exports_refuse_hard_links_without_changing_either_name() {
let f = fixture("hard-links");
let midi = f.write.join("linked.mid");
let preset = f.write.join("linked.json");
std::fs::hard_link(f.outside.join("secret.mid"), &midi).unwrap();
std::fs::hard_link(f.outside.join("secret.json"), &preset).unwrap();
let mut srv = demo_server(Some(&f.read), Some(&f.write));
let before = listing(&f.write);
// Refusal is the safe alternative allowed by #1359: neither the outside inode nor its
// in-root alias may be truncated, and no replacement or temporary file may be left behind.
refused(&mut srv, "file.export_midi", json!({"path": s(&midi)}), "hard-linked");
refused(&mut srv, "track.save_preset", json!({"path": s(&preset), "tracks": ["Kick"]}), "hard-linked");
for path in [&midi, &preset, &f.outside.join("secret.mid"), &f.outside.join("secret.json")] {
assert_eq!(std::fs::read(path).unwrap(), SECRET.as_bytes());
}
assert_eq!(listing(&f.write), before);
// The error offers a new export path; both commands must still work at ordinary paths.
let fresh_midi = f.write.join("fresh.mid");
ok(&mut srv, "file.export_midi", json!({"path": s(&fresh_midi)}));
assert!(std::fs::read(fresh_midi).unwrap().starts_with(b"MThd"));
let fresh_preset = f.write.join("fresh.json");
ok(&mut srv, "track.save_preset", json!({"path": s(&fresh_preset), "tracks": ["Kick"]}));
let preset_json: Value = serde_json::from_slice(&std::fs::read(fresh_preset).unwrap()).unwrap();
assert_eq!(preset_json["name"], "Kick");
}
/// A session file is hostile input too: media it names outside the read root is not loaded,
/// and the refusal is listed with the other missing media.
#[test]
+16 -209
View File
@@ -10,10 +10,6 @@ use std::sync::atomic::Ordering;
const MAX_BLOCK: usize = 1 << 16;
/// Most channels a host strip may ask for.
const MAX_CHANNELS: usize = 64;
/// Queue slots that only releases from `note_off` may take: one per key, so a queue that
/// parameter changes, note-ons or other MIDI filled still has room to release every held key on
/// a reset (#1464).
const RELEASE_RESERVE: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq)]
enum NoteMode {
@@ -83,104 +79,14 @@ impl ClapPlugin {
self.queue.retain(|e| !matches!(e, Event::Param { .. }));
}
/// Queues an event other than a note release; see [`push_event`].
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
/// allocates on the audio thread).
fn push(&mut self, e: Event) {
push_event(&mut self.queue, e, false);
}
}
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
/// allocates on the audio thread). Only a `release` may take the last [`RELEASE_RESERVE`] slots.
/// Returns whether the event was queued.
fn push_event(queue: &mut Vec<Event>, e: Event, release: bool) -> bool {
let room = if release { queue.capacity() } else { queue.capacity().saturating_sub(RELEASE_RESERVE) };
if queue.len() >= room {
return false;
}
let pos = queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
queue.insert(pos, e);
true
}
/// Queues the release of `key` at `time`. The key stays held when the queue has no room, so a
/// later reset still releases it (#1464).
fn queue_release(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode, time: u32, key: u8) {
if let Some(e) = release_event(notes, time, key)
&& push_event(queue, e, true)
&& let Some(h) = held.get_mut(usize::from(key))
{
*h = false;
}
}
/// The release of `key` in the note port's dialect (`None` without a note port).
fn release_event(notes: NoteMode, time: u32, key: u8) -> Option<Event> {
match notes {
NoteMode::Clap => Some(Event::NoteOff { time, key }),
NoteMode::Midi => Some(Event::Midi { time, data: [0x80, key, 0] }),
NoteMode::None => None,
}
}
/// The queue after a reset: [`keep_releases`], then a release at the start of the next block for
/// every key still `held` that has none yet, so each key is released once. Clears `held`.
///
/// It never runs out of room: events other than `note_off` releases fill at most
/// `capacity - RELEASE_RESERVE` slots, and the releases left are one per key.
fn release_on_reset(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode) {
keep_releases(queue);
for e in queue.iter() {
let key = match *e {
Event::NoteOff { key, .. } => Some(key),
// Channel 1, where `note_off` sends them.
Event::Midi { data: [status, key, velocity], .. } if status & 0x0F == 0 && is_midi_note_off(status, velocity) => Some(key),
_ => None,
};
if let Some(h) = key.and_then(|k| held.get_mut(usize::from(k))) {
*h = false;
if self.queue.len() >= self.queue.capacity() {
return;
}
}
for key in 0..128u8 {
if held.get(usize::from(key)).copied().unwrap_or(false)
&& let Some(e) = release_event(notes, 0, key)
{
let _ = push_event(queue, e, true);
}
}
*held = [false; 128];
}
/// What a reset keeps of the pending events: parameter changes, and the releases (CLAP note-offs
/// and MIDI note-offs, one per channel and key), all moved to the start of the next block. Note-ons
/// and other MIDI belong to the position being left. One release per key leaves room in the queue
/// for `all_notes_off`.
fn keep_releases(queue: &mut Vec<Event>) {
let mut clap_off = [false; 128];
let mut midi_off = [[false; 128]; 16];
queue.retain(|e| match *e {
Event::Param { .. } => true,
Event::NoteOff { key, .. } => first_release(clap_off.get_mut(usize::from(key))),
Event::Midi { data: [status, key, velocity], .. } if is_midi_note_off(status, velocity) => {
first_release(midi_off.get_mut(usize::from(status & 0x0F)).and_then(|c| c.get_mut(usize::from(key))))
}
_ => false,
});
for e in queue.iter_mut() {
e.set_time(0);
}
}
/// Marks a key's release as seen; true only the first time (false for a key out of range).
fn first_release(seen: Option<&mut bool>) -> bool {
seen.is_some_and(|s| !std::mem::replace(s, true))
}
/// A MIDI 1.0 note-off: status 0x8n, or a note-on (0x9n) with velocity 0.
fn is_midi_note_off(status: u8, velocity: u8) -> bool {
match status & 0xF0 {
0x80 => true,
0x90 => velocity == 0,
_ => false,
let pos = self.queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
self.queue.insert(pos, e);
}
}
@@ -279,10 +185,8 @@ impl Plugin for ClapPlugin {
fn reset(&mut self) {
self.inst.reset();
// Keep pending releases and release whatever is still held: CLAP asks `reset` to kill
// voices, but some plugins' doesn't (Surge XT's), and they would sound on with nothing
// left to release them (#1464).
release_on_reset(&mut self.queue, &mut self.held, self.notes);
self.queue.retain(|e| matches!(e, Event::Param { .. }));
self.held = [false; 128];
}
fn set_param(&mut self, id: &str, value: f32) -> bool {
@@ -383,7 +287,14 @@ impl Plugin for ClapPlugin {
return;
}
let time = u32::try_from(offset).unwrap_or(u32::MAX);
queue_release(&mut self.queue, &mut self.held, self.notes, time, note);
match self.notes {
NoteMode::Clap => self.push(Event::NoteOff { time, key: note }),
NoteMode::Midi => self.push(Event::Midi { time, data: [0x80, note, 0] }),
NoteMode::None => return,
}
if let Some(h) = self.held.get_mut(note as usize) {
*h = false;
}
}
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
@@ -472,107 +383,3 @@ impl PluginEditor for ClapEditor {
self.link.host().dirty.swap(false, Ordering::Relaxed)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A reset keeps parameter changes and one release per key, at the start of the next block,
/// and drops note-ons and other MIDI.
#[test]
fn reset_keeps_params_and_one_release_per_key() {
let mut q = vec![
Event::Param { time: 0, id: 4, value: 0.5 },
Event::NoteOn { time: 3, key: 60, velocity: 1.0 },
Event::NoteOff { time: 5, key: 60 },
Event::NoteOff { time: 9, key: 60 },
Event::NoteOff { time: 12, key: 64 },
Event::Midi { time: 20, data: [0x90, 62, 90] },
Event::Midi { time: 21, data: [0xB0, 64, 127] },
Event::Midi { time: 22, data: [0x80, 62, 0] },
Event::Midi { time: 23, data: [0x80, 62, 40] },
Event::Midi { time: 24, data: [0x83, 62, 0] },
Event::Midi { time: 25, data: [0x91, 67, 0] },
Event::Midi { time: 26, data: [0x80, 200, 0] },
];
keep_releases(&mut q);
assert_eq!(
q,
vec![
Event::Param { time: 0, id: 4, value: 0.5 },
Event::NoteOff { time: 0, key: 60 },
Event::NoteOff { time: 0, key: 64 },
Event::Midi { time: 0, data: [0x80, 62, 0] },
Event::Midi { time: 0, data: [0x83, 62, 0] },
Event::Midi { time: 0, data: [0x91, 67, 0] },
]
);
}
/// A queue that parameter changes filled still releases every held key on a reset, once
/// each, and keeps the parameter changes.
#[test]
fn a_full_queue_still_releases_every_held_key_on_reset() {
for notes in [NoteMode::Clap, NoteMode::Midi] {
let mut q = Vec::with_capacity(EVENT_CAP);
for id in 0..EVENT_CAP as u32 {
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
}
assert_eq!(q.len(), EVENT_CAP - RELEASE_RESERVE, "parameter changes leave the reserve free");
// Keys 0..4 already have a release queued, and are held again after it.
for key in 0..4u8 {
let _ = push_event(&mut q, release_event(notes, 7, key).unwrap(), true);
}
let mut held = [true; 128];
release_on_reset(&mut q, &mut held, notes);
assert_eq!(held, [false; 128]);
assert_eq!(q.iter().filter(|e| matches!(e, Event::Param { .. })).count(), EVENT_CAP - RELEASE_RESERVE, "{notes:?}");
for key in 0..128u8 {
let want = release_event(notes, 0, key).unwrap();
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
}
assert_eq!(q.len(), EVENT_CAP);
}
}
/// A release that finds the queue full (parameter changes plus duplicate releases) leaves its
/// key held, so the reset that follows still releases it, as the mix's `all_notes_off`
/// then `reset` does on Stop.
#[test]
fn a_release_the_full_queue_drops_is_sent_by_the_next_reset() {
for notes in [NoteMode::Clap, NoteMode::Midi] {
let mut q = Vec::with_capacity(EVENT_CAP);
let mut held = [false; 128];
for id in 0..EVENT_CAP as u32 {
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
}
// Key 0 pressed and released over and over fills the reserve with its releases.
for t in 0..RELEASE_RESERVE as u32 {
held[0] = true;
queue_release(&mut q, &mut held, notes, t, 0);
}
assert_eq!(q.len(), EVENT_CAP, "{notes:?}: the queue is full");
held[5] = true;
queue_release(&mut q, &mut held, notes, 0, 5);
assert!(held[5], "{notes:?}: a release that was not queued leaves its key held");
release_on_reset(&mut q, &mut held, notes);
for key in [0u8, 5] {
let want = release_event(notes, 0, key).unwrap();
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
}
assert_eq!(held, [false; 128]);
}
}
/// A queue full of releases still has room to release every held key after a reset.
#[test]
fn reset_keeps_room_to_release_every_held_key() {
let mut q = Vec::with_capacity(EVENT_CAP);
for i in 0..EVENT_CAP {
q.push(Event::NoteOff { time: i as u32, key: (i % 4) as u8 });
}
keep_releases(&mut q);
let want: Vec<Event> = (0..4u8).map(|key| Event::NoteOff { time: 0, key }).collect();
assert_eq!(q, want, "one release per key remains");
}
}
+4 -4
View File
@@ -4,7 +4,7 @@
//! (stepped enum A/B/C), hidden param 9, latency 3 samples.
//! - `org.soundcraft.test.synth`: instrument, stereo out, CLAP note port; outputs a constant equal
//! to the velocity of the held note (0 when none), switching at the event's sample offset. A
//! MIDI CC 7 (volume) sets the constant to its value / 127. `reset` leaves the note sounding.
//! MIDI CC 7 (volume) sets the constant to its value / 127.
//!
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
@@ -166,9 +166,9 @@ unsafe extern "C" fn stop_processing(p: *const clap_plugin) {
unsafe { fx(p) }.processing = false;
}
/// Leaves the synth's note sounding. CLAP asks `reset` to kill voices, but some synths' doesn't
/// (Surge XT's), so the host must send the releases itself.
unsafe extern "C" fn reset(_p: *const clap_plugin) {}
unsafe extern "C" fn reset(p: *const clap_plugin) {
unsafe { fx(p) }.level = 0.0;
}
fn apply(f: &mut Fx, h: &clap_event_header) {
if h.space_id != CLAP_CORE_EVENT_SPACE_ID {
-42
View File
@@ -109,48 +109,6 @@ fn synth_plays_notes_at_their_offsets() {
assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset");
}
/// Renders one 64-frame stereo block of `p` and returns its left channel.
fn render_block(p: &mut dyn soundcraft_dsp::Plugin) -> Vec<f32> {
let mut io = vec![vec![0.0f32; 64], vec![0.0f32; 64]];
p.process(&mut io, 64);
io.swap_remove(0)
}
/// Stop, Play and Locate run `all_notes_off` then `reset`: the releases reach a synth whose own
/// reset leaves its voices sounding, and a later block is silent (#1464).
#[test]
fn reset_delivers_the_releases_of_held_notes() {
fixture_dir();
let mut p = soundcraft_clap_host::create(SYNTH).unwrap();
p.prepare(48_000.0, 64, 2);
let silent = |b: &[f32]| b.iter().all(|x| x.abs() < 1e-6);
// As the mix engine does on Stop: release the held note, then reset.
p.note_on(0, 60, 127);
assert!(render_block(p.as_mut()).iter().all(|&x| (x - 1.0).abs() < 1e-6), "the note sounds");
p.all_notes_off();
p.reset();
assert!(silent(&render_block(p.as_mut())), "all_notes_off's release was dropped by reset");
// A reset alone releases what is still held.
p.note_on(0, 61, 127);
render_block(p.as_mut());
p.reset();
assert!(silent(&render_block(p.as_mut())), "reset did not release the held note");
// A release queued for a block that never ran is still delivered.
p.note_on(0, 62, 127);
render_block(p.as_mut());
p.note_off(30, 62);
p.reset();
assert!(silent(&render_block(p.as_mut())), "the pending release was dropped by reset");
// A note-on queued for the position being left is dropped, not played after the reset.
p.note_on(10, 63, 127);
p.reset();
assert!(silent(&render_block(p.as_mut())), "a note-on from before the reset sounded");
}
#[test]
fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
fixture_dir();
-3
View File
@@ -25,9 +25,6 @@ log.workspace = true
zip.workspace = true
craft-docpath.workspace = true
[target.'cfg(windows)'.dependencies]
winsafe = { version = "0.0.29", features = ["kernel"] }
[dev-dependencies]
proptest.workspace = true
+1 -1
View File
@@ -103,7 +103,7 @@ fn decode_group_audio(
let bytes = crate::io::read_file(&full)?;
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
let buf = crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES)
.map_err(|r| EngineError::Io(format!("{rel}: {}", r.message("too large to import"))))?;
.ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?;
decoded.push((rel.clone(), buf));
index.insert(key, decoded.len() - 1);
decoded.len() - 1
-3
View File
@@ -154,7 +154,6 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
for t in ids {
crate::edit::separate_all_at(s, t, r.start);
crate::edit::insert_silence(s, t, r);
if let Some(tr) = s.track_mut(t) {
for l in &mut tr.automation {
@@ -186,8 +185,6 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
for t in ids {
crate::edit::separate_all_at(s, t, r.start);
crate::edit::separate_all_at(s, t, r.end);
crate::edit::clear_range(s, t, r, false);
crate::edit::shift_from(s, t, r.end, -r.len());
if let Some(tr) = s.track_mut(t) {
+3 -8
View File
@@ -94,14 +94,9 @@ pub fn specs() -> Vec<CommandSpec> {
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
let r = range_param(e, "file.bounce_stems", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let format_name = match p.get("format") {
None => "wav",
Some(value) => value.as_str().ok_or_else(|| bad("file.bounce_stems", "`format` must be a string"))?,
};
let format = soundcraft_audio_io::FileFormat::from_write_extension(format_name).ok_or_else(|| {
let supported = soundcraft_audio_io::FileFormat::writable_extensions().join(", ");
bad("file.bounce_stems", format!("cannot write `{format_name}` files (supported: {supported})"))
})?;
let format = str_param(p, "format")
.and_then(soundcraft_audio_io::FileFormat::from_write_extension)
.unwrap_or(soundcraft_audio_io::FileFormat::Wav);
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
-65
View File
@@ -118,71 +118,6 @@ fn import_audio_honours_new_tracks() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn bounce_stems_rejects_unsupported_formats_before_writing() {
let dir = std::env::temp_dir().join(format!("sc-stems-unsupported-format-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir(&dir).unwrap();
let stems = dir.join("stems");
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "not-a-format", "start": 0, "end": 1}));
let created_output = stems.exists();
// Own this directory exclusively; remove any baseline output before the assertion can fail.
std::fs::remove_dir_all(&dir).unwrap();
let err = result.map(|_| ()).unwrap_err();
assert!(err.to_string().contains("cannot write `not-a-format`"), "{err}");
assert!(err.to_string().contains("supported: wav"), "{err}");
assert!(!created_output, "an unsupported format must not create the output directory");
}
#[test]
fn bounce_stems_rejects_non_string_formats_before_writing() {
let dir = std::env::temp_dir().join(format!("sc-stems-format-type-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir(&dir).unwrap();
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
let results: Vec<_> = [json!(null), json!(123), json!(false), json!([]), json!({})]
.into_iter()
.enumerate()
.map(|(index, format)| {
let stems = dir.join(index.to_string());
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": format, "start": 0, "end": 1}));
(result, stems.exists())
})
.collect();
std::fs::remove_dir_all(&dir).unwrap();
for (result, created_output) in results {
let err = result.map(|_| ()).unwrap_err();
assert!(err.to_string().contains("`format` must be a string"), "{err}");
assert!(!created_output, "a non-string format must not create the output directory");
}
}
#[test]
fn bounce_stems_accepts_explicit_wav_format() {
let dir = std::env::temp_dir().join(format!("sc-stems-explicit-wav-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir(&dir).unwrap();
let mut e = Engine::default();
let wav = tone_wav(&dir, "tone.wav");
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
let stems = dir.join("stems");
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "wav", "start": 0, "end": 32, "dither": false})).unwrap();
let files = result["files"].as_array().unwrap();
assert_eq!(files.len(), 1);
let path = std::path::Path::new(files[0].as_str().unwrap());
assert_eq!(path.parent(), Some(stems.as_path()));
assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("wav"));
let (info, audio) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap();
assert_eq!(info.format, soundcraft_audio_io::FileFormat::Wav);
assert_eq!(audio.frames(), 32);
assert_eq!(audio.num_channels(), e.session().main_format().channels());
assert!(audio.channels.iter().flatten().any(|sample| sample.abs() > 0.01));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn bounce_refuses_formats_it_cannot_write() {
let mut e = Engine::default();
+1 -1
View File
@@ -75,7 +75,7 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path:
return Err(EngineError::Io(format!("{name}: no audio")));
}
let buf = crate::io::at_rate(buf, e.session().sample_rate.hz(), MAX_SAMPLE_VALUES)
.map_err(|r| EngineError::Io(format!("{name}: {}", r.message("too long for a sample (ten minutes of 192 kHz stereo at most)"))))?;
.ok_or_else(|| EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")))?;
let channels = buf.num_channels();
let s = e.session_mut();
let src = crate::io::add_source(s, name, buf, path, format.format);
+4 -26
View File
@@ -78,9 +78,9 @@ fn rebase_midi_start(clip: &mut Clip, old_start: Samples, t: TimeCtx<'_>) {
}
}
/// Split every clip on a track's active playlist that spans `at`, except time- or edit-locked ones.
/// The piece before `at` keeps the clip's name. The piece after it is a new clip, and takes the next
/// name of the clip's Auto Rename scheme when it has one.
/// Split every clip on a track's active playlist that spans `at`. The piece before `at` keeps the
/// clip's name. The piece after it is a new clip, and takes the next name of the clip's Auto Rename
/// scheme when it has one.
pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
separate_at_if(s, track, at, |_| true)
}
@@ -88,21 +88,11 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
/// [`separate_at`], giving the piece after `at` the next Auto Rename name only when `auto_name`
/// returns true for it. Any other piece keeps the clip's name, and its scheme does not count up.
pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool) -> usize {
split_at(s, track, at, auto_name, true)
}
/// [`separate_at`], splitting time- and edit-locked clips too. For the Event time operations, which
/// move the whole timeline, locked clips included.
pub fn separate_all_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
split_at(s, track, at, |_| true, false)
}
fn split_at(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool, skip_locked: bool) -> usize {
let tc_tempo = s.tempo.clone();
let sr = s.sample_rate;
let t = TimeCtx { tempo: &tc_tempo, sr };
let mut ids = Vec::new();
let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end() && !(skip_locked && locked(c))).count());
let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end()).count());
for _ in 0..n_split {
ids.push(s.new_clip_id());
}
@@ -116,7 +106,6 @@ fn split_at(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Cl
for c in pl.clips.drain(..) {
if at > c.start
&& at < c.end()
&& !(skip_locked && locked(&c))
&& let Some(id) = ids.pop()
&& let Some((l, mut r)) = split_clip(&c, at, id, t)
{
@@ -438,10 +427,6 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
let mut n = 0;
for c in &mut pl.clips {
// Locked clips keep their extent, like they don't move for nudge.
if locked(c) {
continue;
}
if let Some(i) = c.range().intersect(&range)
&& i != c.range()
{
@@ -463,9 +448,6 @@ pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
let mut n = 0;
for c in &mut pl.clips {
if locked(c) {
continue;
}
if c.range().contains(at) && at > c.start {
let old = c.start;
c.trim_start_to(at);
@@ -476,14 +458,10 @@ pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
n
}
/// Trim the end of clips after `at` to `at` (Trim End to Insertion).
pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
let mut n = 0;
for c in &mut pl.clips {
if locked(c) {
continue;
}
if at > c.start && at < c.end() {
c.trim_end_to(at);
n += 1;
+1 -251
View File
@@ -891,99 +891,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
return deliver(name, bytes).map_err(EngineError::Io);
}
crate::file_access::check_write(path).map_err(EngineError::Io)?;
// Follow existing symlinks, and refuse dangling links even without automation roots.
let named = Path::new(path);
let path = match std::fs::symlink_metadata(named) {
Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(named).map_err(|err| EngineError::Io(format!("{path}: {err}")))?,
Ok(_) => named.to_path_buf(),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => named.to_path_buf(),
Err(err) => return Err(EngineError::Io(format!("{path}: {err}"))),
};
crate::file_access::check_write(&path).map_err(EngineError::Io)?;
let metadata = match std::fs::metadata(&path) {
Ok(m) if m.is_file() => Some(m),
Ok(_) => return Err(EngineError::Io(format!("{}: not a regular file", path.display()))),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
};
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
if metadata.as_ref().is_some_and(|m| m.permissions().readonly()) {
return Err(err(std::io::ErrorKind::PermissionDenied.into()));
}
write_export_checked(&path, bytes).map_err(err)
}
/// Keep the original file and its security metadata. Refuse hard links before truncation: a
/// link inside an automation root can name a file outside it (#1359).
#[cfg(unix)]
fn write_export_checked(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _};
let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(false).mode(0o600).open(path)?;
let opened = file.metadata()?;
if !opened.is_file() {
return Err(std::io::Error::other("not a regular file"));
}
if opened.nlink() > 1 {
return Err(hard_link_export_error());
}
file.set_len(0)?;
file.write_all(bytes)?;
file.sync_all()
}
#[cfg(any(unix, windows))]
fn hard_link_export_error() -> std::io::Error {
std::io::Error::other("refusing to overwrite a hard-linked file; choose a new export path")
}
#[cfg(not(any(unix, windows)))]
fn write_export_checked(_path: &Path, _bytes: &[u8]) -> std::io::Result<()> {
Err(std::io::ErrorKind::Unsupported.into())
}
/// Windows' stable std metadata API does not expose the link count. Use a safe kernel wrapper
/// and the same handle for the check and write; no replacement means the destination DACL stays.
#[cfg(windows)]
fn write_export_checked(path: &Path, mut bytes: &[u8]) -> std::io::Result<()> {
use winsafe::{HFILE, co};
// Let std retain its Windows filename normalization and create missing destinations without
// truncation. Canonicalization then supplies a verbatim path, including long and UNC paths,
// to the raw Win32 wrapper. The handle below is checked before any existing bytes change.
let _opened = std::fs::OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
let absolute = std::fs::canonicalize(path)?;
let path = absolute.to_str().filter(|p| !p.contains('\0')).ok_or(std::io::ErrorKind::InvalidInput)?;
let io_error = |err: winsafe::co::ERROR| std::io::Error::other(err.to_string());
let (file, _) = HFILE::CreateFile(
path,
co::GENERIC::WRITE,
Some(co::FILE_SHARE::READ | co::FILE_SHARE::WRITE | co::FILE_SHARE::DELETE),
None,
co::DISPOSITION::OPEN_EXISTING,
co::FILE_ATTRIBUTE::NORMAL,
Some(co::FILE_FLAG::WRITE_THROUGH),
None,
None,
)
.map_err(io_error)?;
if file.GetFileType().map_err(io_error)? != co::FILE_TYPE::DISK {
return Err(std::io::Error::other("not a regular file"));
}
match file.GetFileInformationByHandle().map_err(io_error)?.nNumberOfLinks {
1 => {}
0 => return Err(std::io::ErrorKind::Unsupported.into()),
_ => return Err(hard_link_export_error()),
}
file.SetEndOfFile().map_err(io_error)?;
while !bytes.is_empty() {
// Win32 takes a DWORD length. Small bounded writes also handle short writes correctly.
let count = file.WriteFile(&bytes[..bytes.len().min(64 * 1024)]).map_err(io_error)? as usize;
if count == 0 {
return Err(std::io::ErrorKind::WriteZero.into());
}
bytes = &bytes[count..];
}
Ok(())
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
}
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
@@ -1111,164 +1019,6 @@ mod stem_tests {
let _ = std::fs::remove_dir_all(&root);
}
}
#[cfg(test)]
mod export_replacement_tests {
use super::*;
#[cfg(unix)]
use std::os::unix::fs::{PermissionsExt, symlink};
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("soundcraft-export-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn export_refuses_hard_links_before_truncating_and_refuses_nonfiles() {
let dir = scratch("hard-links");
let original = dir.join("original.mid");
let write_root = dir.join("write");
std::fs::create_dir(&write_root).unwrap();
let destination = write_root.join("export.mid");
std::fs::write(&original, b"old").unwrap();
std::fs::hard_link(&original, &destination).unwrap();
for confined in [false, true] {
let roots = if confined { crate::file_access::AutomationRoots::new(&[], &[&write_root]).unwrap() } else { None };
let result = crate::file_access::confine(roots.as_ref(), || write_file(destination.to_str().unwrap(), b"new"));
assert!(result.unwrap_err().to_string().contains("hard-linked"));
}
assert_eq!(std::fs::read(&original).unwrap(), b"old");
assert_eq!(std::fs::read(&destination).unwrap(), b"old");
let folder = dir.join("folder");
std::fs::create_dir(&folder).unwrap();
assert!(write_file(folder.to_str().unwrap(), b"new").is_err());
assert!(folder.is_dir());
assert!(!std::fs::read_dir(&dir).unwrap().any(|e| e.unwrap().file_name().to_string_lossy().starts_with(".soundcraft-")));
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(windows)]
#[test]
fn export_supports_paths_longer_than_max_path() {
let root = scratch("long-path");
let mut dir = root.clone();
while dir.as_os_str().len() < 280 {
dir.push("long-folder-component");
}
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("export.mid");
write_file(path.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
write_file(path.to_str().unwrap(), b"replacement").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"replacement");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn export_writes_all_chunks_and_truncates_an_existing_file() {
let dir = scratch("chunks");
let path = dir.join("export.mid");
let bytes = vec![0x5a; 128 * 1024 + 17];
write_file(path.to_str().unwrap(), &bytes).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
write_file(path.to_str().unwrap(), b"").unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn export_refuses_readonly_files_without_changing_their_bytes() {
let dir = scratch("readonly");
let path = dir.join("readonly.mid");
std::fs::write(&path, b"old contents").unwrap();
let original = std::fs::metadata(&path).unwrap().permissions();
let mut readonly = original.clone();
readonly.set_readonly(true);
std::fs::set_permissions(&path, readonly).unwrap();
let result = write_file(path.to_str().unwrap(), b"new");
let bytes = std::fs::read(&path).unwrap();
std::fs::set_permissions(&path, original).unwrap();
assert!(result.is_err());
assert_eq!(bytes, b"old contents");
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(any(unix, windows))]
#[test]
fn export_preserves_the_existing_file_identity_and_security_metadata() {
let dir = scratch("identity");
let path = dir.join("existing.mid");
std::fs::write(&path, b"long old contents").unwrap();
#[cfg(unix)]
let identity = |p: &Path| {
use std::os::unix::fs::MetadataExt as _;
let m = std::fs::metadata(p).unwrap();
(m.dev(), m.ino(), m.uid(), m.gid())
};
#[cfg(windows)]
let identity = |p: &Path| {
use winsafe::{HFILE, co};
let (file, _) = HFILE::CreateFile(
p.to_str().unwrap(),
co::GENERIC::READ,
None,
None,
co::DISPOSITION::OPEN_EXISTING,
co::FILE_ATTRIBUTE::NORMAL,
None,
None,
None,
)
.unwrap();
let i = file.GetFileInformationByHandle().unwrap();
(i.dwVolumeSerialNumber, i.nFileIndex())
};
let before = identity(&path);
write_file(path.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
// No inode/file-record replacement: owner, group and access-control lists stay attached
// to the same file rather than being inherited anew from the parent directory.
assert_eq!(identity(&path), before);
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn export_replacement_preserves_existing_permissions_and_new_files_are_private() {
let dir = scratch("permissions");
for mode in [0o600, 0o640] {
let path = dir.join(format!("{mode:o}.mid"));
std::fs::write(&path, b"old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
write_file(path.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, mode);
}
let path = dir.join("new.mid");
write_file(path.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o077, 0);
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(unix)]
#[test]
fn export_replacement_preserves_a_symlink_and_refuses_a_dangling_one() {
let dir = scratch("symlinks");
let target = dir.join("target.mid");
let link = dir.join("link.mid");
std::fs::write(&target, b"old").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
symlink("target.mid", &link).unwrap();
for confined in [false, true] {
let roots = if confined { crate::file_access::AutomationRoots::new(&[], &[&dir]).unwrap() } else { None };
crate::file_access::confine(roots.as_ref(), || write_file(link.to_str().unwrap(), b"new")).unwrap();
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
assert_eq!(std::fs::read(&target).unwrap(), b"new");
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
}
let missing = dir.join("missing.mid");
let dangling = dir.join("dangling.mid");
symlink(&missing, &dangling).unwrap();
assert!(write_file(dangling.to_str().unwrap(), b"new").is_err());
assert!(std::fs::symlink_metadata(&dangling).unwrap().file_type().is_symlink());
assert!(!missing.exists());
assert!(!std::fs::read_dir(&dir).unwrap().any(|e| e.unwrap().file_name().to_string_lossy().starts_with(".soundcraft-")));
let _ = std::fs::remove_dir_all(dir);
}
}
#[cfg(test)]
mod clip_tests {
+7 -51
View File
@@ -7,48 +7,18 @@ use super::{
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30;
/// The most a file's audio may grow when converted to the session rate. Sessions run at 8 kHz to
/// 768 kHz (96×); past this a file is lying about its rate, and converting a few kilobytes would
/// otherwise allocate and filter hundreds of megabytes.
const MAX_RATE_EXPANSION: u32 = 128;
/// Why [`at_rate`] refused a conversion.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum RateError {
/// The converted audio would hold more samples than the limit.
TooLarge,
/// The file's rate is so far below the session's that converting would grow it past [`MAX_RATE_EXPANSION`].
Ratio { from: u32, to: u32 },
}
impl RateError {
/// The refusal as a sentence: `size` says what a size refusal means to the caller.
pub(crate) fn message(&self, size: &str) -> String {
match self {
RateError::TooLarge => size.to_string(),
RateError::Ratio { from, to } => {
format!("its {from} Hz sample rate is too low to convert to the session's {to} Hz (at most {MAX_RATE_EXPANSION}-fold)")
}
}
}
}
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples, or grow more
/// than [`MAX_RATE_EXPANSION`]-fold: a file with a tiny sample rate grows by orders of magnitude.
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> std::result::Result<AudioBuffer, RateError> {
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples: a file with a
/// tiny sample rate grows by orders of magnitude.
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> Option<AudioBuffer> {
let from = buf.sample_rate.max(1);
// widened integers: dividing first would truncate 128.86-fold down to 128
if u64::from(rate) > u64::from(from) * u64::from(MAX_RATE_EXPANSION) {
return Err(RateError::Ratio { from, to: rate });
}
if buf.frames() as f64 * buf.num_channels() as f64 * f64::from(rate) / f64::from(from) > limit as f64 {
return Err(RateError::TooLarge);
return None;
}
if from != rate {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, from, rate);
buf.sample_rate = rate;
}
Ok(buf)
Some(buf)
}
/// A relative `Audio Files/` path no other source in `s` already uses.
@@ -145,7 +115,7 @@ pub fn decode_import(path: &str, rate: u32) -> Result<DecodedImport> {
fn decode_import_bytes(name: &str, bytes: &[u8], rate: u32) -> Result<(soundcraft_audio_io::AudioInfo, Arc<SourceAudio>)> {
let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase);
let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| EngineError::Io(format!("{name}: {}", e.message("file is too large to import"))))?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
if buf.frames() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
}
@@ -245,22 +215,8 @@ mod rate_tests {
#[test]
fn a_tiny_sample_rate_is_refused_before_it_is_converted() {
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
assert!(at_rate(buf(1), 48_000, 1_000_000).is_err());
assert!(at_rate(buf(1), 48_000, 1_000_000).is_none());
let ok = at_rate(buf(8_000), 48_000, 1_000_000).unwrap();
assert_eq!((ok.sample_rate, ok.frames()), (48_000, 600));
}
#[test]
fn an_extreme_rate_expansion_is_refused_whatever_the_sample_budget() {
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
// 100 frames at 300 Hz would fit the budget as 128 000 samples, but grow 640-fold.
assert_eq!(at_rate(buf(300), 192_000, usize::MAX).unwrap_err(), RateError::Ratio { from: 300, to: 192_000 });
assert!(at_rate(buf(1_500), 192_000, usize::MAX).is_ok(), "128-fold is the supported extreme");
assert!(at_rate(buf(8_000), 768_000, usize::MAX).is_ok(), "the slowest file at the fastest session");
// 128.86-fold: integer division would truncate it to 128 and let it through
assert_eq!(at_rate(buf(1_490), 192_000, usize::MAX).unwrap_err(), RateError::Ratio { from: 1_490, to: 192_000 });
// a short file that is only over the sample budget is a size refusal, not a rate one
assert_eq!(at_rate(buf(48_000), 48_000, 10).unwrap_err(), RateError::TooLarge);
assert!(RateError::Ratio { from: 300, to: 192_000 }.message("big").contains("300 Hz"));
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ pub(crate) fn open_session_linked(e: &mut Engine, path: &Path, links: &Path) ->
/// Decode one source's audio file at the session rate.
pub(super) fn decode_source(bytes: &[u8], ext: Option<&str>, rate: u32) -> std::result::Result<Arc<SourceAudio>, String> {
let (_, buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| e.message("too large to load"))?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or("too large to load")?;
Ok(Arc::new(SourceAudio::new(buf)))
}
-45
View File
@@ -579,48 +579,3 @@ fn moving_a_group_left_past_zero_keeps_its_spacing() {
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": -1000}));
assert_eq!(clips(&e, t), [(0, 100), (1500, 100)]);
}
#[test]
fn separate_and_range_edits_leave_locked_clips_whole() {
let edits = [
("edit.separate", json!({"at": 24_000})),
("edit.separate", json!({"start": 12_000, "end": 36_000})),
("edit.separate_on_grid", json!({"start": 12_000, "end": 36_000})),
("edit.clear", json!({"start": 12_000, "end": 36_000})),
("edit.insert_silence", json!({"start": 24_000, "end": 25_000})),
];
for lock in [None, Some("clip.edit_lock"), Some("clip.time_lock")] {
for (cmd, p) in &edits {
let (mut e, t, ids) = long("slip", &[(0, 48_000, 0)]);
if let Some(lock) = lock {
run(&mut e, lock, json!({"clips": [ids[0].0]}));
}
let mut p = p.clone();
p["tracks"] = json!([t.0]);
run(&mut e, cmd, p);
if lock.is_some() {
assert_eq!(clips(&e, t), vec![(0, 48_000)], "{lock:?} {cmd}");
} else {
assert_ne!(clips(&e, t), vec![(0, 48_000)], "unlocked control {cmd}");
}
}
}
}
/// Event time edits move the entire timeline, including clips protected from ordinary edits.
#[test]
fn event_time_edits_split_and_move_locked_clips() {
for lock in ["clip.edit_lock", "clip.time_lock"] {
for (command, expected) in [
("event.insert_time", vec![(0, 12_000, 0), (36_000, 36_000, 12_000)]),
("event.cut_time", vec![(0, 12_000, 0), (12_000, 12_000, 36_000)]),
] {
let (mut e, t, ids) = long("slip", &[(0, 48_000, 0)]);
run(&mut e, lock, json!({"clips": [ids[0].0]}));
run(&mut e, command, json!({"start": 12_000, "end": 36_000}));
let actual: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect();
assert_eq!(actual, expected, "{lock} {command}");
assert!(e.session().track(t).unwrap().clips().iter().all(soundcraft_engine::edit::locked));
}
}
}
@@ -18,68 +18,3 @@ fn trim_to_selection_keeps_clips_sorted() {
let clips = engine.session().track(track).unwrap().clips();
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(long, 0, 30), (short, 5, 5)]);
}
fn three_clip_session() -> (Session, soundcraft_model::TrackId, [soundcraft_model::ClipId; 3]) {
let mut session = Session::default();
let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let ids = [session.new_clip_id(), session.new_clip_id(), session.new_clip_id()];
session.track_mut(track).unwrap().playlist_mut().unwrap().clips = vec![
Clip::audio(ids[0], "free", SourceId(1), 0, 0, 100),
Clip::audio(ids[1], "edit-locked", SourceId(1), 0, 100, 100),
Clip::audio(ids[2], "time-locked", SourceId(1), 0, 200, 100),
];
let pl = session.track_mut(track).unwrap().playlist_mut().unwrap();
pl.clips[1].edit_locked = true;
pl.clips[2].time_locked = true;
(session, track, ids)
}
#[test]
fn trim_to_selection_leaves_locked_clips_whole() {
let (session, track, ids) = three_clip_session();
let mut engine = Engine::new(session);
let result = engine.execute("edit.trim_to_selection", &json!({"tracks": [track.0], "start": 50, "end": 250})).unwrap();
let clips = engine.session().track(track).unwrap().clips();
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(ids[0], 50, 50), (ids[1], 100, 100), (ids[2], 200, 100)]);
assert_eq!(result["trimmed"], json!(1));
engine.undo();
assert_eq!(engine.session().track(track).unwrap().clips()[0].length, 100);
}
#[test]
fn edit_trim_skips_locked_clips() {
let (session, track, ids) = three_clip_session();
let mut engine = Engine::new(session);
engine.execute("edit.trim_start_to_insertion", &json!({"tracks": [track.0], "at": 150})).unwrap();
engine.execute("edit.trim_end_to_insertion", &json!({"tracks": [track.0], "at": 250})).unwrap();
let clips = engine.session().track(track).unwrap().clips();
assert_eq!(
clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(),
vec![(ids[0], 0, 100), (ids[1], 100, 100), (ids[2], 200, 100)],
"only unlocked clips trim; the edit-locked and time-locked ones keep their extent"
);
}
/// A lock on one clip must not suppress eligible unlocked clips on the same track.
#[test]
fn insertion_trims_still_trim_unlocked_clips_beside_both_lock_kinds() {
for (command, expected_start) in [("edit.trim_start_to_insertion", 50), ("edit.trim_end_to_insertion", 0)] {
let (mut session, track, ids) = three_clip_session();
for clip in &mut session.track_mut(track).unwrap().playlist_mut().unwrap().clips {
clip.start = 0;
}
let before = session.clone();
let mut engine = Engine::new(session);
let result = engine.execute(command, &json!({"tracks": [track.0], "at": 50})).unwrap();
assert_eq!(result["trimmed"], json!(1), "{command}");
let clips = engine.session().track(track).unwrap().clips();
let free = clips.iter().find(|c| c.id == ids[0]).unwrap();
assert_eq!((free.start, free.length), (expected_start, 50), "{command}");
for id in &ids[1..] {
let locked = clips.iter().find(|c| c.id == *id).unwrap();
assert_eq!((locked.start, locked.length), (0, 100), "{command}");
}
assert!(engine.undo());
assert_eq!(engine.session(), &before, "{command} undo");
}
}
+21 -136
View File
@@ -1,6 +1,4 @@
use super::{AutoParam, Samples, Session, TICKS_PER_QUARTER, Track, Transport, db_to_gain};
use soundcraft_dsp::MIN_DB;
use soundcraft_model::AutomationLane;
pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording: bool) -> Transport {
let sr = s.sample_rate;
@@ -23,146 +21,15 @@ pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording:
}
}
/// One term of a fader's level in dB: a fixed value, or an automation lane (with its default).
#[derive(Clone, Copy)]
pub(crate) enum Level<'a> {
Fixed(f32),
Lane(&'a AutomationLane, f32),
}
impl Level<'_> {
fn at(&self, pos: Samples) -> f32 {
match self {
Level::Fixed(v) => *v,
Level::Lane(l, default) => l.value_at(pos, *default),
}
}
/// The lane's first breakpoint after `pos`. Up to it, the level is linear in time.
fn next_point(&self, pos: Samples) -> Option<Samples> {
match self {
Level::Fixed(_) => None,
Level::Lane(l, _) => l.points.get(l.points.partition_point(|p| p.at <= pos)).map(|p| p.at),
}
}
}
/// A track's volume: its automation lane when the mode reads it, else the fader.
pub(crate) fn volume_level(t: &Track) -> Level<'_> {
pub(crate) fn volume_db_at(t: &Track, pos: Samples) -> f32 {
if t.mixer.automation_mode.reads()
&& t.mixer.automation_mode != soundcraft_model::AutomationMode::Write
&& let Some(l) = t.lane(&AutoParam::Volume)
&& !l.points.is_empty()
{
return Level::Lane(l, t.mixer.volume_db);
return l.value_at(pos, t.mixer.volume_db);
}
Level::Fixed(t.mixer.volume_db)
}
/// Calls `f(i, j, d0, d1)` for each stretch `i..=j` of the block at `pos` over which the sum of
/// `levels` is linear in dB, with its values at `i` and `j`. Lanes are linear between breakpoints,
/// so a stretch ends at the next breakpoint or at the block's last frame.
fn for_each_segment(levels: &[Level<'_>], pos: Samples, frames: usize, mut f: impl FnMut(usize, usize, f32, f32)) {
let db = |at: Samples| levels.iter().map(|l| l.at(at)).sum::<f32>();
let last = frames.saturating_sub(1);
let mut i = 0usize;
while i < frames {
let at = pos.saturating_add(i as i64);
let j = levels
.iter()
.filter_map(|l| l.next_point(at))
.min()
.map_or(last, |k| usize::try_from(k.saturating_sub(pos)).map_or(last, |j| j.min(last)));
f(i, j, db(at), db(pos.saturating_add(j as i64)));
i = j + 1;
}
}
/// Multiplies `x` by a gain that goes from `d0` dB at its first sample to `d1` dB at its last,
/// linearly in dB, capped at `max_db` and silent at or below [`MIN_DB`] (or NaN), as `db_to_gain`
/// would give it sample by sample. Linear in dB is geometric in gain, so the ramp steps by one ratio
/// instead of converting every sample; the ends are converted exactly.
pub(crate) fn scale(x: &mut [f32], d0: f32, d1: f32, max_db: f32) {
let Some(n) = x.len().checked_sub(1) else { return };
// Match db_to_gain's ceiling even when the caller requests no fader cap.
let max_db = max_db.min(200.0);
if d0.is_nan() || d1.is_nan() {
x.fill(0.0);
return;
}
if d0 == d1 || n == 0 {
let g = gain(d0, max_db);
x.iter_mut().for_each(|v| *v *= g);
return;
}
// Extreme finite trim values can underflow or overflow the geometric recurrence.
// Convert those rare ramps directly so an inaudible prefix can recover normally.
if !(-1000.0..=1000.0).contains(&d0) || !(-1000.0..=1000.0).contains(&d1) {
for (i, v) in x.iter_mut().enumerate() {
let t = i as f64 / n as f64;
let db = if i == 0 {
f64::from(d0)
} else if i == n {
f64::from(d1)
} else {
f64::from(d0) * (1.0 - t) + f64::from(d1) * t
};
*v *= gain(db as f32, max_db);
}
return;
}
let step = (f64::from(d1) - f64::from(d0)) / n as f64;
let ratio = 10f64.powf(step / 20.0);
let (floor, cap) = (f64::from(MIN_DB), f64::from(max_db));
let gcap = f64::from(db_to_gain(max_db));
let (mut d, mut g) = (f64::from(d0), 10f64.powf(f64::from(d0) / 20.0));
if let Some((first, rest)) = x.split_first_mut() {
*first *= gain(d0, max_db);
if let Some((last, mid)) = rest.split_last_mut() {
for v in mid {
d += step;
g *= ratio;
let gd = if d.is_nan() || d <= floor {
0.0
} else if d >= cap {
gcap
} else {
g
};
*v *= gd as f32;
}
*last *= gain(d1, max_db);
}
}
}
/// The gain of `db`, at most `max_db`.
fn gain(db: f32, max_db: f32) -> f32 {
if db.is_nan() { 0.0 } else { db_to_gain(db.min(max_db)) }
}
/// Fader gain over the block at `pos`, read at every frame: multiplies `buf`'s channels by the
/// gain of the sum of `levels` (dB, at most `max_db`).
pub(crate) fn apply_gain(levels: &[Level<'_>], pos: Samples, frames: usize, max_db: f32, buf: &mut [Vec<f32>]) {
for_each_segment(levels, pos, frames, |i, j, d0, d1| {
for c in buf.iter_mut() {
if let Some(x) = c.get_mut(i..=j) {
scale(x, d0, d1, max_db);
}
}
});
}
/// [`apply_gain`] from `src` into `dst`.
pub(crate) fn copy_with_gain(levels: &[Level<'_>], pos: Samples, frames: usize, max_db: f32, src: &[Vec<f32>], dst: &mut [Vec<f32>]) {
for_each_segment(levels, pos, frames, |i, j, d0, d1| {
for (d, s) in dst.iter_mut().zip(src) {
if let (Some(d), Some(s)) = (d.get_mut(i..=j), s.get(i..=j)) {
d.copy_from_slice(s);
scale(d, d0, d1, max_db);
}
}
});
t.mixer.volume_db
}
pub(crate) fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 {
@@ -202,3 +69,21 @@ pub(crate) fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
}
base
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct GainRamp {
g0: f32,
g1: f32,
n: usize,
}
impl GainRamp {
pub(crate) fn new(db0: f32, db1: f32, n: usize) -> Self {
GainRamp { g0: db_to_gain(db0), g1: db_to_gain(db1), n }
}
#[inline]
pub(crate) fn at(&self, i: usize) -> f32 {
if self.n == 0 { self.g0 } else { self.g0 + (self.g1 - self.g0) * (i as f32 / self.n as f32) }
}
}
-4
View File
@@ -105,10 +105,6 @@ impl ClipFx {
}
}
}
/// Whether the EQ or compressor is on: only they carry parameters that settle (a gain does not).
pub(crate) fn has_smoothed_modules(&self) -> bool {
self.eq.is_some() || self.comp.is_some()
}
pub(crate) fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
if let Some(eq) = &mut self.eq {
eq.process(io, frames);
+16 -3
View File
@@ -71,7 +71,7 @@ mod routing;
mod strip;
mod threads;
use automation::{copy_with_gain, pan_at, pan_segment, send_level, transport_at, volume_level};
use automation::{GainRamp, pan_at, pan_segment, send_level, transport_at, volume_db_at};
pub use instances::{INSTRUMENT_SLOT, InstanceSpec, NewInstance, PluginFailures, PreparedInstance, create_instance, instance_specs, is_third_party};
pub use meter::StripMeter;
use midi::sync_sample;
@@ -539,8 +539,21 @@ impl MixEngine {
}
}
}
// Read at every sample, like track faders: a breakpoint acts on its own sample.
copy_with_gain(&[volume_level(t)], pos, frames, f32::INFINITY, &strip.buf, &mut self.main);
// Match track faders: the next block's first sample must not ramp this one.
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
let v0 = volume_db_at(t, pos);
let v1 = volume_db_at(t, last);
let ramp = GainRamp::new(v0, v1, frames.saturating_sub(1));
for (ch, src) in strip.buf.iter().enumerate().take(self.main.len()) {
if let Some(m) = self.main.get_mut(ch) {
for i in 0..frames {
let g = ramp.at(i);
if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) {
*d = x * g;
}
}
}
}
let m = self.meters.entry(t.id).or_default();
m.measure(&self.main, frames, 0.0);
m.plugin_failures = strip.failures;
+6 -58
View File
@@ -132,85 +132,33 @@ pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec<f32>]) -> Vec<Vec<f32>> {
out
}
/// Block size for clip effects in [`render_clips`].
const CLIP_FX_BLOCK: usize = 4096;
/// Most a clip effect keeps rendering past its clip's end in [`render_clips`], in seconds.
const MAX_CLIP_FX_TAIL_SECONDS: f64 = 10.0;
/// Silent lead-in run before a clip with effects so smoothed parameters (EQ) settle, in seconds
/// (the smoothers settle within ~0.3 s).
const CLIP_FX_SETTLE_SECONDS: f64 = 1.0;
/// Frames an effect advances its smoothed parameters by at a time (the EQ's chunk). Processing starts on a
/// multiple of it from the selection's start, as a render of the whole selection would, so the smoothers
/// step at the same samples.
const CLIP_FX_CHUNK: usize = 32;
/// A tail block whose peak is below this (-180 dBFS) ends a clip effect's tail.
const CLIP_FX_TAIL_FLOOR: f32 = 1e-9;
/// Render only one track's clips (no mixer processing) — Consolidate.
pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
let Some(t) = s.track(track) else { return Vec::new() };
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / t.channels().max(1));
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
let mut scratch = Vec::new();
// One reusable block for every clip with effects.
let mut block: Vec<Vec<f32>> = Vec::new();
let max_tail = s.sample_rate.samples(MAX_CLIP_FX_TAIL_SECONDS).max(0);
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
match clip_fx_params(s, c.id.0) {
None => render_clip(s, c, range.start, len, &mut out, &mut scratch),
Some(params) => {
if block.len() != out.len() {
block = vec![vec![0.0f32; CLIP_FX_BLOCK]; out.len()];
}
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, out.len());
let mut tmp = vec![vec![0.0f32; len]; out.len()];
render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, tmp.len());
fx.configure(params);
// The effects start from silence, so nothing comes out before the clip starts:
// process from there through the clip and a bounded tail, not the whole selection.
let offset = |at: Samples| usize::try_from(at.saturating_sub(range.start).max(0)).unwrap_or(usize::MAX).min(len);
let first = offset(c.start);
let clip_end = offset(c.end());
let tail_end = offset(c.end().saturating_add(max_tail));
// Smoothed parameters start at their defaults; settle them on silence, as the
// full-span render did, so the clip opens at the configured setting.
// (the clip's own samples start inside the first chunk when `first` is not on the grid)
let grid = first - first % CLIP_FX_CHUNK;
let mut lead = grid.min(usize::try_from(s.sample_rate.samples(CLIP_FX_SETTLE_SECONDS).max(0)).unwrap_or(0));
lead -= lead % CLIP_FX_CHUNK;
// a gain-only effect has no smoother to settle
if !fx.has_smoothed_modules() {
lead = 0;
}
while lead > 0 {
let n = lead.min(CLIP_FX_BLOCK);
for ch in &mut block {
ch.fill(0.0);
}
fx.process(&mut block, n);
lead -= n;
}
let mut done = grid;
let mut done = 0;
while done < len {
let n = (len - done).min(CLIP_FX_BLOCK);
for ch in &mut block {
ch.fill(0.0);
}
if done < clip_end {
render_clip(s, c, range.start.saturating_add(done as i64), n, &mut block, &mut scratch);
}
let n = (len - done).min(4096);
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch.get(done..done + n).map(<[f32]>::to_vec).unwrap_or_default()).collect();
fx.process(&mut block, n);
let mut peak = 0.0f32;
for (o, b) in out.iter_mut().zip(block.iter()) {
if let Some(dst) = o.get_mut(done..done + n) {
for (x, y) in dst.iter_mut().zip(b.iter()) {
*x += *y;
peak = peak.max(y.abs());
}
}
}
done += n;
if done >= clip_end && (done >= tail_end || peak < CLIP_FX_TAIL_FLOOR) {
break;
}
}
}
}
+22 -9
View File
@@ -1,4 +1,4 @@
use super::automation::{Level, apply_gain, transport_at, volume_level};
use super::automation::{GainRamp, transport_at, volume_db_at};
use super::clips::{ClipFx, clip_fx_params, render_clip};
use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance_for, is_third_party};
use super::midi::{chase_controllers, schedule_notes};
@@ -334,7 +334,7 @@ fn render_instrument(s: &Session, t: &Track, strip: &mut Strip, blk: Block, tran
fn run_inserts(t: &Track, strip: &mut Strip, blk: Block, transport: &Transport) {
let Block { pos, frames, .. } = blk;
let mut gr = 0.0f32;
let mut latency = strip.instrument.as_ref().filter(|i| !i.failed).map_or(0, |i| i.plugin.latency());
let mut latency = 0usize;
for (i, ins) in t.mixer.inserts.iter().enumerate() {
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
if !ins.active || ins.bypass || slot.failed {
@@ -356,28 +356,41 @@ fn run_inserts(t: &Track, strip: &mut Strip, blk: Block, transport: &Transport)
strip.latency = latency.min(MAX_LATENCY);
}
/// Fader gain (volume, trim and VCA automation), read at every sample of the block.
/// Fader gain (volume, trim and VCA automation), ramped across the block.
fn apply_fader(s: &Session, t: &Track, strip: &mut Strip, blk: Block) {
let Block { pos, frames, .. } = blk;
// Endpoints are the first and last sample of this block. The next block starts on its own
// first sample, so a breakpoint there does not ramp the block before it.
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
let mut v0 = volume_db_at(t, pos);
let mut v1 = volume_db_at(t, last);
// Trim automation (an offset on top of the volume curve).
let trim = t
if let Some(l) = t
.automation
.iter()
.find(|l| matches!(&l.param, AutoParam::Plugin { slot: u8::MAX, param } if param == "trim"))
.filter(|l| !l.points.is_empty())
.map_or(Level::Fixed(0.0), |l| Level::Lane(l, 0.0));
{
v0 += l.value_at(pos, 0.0);
v1 += l.value_at(last, 0.0);
}
// VCA master: its fader offsets the member's, and its mute mutes the member.
let mut vca_level = Level::Fixed(0.0);
if let Some(vid) = t.mixer.vca
&& let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca)
{
vca_level = volume_level(vca);
v0 += volume_db_at(vca, pos);
v1 += volume_db_at(vca, last);
if vca.mixer.mute {
strip.muted = true;
}
}
let levels = if strip.muted { [Level::Fixed(-144.0), Level::Fixed(0.0), Level::Fixed(0.0)] } else { [volume_level(t), trim, vca_level] };
apply_gain(&levels, pos, frames, 12.0, &mut strip.buf);
let (g0, g1) = if strip.muted { (-144.0, -144.0) } else { (v0.min(12.0), v1.min(12.0)) };
let ramp = GainRamp::new(g0, g1, frames.saturating_sub(1));
for c in strip.buf.iter_mut() {
for (i, x) in c.iter_mut().enumerate().take(frames) {
*x *= ramp.at(i);
}
}
}
/// Track mute gates the audio and its pre-fader tap without splitting processor blocks.
+7 -309
View File
@@ -941,84 +941,6 @@ fn a_plugins_bogus_latency_is_capped() {
assert_eq!((eng.track_latency, eng.latency), (MAX_LATENCY, 2 * MAX_LATENCY));
}
#[test]
fn a_hosted_instruments_latency_is_compensated() {
// #1474: a synth that reports latency plays late. Its delay must count toward the mix
// latency and align, the way an insert's does.
struct LatentSynth {
inner: Box<dyn Plugin>,
n: usize,
t: usize,
pending: Option<usize>,
}
impl Plugin for LatentSynth {
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
self.inner.info()
}
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
self.inner.prepare(sr, mb, ch);
}
fn reset(&mut self) {
self.inner.reset();
}
fn set_param(&mut self, id: &str, v: f32) -> bool {
self.inner.set_param(id, v)
}
fn param(&self, id: &str) -> Option<f32> {
self.inner.param(id)
}
fn note_on(&mut self, offset: usize, _: u8, _: u8) {
self.pending = Some(self.t + offset + self.n);
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
for c in io.iter_mut() {
for (i, x) in c.iter_mut().take(frames).enumerate() {
*x = if Some(self.t + i) == self.pending { 0.25 } else { 0.0 };
}
}
self.t += frames;
}
fn latency(&self) -> usize {
self.n
}
}
use soundcraft_midi::{Note, Sequence};
let n = 500;
let mut s = Session::default();
s.edit.delay_compensation = true;
let audio = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
let mut click = vec![0.0f32; 48_000];
click[24_000] = 0.25;
s.pool.insert(SourceId(1474), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![click.clone(), click] })));
let cid = s.new_clip_id();
s.track_mut(audio).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "click", SourceId(1474), 0, 0, 48_000));
let inst = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
let mut seq = Sequence::default();
seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 960, length: 240 });
let mcid = s.new_clip_id();
s.track_mut(inst).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(mcid, "note", 0, 48_000, seq));
s.track_mut(inst).unwrap().instrument = Some(Insert::new("clap:test.latent"));
let mut eng = MixEngine::new(48_000.0, 1024);
eng.set_external_instances(true);
eng.adopt(vec![PreparedInstance {
track: inst,
slot: INSTRUMENT_SLOT,
id: "clap:test.latent".into(),
ch: 2,
plugin: Box::new(LatentSynth { inner: soundcraft_dsp::create("gain").unwrap(), n, t: 0, pending: None }),
}]);
let mut out = vec![vec![0.0; 1024]; 2];
let mut rendered = Vec::new();
for b in 0..48 {
eng.render(&s, b * 1024, 1024, &mut out);
rendered.extend_from_slice(&out[0]);
}
assert_eq!(eng.latency, n, "the hosted instrument's latency is part of the mix latency");
let impulses: Vec<_> = rendered.iter().enumerate().filter(|(_, v)| v.abs() > 1e-6).map(|(i, _)| i).collect();
assert_eq!(impulses, vec![24_000 + n], "both the audio click and instrument impulse align");
assert!((rendered[24_000 + n] - 0.5).abs() < 1e-6, "the aligned impulses add, neither is missing");
}
#[test]
fn input_monitoring_replaces_clips() {
let (mut s, t) = session_with_dc(0.5, 4800);
@@ -1275,238 +1197,14 @@ fn apply_matrix_span_matches_the_plain_per_sample_sum() {
}
}
/// A volume lane through `points` (sample, dB).
fn volume_lane(points: &[(i64, f32)]) -> soundcraft_model::AutomationLane {
let mut l = soundcraft_model::AutomationLane::new(AutoParam::Volume);
for &(at, db) in points {
l.set_point(at, db);
}
l
}
/// Volume automation is read at every sample: a breakpoint acts on its own sample, and between
/// breakpoints the gain follows the lane (linear in dB), whatever size the render blocks are. A
/// 480-sample crossfade used to be resolved once per block, so it moved by up to a block and left
/// a hole or not depending on where the block edge fell.
#[test]
fn volume_automation_follows_its_breakpoints_in_any_block_size() {
let (mut s, a) = session_with_dc(0.5, 8192);
let b = add_dc_track(&mut s, 0.5, 8192);
// A falls from 0 to -144 dB across 2760..3240 while B rises, and back across 5760..6240.
let fall = volume_lane(&[(0, 0.0), (2760, 0.0), (3240, -144.0), (5760, -144.0), (6240, 0.0)]);
let rise = volume_lane(&[(0, -144.0), (2760, -144.0), (3240, 0.0), (5760, 0.0), (6240, -144.0)]);
// One track's level at 0 dB, through the pan law.
let per_track = render_range(&s, Range::new(0, 64), 64)[0][0] / 2.0;
s.track_mut(a).unwrap().automation.push(fall.clone());
s.track_mut(b).unwrap().automation.push(rise.clone());
let want = |i: i64| per_track * (db_to_gain(fall.value_at(i, 0.0)) + db_to_gain(rise.value_at(i, 0.0)));
for block in [1024, 512, 333, 64] {
let out = render_range(&s, Range::new(0, 8192), block);
for (i, &x) in out[0].iter().enumerate() {
let w = want(i as i64);
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "block {block}, sample {i}: {x} vs {w}");
fn gain_ramp_matches_converting_both_ends_every_sample() {
for (db0, db1, n) in [(-6.0f32, 3.0f32, 512usize), (0.0, 0.0, 1), (-144.0, 0.0, 300), (2.5, -60.0, 0), (f32::NAN, -3.0, 64), (-200.0, 12.0, 7)] {
let ramp = crate::automation::GainRamp::new(db0, db1, n);
let (g0, g1) = (db_to_gain(db0), db_to_gain(db1));
for i in 0..=n {
let want = if n == 0 { g0 } else { g0 + (g1 - g0) * (i as f32 / n as f32) };
assert_eq!(ramp.at(i).to_bits(), want.to_bits(), "{db0} -> {db1} dB, sample {i} of {n}");
}
}
}
/// A master fader's breakpoint acts on its own sample too.
#[test]
fn master_volume_automation_follows_its_breakpoints_in_any_block_size() {
let (mut s, _) = session_with_dc(0.5, 4096);
let dry = render_range(&s, Range::new(0, 64), 64)[0][0];
let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);
let lane = volume_lane(&[(0, 0.0), (1500, 0.0), (1501, -144.0), (2600, -144.0), (2700, -6.0)]);
s.track_mut(m).unwrap().automation.push(lane.clone());
for block in [1024, 256, 100] {
let out = render_range(&s, Range::new(0, 4096), block);
for (i, &x) in out[0].iter().enumerate() {
let w = dry * db_to_gain(lane.value_at(i as i64, 0.0));
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "block {block}, sample {i}: {x} vs {w}");
}
}
}
/// `scale` gives what `db_to_gain` gives sample by sample, from a ramp's first sample to its last:
/// silence at or below -144 dB, the cap, NaN, extreme values and one- or two-sample ramps.
#[test]
fn stepped_fader_ramps_match_converting_every_sample() {
let cases: &[(&str, f32, f32, usize, f32)] = &[
("down to silence", 0.0, -144.0, 480, 12.0),
("up from silence", -144.0, 0.0, 480, 12.0),
("across the cap", 6.0, 18.0, 300, 12.0),
("below the floor and back", -200.0, -20.0, 512, 12.0),
("flat", -6.0, -6.0, 1024, 12.0),
("one sample", -6.0, 3.0, 1, 12.0),
("two samples", -6.0, 3.0, 2, 12.0),
("empty", 0.0, -6.0, 0, 12.0),
("NaN start", f32::NAN, -3.0, 64, 12.0),
("long ramp in one block", 0.0, -60.0, 16_384, 12.0),
("recover from deep silence", -10_000.0, 0.0, 1024, 12.0),
("descend from extreme trim", 10_000.0, -100.0, 1024, 12.0),
("no cap", -12.0, 6.0, 1000, f32::INFINITY),
("gain helper ceiling", 300.0, 0.0, 1024, f32::INFINITY),
("extreme exact endpoint", -1e20, -20.0, 64, 12.0),
("negative infinite start", f32::NEG_INFINITY, -20.0, 64, 12.0),
("positive infinite end", -20.0, f32::INFINITY, 64, 12.0),
("opposite infinities", f32::NEG_INFINITY, f32::INFINITY, 64, 12.0),
];
for &(name, d0, d1, n, max_db) in cases {
let mut x = vec![1.0f32; n];
crate::automation::scale(&mut x, d0, d1, max_db);
for (k, &got) in x.iter().enumerate() {
let at = if k == 0 {
d0
} else if k + 1 == n {
d1
} else {
let t = k as f64 / (n - 1) as f64;
(f64::from(d0) * (1.0 - t) + f64::from(d1) * t) as f32
};
let want = if d0.is_nan() || d1.is_nan() || at.is_nan() { 0.0 } else { db_to_gain(at.min(max_db)) };
assert!((got - want).abs() <= 1e-5 * want.max(1e-6) || (got == 0.0 && want == 0.0), "{name}, sample {k} of {n}: {got} vs {want}");
}
if d0.is_nan() || d1.is_nan() {
assert!(x.iter().all(|&g| g == 0.0), "{name}: a NaN end is silence");
continue;
}
assert_eq!(x.first().copied().unwrap_or(0.0), if n == 0 { 0.0 } else { db_to_gain(d0.min(max_db)) }, "{name}: first sample exact");
if n > 1 {
assert_eq!(x[n - 1], db_to_gain(d1.min(max_db)), "{name}: last sample exact");
}
}
}
/// Volume, trim and VCA lanes with breakpoints at different samples add up at every sample, a
/// muted track stays silent, and a render that starts inside a ramp or has breakpoints on block
/// edges reads the same curve.
#[test]
fn summed_fader_lanes_are_read_at_every_sample() {
let (mut s, a) = session_with_dc(0.5, 12_288);
let muted = add_dc_track(&mut s, 0.5, 12_288);
let vca = s.add_track(TrackKind::Vca, ChannelFormat::Mono, None);
let one_track = render_range(&s, Range::new(0, 64), 64)[0][0] / 2.0;
s.track_mut(muted).unwrap().mixer.mute = true;
let vol = volume_lane(&[(0, 0.0), (1024, 0.0), (2048, -30.0), (4096, -6.0)]);
let mut trim = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: u8::MAX, param: "trim".into() });
for (at, db) in [(0, 0.0), (1500, 0.0), (3000, 6.0)] {
trim.set_point(at, db);
}
let vca_lane = volume_lane(&[(0, 0.0), (5120, 0.0), (7000, -12.0)]);
let tr = s.track_mut(a).unwrap();
tr.automation.push(vol.clone());
tr.automation.push(trim.clone());
tr.mixer.vca = Some(vca.0);
s.track_mut(vca).unwrap().automation.push(vca_lane.clone());
// dB at `i`, from the breakpoints themselves.
let lin = |pts: &[(i64, f32)], i: i64| match pts.iter().position(|p| p.0 > i) {
None => pts[pts.len() - 1].1,
Some(0) => pts[0].1,
Some(k) => pts[k - 1].1 + (pts[k].1 - pts[k - 1].1) * ((i - pts[k - 1].0) as f32 / (pts[k].0 - pts[k - 1].0) as f32),
};
let want = |i: i64| {
let db = lin(&[(0, 0.0), (1024, 0.0), (2048, -30.0), (4096, -6.0)], i)
+ lin(&[(0, 0.0), (1500, 0.0), (3000, 6.0)], i)
+ lin(&[(0, 0.0), (5120, 0.0), (7000, -12.0)], i);
one_track * db_to_gain(db.min(12.0))
};
for (start, block) in [(0i64, 1024), (0, 512), (1700, 333), (1024, 1024), (2047, 64)] {
let out = render_range(&s, Range::new(start, 12_288), block);
for (k, &x) in out[0].iter().enumerate() {
let i = start + k as i64;
let w = want(i);
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "start {start}, block {block}, sample {i}: {x} vs {w}");
}
}
}
/// Consolidate's clip-effect pass, processing every clip's effects across the whole selection
/// (what `render_clips` did before it limited them to the clip and its tail).
fn render_clips_full_span(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
let t = s.track(track).unwrap();
let len = range.len() as usize;
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
let mut scratch = Vec::new();
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
let params = crate::clips::clip_fx_params(s, c.id.0).unwrap();
let mut tmp = vec![vec![0.0f32; len]; out.len()];
crate::clips::render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
let mut fx = crate::clips::ClipFx::new(48_000.0, tmp.len());
fx.configure(params);
for done in (0..len).step_by(4096) {
let n = (len - done).min(4096);
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch[done..done + n].to_vec()).collect();
fx.process(&mut block, n);
for (o, b) in out.iter_mut().zip(&block) {
o[done..done + n].iter_mut().zip(b).for_each(|(x, y)| *x += y);
}
}
}
out
}
#[test]
fn consolidate_clip_effects_cover_the_clip_and_its_tail() {
let fx_sets: [&[(&str, f64)]; 3] =
[&[("comp.lookahead", 5.0), ("comp.threshold", -20.0), ("gain", -6.0)], &[("eq.hpf_on", 1.0), ("gain", 3.0)], &[("eq.low_shelf_gain", 12.0)]];
for (k, params) in fx_sets.iter().enumerate() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })));
let cid = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(900), 0, 20_000, 4800));
for (param, value) in params.iter() {
s.edit.values.insert(format!("clip_fx.{}.{param}", cid.0), *value);
}
let range = Range::new(5_000, 100_000);
let got = render_clips(&s, t, range);
let want = render_clips_full_span(&s, t, range);
assert_eq!(got.len(), want.len());
assert!(want[0].iter().any(|x| x.abs() > 0.05), "the fixture should sound");
if k == 0 {
// The compressor's lookahead output after the clip's end is kept.
assert!(got[0][20_000 + 4800 - 5_000 + 10].abs() > 1e-3);
}
for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() {
assert!((a - b).abs() < 1e-5, "set {k}, sample {i}: {a} vs {b}");
}
}
}
/// A clip that starts a few frames into the selection, while the EQ's smoothers are still settling: the
/// effect's 32-frame chunks must line up with the full-span render's.
#[test]
fn consolidate_clip_effects_keep_the_reference_chunk_grid() {
for offset in [1i64, 31, 32, 33, 1000] {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })));
let cid = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(900), 0, offset, 4800));
s.edit.values.insert(format!("clip_fx.{}.eq.low_shelf_gain", cid.0), 12.0);
let range = Range::new(0, 20_000);
let got = render_clips(&s, t, range);
let want = render_clips_full_span(&s, t, range);
for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() {
assert!((a - b).abs() < 1e-5, "clip at {offset}, sample {i}: {a} vs {b}");
}
}
}
#[test]
fn consolidate_many_tiny_effect_clips_stays_fast() {
// Thousands of one-sample clips with effects across a long selection: each one is processed
// over its own span, not the whole selection.
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 1]] })));
for i in 0..2000i64 {
let cid = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "x", SourceId(900), 0, i * 1000, 1));
s.edit.values.insert(format!("clip_fx.{}.gain", cid.0), -6.0);
}
let start = std::time::Instant::now();
let out = render_clips(&s, t, Range::new(0, 4_000_000));
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
assert!((out[0][1000] - 0.5 * db_to_gain(-6.0)).abs() < 1e-6);
assert_eq!(out[0][1001], 0.0);
}
+1 -15
View File
@@ -164,10 +164,7 @@ impl AutomationLane {
// Widen so far-apart breakpoints cannot overflow i64.
let span = i128::from(b.at) - i128::from(a.at);
let off = i128::from(at) - i128::from(a.at);
// Interpolate in f64: the f32 difference overflows for
// opposite-sign values near f32::MAX.
let (av, bv) = (f64::from(a.value), f64::from(b.value));
(av + (bv - av) * (off as f64 / span as f64)) as f32
a.value + (b.value - a.value) * (off as f64 / span as f64) as f32
}
(Some(a), _) => a.value,
_ => default,
@@ -284,17 +281,6 @@ mod tests {
assert!(w.is_finite() && (-10.0..=0.0).contains(&w));
}
#[test]
fn value_at_opposite_sign_extremes_stays_finite() {
let mut l = AutomationLane::new(AutoParam::Plugin { slot: 0, param: "Cutoff".into() });
l.set_point(0, -3.0e38);
l.set_point(1000, 3.0e38);
let v = l.value_at(500, 0.0);
assert!(v.is_finite() && v.abs() < 1.0e32, "mid value {v}");
let w = l.value_at(1, 0.0);
assert!(w.is_finite() && w < -2.9e38, "early value {w}");
}
#[test]
fn pan_label_max_does_not_overflow() {
assert_eq!(AutoParam::Pan(255).label(), "pan 256");
-27
View File
@@ -1,27 +0,0 @@
//! Release and source Flatpaks must allow release checks and host update helpers.
fn finish_args(manifest: &str) -> Vec<&str> {
manifest
.split("finish-args:")
.nth(1)
.unwrap_or_default()
.lines()
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
.filter_map(|line| line.trim().strip_prefix("- "))
.map(|arg| arg.trim_matches(['\'', '"']))
.collect()
}
#[test]
fn flatpak_update_permissions() {
let manifests = [
include_str!("../../../packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml"),
include_str!("../../../packaging/linux/flatpak/ai.storyteller.soundcraft.yml"),
];
let args = manifests.map(finish_args);
assert_eq!(args[0], args[1], "source and release permissions must agree");
for permissions in args {
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
assert!(permissions.contains(&required), "missing update permission: {required}");
}
}
}
+2 -5
View File
@@ -1,6 +1,6 @@
# Where SoundCraft falls short of Pro Tools
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-11-04 · **Change:** trivial (G-04: volume automation read at every sample, #1354; previously trivial: G-01: `setup.playback_engine` applies the device buffer in the desktop app; G-04: Clip Looping crossfade and loop count) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** trivial (G-01: `setup.playback_engine` applies the device buffer in the desktop app; G-04: Clip Looping crossfade and loop count) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing
real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**
@@ -92,9 +92,7 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t
**Fixed (2026-10-16):** Aux, folder and master latency calculations now skip inserts already
render block, matching track faders. A breakpoint at the next block no longer ramps the preceding
block, and ramps reach their last-sample value. Regression tests cover nonzero render starts,
one-sample callbacks and several block sizes. Since #1354, track and master volume automation is read at
every sample, so its interior breakpoints no longer depend on the block; send levels and plugin parameters
are still read once per block.
one-sample callbacks and several block sizes. Interior automation breakpoints remain block-dependent.
disabled for producing NaN or infinity. Subsequent blocks compensate only for healthy inserts;
regression tests verify rendered impulse timing, retained healthy latency and reset/retry at
@@ -232,7 +230,6 @@ missing. Out of scope unless the owner decides otherwise.
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | trivial | G-04: track and master volume automation read at every sample, so breakpoints act on their own sample in any block size (#1354); estimates unchanged |
| 2026-10-25 | trivial | G-04: Clip Looping (#1030) lacks the loop crossfade option, and Number of Loops stops at 512 while length and fill make up to 1000 loops |
| 2026-10-25 | trivial | G-01: `setup.playback_engine` and `setup.hardware` apply the device and buffer in the desktop app; headless returns `applied: false` |
| 2026-10-24 | trivial | G-08: 27 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing |
+4 -4
View File
@@ -22,7 +22,7 @@ FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
TEST=1
OCI=0
TARBALL=""
while [[ $# -gt 0 ]]; do
while [ $# -gt 0 ]; do
case "$1" in
--no-test) TEST=0; shift ;;
--oci) OCI=1; shift ;;
@@ -40,7 +40,7 @@ case "$ARCH" in
esac
BASENAME="soundcraft-$VERSION-linux-$ARCH"
TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}"
[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
for tool in flatpak flatpak-builder; do
command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
done
@@ -51,7 +51,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle"
rm -rf "$WORK"
mkdir -p "$WORK/stage"
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
[[ -x "$WORK/stage/bin/soundcraft" ]] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; }
[ -x "$WORK/stage/bin/soundcraft" ] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; }
cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
@@ -70,7 +70,7 @@ if [[ "$OCI" = 1 ]]; then
echo "wrote OCI layout to $OUT_OCI"
fi
if [[ "$TEST" = 1 ]]; then
if [ "$TEST" = 1 ]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
flatpak info --user "$APP_ID"
flatpak run --command=soundcraft-cli "$APP_ID" --version
@@ -18,8 +18,6 @@ runtime-version: '25.08'
sdk: org.freedesktop.Sdk
command: soundcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
@@ -23,8 +23,6 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: soundcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland