mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-12 08:53:41 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d47f48e65 | ||
|
|
1f96843a24 |
@@ -27,10 +27,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
|
||||
@@ -27,10 +27,6 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
Generated
+6
-7
@@ -1064,17 +1064,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1097,7 +1097,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -1105,7 +1105,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1126,7 +1126,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1136,7 +1136,6 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"rustix 1.1.5",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+4
-4
@@ -50,15 +50,15 @@ zip = { version = "9", default-features = false }
|
||||
cpal = "0.18"
|
||||
|
||||
# Which file paths stored in a session may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["serde"] }
|
||||
egui = { version = "0.37" }
|
||||
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
|
||||
@@ -454,6 +454,50 @@ 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)?;
|
||||
@@ -663,6 +707,24 @@ 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;
|
||||
|
||||
@@ -94,14 +94,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
|
||||
let r = range_param(e, "file.bounce_stems", p)?;
|
||||
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
|
||||
let format_name = match p.get("format") {
|
||||
None => "wav",
|
||||
Some(value) => value.as_str().ok_or_else(|| bad("file.bounce_stems", "`format` must be a string"))?,
|
||||
};
|
||||
let format = soundcraft_audio_io::FileFormat::from_write_extension(format_name).ok_or_else(|| {
|
||||
let supported = soundcraft_audio_io::FileFormat::writable_extensions().join(", ");
|
||||
bad("file.bounce_stems", format!("cannot write `{format_name}` files (supported: {supported})"))
|
||||
})?;
|
||||
let format = str_param(p, "format")
|
||||
.and_then(soundcraft_audio_io::FileFormat::from_write_extension)
|
||||
.unwrap_or(soundcraft_audio_io::FileFormat::Wav);
|
||||
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
|
||||
Some("16") => soundcraft_audio_io::BitDepth::Int16,
|
||||
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
|
||||
|
||||
@@ -118,71 +118,6 @@ fn import_audio_honours_new_tracks() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_stems_rejects_unsupported_formats_before_writing() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-stems-unsupported-format-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let stems = dir.join("stems");
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
|
||||
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "not-a-format", "start": 0, "end": 1}));
|
||||
let created_output = stems.exists();
|
||||
// Own this directory exclusively; remove any baseline output before the assertion can fail.
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let err = result.map(|_| ()).unwrap_err();
|
||||
assert!(err.to_string().contains("cannot write `not-a-format`"), "{err}");
|
||||
assert!(err.to_string().contains("supported: wav"), "{err}");
|
||||
assert!(!created_output, "an unsupported format must not create the output directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_stems_rejects_non_string_formats_before_writing() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-stems-format-type-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
|
||||
let results: Vec<_> = [json!(null), json!(123), json!(false), json!([]), json!({})]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, format)| {
|
||||
let stems = dir.join(index.to_string());
|
||||
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": format, "start": 0, "end": 1}));
|
||||
(result, stems.exists())
|
||||
})
|
||||
.collect();
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
for (result, created_output) in results {
|
||||
let err = result.map(|_| ()).unwrap_err();
|
||||
assert!(err.to_string().contains("`format` must be a string"), "{err}");
|
||||
assert!(!created_output, "a non-string format must not create the output directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_stems_accepts_explicit_wav_format() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-stems-explicit-wav-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let mut e = Engine::default();
|
||||
let wav = tone_wav(&dir, "tone.wav");
|
||||
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
|
||||
let stems = dir.join("stems");
|
||||
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "wav", "start": 0, "end": 32, "dither": false})).unwrap();
|
||||
let files = result["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
let path = std::path::Path::new(files[0].as_str().unwrap());
|
||||
assert_eq!(path.parent(), Some(stems.as_path()));
|
||||
assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("wav"));
|
||||
let (info, audio) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap();
|
||||
assert_eq!(info.format, soundcraft_audio_io::FileFormat::Wav);
|
||||
assert_eq!(audio.frames(), 32);
|
||||
assert_eq!(audio.num_channels(), e.session().main_format().channels());
|
||||
assert!(audio.channels.iter().flatten().any(|sample| sample.abs() > 0.01));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_refuses_formats_it_cannot_write() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -427,10 +427,6 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
for c in &mut pl.clips {
|
||||
// Locked clips keep their extent, like they don't move for nudge.
|
||||
if locked(c) {
|
||||
continue;
|
||||
}
|
||||
if let Some(i) = c.range().intersect(&range)
|
||||
&& i != c.range()
|
||||
{
|
||||
@@ -452,9 +448,6 @@ pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
for c in &mut pl.clips {
|
||||
if locked(c) {
|
||||
continue;
|
||||
}
|
||||
if c.range().contains(at) && at > c.start {
|
||||
let old = c.start;
|
||||
c.trim_start_to(at);
|
||||
@@ -465,14 +458,10 @@ pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
n
|
||||
}
|
||||
|
||||
/// Trim the end of clips after `at` to `at` (Trim End to Insertion).
|
||||
pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
for c in &mut pl.clips {
|
||||
if locked(c) {
|
||||
continue;
|
||||
}
|
||||
if at > c.start && at < c.end() {
|
||||
c.trim_end_to(at);
|
||||
n += 1;
|
||||
|
||||
@@ -18,68 +18,3 @@ fn trim_to_selection_keeps_clips_sorted() {
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(long, 0, 30), (short, 5, 5)]);
|
||||
}
|
||||
|
||||
fn three_clip_session() -> (Session, soundcraft_model::TrackId, [soundcraft_model::ClipId; 3]) {
|
||||
let mut session = Session::default();
|
||||
let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let ids = [session.new_clip_id(), session.new_clip_id(), session.new_clip_id()];
|
||||
session.track_mut(track).unwrap().playlist_mut().unwrap().clips = vec![
|
||||
Clip::audio(ids[0], "free", SourceId(1), 0, 0, 100),
|
||||
Clip::audio(ids[1], "edit-locked", SourceId(1), 0, 100, 100),
|
||||
Clip::audio(ids[2], "time-locked", SourceId(1), 0, 200, 100),
|
||||
];
|
||||
let pl = session.track_mut(track).unwrap().playlist_mut().unwrap();
|
||||
pl.clips[1].edit_locked = true;
|
||||
pl.clips[2].time_locked = true;
|
||||
(session, track, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_to_selection_leaves_locked_clips_whole() {
|
||||
let (session, track, ids) = three_clip_session();
|
||||
let mut engine = Engine::new(session);
|
||||
let result = engine.execute("edit.trim_to_selection", &json!({"tracks": [track.0], "start": 50, "end": 250})).unwrap();
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(ids[0], 50, 50), (ids[1], 100, 100), (ids[2], 200, 100)]);
|
||||
assert_eq!(result["trimmed"], json!(1));
|
||||
engine.undo();
|
||||
assert_eq!(engine.session().track(track).unwrap().clips()[0].length, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_trim_skips_locked_clips() {
|
||||
let (session, track, ids) = three_clip_session();
|
||||
let mut engine = Engine::new(session);
|
||||
engine.execute("edit.trim_start_to_insertion", &json!({"tracks": [track.0], "at": 150})).unwrap();
|
||||
engine.execute("edit.trim_end_to_insertion", &json!({"tracks": [track.0], "at": 250})).unwrap();
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
assert_eq!(
|
||||
clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(),
|
||||
vec![(ids[0], 0, 100), (ids[1], 100, 100), (ids[2], 200, 100)],
|
||||
"only unlocked clips trim; the edit-locked and time-locked ones keep their extent"
|
||||
);
|
||||
}
|
||||
|
||||
/// A lock on one clip must not suppress eligible unlocked clips on the same track.
|
||||
#[test]
|
||||
fn insertion_trims_still_trim_unlocked_clips_beside_both_lock_kinds() {
|
||||
for (command, expected_start) in [("edit.trim_start_to_insertion", 50), ("edit.trim_end_to_insertion", 0)] {
|
||||
let (mut session, track, ids) = three_clip_session();
|
||||
for clip in &mut session.track_mut(track).unwrap().playlist_mut().unwrap().clips {
|
||||
clip.start = 0;
|
||||
}
|
||||
let before = session.clone();
|
||||
let mut engine = Engine::new(session);
|
||||
let result = engine.execute(command, &json!({"tracks": [track.0], "at": 50})).unwrap();
|
||||
assert_eq!(result["trimmed"], json!(1), "{command}");
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
let free = clips.iter().find(|c| c.id == ids[0]).unwrap();
|
||||
assert_eq!((free.start, free.length), (expected_start, 50), "{command}");
|
||||
for id in &ids[1..] {
|
||||
let locked = clips.iter().find(|c| c.id == *id).unwrap();
|
||||
assert_eq!((locked.start, locked.length), (0, 100), "{command}");
|
||||
}
|
||||
assert!(engine.undo());
|
||||
assert_eq!(engine.session(), &before, "{command} undo");
|
||||
}
|
||||
}
|
||||
|
||||
+21
-136
@@ -1,6 +1,4 @@
|
||||
use super::{AutoParam, Samples, Session, TICKS_PER_QUARTER, Track, Transport, db_to_gain};
|
||||
use soundcraft_dsp::MIN_DB;
|
||||
use soundcraft_model::AutomationLane;
|
||||
|
||||
pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording: bool) -> Transport {
|
||||
let sr = s.sample_rate;
|
||||
@@ -23,146 +21,15 @@ pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording:
|
||||
}
|
||||
}
|
||||
|
||||
/// One term of a fader's level in dB: a fixed value, or an automation lane (with its default).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum Level<'a> {
|
||||
Fixed(f32),
|
||||
Lane(&'a AutomationLane, f32),
|
||||
}
|
||||
|
||||
impl Level<'_> {
|
||||
fn at(&self, pos: Samples) -> f32 {
|
||||
match self {
|
||||
Level::Fixed(v) => *v,
|
||||
Level::Lane(l, default) => l.value_at(pos, *default),
|
||||
}
|
||||
}
|
||||
|
||||
/// The lane's first breakpoint after `pos`. Up to it, the level is linear in time.
|
||||
fn next_point(&self, pos: Samples) -> Option<Samples> {
|
||||
match self {
|
||||
Level::Fixed(_) => None,
|
||||
Level::Lane(l, _) => l.points.get(l.points.partition_point(|p| p.at <= pos)).map(|p| p.at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A track's volume: its automation lane when the mode reads it, else the fader.
|
||||
pub(crate) fn volume_level(t: &Track) -> Level<'_> {
|
||||
pub(crate) fn volume_db_at(t: &Track, pos: Samples) -> f32 {
|
||||
if t.mixer.automation_mode.reads()
|
||||
&& t.mixer.automation_mode != soundcraft_model::AutomationMode::Write
|
||||
&& let Some(l) = t.lane(&AutoParam::Volume)
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
return Level::Lane(l, t.mixer.volume_db);
|
||||
return l.value_at(pos, t.mixer.volume_db);
|
||||
}
|
||||
Level::Fixed(t.mixer.volume_db)
|
||||
}
|
||||
|
||||
/// Calls `f(i, j, d0, d1)` for each stretch `i..=j` of the block at `pos` over which the sum of
|
||||
/// `levels` is linear in dB, with its values at `i` and `j`. Lanes are linear between breakpoints,
|
||||
/// so a stretch ends at the next breakpoint or at the block's last frame.
|
||||
fn for_each_segment(levels: &[Level<'_>], pos: Samples, frames: usize, mut f: impl FnMut(usize, usize, f32, f32)) {
|
||||
let db = |at: Samples| levels.iter().map(|l| l.at(at)).sum::<f32>();
|
||||
let last = frames.saturating_sub(1);
|
||||
let mut i = 0usize;
|
||||
while i < frames {
|
||||
let at = pos.saturating_add(i as i64);
|
||||
let j = levels
|
||||
.iter()
|
||||
.filter_map(|l| l.next_point(at))
|
||||
.min()
|
||||
.map_or(last, |k| usize::try_from(k.saturating_sub(pos)).map_or(last, |j| j.min(last)));
|
||||
f(i, j, db(at), db(pos.saturating_add(j as i64)));
|
||||
i = j + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Multiplies `x` by a gain that goes from `d0` dB at its first sample to `d1` dB at its last,
|
||||
/// linearly in dB, capped at `max_db` and silent at or below [`MIN_DB`] (or NaN), as `db_to_gain`
|
||||
/// would give it sample by sample. Linear in dB is geometric in gain, so the ramp steps by one ratio
|
||||
/// instead of converting every sample; the ends are converted exactly.
|
||||
pub(crate) fn scale(x: &mut [f32], d0: f32, d1: f32, max_db: f32) {
|
||||
let Some(n) = x.len().checked_sub(1) else { return };
|
||||
// Match db_to_gain's ceiling even when the caller requests no fader cap.
|
||||
let max_db = max_db.min(200.0);
|
||||
if d0.is_nan() || d1.is_nan() {
|
||||
x.fill(0.0);
|
||||
return;
|
||||
}
|
||||
if d0 == d1 || n == 0 {
|
||||
let g = gain(d0, max_db);
|
||||
x.iter_mut().for_each(|v| *v *= g);
|
||||
return;
|
||||
}
|
||||
// Extreme finite trim values can underflow or overflow the geometric recurrence.
|
||||
// Convert those rare ramps directly so an inaudible prefix can recover normally.
|
||||
if !(-1000.0..=1000.0).contains(&d0) || !(-1000.0..=1000.0).contains(&d1) {
|
||||
for (i, v) in x.iter_mut().enumerate() {
|
||||
let t = i as f64 / n as f64;
|
||||
let db = if i == 0 {
|
||||
f64::from(d0)
|
||||
} else if i == n {
|
||||
f64::from(d1)
|
||||
} else {
|
||||
f64::from(d0) * (1.0 - t) + f64::from(d1) * t
|
||||
};
|
||||
*v *= gain(db as f32, max_db);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let step = (f64::from(d1) - f64::from(d0)) / n as f64;
|
||||
let ratio = 10f64.powf(step / 20.0);
|
||||
let (floor, cap) = (f64::from(MIN_DB), f64::from(max_db));
|
||||
let gcap = f64::from(db_to_gain(max_db));
|
||||
let (mut d, mut g) = (f64::from(d0), 10f64.powf(f64::from(d0) / 20.0));
|
||||
if let Some((first, rest)) = x.split_first_mut() {
|
||||
*first *= gain(d0, max_db);
|
||||
if let Some((last, mid)) = rest.split_last_mut() {
|
||||
for v in mid {
|
||||
d += step;
|
||||
g *= ratio;
|
||||
let gd = if d.is_nan() || d <= floor {
|
||||
0.0
|
||||
} else if d >= cap {
|
||||
gcap
|
||||
} else {
|
||||
g
|
||||
};
|
||||
*v *= gd as f32;
|
||||
}
|
||||
*last *= gain(d1, max_db);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The gain of `db`, at most `max_db`.
|
||||
fn gain(db: f32, max_db: f32) -> f32 {
|
||||
if db.is_nan() { 0.0 } else { db_to_gain(db.min(max_db)) }
|
||||
}
|
||||
|
||||
/// Fader gain over the block at `pos`, read at every frame: multiplies `buf`'s channels by the
|
||||
/// gain of the sum of `levels` (dB, at most `max_db`).
|
||||
pub(crate) fn apply_gain(levels: &[Level<'_>], pos: Samples, frames: usize, max_db: f32, buf: &mut [Vec<f32>]) {
|
||||
for_each_segment(levels, pos, frames, |i, j, d0, d1| {
|
||||
for c in buf.iter_mut() {
|
||||
if let Some(x) = c.get_mut(i..=j) {
|
||||
scale(x, d0, d1, max_db);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// [`apply_gain`] from `src` into `dst`.
|
||||
pub(crate) fn copy_with_gain(levels: &[Level<'_>], pos: Samples, frames: usize, max_db: f32, src: &[Vec<f32>], dst: &mut [Vec<f32>]) {
|
||||
for_each_segment(levels, pos, frames, |i, j, d0, d1| {
|
||||
for (d, s) in dst.iter_mut().zip(src) {
|
||||
if let (Some(d), Some(s)) = (d.get_mut(i..=j), s.get(i..=j)) {
|
||||
d.copy_from_slice(s);
|
||||
scale(d, d0, d1, max_db);
|
||||
}
|
||||
}
|
||||
});
|
||||
t.mixer.volume_db
|
||||
}
|
||||
|
||||
pub(crate) fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 {
|
||||
@@ -202,3 +69,21 @@ pub(crate) fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct GainRamp {
|
||||
g0: f32,
|
||||
g1: f32,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl GainRamp {
|
||||
pub(crate) fn new(db0: f32, db1: f32, n: usize) -> Self {
|
||||
GainRamp { g0: db_to_gain(db0), g1: db_to_gain(db1), n }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn at(&self, i: usize) -> f32 {
|
||||
if self.n == 0 { self.g0 } else { self.g0 + (self.g1 - self.g0) * (i as f32 / self.n as f32) }
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -71,7 +71,7 @@ mod routing;
|
||||
mod strip;
|
||||
mod threads;
|
||||
|
||||
use automation::{copy_with_gain, pan_at, pan_segment, send_level, transport_at, volume_level};
|
||||
use automation::{GainRamp, pan_at, pan_segment, send_level, transport_at, volume_db_at};
|
||||
pub use instances::{INSTRUMENT_SLOT, InstanceSpec, NewInstance, PluginFailures, PreparedInstance, create_instance, instance_specs, is_third_party};
|
||||
pub use meter::StripMeter;
|
||||
use midi::sync_sample;
|
||||
@@ -539,8 +539,21 @@ impl MixEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Read at every sample, like track faders: a breakpoint acts on its own sample.
|
||||
copy_with_gain(&[volume_level(t)], pos, frames, f32::INFINITY, &strip.buf, &mut self.main);
|
||||
// Match track faders: the next block's first sample must not ramp this one.
|
||||
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
|
||||
let v0 = volume_db_at(t, pos);
|
||||
let v1 = volume_db_at(t, last);
|
||||
let ramp = GainRamp::new(v0, v1, frames.saturating_sub(1));
|
||||
for (ch, src) in strip.buf.iter().enumerate().take(self.main.len()) {
|
||||
if let Some(m) = self.main.get_mut(ch) {
|
||||
for i in 0..frames {
|
||||
let g = ramp.at(i);
|
||||
if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) {
|
||||
*d = x * g;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let m = self.meters.entry(t.id).or_default();
|
||||
m.measure(&self.main, frames, 0.0);
|
||||
m.plugin_failures = strip.failures;
|
||||
|
||||
+22
-9
@@ -1,4 +1,4 @@
|
||||
use super::automation::{Level, apply_gain, transport_at, volume_level};
|
||||
use super::automation::{GainRamp, transport_at, volume_db_at};
|
||||
use super::clips::{ClipFx, clip_fx_params, render_clip};
|
||||
use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance_for, is_third_party};
|
||||
use super::midi::{chase_controllers, schedule_notes};
|
||||
@@ -334,7 +334,7 @@ fn render_instrument(s: &Session, t: &Track, strip: &mut Strip, blk: Block, tran
|
||||
fn run_inserts(t: &Track, strip: &mut Strip, blk: Block, transport: &Transport) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
let mut gr = 0.0f32;
|
||||
let mut latency = strip.instrument.as_ref().filter(|i| !i.failed).map_or(0, |i| i.plugin.latency());
|
||||
let mut latency = 0usize;
|
||||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||||
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
|
||||
if !ins.active || ins.bypass || slot.failed {
|
||||
@@ -356,28 +356,41 @@ fn run_inserts(t: &Track, strip: &mut Strip, blk: Block, transport: &Transport)
|
||||
strip.latency = latency.min(MAX_LATENCY);
|
||||
}
|
||||
|
||||
/// Fader gain (volume, trim and VCA automation), read at every sample of the block.
|
||||
/// Fader gain (volume, trim and VCA automation), ramped across the block.
|
||||
fn apply_fader(s: &Session, t: &Track, strip: &mut Strip, blk: Block) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
// Endpoints are the first and last sample of this block. The next block starts on its own
|
||||
// first sample, so a breakpoint there does not ramp the block before it.
|
||||
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
|
||||
let mut v0 = volume_db_at(t, pos);
|
||||
let mut v1 = volume_db_at(t, last);
|
||||
// Trim automation (an offset on top of the volume curve).
|
||||
let trim = t
|
||||
if let Some(l) = t
|
||||
.automation
|
||||
.iter()
|
||||
.find(|l| matches!(&l.param, AutoParam::Plugin { slot: u8::MAX, param } if param == "trim"))
|
||||
.filter(|l| !l.points.is_empty())
|
||||
.map_or(Level::Fixed(0.0), |l| Level::Lane(l, 0.0));
|
||||
{
|
||||
v0 += l.value_at(pos, 0.0);
|
||||
v1 += l.value_at(last, 0.0);
|
||||
}
|
||||
// VCA master: its fader offsets the member's, and its mute mutes the member.
|
||||
let mut vca_level = Level::Fixed(0.0);
|
||||
if let Some(vid) = t.mixer.vca
|
||||
&& let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca)
|
||||
{
|
||||
vca_level = volume_level(vca);
|
||||
v0 += volume_db_at(vca, pos);
|
||||
v1 += volume_db_at(vca, last);
|
||||
if vca.mixer.mute {
|
||||
strip.muted = true;
|
||||
}
|
||||
}
|
||||
let levels = if strip.muted { [Level::Fixed(-144.0), Level::Fixed(0.0), Level::Fixed(0.0)] } else { [volume_level(t), trim, vca_level] };
|
||||
apply_gain(&levels, pos, frames, 12.0, &mut strip.buf);
|
||||
let (g0, g1) = if strip.muted { (-144.0, -144.0) } else { (v0.min(12.0), v1.min(12.0)) };
|
||||
let ramp = GainRamp::new(g0, g1, frames.saturating_sub(1));
|
||||
for c in strip.buf.iter_mut() {
|
||||
for (i, x) in c.iter_mut().enumerate().take(frames) {
|
||||
*x *= ramp.at(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Track mute gates the audio and its pre-fader tap without splitting processor blocks.
|
||||
|
||||
+7
-217
@@ -941,84 +941,6 @@ fn a_plugins_bogus_latency_is_capped() {
|
||||
assert_eq!((eng.track_latency, eng.latency), (MAX_LATENCY, 2 * MAX_LATENCY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hosted_instruments_latency_is_compensated() {
|
||||
// #1474: a synth that reports latency plays late. Its delay must count toward the mix
|
||||
// latency and align, the way an insert's does.
|
||||
struct LatentSynth {
|
||||
inner: Box<dyn Plugin>,
|
||||
n: usize,
|
||||
t: usize,
|
||||
pending: Option<usize>,
|
||||
}
|
||||
impl Plugin for LatentSynth {
|
||||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||||
self.inner.info()
|
||||
}
|
||||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||||
self.inner.prepare(sr, mb, ch);
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||||
self.inner.set_param(id, v)
|
||||
}
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.inner.param(id)
|
||||
}
|
||||
fn note_on(&mut self, offset: usize, _: u8, _: u8) {
|
||||
self.pending = Some(self.t + offset + self.n);
|
||||
}
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
for c in io.iter_mut() {
|
||||
for (i, x) in c.iter_mut().take(frames).enumerate() {
|
||||
*x = if Some(self.t + i) == self.pending { 0.25 } else { 0.0 };
|
||||
}
|
||||
}
|
||||
self.t += frames;
|
||||
}
|
||||
fn latency(&self) -> usize {
|
||||
self.n
|
||||
}
|
||||
}
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
let n = 500;
|
||||
let mut s = Session::default();
|
||||
s.edit.delay_compensation = true;
|
||||
let audio = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
let mut click = vec![0.0f32; 48_000];
|
||||
click[24_000] = 0.25;
|
||||
s.pool.insert(SourceId(1474), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![click.clone(), click] })));
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(audio).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "click", SourceId(1474), 0, 0, 48_000));
|
||||
let inst = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
|
||||
let mut seq = Sequence::default();
|
||||
seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 960, length: 240 });
|
||||
let mcid = s.new_clip_id();
|
||||
s.track_mut(inst).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(mcid, "note", 0, 48_000, seq));
|
||||
s.track_mut(inst).unwrap().instrument = Some(Insert::new("clap:test.latent"));
|
||||
let mut eng = MixEngine::new(48_000.0, 1024);
|
||||
eng.set_external_instances(true);
|
||||
eng.adopt(vec![PreparedInstance {
|
||||
track: inst,
|
||||
slot: INSTRUMENT_SLOT,
|
||||
id: "clap:test.latent".into(),
|
||||
ch: 2,
|
||||
plugin: Box::new(LatentSynth { inner: soundcraft_dsp::create("gain").unwrap(), n, t: 0, pending: None }),
|
||||
}]);
|
||||
let mut out = vec![vec![0.0; 1024]; 2];
|
||||
let mut rendered = Vec::new();
|
||||
for b in 0..48 {
|
||||
eng.render(&s, b * 1024, 1024, &mut out);
|
||||
rendered.extend_from_slice(&out[0]);
|
||||
}
|
||||
assert_eq!(eng.latency, n, "the hosted instrument's latency is part of the mix latency");
|
||||
let impulses: Vec<_> = rendered.iter().enumerate().filter(|(_, v)| v.abs() > 1e-6).map(|(i, _)| i).collect();
|
||||
assert_eq!(impulses, vec![24_000 + n], "both the audio click and instrument impulse align");
|
||||
assert!((rendered[24_000 + n] - 0.5).abs() < 1e-6, "the aligned impulses add, neither is missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_monitoring_replaces_clips() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
@@ -1275,146 +1197,14 @@ fn apply_matrix_span_matches_the_plain_per_sample_sum() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A volume lane through `points` (sample, dB).
|
||||
fn volume_lane(points: &[(i64, f32)]) -> soundcraft_model::AutomationLane {
|
||||
let mut l = soundcraft_model::AutomationLane::new(AutoParam::Volume);
|
||||
for &(at, db) in points {
|
||||
l.set_point(at, db);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// Volume automation is read at every sample: a breakpoint acts on its own sample, and between
|
||||
/// breakpoints the gain follows the lane (linear in dB), whatever size the render blocks are. A
|
||||
/// 480-sample crossfade used to be resolved once per block, so it moved by up to a block and left
|
||||
/// a hole or not depending on where the block edge fell.
|
||||
#[test]
|
||||
fn volume_automation_follows_its_breakpoints_in_any_block_size() {
|
||||
let (mut s, a) = session_with_dc(0.5, 8192);
|
||||
let b = add_dc_track(&mut s, 0.5, 8192);
|
||||
// A falls from 0 to -144 dB across 2760..3240 while B rises, and back across 5760..6240.
|
||||
let fall = volume_lane(&[(0, 0.0), (2760, 0.0), (3240, -144.0), (5760, -144.0), (6240, 0.0)]);
|
||||
let rise = volume_lane(&[(0, -144.0), (2760, -144.0), (3240, 0.0), (5760, 0.0), (6240, -144.0)]);
|
||||
// One track's level at 0 dB, through the pan law.
|
||||
let per_track = render_range(&s, Range::new(0, 64), 64)[0][0] / 2.0;
|
||||
s.track_mut(a).unwrap().automation.push(fall.clone());
|
||||
s.track_mut(b).unwrap().automation.push(rise.clone());
|
||||
let want = |i: i64| per_track * (db_to_gain(fall.value_at(i, 0.0)) + db_to_gain(rise.value_at(i, 0.0)));
|
||||
for block in [1024, 512, 333, 64] {
|
||||
let out = render_range(&s, Range::new(0, 8192), block);
|
||||
for (i, &x) in out[0].iter().enumerate() {
|
||||
let w = want(i as i64);
|
||||
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "block {block}, sample {i}: {x} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,13 @@ 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);
|
||||
let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(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 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() {
|
||||
@@ -163,9 +169,17 @@ 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() && self.autosave_dir.is_some() {
|
||||
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()))?;
|
||||
}
|
||||
for dir in plan.iter().filter_map(|(_, _, path)| path.parent()) {
|
||||
soundcraft_engine::autosave::create_folder(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
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()))?;
|
||||
}
|
||||
}
|
||||
let takes = plan
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
//! Release and source Flatpaks must allow release checks and host update helpers.
|
||||
fn finish_args(manifest: &str) -> Vec<&str> {
|
||||
manifest
|
||||
.split("finish-args:")
|
||||
.nth(1)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
|
||||
.filter_map(|line| line.trim().strip_prefix("- "))
|
||||
.map(|arg| arg.trim_matches(['\'', '"']))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatpak_update_permissions() {
|
||||
let manifests = [
|
||||
include_str!("../../../packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml"),
|
||||
include_str!("../../../packaging/linux/flatpak/ai.storyteller.soundcraft.yml"),
|
||||
];
|
||||
let args = manifests.map(finish_args);
|
||||
assert_eq!(args[0], args[1], "source and release permissions must agree");
|
||||
for permissions in args {
|
||||
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
|
||||
assert!(permissions.contains(&required), "missing update permission: {required}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-5
@@ -1,6 +1,6 @@
|
||||
# Where SoundCraft falls short of Pro Tools
|
||||
|
||||
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-11-04 · **Change:** trivial (G-04: volume automation read at every sample, #1354; previously trivial: G-01: `setup.playback_engine` applies the device buffer in the desktop app; G-04: Clip Looping crossfade and loop count) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** trivial (G-01: `setup.playback_engine` applies the device buffer in the desktop app; G-04: Clip Looping crossfade and loop count) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing
|
||||
real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**
|
||||
@@ -92,9 +92,7 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t
|
||||
**Fixed (2026-10-16):** Aux, folder and master latency calculations now skip inserts already
|
||||
render block, matching track faders. A breakpoint at the next block no longer ramps the preceding
|
||||
block, and ramps reach their last-sample value. Regression tests cover nonzero render starts,
|
||||
one-sample callbacks and several block sizes. Since #1354, track and master volume automation is read at
|
||||
every sample, so its interior breakpoints no longer depend on the block; send levels and plugin parameters
|
||||
are still read once per block.
|
||||
one-sample callbacks and several block sizes. Interior automation breakpoints remain block-dependent.
|
||||
|
||||
disabled for producing NaN or infinity. Subsequent blocks compensate only for healthy inserts;
|
||||
regression tests verify rendered impulse timing, retained healthy latency and reset/retry at
|
||||
@@ -232,7 +230,6 @@ missing. Out of scope unless the owner decides otherwise.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | trivial | G-04: track and master volume automation read at every sample, so breakpoints act on their own sample in any block size (#1354); estimates unchanged |
|
||||
| 2026-10-25 | trivial | G-04: Clip Looping (#1030) lacks the loop crossfade option, and Number of Loops stops at 512 while length and fill make up to 1000 loops |
|
||||
| 2026-10-25 | trivial | G-01: `setup.playback_engine` and `setup.hardware` apply the device and buffer in the desktop app; headless returns `applied: false` |
|
||||
| 2026-10-24 | trivial | G-08: 27 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing |
|
||||
|
||||
@@ -18,8 +18,6 @@ runtime-version: '25.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: soundcraft
|
||||
finish-args:
|
||||
# The shared updater inspects and updates this installation via flatpak-spawn --host.
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
|
||||
@@ -23,8 +23,6 @@ sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
command: soundcraft
|
||||
finish-args:
|
||||
# The shared updater inspects and updates this installation via flatpak-spawn --host.
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
|
||||
Reference in New Issue
Block a user