mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-12 00:44:57 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1fda0dc59 | ||
|
|
ba6bac7f88 | ||
|
|
04878f8fda |
Generated
+24
-25
@@ -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.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -4355,7 +4354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4365,7 +4364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4377,7 +4376,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
@@ -4390,7 +4389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4403,7 +4402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -4421,7 +4420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4432,7 +4431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -4455,7 +4454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4465,7 +4464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -4481,7 +4480,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4494,7 +4493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -4509,7 +4508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4519,7 +4518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -4549,7 +4548,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4561,7 +4560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4577,7 +4576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6225,7 +6224,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+5
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.19.1"
|
||||
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 }
|
||||
|
||||
+16
-209
@@ -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,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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1208,95 +1208,3 @@ fn gain_ramp_matches_converting_both_ends_every_sample() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,6 +18,8 @@ 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,6 +23,8 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user