Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 8fcab7fa62 test: keep Flatpak permission parsing within strict Clippy rules 2026-11-11 14:08:30 -05:00
Dylan M. Taylor 2486218f67 fix(flatpak): allow release checks and self-update helpers 2026-11-11 13:59:18 -05: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
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
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
18 changed files with 572 additions and 160 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
+7 -6
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",
+4 -4
View File
@@ -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 }
-62
View File
@@ -454,50 +454,6 @@ pub fn create_folder(dir: &Path) -> std::io::Result<()> {
builder.create(dir)
}
/// A folder at a predictable place in a shared folder (the system's temporary folder), for work only
/// this account may see. It is created exclusively, owner-only; one that already exists must be a
/// real folder (not a link) owned by this account and open to no one else, since another account
/// could have made it first and could read, or swap, what is written into it.
pub fn create_private_folder(dir: &Path) -> std::io::Result<()> {
if let Some(parent) = dir.parent() {
std::fs::create_dir_all(parent)?;
}
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
match builder.create(dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => check_own_private_folder(dir),
Err(e) => Err(e),
}
}
#[cfg(unix)]
fn check_own_private_folder(dir: &Path) -> std::io::Result<()> {
use std::os::unix::fs::MetadataExt;
let refuse = |why: &str| Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, why.to_string()));
let meta = std::fs::symlink_metadata(dir)?;
if !meta.is_dir() {
return refuse("it exists and is not a folder");
}
if meta.mode() & 0o077 != 0 {
return refuse("it exists and is open to other accounts");
}
// This account's id, from a file it creates (std has no direct call for it).
let probe = dir.parent().unwrap_or(dir).join(format!(".soundcraft-owner-{}", std::process::id()));
let own = std::fs::OpenOptions::new().write(true).create_new(true).open(&probe).and_then(|f| f.metadata());
let _ = std::fs::remove_file(&probe);
if own?.uid() != meta.uid() {
return refuse("it exists and belongs to another account");
}
Ok(())
}
#[cfg(not(unix))]
fn check_own_private_folder(_dir: &Path) -> std::io::Result<()> {
Ok(())
}
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
@@ -707,24 +663,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(unix)]
#[test]
fn a_private_folder_is_refused_when_it_already_exists_open() {
use std::os::unix::fs::PermissionsExt;
let base = folder("private-folder");
let dir = base.join("soundcraft-recordings-1");
create_private_folder(&dir).unwrap();
create_private_folder(&dir).unwrap();
assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(create_private_folder(&dir).is_err(), "open to others");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
let link = base.join("soundcraft-recordings-2");
std::os::unix::fs::symlink(&dir, &link).unwrap();
assert!(create_private_folder(&link).is_err(), "a link is not our folder");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn a_take_still_recording_at_a_crash_is_placed_on_recovery() {
use crate::io::Take;
+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();
+11
View File
@@ -427,6 +427,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 +452,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 +465,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;
@@ -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) }
}
}
+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;
+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.
+217 -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,146 @@ 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}");
}
}
}
+3 -17
View File
@@ -155,13 +155,7 @@ impl SoundApp {
/// autosave folder. Returns the takes it records.
fn arm_recorder(&mut self) -> Result<Vec<soundcraft_engine::io::Take>, String> {
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
// Without autosave storage the takes of a session never saved go to a folder of their own under
// the shared temporary folder, made private below, not into the shared folder itself.
let shared_temp = self.autosave_dir.is_none();
let unsaved_dir = self
.autosave_dir
.as_ref()
.map_or_else(|| std::env::temp_dir().join(format!("soundcraft-recordings-{}", std::process::id())), |d| d.join(name));
let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name));
let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) };
let plan = soundcraft_engine::io::plan_takes(&self.engine, &unsaved_dir, r.channels);
if plan.is_empty() {
@@ -169,17 +163,9 @@ impl SoundApp {
}
// The takes are unsaved work: the folder they go into under the autosave root is
// owner-only, like the autosaves, before the recorder makes anything in it.
if self.engine.path.is_none() {
if shared_temp {
soundcraft_engine::autosave::create_private_folder(&unsaved_dir).map_err(|e| format!("{}: {e}", unsaved_dir.display()))?;
}
if self.engine.path.is_none() && self.autosave_dir.is_some() {
for dir in plan.iter().filter_map(|(_, _, path)| path.parent()) {
let made = if shared_temp {
soundcraft_engine::autosave::create_private_folder(dir)
} else {
soundcraft_engine::autosave::create_folder(dir)
};
made.map_err(|e| format!("{}: {e}", dir.display()))?;
soundcraft_engine::autosave::create_folder(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
}
let takes = plan
+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 |
@@ -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