Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 0204a7805e chore: bump version to 0.19.1 for the Flatpak update fix 2026-11-11 14:41:01 -05:00
FredApps d255ca4fe9 Merge pull request #1436 from Silvertree2010/fix/1433-separate-splits-locked-clips-edi
Keep time- and edit-locked clips whole on separate and range edits
2026-11-11 20:37:20 +01:00
FredApps 1fe1d06073 test: cover Event time edits on locked clips 2026-11-11 20:24:33 +01:00
Dylan M. Taylor 10e4bc7b87 Fix Flatpak permissions for update checks and self-updates (#1504)
* fix(flatpak): allow release checks and self-update helpers

* test: keep Flatpak permission parsing within strict Clippy rules
2026-11-11 14:19:10 -05:00
1d696bb3ef Replace hard-linked export destinations (#1360)
* Replace hard-linked export destinations

Fixes #1359

Co-Authored-By: GPT-6-Luna <noreply@openai.com>

* fix(engine): preserve export links and private permissions

* fix(io): preserve export security by refusing hard-linked destinations

* test(automation): require safe hard-link export refusal on Windows and Unix

---------

Co-authored-by: GPT-6-Luna <noreply@openai.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-11 20:04:44 +01:00
Dylan M. Taylor 9f342bf403 Merge pull request #1502 from storytold/chore/craft-update-6e03f65
Updates: take craft-update from craft-libs main at 6e03f65bf
2026-11-11 12:45:08 -05:00
Dylan M. Taylor 17649aa693 Updates: take craft-update from craft-libs main at 6e03f65bf 2026-11-11 12:45:03 -05:00
Dylan M. Taylor 0e511b1259 ci: run Harden-Runner on every job (#1501)
Adds step-security/harden-runner (audit, pinned to v2.22.4 like
craft-common-actions) as the first step of each job that did not have it,
so every workflow in the org's public repos reports its egress to
StepSecurity. Audit, not block: block-mode allow lists come from each
job's observed traffic.
2026-11-11 12:41:53 -05:00
Jonathan CardosoandFredApps 927c4f7b72 Read volume automation at every sample, not once per block (#1355)
* Read volume automation at every sample, not once per block

Track and master faders read the dB curve at a block's first and last
sample and ramped linearly in gain between them, so a breakpoint acted up
to a block early or late and a short crossfade came out differently
depending on where the block edges fell. Both now take the gain at each
sample: the curve is read at its breakpoints and interpolated in dB in
between, as the lane defines it. Blocks with no change keep one gain.

Fixes #1354.

* docs: volume breakpoints no longer depend on the render block (G-04)

* Step fader ramps by a ratio and scale channels in contiguous runs

Reading the fader at every sample converted dB to gain per sample on
every ramp, and applied it sample by sample across channels, even when
nothing moved. A stretch that is linear in dB is geometric in gain, so
each stretch now converts its two ends exactly and steps the samples in
between by one ratio (in f64, so it does not drift), with the -144 dB
floor and the cap checked per sample. Each channel is scaled over the
whole stretch at once, and a stretch with no change is a plain multiply.

Tests: stepped ramps against db_to_gain sample by sample (floor, cap,
NaN, one or two samples, a 16384-sample ramp), and summed volume, trim
and VCA lanes with a muted track, read from renders that start inside a
ramp or on block edges.

* fix(mix): recover extreme automation ramps from silence

* fix(mix): preserve extreme ramp endpoints and silence NaNs

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-11 18:41:07 +01:00
69af50aac2 fix(engine): trim skips locked clips (#1432) (#1442)
* trim skips locked clips

* docs(engine): add missing docstring on trim_end_to

* test(engine): cover insertion trims beside locked clips

---------

Co-authored-by: rixpsed <183939167+rixpsed@users.noreply.github.com>
Co-authored-by: rixpsed <rixpsed@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-11 18:18:03 +01:00
Vladimir BabinandFredApps e86d09ab2a Compensate a hosted instrument's latency (#1476)
* Compensate a hosted instrument's latency

Delay compensation summed only insert latency, so a synth reporting
latency played late against everything else in playback and bounces.
run_inserts now seeds the strip latency with the instrument's.

Fixes #1474. Added a mix test that fails without the change: a latent
hosted instrument now contributes its latency to the mix.

Signed-off-by: Vladimir Babin <vovababin@gmail.com>

* test(mix): verify rendered instrument latency alignment

---------

Signed-off-by: Vladimir Babin <vovababin@gmail.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-11 18:08:21 +01:00
FredApps ad34dbf87e Merge pull request #1364 from MMexicann/fix/stem-bounce-output-format
fix(engine): reject unsupported stem bounce formats
2026-11-11 17:43:58 +01:00
ljkx ff4be04936 CLAP host: a reset keeps the queued note-offs, so instruments stop at Stop, Play and Locate (#1464) (#1470)
* CLAP host: a reset keeps the queued note-offs and releases held notes (#1464)

ClapPlugin::reset kept only parameter changes in its event queue and cleared
the held keys, so the note-offs all_notes_off had just queued on Stop, Play
and Locate never reached the plugin. A plugin whose own reset leaves its
voices sounding (Surge XT) played them on at the next Play, and All MIDI
Notes Off had nothing left to release.

Like the VST3 host since #441, reset now keeps the pending releases (CLAP
and MIDI note-offs, one per key, at the start of the next block) and
releases whatever is still held. The fixture synth's reset no longer
silences it, so the integration test sees what Surge XT does.

* CLAP host: keep queue room for the releases a reset sends (#1464)

A queue that distinct parameter changes filled had no room left for the
releases reset queues, so a held note could still sound on. The last 128
queue slots now take only note_off releases, and reset queues one release
per held key that has none yet, which always fits in that reserve.

* CLAP host: a release the full queue drops leaves its key held (#1464)

note_off cleared the key's held flag even when its release did not fit in
the queue, so the reset after all_notes_off had nothing left to release.
The flag is now cleared only when the release was queued; the reset then
sends it, after keep_releases has made room.
2026-11-11 15:07:54 +01:00
NetterSilverandClaude Haiku 5.5 1483dc833b Fix AutomationLane::value_at overflow between extreme opposite-sign points (#1444)
Found while testing crates/model/src/automation.rs:~161 (AutomationLane::value_at)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-11 15:05:23 +01:00
Dylan M. Taylor dc4f82ea9f packaging: use [[ for bash tests (SonarCloud S7688) (#1488)
Behaviour-preserving: `[[ ]]` in place of `[ ]` for bash tests (SonarCloud shell:S7688). Every script has a bash shebang; no test uses `-a`/`-o`, escaped parentheses or an unquoted pattern on the right of `=`. `bash -n` passes on each script; the rest is left to CI.

- `packaging/linux/flatpak-bundle.sh` (4)
2026-11-10 18:45:08 -05:00
Dylan M. Taylor df67089ccd Consolidate: run clip effects over the clip and a bounded tail (#1307)
* Consolidate: run clip effects over the clip and a bounded tail

render_clips allocated and ran a clip-effects workspace spanning the
whole selection for every clip with effects, however short the clip.
Many tiny effect clips in a long selection multiplied allocation and
effect processing by the selection length.

Process each clip's effects in reused 4096-frame blocks from the clip's
start (the effects start from silence, so nothing comes out before it)
through its end, then keep rendering the tail until a block falls below
-180 dBFS or 10 s past the clip's end.

* address review: settle clip effect smoothers on a bounded silent lead-in

* address review: skip the lead-in for gain-only clip effects; keep the full-span chunk grid
2026-11-10 17:58:40 -05:00
Dylan M. Taylor 44e80dcc24 Import: refuse sample-rate conversions that grow audio more than 128-fold (#1327)
* fix(engine): refuse extreme sample-rate expansions before resampling

* address review: widened integer rate-ratio check and a distinct rate-limit error
2026-11-10 17:58:18 -05:00
Mex e1b22258bc test(engine): cover explicit WAV stems and reusable scratch directories
Address the review by clearing stale test scratch directories before setup and exercising an explicit supported WAV format through the real command, including decoded output format, frames and audio.
2026-11-08 13:42:03 +01:00
Mex 61becd52c5 Merge branch 'storytold:main' into fix/stem-bounce-output-format 2026-11-08 13:33:28 +01:00
NetterSilverandClaude Opus 5.5 654deabd54 Keep time- and edit-locked clips whole on separate and range edits
Fixes #1433

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:41:54 +00:00
Mex b93a182ef7 test(engine): cover non-string stem output formats
Exercise null, numeric, boolean, array and object format values through Engine::execute and verify the parameter error without creating a destination. The regression fails before the guard; all 363 engine unit tests pass with it.
2026-11-05 00:11:18 +01:00
Mex bf2a6b52de fix(engine): reject non-string stem output formats
Address review r3017244428: only an absent format defaults to WAV; a present non-string value returns a parameter error before creating the output directory.
2026-11-05 00:09:33 +01:00
Mex b8e1e69134 test(engine): cover unsupported stem output formats 2026-11-04 23:35:04 +01:00
Mex b6946d3a94 fix(engine): reject unsupported stem bounce formats
Refactor file.bounce_stems command to improve format handling.
2026-11-04 23:33:22 +01:00
32 changed files with 1419 additions and 147 deletions
+4
View File
@@ -27,6 +27,10 @@ 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,6 +27,10 @@ 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
+26 -24
View File
@@ -1064,17 +1064,17 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
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=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
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=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
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=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -1136,6 +1136,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -4330,7 +4331,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"chrono",
"clap",
@@ -4354,7 +4355,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"log",
"serde",
@@ -4364,7 +4365,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"log",
"proptest",
@@ -4376,7 +4377,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"clap",
"log",
@@ -4389,7 +4390,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4402,7 +4403,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"clap",
"clap_complete",
@@ -4420,7 +4421,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"log",
"proptest",
@@ -4431,7 +4432,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"craft-docpath",
"log",
@@ -4449,12 +4450,13 @@ dependencies = [
"soundcraft-video",
"soundcraft-vst3-host",
"thiserror 2.0.21",
"winsafe",
"zip",
]
[[package]]
name = "soundcraft-midi"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"proptest",
"serde",
@@ -4464,7 +4466,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"log",
"rayon",
@@ -4480,7 +4482,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"proptest",
"serde",
@@ -4493,7 +4495,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"cpal",
"log",
@@ -4508,7 +4510,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"proptest",
"serde",
@@ -4518,7 +4520,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"craft-ui",
"craft-update",
@@ -4548,7 +4550,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"craft-media",
"image",
@@ -4560,7 +4562,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4576,7 +4578,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"eframe",
"egui",
@@ -6224,7 +6226,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.19.1"
dependencies = [
"serde_json",
]
+5 -5
View File
@@ -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 = "1b34eba41e62138fc2438d2734523b302840b45d" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
# 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 = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["serde"] }
egui = { version = "0.37" }
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
egui_extras = { version = "0.37", default-features = false }
@@ -310,6 +310,36 @@ 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]
+209 -16
View File
@@ -10,6 +10,10 @@ 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 {
@@ -79,14 +83,104 @@ impl ClapPlugin {
self.queue.retain(|e| !matches!(e, Event::Param { .. }));
}
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
/// allocates on the audio thread).
/// Queues an event other than a note release; see [`push_event`].
fn push(&mut self, e: Event) {
if self.queue.len() >= self.queue.capacity() {
return;
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;
}
let pos = self.queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
self.queue.insert(pos, e);
}
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,
}
}
@@ -185,8 +279,10 @@ impl Plugin for ClapPlugin {
fn reset(&mut self) {
self.inst.reset();
self.queue.retain(|e| matches!(e, Event::Param { .. }));
self.held = [false; 128];
// 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);
}
fn set_param(&mut self, id: &str, value: f32) -> bool {
@@ -287,14 +383,7 @@ impl Plugin for ClapPlugin {
return;
}
let time = u32::try_from(offset).unwrap_or(u32::MAX);
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;
}
queue_release(&mut self.queue, &mut self.held, self.notes, time, note);
}
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
@@ -383,3 +472,107 @@ 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.
//! MIDI CC 7 (volume) sets the constant to its value / 127. `reset` leaves the note sounding.
//!
//! - `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;
}
unsafe extern "C" fn reset(p: *const clap_plugin) {
unsafe { fx(p) }.level = 0.0;
}
/// 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) {}
fn apply(f: &mut Fx, h: &clap_event_header) {
if h.space_id != CLAP_CORE_EVENT_SPACE_ID {
+42
View File
@@ -109,6 +109,48 @@ 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,6 +25,9 @@ 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)
.ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?;
.map_err(|r| EngineError::Io(format!("{rel}: {}", r.message("too large to import"))))?;
decoded.push((rel.clone(), buf));
index.insert(key, decoded.len() - 1);
decoded.len() - 1
+3
View File
@@ -154,6 +154,7 @@ 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 {
@@ -185,6 +186,8 @@ 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) {
+8 -3
View File
@@ -94,9 +94,14 @@ 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 = str_param(p, "format")
.and_then(soundcraft_audio_io::FileFormat::from_write_extension)
.unwrap_or(soundcraft_audio_io::FileFormat::Wav);
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 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,6 +118,71 @@ 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)
.ok_or_else(|| EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")))?;
.map_err(|r| EngineError::Io(format!("{name}: {}", r.message("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);
+26 -4
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`. 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`, 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.
pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
separate_at_if(s, track, at, |_| true)
}
@@ -88,11 +88,21 @@ 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()).count());
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());
for _ in 0..n_split {
ids.push(s.new_clip_id());
}
@@ -106,6 +116,7 @@ pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: i
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)
{
@@ -427,6 +438,10 @@ 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()
{
@@ -448,6 +463,9 @@ 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);
@@ -458,10 +476,14 @@ 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;
+251 -1
View File
@@ -891,7 +891,99 @@ 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)?;
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
// 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(())
}
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
@@ -1019,6 +1111,164 @@ 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 {
+51 -7
View File
@@ -7,18 +7,48 @@ use super::{
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30;
/// `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> {
/// 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> {
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 None;
return Err(RateError::TooLarge);
}
if from != rate {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, from, rate);
buf.sample_rate = rate;
}
Some(buf)
Ok(buf)
}
/// A relative `Audio Files/` path no other source in `s` already uses.
@@ -115,7 +145,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).ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| EngineError::Io(format!("{name}: {}", e.message("file is too large to import"))))?;
if buf.frames() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
}
@@ -215,8 +245,22 @@ 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_none());
assert!(at_rate(buf(1), 48_000, 1_000_000).is_err());
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).ok_or("too large to load")?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| e.message("too large to load"))?;
Ok(Arc::new(SourceAudio::new(buf)))
}
+45
View File
@@ -579,3 +579,48 @@ 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,3 +18,68 @@ 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");
}
}
+136 -21
View File
@@ -1,4 +1,6 @@
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;
@@ -21,15 +23,146 @@ pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording:
}
}
pub(crate) fn volume_db_at(t: &Track, pos: Samples) -> f32 {
/// 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<'_> {
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 l.value_at(pos, t.mixer.volume_db);
return Level::Lane(l, t.mixer.volume_db);
}
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);
}
}
});
}
pub(crate) fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 {
@@ -69,21 +202,3 @@ 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,6 +105,10 @@ 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);
+3 -16
View File
@@ -71,7 +71,7 @@ mod routing;
mod strip;
mod threads;
use automation::{GainRamp, pan_at, pan_segment, send_level, transport_at, volume_db_at};
use automation::{copy_with_gain, pan_at, pan_segment, send_level, transport_at, volume_level};
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,21 +539,8 @@ impl MixEngine {
}
}
}
// 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;
}
}
}
}
// 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);
let m = self.meters.entry(t.id).or_default();
m.measure(&self.main, frames, 0.0);
m.plugin_failures = strip.failures;
+59 -7
View File
@@ -132,33 +132,85 @@ 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) => {
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());
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());
fx.configure(params);
let mut done = 0;
while done < len {
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();
// 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;
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);
}
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;
}
}
}
}
+9 -22
View File
@@ -1,4 +1,4 @@
use super::automation::{GainRamp, transport_at, volume_db_at};
use super::automation::{Level, apply_gain, transport_at, volume_level};
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 = 0usize;
let mut latency = strip.instrument.as_ref().filter(|i| !i.failed).map_or(0, |i| i.plugin.latency());
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,41 +356,28 @@ 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), ramped across the block.
/// Fader gain (volume, trim and VCA automation), read at every sample of 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).
if let Some(l) = t
let trim = t
.automation
.iter()
.find(|l| matches!(&l.param, AutoParam::Plugin { slot: u8::MAX, param } if param == "trim"))
.filter(|l| !l.points.is_empty())
{
v0 += l.value_at(pos, 0.0);
v1 += l.value_at(last, 0.0);
}
.map_or(Level::Fixed(0.0), |l| Level::Lane(l, 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)
{
v0 += volume_db_at(vca, pos);
v1 += volume_db_at(vca, last);
vca_level = volume_level(vca);
if vca.mixer.mute {
strip.muted = true;
}
}
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);
}
}
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);
}
/// Track mute gates the audio and its pre-fader tap without splitting processor blocks.
+309 -7
View File
@@ -941,6 +941,84 @@ 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);
@@ -1197,14 +1275,238 @@ 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 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}");
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}");
}
}
}
/// 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);
}
+15 -1
View File
@@ -164,7 +164,10 @@ 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);
a.value + (b.value - a.value) * (off as f64 / span as f64) as f32
// 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
}
(Some(a), _) => a.value,
_ => default,
@@ -281,6 +284,17 @@ 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
@@ -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}");
}
}
}
+5 -2
View File
@@ -1,6 +1,6 @@
# Where SoundCraft falls short of Pro Tools
> **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
> **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
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,7 +92,9 @@ 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. Interior automation breakpoints remain block-dependent.
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.
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
@@ -230,6 +232,7 @@ 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,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