mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-30 05:26:52 -06:00
Compare commits
74
Commits
release
...
m/land-995
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81ab012724 | ||
|
|
a62d955a39 | ||
|
|
52083e07f1 | ||
|
|
b46584cc30 | ||
|
|
52b5cea05f | ||
|
|
228fd37ab7 | ||
|
|
60d18f225d | ||
|
|
ba0c6e381f | ||
|
|
bcb872f1a3 | ||
|
|
b119e3adce | ||
|
|
0d8dde8294 | ||
|
|
94fb7db790 | ||
|
|
3f5377c877 | ||
|
|
3b87c6dcc2 | ||
|
|
d2244d0765 | ||
|
|
e2809001cf | ||
|
|
92c925a1dc | ||
|
|
140e63fe09 | ||
|
|
c3cf5fe236 | ||
|
|
1ce93ab2ea | ||
|
|
dd1ac56430 | ||
|
|
fda41ecd12 | ||
|
|
6a10dfa19d | ||
|
|
672f5ab767 | ||
|
|
11ab6a999c | ||
|
|
b84d6c95d5 | ||
|
|
1d99907d85 | ||
|
|
57b3ae5357 | ||
|
|
2e6c519bef | ||
|
|
adc7c13a37 | ||
|
|
f5d7ca5d87 | ||
|
|
a6df32ffab | ||
|
|
7b654fd1a3 | ||
|
|
f16e312fbc | ||
|
|
986fb951df | ||
|
|
5dbebaf617 | ||
|
|
5c8654b2d7 | ||
|
|
d97e5e7f16 | ||
|
|
a63b23adf7 | ||
|
|
0ede26f5bc | ||
|
|
09444326d5 | ||
|
|
b609172db6 | ||
|
|
65e5443975 | ||
|
|
1c284e3703 | ||
|
|
179f0c8c5d | ||
|
|
8ca6939981 | ||
|
|
60b2c07a70 | ||
|
|
e600e6bf64 | ||
|
|
90932afed0 | ||
|
|
50d0028e07 | ||
|
|
1a20190149 | ||
|
|
c14dc61473 | ||
|
|
ceb3404c31 | ||
|
|
ed0aed5ccb | ||
|
|
1aab8e449d | ||
|
|
c7a7cfa09b | ||
|
|
79c45a154d | ||
|
|
9adcc1f389 | ||
|
|
795f1cb6ef | ||
|
|
e631966cf7 | ||
|
|
45498c8268 | ||
|
|
0d0d0abc27 | ||
|
|
9dc26fa1ca | ||
|
|
7a9b3e38e1 | ||
|
|
9a99c82d2c | ||
|
|
0c7212854a | ||
|
|
ee55acbc93 | ||
|
|
9f049ac7d0 | ||
|
|
7509b64c5c | ||
|
|
4f771268b0 | ||
|
|
8385ed94c6 | ||
|
|
8f23afe462 | ||
|
|
6d86a52310 | ||
|
|
2e057309b9 |
@@ -154,7 +154,8 @@ pub(crate) struct RunArgs {
|
||||
pub(crate) struct ScriptArgs {
|
||||
/// The script to run
|
||||
pub(crate) file: PathBuf,
|
||||
/// Report a failing line and go on with the next
|
||||
/// Continue after command errors and finish requested output, then exit nonzero if any command failed.
|
||||
/// Malformed JSON stops immediately
|
||||
#[arg(long)]
|
||||
pub(crate) keep_going: bool,
|
||||
#[command(flatten)]
|
||||
|
||||
@@ -20,6 +20,8 @@ pub(crate) enum CliError {
|
||||
ScriptJson { path: PathBuf, line: usize, source: serde_json::Error },
|
||||
#[error("{}:{line}: {id}: {source}", path.display())]
|
||||
ScriptLine { path: PathBuf, line: usize, id: String, source: EngineError },
|
||||
#[error("script finished with command errors")]
|
||||
ScriptFailed,
|
||||
#[error("{}: {source}", path.display())]
|
||||
File { path: PathBuf, source: std::io::Error },
|
||||
#[error("{}: {source}", path.display())]
|
||||
|
||||
@@ -75,6 +75,7 @@ pub(crate) fn script(args: &ScriptArgs, out: &mut dyn Write, err: &mut dyn Write
|
||||
let text = std::fs::read_to_string(path).map_err(|source| CliError::File { path: path.clone(), source })?;
|
||||
let mut e = load_engine(&args.engine, err)?;
|
||||
let keep = args.keep_going;
|
||||
let mut failed = false;
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
@@ -90,10 +91,12 @@ pub(crate) fn script(args: &ScriptArgs, out: &mut dyn Write, err: &mut dyn Write
|
||||
return Err(failure);
|
||||
}
|
||||
outln!(err, "{failure}");
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
finish(&mut e, &args.engine, out)
|
||||
finish(&mut e, &args.engine, out)?;
|
||||
if failed { Err(CliError::ScriptFailed) } else { Ok(()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -238,13 +238,13 @@ fn script_line_errors_stop_unless_keep_going() {
|
||||
let dir = sandbox("script-errors");
|
||||
std::fs::write(dir.join("bad.txt"), "nosuch.cmd {}\ntrack.new {}\n").unwrap();
|
||||
std::fs::write(dir.join("badjson.txt"), "track.new {x\n").unwrap();
|
||||
// A stopping line error carries the `error:` prefix; a --keep-going one is a bare line.
|
||||
// Kept-going line errors stay bare; a final error summary supplies the failing status.
|
||||
let stop = invoke(&dir, &["script", "bad.txt"]);
|
||||
assert_eq!((stop.code, stop.stdout.as_str()), (1, ""));
|
||||
assert_eq!(stop.stderr, "error: bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n");
|
||||
let keep = invoke(&dir, &["script", "bad.txt", "--keep-going"]);
|
||||
assert_eq!((keep.code, keep.stdout.as_str()), (0, "track.new: {\"tracks\":[1]}\n"));
|
||||
assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n");
|
||||
assert_eq!((keep.code, keep.stdout.as_str()), (1, "track.new: {\"tracks\":[1]}\n"));
|
||||
assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\nerror: script finished with command errors\n");
|
||||
assert_fails(&invoke(&dir, &["script", "badjson.txt"]), "badjson.txt:1: bad JSON: key must be a string at line 1 column 2");
|
||||
}
|
||||
|
||||
@@ -515,10 +515,9 @@ fn mcp_roots_with_connect_are_a_usage_error() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A name that isn't valid Unicode works wherever the tool itself reads or writes the file
|
||||
/// (`convert`); a session or bounce the engine opens or writes by text name is refused (exit 1),
|
||||
/// naming the file. Run in-process: the names need no shell.
|
||||
#[cfg(unix)]
|
||||
/// Native file paths round-trip on filesystems that accept non-Unicode names. macOS rejects
|
||||
/// these names with EILSEQ before the tool can write them. Run in-process: no shell is needed.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn non_unicode_file_names() {
|
||||
use std::ffi::OsString;
|
||||
@@ -543,8 +542,26 @@ fn non_unicode_file_names() {
|
||||
std::fs::rename(dir.join("s.scraft"), latin1("scraft")).unwrap();
|
||||
let (code, err) = run(vec!["info".into(), latin1("scraft").into()]);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
let (code, err) = run(vec!["run".into(), "--demo".into(), "--bounce".into(), latin1("wav").into()]);
|
||||
assert_eq!(code, 1);
|
||||
assert!(err.contains("caf\u{FFFD}.") && err.contains("not valid Unicode"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Engine output paths enter JSON, so invalid Unicode must be refused even on a filesystem
|
||||
/// that cannot create such a file (including macOS).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_session_outputs_are_rejected() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let dir = sandbox("nonunicode-outputs");
|
||||
for (flag, name) in [("--bounce", b"caf\xe9.wav".as_slice()), ("--save", b"caf\xe9.scraft".as_slice())] {
|
||||
let path = dir.join(OsString::from_vec(name.to_vec()));
|
||||
let (mut out, mut err) = (Vec::new(), Vec::new());
|
||||
let code = soundcraft_cli::run(vec!["run".into(), "--demo".into(), flag.into(), path.into_os_string()], &mut out, &mut err);
|
||||
let err = String::from_utf8_lossy(&err);
|
||||
assert_eq!(code, 1, "{err}");
|
||||
assert!(err.contains("caf\u{FFFD}.") && err.contains("not valid Unicode"), "{err}");
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ mod closed_stdout;
|
||||
mod man_and_completions;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod run;
|
||||
mod script_status;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let sequence = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("soundcraft-script-status-{}-{stamp}-{sequence}", std::process::id()));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn invoke(dir: &Path, args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")).current_dir(dir).env("SOUNDCRAFT_NO_PREFS", "1").args(args).output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_going_runs_later_commands_and_finishes_but_reports_failure() {
|
||||
let dir = Scratch::new();
|
||||
std::fs::write(
|
||||
dir.0.join("audit.txt"),
|
||||
"audit.deliberately_invalid {}\ntrack.new {\"name\":\"After failure\",\"count\":1,\"format\":\"stereo\"}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let stop = invoke(&dir.0, &["script", "audit.txt", "--save", "stopped.scraft"]);
|
||||
assert_eq!(stop.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&stop.stderr).contains("audit.deliberately_invalid"));
|
||||
assert!(!String::from_utf8_lossy(&stop.stdout).contains("track.new:"));
|
||||
assert!(!dir.0.join("stopped.scraft").exists());
|
||||
|
||||
let keep = invoke(
|
||||
&dir.0,
|
||||
&["script", "audit.txt", "--keep-going", "--bounce", "after.wav", "--start", "0", "--end", "0.01", "--save", "after.scraft", "--inspect"],
|
||||
);
|
||||
assert_eq!(keep.status.code(), Some(1), "{}", String::from_utf8_lossy(&keep.stderr));
|
||||
assert!(String::from_utf8_lossy(&keep.stderr).contains("audit.deliberately_invalid"));
|
||||
let stdout = String::from_utf8_lossy(&keep.stdout);
|
||||
assert!(stdout.contains("track.new:") && stdout.contains("After failure"), "{stdout}");
|
||||
assert!(dir.0.join("after.wav").is_file());
|
||||
let info = invoke(&dir.0, &["info", "after.scraft"]);
|
||||
assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr));
|
||||
assert!(String::from_utf8_lossy(&info.stdout).contains("After failure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_script_and_parsing_or_finish_failures_keep_their_status() {
|
||||
let dir = Scratch::new();
|
||||
std::fs::write(dir.0.join("good.txt"), "track.new {\"name\":\"Good\",\"count\":1,\"format\":\"stereo\"}\n").unwrap();
|
||||
let success = invoke(&dir.0, &["script", "good.txt", "--keep-going"]);
|
||||
assert!(success.status.success(), "{}", String::from_utf8_lossy(&success.stderr));
|
||||
|
||||
std::fs::write(dir.0.join("bad.txt"), "track.new {bad\ntrack.new {\"name\":\"Too late\"}\n").unwrap();
|
||||
let malformed = invoke(&dir.0, &["script", "bad.txt", "--keep-going", "--save", "malformed.scraft"]);
|
||||
assert_eq!(malformed.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&malformed.stderr).contains("bad JSON"));
|
||||
assert!(!String::from_utf8_lossy(&malformed.stdout).contains("track.new:"));
|
||||
assert!(!dir.0.join("malformed.scraft").exists());
|
||||
|
||||
std::fs::write(dir.0.join("parent"), "not a directory").unwrap();
|
||||
for (option, path) in [("--save", "parent/out.scraft"), ("--bounce", "parent/out.wav")] {
|
||||
let fail = invoke(&dir.0, &["script", "good.txt", "--keep-going", option, path]);
|
||||
assert_eq!(fail.status.code(), Some(1), "{option}: {}", String::from_utf8_lossy(&fail.stderr));
|
||||
assert!(String::from_utf8_lossy(&fail.stderr).contains("parent"));
|
||||
}
|
||||
|
||||
std::fs::write(dir.0.join("engine.txt"), "session.save {}\ntrack.new {\"name\":\"After engine failure\",\"count\":1}\n").unwrap();
|
||||
let fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "engine.scraft"]);
|
||||
assert_eq!(fail.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&fail.stderr).contains("session.save"));
|
||||
assert!(String::from_utf8_lossy(&fail.stdout).contains("track.new:"));
|
||||
assert!(dir.0.join("engine.scraft").is_file());
|
||||
|
||||
let both_fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "parent/out.scraft"]);
|
||||
assert_eq!(both_fail.status.code(), Some(1));
|
||||
let errors = String::from_utf8_lossy(&both_fail.stderr);
|
||||
assert!(errors.contains("session.save") && errors.contains("parent"), "{errors}");
|
||||
assert!(!errors.contains("script finished with command errors"), "{errors}");
|
||||
}
|
||||
@@ -61,3 +61,7 @@ display_name = "Matt Smith-Burlage"
|
||||
|
||||
[people.ProdByBuddha]
|
||||
sync_github_name = true
|
||||
|
||||
[people.luigiPinna]
|
||||
real_name = "Luigi Pinna"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -580,13 +580,46 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
Ok((info, AudioBuffer { sample_rate, channels: planar }))
|
||||
}
|
||||
|
||||
/// Frames symphonia's AAC decoder yields for every packet; it supports no other frame length.
|
||||
const AAC_FRAMES_PER_PACKET: u64 = 1024;
|
||||
|
||||
/// Whether `track` of `reader` is AAC in MP4. The MP4 reader reports the stts total as the length,
|
||||
/// but the decoder yields a whole frame per packet (sample durations are not applied), so a last
|
||||
/// sample whose duration differs from a frame makes the two disagree.
|
||||
fn is_mp4_aac(reader: &dyn FormatReader, track_id: u32) -> bool {
|
||||
reader.format_info().short_name == "isomp4"
|
||||
&& reader.tracks().iter().find(|t| t.id == track_id).and_then(audio_params).is_some_and(|p| p.codec == CODEC_ID_AAC)
|
||||
}
|
||||
|
||||
/// The decoded length of AAC in MP4: count the track's packets without decoding them.
|
||||
fn mp4_aac_frames(reader: &mut dyn FormatReader, track_id: u32) -> Result<u64> {
|
||||
let mut frames: u64 = 0;
|
||||
loop {
|
||||
match reader.next_packet() {
|
||||
Ok(Some(packet)) if packet.track_id == track_id => frames = frames.saturating_add(AAC_FRAMES_PER_PACKET),
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => break,
|
||||
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(SymError::ResetRequired) => break,
|
||||
Err(e) => {
|
||||
demux_failure(frames, e)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
|
||||
guarded(|| {
|
||||
let reader = match open(bytes, detected)? {
|
||||
let mut reader = match open(bytes, detected)? {
|
||||
Opened::Reader(reader) => reader,
|
||||
Opened::Empty(info) => return Ok(info),
|
||||
};
|
||||
let (_, info) = info_of(reader.as_ref(), detected)?;
|
||||
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
|
||||
if info.frames > 0 && info.channels > 0 && is_mp4_aac(reader.as_ref(), track_id) {
|
||||
info.frames = mp4_aac_frames(reader.as_mut(), track_id)?;
|
||||
}
|
||||
if info.frames > 0 && info.channels > 0 {
|
||||
return Ok(info);
|
||||
}
|
||||
|
||||
@@ -342,7 +342,10 @@ pub(crate) fn read_channel_mask(b: &[u8]) -> Option<u32> {
|
||||
|
||||
fn put_fixed_str(out: &mut Vec<u8>, s: &str, len: usize) {
|
||||
let bytes = s.as_bytes();
|
||||
let n = bytes.len().min(len);
|
||||
let mut n = bytes.len().min(len);
|
||||
while !s.is_char_boundary(n) {
|
||||
n -= 1;
|
||||
}
|
||||
out.extend_from_slice(bytes.get(..n).unwrap_or_default());
|
||||
out.resize(out.len() + (len - n), 0);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ mod decode_read_list;
|
||||
mod file_format;
|
||||
mod float_headroom;
|
||||
mod malformed;
|
||||
mod mp4_length;
|
||||
mod roundtrip;
|
||||
mod stream;
|
||||
mod wav_empty_data;
|
||||
|
||||
@@ -208,6 +208,70 @@ fn caf_sizes_beyond_the_file_are_errors_not_aborts() {
|
||||
assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets")));
|
||||
}
|
||||
|
||||
/// An EBML element: `id`, then `size` as an 8-byte vint (so any size up to 2^56 − 2 fits, and
|
||||
/// `u64::MAX` writes the reserved "unknown size"), then `body`.
|
||||
fn ebml(id: &[u8], size: u64, body: &[u8]) -> Vec<u8> {
|
||||
let size = if size == u64::MAX { 0x01FF_FFFF_FFFF_FFFF } else { 0x0100_0000_0000_0000 | size };
|
||||
[id, &size.to_be_bytes(), body].concat()
|
||||
}
|
||||
|
||||
/// The EBML header of a Matroska file.
|
||||
fn ebml_header() -> Vec<u8> {
|
||||
let doc_type = ebml(&[0x42, 0x82], 8, b"matroska");
|
||||
ebml(&[0x1A, 0x45, 0xDF, 0xA3], doc_type.len() as u64, &doc_type)
|
||||
}
|
||||
|
||||
/// #878: symphonia's Matroska reader takes element sizes from the file. With symphonia 0.5 a size
|
||||
/// far beyond the file was allocated up front and aborted the process (no `catch_unwind` can catch
|
||||
/// an allocation failure); since 0.6 such reads grow with the data actually read. Keep it that way
|
||||
/// for the header, for codec data and for block frames.
|
||||
#[test]
|
||||
fn matroska_sizes_beyond_the_file_are_errors_not_aborts() {
|
||||
let huge = 1u64 << 47;
|
||||
// The 30-byte file from the report: EBML header and DocType sizes of 2^48 − 1 and 2^47.
|
||||
let reported = [
|
||||
0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x42, 0x82, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6D,
|
||||
0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61,
|
||||
];
|
||||
let segment = |body: Vec<u8>| [ebml_header(), ebml(&[0x18, 0x53, 0x80, 0x67], u64::MAX, &body)].concat();
|
||||
let info = [ebml(&[0x2A, 0xD7, 0xB1], 3, &[0x0F, 0x42, 0x40]), ebml(&[0x4D, 0x80], 4, b"test"), ebml(&[0x57, 0x41], 4, b"test")].concat();
|
||||
let info = ebml(&[0x15, 0x49, 0xA9, 0x66], info.len() as u64, &info);
|
||||
// A valid 16-bit PCM track entry's children, followed by `extra`.
|
||||
let pcm = |extra: &[u8]| {
|
||||
let audio = [ebml(&[0xB5], 8, &48_000f64.to_be_bytes()), ebml(&[0x9F], 1, &[1]), ebml(&[0x62, 0x64], 1, &[16])].concat();
|
||||
[
|
||||
ebml(&[0xD7], 1, &[1]),
|
||||
ebml(&[0x73, 0xC5], 1, &[1]),
|
||||
ebml(&[0x83], 1, &[2]),
|
||||
ebml(&[0x86], 13, b"A_PCM/INT/LIT"),
|
||||
ebml(&[0xE1], audio.len() as u64, &audio),
|
||||
extra.to_vec(),
|
||||
]
|
||||
.concat()
|
||||
};
|
||||
// That track with a CodecPrivate claiming 2^46 bytes, inside parents that claim even more: the
|
||||
// segment is valid up to that read, which runs out of file.
|
||||
let track = ebml(&[0xAE], huge, &pcm(&ebml(&[0x63, 0xA2], huge >> 1, &[0; 16])));
|
||||
let codec_data = segment([info.clone(), ebml(&[0x16, 0x54, 0xAE, 0x6B], huge, &track)].concat());
|
||||
// The plain track, then a cluster whose SimpleBlock claims 2^47 bytes of frame data.
|
||||
let track = pcm(&[]);
|
||||
let track = ebml(&[0xAE], track.len() as u64, &track);
|
||||
let block = ebml(&[0x1F, 0x43, 0xB6, 0x75], u64::MAX, &[ebml(&[0xE7], 1, &[0]), ebml(&[0xA3], huge, &[0x81, 0, 0, 0x80])].concat());
|
||||
let cluster = segment([info, ebml(&[0x16, 0x54, 0xAE, 0x6B], track.len() as u64, &track), block].concat());
|
||||
let out_of_file = |r: Result<_, AudioError>| matches!(r, Err(AudioError::Malformed(m)) if m.contains("end of file"));
|
||||
for hint in [None, Some("mkv"), Some("wav")] {
|
||||
assert!(probe(&reported, hint).is_err(), "{hint:?}");
|
||||
assert!(decode(&reported, hint).is_err(), "{hint:?}");
|
||||
// Refused at the oversized read itself, not by anything before it.
|
||||
assert!(out_of_file(probe(&codec_data, hint).map(|_| ())), "{hint:?}: {:?}", probe(&codec_data, hint).err());
|
||||
assert!(out_of_file(decode(&codec_data, hint).map(|_| ())), "{hint:?}");
|
||||
// The file and its track open, so decoding reaches the block; the block reader stops at the
|
||||
// end of the file, a best-effort read of what is there.
|
||||
assert!(probe(&cluster, hint).is_ok(), "{hint:?}: {:?}", probe(&cluster, hint).err());
|
||||
assert!(decode(&cluster, hint).map_or(0, |(_, b)| b.frames()) < 1_000, "{hint:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_caf_still_reads() {
|
||||
let pcm: Vec<u8> = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! The length `probe` reports for AAC in MP4 is the length `decode` produces (issue #1113).
|
||||
|
||||
use soundcraft_audio_io::{decode, probe};
|
||||
|
||||
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
[&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()
|
||||
}
|
||||
|
||||
/// A full atom (version 0, no flags) whose body is `fields` as big-endian u32s.
|
||||
fn full(kind: &[u8; 4], fields: &[u32]) -> Vec<u8> {
|
||||
let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();
|
||||
atom(kind, &body)
|
||||
}
|
||||
|
||||
/// An MPEG-4 descriptor with a one-byte length.
|
||||
fn descriptor(tag: u8, body: &[u8]) -> Vec<u8> {
|
||||
[&[tag, body.len() as u8][..], body].concat()
|
||||
}
|
||||
|
||||
/// A silent AAC-LC raw frame for one channel: an SCE with `max_sfb` 0, then ID_END.
|
||||
const SILENT_SCE: [u8; 4] = [0x00, 0xC8, 0x00, 0x07];
|
||||
|
||||
/// A mono 48 kHz AAC-LC MP4 without an edit list: `packets` frames, the last lasting `last_dur` in stts.
|
||||
fn aac_mp4(packets: u32, last_dur: u32) -> Vec<u8> {
|
||||
// AudioSpecificConfig: AAC-LC, 48 kHz, one channel.
|
||||
let dsi = descriptor(5, &[0x11, 0x88]);
|
||||
let dcd = descriptor(4, &[&[0x40, 0x15, 0, 0, 0][..], &[0; 8], &dsi].concat());
|
||||
let es = descriptor(3, &[&[0, 1, 0][..], &dcd, &descriptor(6, &[2])].concat());
|
||||
let esds = atom(b"esds", &[&[0; 4][..], &es].concat());
|
||||
let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
|
||||
let entry = atom(b"mp4a", &[&base[..], &(48_000u32 << 16).to_be_bytes(), &esds].concat());
|
||||
let total = (packets - 1) * 1024 + last_dur;
|
||||
let moov = |offset: u32| {
|
||||
let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &entry].concat());
|
||||
let stbl = [
|
||||
stsd,
|
||||
full(b"stts", &[2, packets - 1, 1024, 1, last_dur]),
|
||||
full(b"stsc", &[1, 1, packets, 1]),
|
||||
full(b"stsz", &[SILENT_SCE.len() as u32, packets]),
|
||||
full(b"stco", &[1, offset]),
|
||||
];
|
||||
let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();
|
||||
let mdia = [full(b"mdhd", &[0, 0, 48_000, total, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];
|
||||
let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();
|
||||
atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, total, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())
|
||||
};
|
||||
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
|
||||
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
|
||||
[ftyp, moov(offset), atom(b"mdat", &SILENT_SCE.repeat(packets as usize))].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aac_in_mp4_probe_length_matches_the_decoded_length() {
|
||||
// A last sample lasting until the track end (longer than a packet), and a control with a short one.
|
||||
for last_dur in [1145, 546] {
|
||||
let file = aac_mp4(10, last_dur);
|
||||
let (_, buf) = decode(&file, Some("m4a")).unwrap();
|
||||
assert_eq!(buf.frames(), 10 * 1024, "decoded frames, last stts duration {last_dur}");
|
||||
assert_eq!(probe(&file, Some("m4a")).unwrap().frames, buf.frames() as u64, "probed frames, last stts duration {last_dur}");
|
||||
}
|
||||
}
|
||||
@@ -316,3 +316,13 @@ fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
|
||||
let frames = u64::MAX / 4;
|
||||
assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwf_truncation_keeps_utf8_boundaries() {
|
||||
let src = sine(48_000, 1, 10);
|
||||
let bwf = BwfInfo { originator: format!("{}é", "a".repeat(31)), description: format!("{}é", "d".repeat(255)), ..BwfInfo::default() };
|
||||
let bytes = encode(&src, &EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() }).unwrap();
|
||||
let out = probe(&bytes, None).unwrap().bwf.unwrap();
|
||||
assert_eq!(out.originator, "a".repeat(31));
|
||||
assert_eq!(out.description, "d".repeat(255));
|
||||
}
|
||||
|
||||
@@ -199,6 +199,9 @@ impl<B: Backend> Server<B> {
|
||||
|
||||
/// Handle one JSON-RPC message; returns the reply line (None for notifications).
|
||||
pub fn handle(&mut self, msg: &Value) -> Option<Value> {
|
||||
if !msg.is_object() {
|
||||
return Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request"}}));
|
||||
}
|
||||
let id = msg.get("id").cloned();
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
@@ -255,6 +258,9 @@ impl<B: Backend> Server<B> {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(Value::Array(batch)) if batch.is_empty() => {
|
||||
Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request: empty batch"}}))
|
||||
}
|
||||
Ok(Value::Array(batch)) => {
|
||||
let replies: Vec<Value> = batch.iter().filter_map(|m| self.handle(m)).collect();
|
||||
if replies.is_empty() { None } else { Some(Value::Array(replies)) }
|
||||
@@ -270,3 +276,39 @@ impl<B: Backend> Server<B> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Headless;
|
||||
use soundcraft_engine::Engine;
|
||||
|
||||
fn reply(line: &str) -> String {
|
||||
let mut server = Server::new(Headless::new(Engine::default()));
|
||||
let mut out = Vec::new();
|
||||
server.serve(line.as_bytes(), &mut out).unwrap();
|
||||
String::from_utf8(out).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_requests_get_an_invalid_request_error() {
|
||||
for input in ["[]", "42", "[1]", "\"x\""] {
|
||||
let text = reply(input);
|
||||
assert_eq!(text.lines().count(), 1, "input {input}: reply was {text:?}");
|
||||
let v: Value = serde_json::from_str(text.trim()).unwrap();
|
||||
let e = match &v {
|
||||
Value::Array(a) => a[0].clone(),
|
||||
other => other.clone(),
|
||||
};
|
||||
assert_eq!(e["error"]["code"], -32600, "input {input}");
|
||||
assert_eq!(e["id"], Value::Null, "input {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notifications_still_get_no_reply() {
|
||||
assert_eq!(reply(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), "");
|
||||
let v: Value = serde_json::from_str(reply(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).trim()).unwrap();
|
||||
assert_eq!(v["id"], 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,3 +395,63 @@ impl LoudnessMeter {
|
||||
gain_to_db(self.true_peak)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn noise(len: usize, seed: u64, amp: f32) -> Vec<f32> {
|
||||
let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
((x >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0) * amp
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// True peak as first written: the history shifted every sample, each phase summed on its own.
|
||||
fn true_peak_reference(coefs: &[[f32; TP_PHASES]; TP_TAPS], x: &[f32]) -> f32 {
|
||||
let mut h = [0.0f32; TP_TAPS];
|
||||
let mut tp = 0.0f32;
|
||||
for v in x.iter().map(|v| if v.is_finite() { *v } else { 0.0 }) {
|
||||
h.copy_within(0..TP_TAPS - 1, 1);
|
||||
h[0] = v;
|
||||
let mut peak = v.abs();
|
||||
for p in 0..TP_PHASES {
|
||||
let y: f32 = coefs.iter().zip(h.iter()).map(|(c, b)| c[p] * b).sum();
|
||||
peak = peak.max(y.abs());
|
||||
}
|
||||
if peak > tp {
|
||||
tp = peak;
|
||||
}
|
||||
}
|
||||
tp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn true_peak_matches_the_shifted_history() {
|
||||
let sr = 48_000.0;
|
||||
let len = 20_000;
|
||||
let mut l: Vec<f32> =
|
||||
(0..len).map(|i| 0.6 * (std::f32::consts::TAU * 0.25 * i as f32 + 0.7).sin()).zip(noise(len, 1, 0.3)).map(|(a, b)| a + b).collect();
|
||||
let r = noise(len, 2, 0.9);
|
||||
l[123] = f32::NAN;
|
||||
l[4_567] = f32::INFINITY;
|
||||
for block in [1, 7, 480, len] {
|
||||
let mut m = LoudnessMeter::new(sr, 2);
|
||||
let want = true_peak_reference(&m.tp_coefs, &l).max(true_peak_reference(&m.tp_coefs, &r));
|
||||
for at in (0..len).step_by(block) {
|
||||
let n = block.min(len - at);
|
||||
m.process(&[l[at..at + n].to_vec(), r[at..at + n].to_vec()], n);
|
||||
}
|
||||
assert_eq!(m.true_peak.to_bits(), want.to_bits(), "block {block}");
|
||||
m.reset();
|
||||
let quiet = noise(500, 3, 0.1);
|
||||
m.process(&[quiet.clone(), quiet.clone()], 500);
|
||||
assert_eq!(m.true_peak.to_bits(), true_peak_reference(&m.tp_coefs, &quiet).to_bits(), "after reset, block {block}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,3 +671,44 @@ impl Plugin for Maximizer {
|
||||
self.gr_meter
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The window minimum as first written: the last `la` requests in a ring, scanned whole.
|
||||
#[test]
|
||||
fn window_min_matches_a_scan_of_the_last_requests() {
|
||||
let mut x = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut next = move || {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
x
|
||||
};
|
||||
for la in [1usize, 2, 3, 7, 64, 480] {
|
||||
let mut m = Maximizer::new();
|
||||
m.win = vec![(0, 1.0); la];
|
||||
let mut ring = vec![1.0f32; la];
|
||||
let mut at = 0;
|
||||
for i in 0..6_000 {
|
||||
if i == 3_000 {
|
||||
m.reset();
|
||||
ring.iter_mut().for_each(|v| *v = 1.0);
|
||||
at = 0;
|
||||
}
|
||||
let r = next();
|
||||
let req = match r % 16 {
|
||||
0 => f32::NAN,
|
||||
1 => 1.0,
|
||||
2 => 0.25,
|
||||
_ => (r >> 40) as f32 / (1u64 << 24) as f32 * 1.5,
|
||||
};
|
||||
ring[at] = req;
|
||||
at = (at + 1) % la;
|
||||
let want = ring.iter().copied().fold(1.0f32, f32::min);
|
||||
assert_eq!(m.window_min(req, la).to_bits(), want.to_bits(), "la {la}, sample {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,3 +310,17 @@ pub(crate) fn sort_events<T: Copy>(ev: &mut [T], key: impl Fn(&T) -> usize) {
|
||||
pub(crate) fn note_hz(note: f32) -> f32 {
|
||||
440.0 * 2f32.powf((note - 69.0) / 12.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn flush_matches_the_finite_and_magnitude_test() {
|
||||
let edge = 1.0e-25f32.to_bits();
|
||||
let special = [0, 0x8000_0000, 1, edge - 1, edge, edge + 1, 0x7f7f_ffff, 0x7f80_0000, 0xff80_0000, 0x7fc0_0000, 0xffc0_0001];
|
||||
for b in (0..=u32::MAX).step_by(997).chain(special).chain(special.map(|b| b | 0x8000_0000)) {
|
||||
let x = f32::from_bits(b);
|
||||
let want = if x.is_finite() && x.abs() > 1.0e-25 { x } else { 0.0 };
|
||||
assert_eq!(super::flush(x).to_bits(), want.to_bits(), "{b:#x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ impl Autosave {
|
||||
|
||||
/// Delete the folder: the session closed with nothing left to recover.
|
||||
fn discard(mut self) {
|
||||
drop(self.lock.take());
|
||||
self.lock = None;
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
@@ -286,6 +286,7 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
|
||||
let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok());
|
||||
let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified());
|
||||
let recording = dir.join(RECORDING).is_file();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
drop(lock);
|
||||
match saved {
|
||||
Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => {
|
||||
|
||||
@@ -28,6 +28,7 @@ mod plugin_state;
|
||||
mod query;
|
||||
pub(crate) mod sampler;
|
||||
mod setup_more;
|
||||
mod strip_silence;
|
||||
mod surround;
|
||||
mod tab;
|
||||
mod track;
|
||||
@@ -63,7 +64,7 @@ mod panic_probe {
|
||||
pub use file::bounce_job;
|
||||
|
||||
pub(crate) use edit::remember_selection;
|
||||
pub use edit::strip_silence_scan;
|
||||
pub use strip_silence::strip_silence_scan;
|
||||
|
||||
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
|
||||
/// `Err(reason)` = disabled.
|
||||
@@ -160,6 +161,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(sampler::specs());
|
||||
v.extend(video::specs());
|
||||
v.extend(zoom_toggle::specs());
|
||||
v.extend(strip_silence::specs());
|
||||
#[cfg(test)]
|
||||
v.extend(panic_probe::specs());
|
||||
v
|
||||
|
||||
@@ -228,6 +228,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
nc.content = soundcraft_model::ClipContent::Audio { source: src, offset: 0 };
|
||||
nc.length = out_len;
|
||||
nc.stretch = 1.0;
|
||||
nc.clamp_fades();
|
||||
super::more_util::replace_clip(s, t, *id, nc);
|
||||
rendered += 1;
|
||||
}
|
||||
@@ -246,7 +247,7 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if let Some((src, off, len)) = orig {
|
||||
c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
|
||||
c.length = len.max(1);
|
||||
c.fade_out.len = c.fade_out.len.min(c.length);
|
||||
c.clamp_fades();
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -496,6 +497,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warping_and_unwarping_keep_fades_within_the_clip() {
|
||||
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
|
||||
if let Some(c) = e.session_mut().find_clip_mut(cid) {
|
||||
c.fade_in.len = 20_000;
|
||||
c.fade_out.len = 20_000;
|
||||
}
|
||||
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap();
|
||||
let c = e.session().find_clip(cid).unwrap().1.clone();
|
||||
assert!(c.fade_in.len <= c.length && c.fade_out.len <= c.length, "{} {} {}", c.length, c.fade_in.len, c.fade_out.len);
|
||||
|
||||
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
|
||||
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap();
|
||||
if let Some(c) = e.session_mut().find_clip_mut(cid) {
|
||||
c.fade_in.len = 40_000;
|
||||
}
|
||||
e.execute("clip.remove_warp", &json!({"clips": [cid.0]})).unwrap();
|
||||
let c = e.session().find_clip(cid).unwrap().1.clone();
|
||||
assert!(c.fade_in.len <= c.length, "{} {}", c.length, c.fade_in.len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Edit menu commands.
|
||||
use super::{
|
||||
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, f64_or, has_clipboard, has_range, has_selection,
|
||||
has_tracks, i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
|
||||
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, has_clipboard, has_range, has_selection, has_tracks,
|
||||
i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
|
||||
};
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
@@ -125,15 +125,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_range,
|
||||
strip_silence
|
||||
),
|
||||
cmd!(
|
||||
query "edit.strip_silence_preview",
|
||||
"Strip Silence Rectangles",
|
||||
[],
|
||||
None,
|
||||
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)",
|
||||
has_range,
|
||||
strip_silence_preview
|
||||
),
|
||||
cmd!(
|
||||
"edit.fades_create",
|
||||
"Create...",
|
||||
@@ -821,38 +812,8 @@ fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"tracks": tracks.len()}))
|
||||
}
|
||||
|
||||
/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads.
|
||||
type StripSettings = (Vec<TrackId>, soundcraft_time::Range, f32, Samples, (Samples, Samples));
|
||||
|
||||
/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the
|
||||
/// command in errors.
|
||||
fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result<StripSettings> {
|
||||
let tracks = tracks_required(e, cmd, p)?;
|
||||
let r = range_param(e, cmd, p)?;
|
||||
let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0);
|
||||
let sr = e.session().sample_rate;
|
||||
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
|
||||
Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0))))
|
||||
}
|
||||
|
||||
/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit
|
||||
/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a
|
||||
/// worker thread outside the web build, and on the frame thread in the web build or when no thread
|
||||
/// can be started.
|
||||
pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result<crate::io::StripScan> {
|
||||
let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?;
|
||||
Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() })
|
||||
}
|
||||
|
||||
fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let found = strip_silence_scan(e, p)?.run()?;
|
||||
let tracks: Vec<Value> =
|
||||
found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::<Vec<_>>()})).collect();
|
||||
Ok(json!({"tracks": tracks}))
|
||||
}
|
||||
|
||||
fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let (tracks, r, thr, min_len, (pre, post)) = strip_settings(e, "edit.strip_silence", p)?;
|
||||
let (tracks, r, thr, min_len, (pre, post)) = super::strip_silence::strip_settings(e, "edit.strip_silence", p)?;
|
||||
let mode = match p.get("mode") {
|
||||
None => crate::io::StripMode::Strip,
|
||||
Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?,
|
||||
|
||||
@@ -163,6 +163,13 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if m.start >= r.start {
|
||||
m.start += r.len();
|
||||
m.end += r.len();
|
||||
} else if m.end > r.start {
|
||||
m.end += r.len();
|
||||
}
|
||||
}
|
||||
for (at, _) in &mut s.key_signatures {
|
||||
if *at >= r.start {
|
||||
*at = at.saturating_add(r.len());
|
||||
}
|
||||
}
|
||||
Ok(json!({"inserted": r.len()}))
|
||||
@@ -190,6 +197,16 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if m.start >= r.end {
|
||||
m.start -= r.len();
|
||||
m.end -= r.len();
|
||||
} else if m.end >= r.end {
|
||||
m.end -= r.len();
|
||||
} else if m.end > r.start {
|
||||
m.end = r.start;
|
||||
}
|
||||
}
|
||||
s.key_signatures.retain(|(at, _)| !r.contains(*at));
|
||||
for (at, _) in &mut s.key_signatures {
|
||||
if *at >= r.end {
|
||||
*at -= r.len();
|
||||
}
|
||||
}
|
||||
Ok(json!({"removed": r.len()}))
|
||||
@@ -302,7 +319,10 @@ fn add_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
(None, Some(a), Some(b)) => (a, b),
|
||||
(None, Some(p), None) | (None, None, Some(p)) => (p, p),
|
||||
_ if kind == MarkerKind::Selection => (sel.start, sel.end),
|
||||
_ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start),
|
||||
_ => {
|
||||
let point = if e.transport.playing { e.transport.position } else { sel.start };
|
||||
(point, point)
|
||||
}
|
||||
};
|
||||
let s = e.session_mut();
|
||||
let n = s.markers.len() + 1;
|
||||
@@ -473,6 +493,19 @@ mod tests {
|
||||
assert_eq!(find(&c), Some((1000, 2000)));
|
||||
}
|
||||
|
||||
/// A plain marker added during playback is a point at the playhead, whatever the edit selection is.
|
||||
#[test]
|
||||
fn markers_add_while_playing_is_a_point_at_the_playhead() {
|
||||
let mut e = demo_engine();
|
||||
e.session_mut().edit.selection.start = 48_000;
|
||||
e.session_mut().edit.selection.end = 48_000;
|
||||
e.transport.playing = true;
|
||||
e.transport.position = 4800;
|
||||
let r = e.execute("markers.add", &json!({"name": "M"})).unwrap();
|
||||
let m = e.session().markers.iter().find(|m| Some(m.id.0) == r["id"].as_u64()).map(|m| (m.start, m.end));
|
||||
assert_eq!(m, Some((4800, 4800)));
|
||||
}
|
||||
|
||||
/// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it.
|
||||
#[test]
|
||||
fn edit_marker_sets_documented_color() {
|
||||
|
||||
@@ -124,6 +124,12 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let dt = i64_or(p, "ticks", 0);
|
||||
let ds = i64_or(p, "semitones", 0);
|
||||
let seq = seq_mut(e, id, "midi.notes_move")?;
|
||||
// Clamp the shared delta so the earliest selected note stops at tick 0 and the selection keeps its spacing.
|
||||
let earliest = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| n.start).min();
|
||||
let dt = match earliest {
|
||||
Some(m) => dt.max(0i64.saturating_sub(m).min(0)),
|
||||
None => dt,
|
||||
};
|
||||
for (i, n) in seq.notes.iter_mut().enumerate() {
|
||||
if idx.contains(&i) {
|
||||
n.start = n.start.saturating_add(dt).max(0);
|
||||
@@ -168,6 +174,36 @@ mod tests {
|
||||
assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_move_left_past_zero_keeps_spacing() {
|
||||
let mut e = Engine::default();
|
||||
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
|
||||
let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
for s in [100, 200, 300] {
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap();
|
||||
}
|
||||
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": -150})).unwrap();
|
||||
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
|
||||
let starts: Vec<i64> = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect();
|
||||
assert_eq!(starts, [0, 100, 300]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_move_without_left_overflow_does_not_shift_selection() {
|
||||
let mut e = Engine::default();
|
||||
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
|
||||
let clip = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap()["clip"].as_u64().unwrap();
|
||||
for s in [0, 100] {
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap();
|
||||
}
|
||||
seq_mut(&mut e, ClipId(clip), "test").unwrap().notes[0].start = -50;
|
||||
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": 0})).unwrap();
|
||||
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
|
||||
let starts: Vec<i64> = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect();
|
||||
assert_eq!(starts, [0, 100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_move_huge_params_do_not_overflow() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -155,6 +155,14 @@ pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples)
|
||||
if !(0.1..=10.0).contains(&ratio) {
|
||||
return Err(bad(cmd, format!("stretch ratio {ratio:.3} is outside 0.1..10")));
|
||||
}
|
||||
// The source span read in can be 10× the output, and both are held for every channel: bound
|
||||
// each like any other offline render (a session's clip length and stretch are not trusted).
|
||||
let channels = audio.buffer.channels.len().max(1);
|
||||
let limit = crate::io::render_limit(channels);
|
||||
if src_len > limit as f64 || usize::try_from(out_len).map_or(true, |n| n > limit) {
|
||||
let secs = limit as f64 / s.sample_rate.as_f64();
|
||||
return Err(bad(cmd, format!("the clip is too long to stretch: one render holds at most {secs:.0} s of {channels}-channel audio")));
|
||||
}
|
||||
let a = usize::try_from(offset.max(0)).unwrap_or(0);
|
||||
let n = src_len as usize;
|
||||
let ch: Vec<Vec<f32>> = audio
|
||||
@@ -213,4 +221,38 @@ mod tests {
|
||||
s.sample_rate = soundcraft_time::SampleRate::new(96_000).unwrap();
|
||||
assert_eq!(max_render(&s), soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples);
|
||||
}
|
||||
|
||||
/// A stereo session with a one-frame source and one clip on it.
|
||||
fn stretched_clip(length: Samples, stretch: f64) -> (Session, Clip) {
|
||||
use soundcraft_model::{ChannelFormat, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Long"));
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5]; 2] };
|
||||
let src = crate::io::add_source(&mut s, "One", buf, None, FileFormat::Wav);
|
||||
let cid = s.new_clip_id();
|
||||
let mut clip = Clip::audio(cid, "Clip", src, 0, 0, length);
|
||||
clip.stretch = stretch;
|
||||
crate::edit::place_clip(&mut s, t, clip.clone());
|
||||
(s, clip)
|
||||
}
|
||||
|
||||
/// #895: a session's clip length and stretch made the source span read in 10× the one-hour
|
||||
/// output limit, for every channel (about 14 GB here), and the allocation aborted the app.
|
||||
#[test]
|
||||
fn a_stretch_over_the_render_limit_is_refused_before_allocating() {
|
||||
let hour = Session::default().sample_rate.samples(3600.0);
|
||||
let (mut s, clip) = stretched_clip(hour, 0.1);
|
||||
let err = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap_err();
|
||||
assert!(err.to_string().contains("too long to stretch"), "{err}");
|
||||
|
||||
let mut e = crate::Engine::new(s);
|
||||
assert!(e.execute("clip.remove_pitch_shift", &serde_json::json!({"clips": [clip.id.0]})).is_err());
|
||||
let (_, kept) = e.session().find_clip(clip.id).unwrap();
|
||||
assert_eq!((kept.stretch, kept.length), (0.1, hour), "the clip is left as it was");
|
||||
|
||||
// An ordinary stretch still renders, at the length asked for.
|
||||
let (mut s, clip) = stretched_clip(1_000, 0.5);
|
||||
let src = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap().unwrap();
|
||||
assert!(s.pool.get(src).unwrap().buffer.channels.iter().all(|c| c.len() == 1_000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,13 +42,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
prepare_dpe
|
||||
),
|
||||
cmd!(noundo "setup.hardware", "Hardware...", ["Setup"], None, "{device?, buffer_size?: 16..8192} — audio interface settings (sample rate is the session's)", always, |e, p| engine_settings(e, p)),
|
||||
cmd!(noundo "setup.playback_engine", "Playback Engine...", ["Setup"], None, "{device?, buffer_size?: 16..8192, delay_compensation?: bool}", always, |e, p| {
|
||||
if let Some(v) = p.get("delay_compensation").and_then(Value::as_bool) {
|
||||
e.session_mut().edit.delay_compensation = v;
|
||||
}
|
||||
engine_settings(e, p)
|
||||
}),
|
||||
cmd!(
|
||||
noundo "setup.hardware",
|
||||
"Hardware...",
|
||||
["Setup"],
|
||||
None,
|
||||
"{device?: output name, buffer_size?: 16..=8192 or null} — output device and buffer. Applied only in the desktop app (Playback Engine window / ui.audio_device), which returns the open device. Headless stores the request and returns applied: false; buffer_size is null when unset. Any integer in range, not rounded to the window's power-of-two sizes. Sample rate is the session's",
|
||||
always,
|
||||
|e, p| engine_settings(e, p, "setup.hardware")
|
||||
),
|
||||
cmd!(
|
||||
noundo "setup.playback_engine",
|
||||
"Playback Engine...",
|
||||
["Setup"],
|
||||
None,
|
||||
"{device?: output name, buffer_size?: 16..=8192 or null, delay_compensation?: bool} — device and buffer as setup.hardware (applied only in the desktop app, or via ui.audio_device); delay_compensation is applied here too. Headless returns applied: false and does not invent a buffer",
|
||||
always,
|
||||
|e, p| engine_settings(e, p, "setup.playback_engine")
|
||||
),
|
||||
cmd!(noundo "setup.disk_allocation", "Disk Allocation...", ["Setup"], None, "{path?, track?} — the record folder for the session, or for one track", always, disk_allocation),
|
||||
cmd!(noundo "setup.peripherals", "Peripherals...", ["Setup"], None, "{settings?: {name: number|bool|string}} — control surface / sync device settings", always, peripherals),
|
||||
cmd!(
|
||||
@@ -156,18 +167,69 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"prepared": n}))
|
||||
}
|
||||
|
||||
fn engine_settings(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
/// A `buffer_size` argument: absent, cleared (`null`), or an integer in 16..=8192.
|
||||
enum BufferParam {
|
||||
Skip,
|
||||
Clear,
|
||||
Set(u64),
|
||||
}
|
||||
|
||||
/// Same range as `ui.audio_device`. Out of range and non-numbers error; there is no clamp and no
|
||||
/// power-of-two rounding (the Playback Engine window lists those sizes, the command keeps the number).
|
||||
fn buffer_param(id: &str, p: &Value) -> Result<BufferParam> {
|
||||
match p.get("buffer_size") {
|
||||
None => Ok(BufferParam::Skip),
|
||||
Some(Value::Null) => Ok(BufferParam::Clear),
|
||||
Some(v) => {
|
||||
let n = v.as_u64().filter(|n| (16..=8192).contains(n)).ok_or_else(|| bad(id, "`buffer_size` must be a number in 16..=8192 or null"))?;
|
||||
Ok(BufferParam::Set(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `device` as `ui.audio_device`'s `output`: a name, or `null`/empty to clear. Absent leaves it.
|
||||
fn device_param<'a>(id: &str, p: &'a Value) -> Result<Option<Option<&'a str>>> {
|
||||
match p.get("device") {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(Value::String(name)) => Ok(Some((!name.is_empty()).then_some(name.as_str()))),
|
||||
Some(_) => Err(bad(id, "`device` must be a string or null")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Headless `setup.hardware` / `setup.playback_engine`. Playback never reads `engine.device` or
|
||||
/// `engine.buffer_size` (only this command does); they are kept so a later call can report the
|
||||
/// request. The result says `applied: false` and leaves `buffer_size` null until one was stored.
|
||||
fn engine_settings(e: &mut Engine, p: &Value, id: &str) -> Result<Value> {
|
||||
let buffer = buffer_param(id, p)?;
|
||||
let device = device_param(id, p)?;
|
||||
if id == "setup.playback_engine"
|
||||
&& let Some(on) = p.get("delay_compensation").and_then(Value::as_bool)
|
||||
{
|
||||
e.session_mut().edit.delay_compensation = on;
|
||||
}
|
||||
let s = e.session_mut();
|
||||
if let Some(b) = p.get("buffer_size").and_then(Value::as_u64) {
|
||||
let b = b.clamp(16, 8192).next_power_of_two().min(8192);
|
||||
s.edit.values.insert("engine.buffer_size".into(), b as f64);
|
||||
match buffer {
|
||||
BufferParam::Skip => {}
|
||||
BufferParam::Clear => {
|
||||
s.edit.values.remove("engine.buffer_size");
|
||||
}
|
||||
BufferParam::Set(n) => {
|
||||
s.edit.values.insert("engine.buffer_size".into(), n as f64);
|
||||
}
|
||||
}
|
||||
if let Some(d) = str_param(p, "device") {
|
||||
set_text(&mut s.edit, "engine.device", Some(d));
|
||||
if let Some(name) = device {
|
||||
set_text(&mut s.edit, "engine.device", name);
|
||||
}
|
||||
// Exact integers only: a hand-edited fraction is not reported as a buffer size.
|
||||
let buffer_size = s.edit.values.get("engine.buffer_size").copied().and_then(|v| {
|
||||
let n = v as u64;
|
||||
(v.is_finite() && (n as f64) == v).then_some(n)
|
||||
});
|
||||
Ok(json!({
|
||||
"applied": false,
|
||||
"device": text(&s.edit, "engine.device"),
|
||||
"buffer_size": s.edit.value("engine.buffer_size", 1024.0),
|
||||
"buffer_size": buffer_size,
|
||||
"sample_rate": s.sample_rate.hz(),
|
||||
"bit_depth": s.bit_depth,
|
||||
"delay_compensation": s.edit.delay_compensation,
|
||||
@@ -307,7 +369,7 @@ fn current_timecode(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let at = s.edit.selection.start.max(0);
|
||||
let abs = tc.to_samples(s.frame_rate, s.sample_rate);
|
||||
s.timecode_start = abs - at;
|
||||
s.timecode_start = abs.saturating_sub(at).clamp(-soundcraft_time::MAX_POSITION, soundcraft_time::MAX_POSITION);
|
||||
Ok(json!({"timecode_start": s.timecode_start, "at": at}))
|
||||
}
|
||||
|
||||
@@ -567,6 +629,20 @@ mod tests {
|
||||
assert_eq!(shown, "01:00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_timecode_out_of_range_is_clamped() {
|
||||
use soundcraft_time::MAX_POSITION;
|
||||
for tc in ["-9223372036854775807:00:00:00", "9999999:00:00:00"] {
|
||||
let mut e = Engine::default();
|
||||
e.execute("edit.select", &json!({"start": 48_000})).unwrap();
|
||||
e.execute("setup.current_timecode", &json!({"timecode": tc})).unwrap();
|
||||
let start = e.session().timecode_start;
|
||||
assert!((-MAX_POSITION..=MAX_POSITION).contains(&start), "{tc}: start {start}");
|
||||
let reloaded = Session::from_json(&e.session().to_json().unwrap()).unwrap();
|
||||
assert_eq!(reloaded.timecode_start, start, "{tc}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_countoff_settings() {
|
||||
let mut e = Engine::default();
|
||||
@@ -587,7 +663,8 @@ mod tests {
|
||||
fn engine_and_midi_settings() {
|
||||
let mut e = Engine::default();
|
||||
let r = e.execute("setup.playback_engine", &json!({"buffer_size": 300, "device": "Interface"})).unwrap();
|
||||
assert_eq!(r["buffer_size"], 512.0);
|
||||
assert_eq!(r["applied"], false);
|
||||
assert_eq!(r["buffer_size"], 300);
|
||||
assert_eq!(r["device"], "Interface");
|
||||
let r = e.execute("setup.midi_input_filter", &json!({"filter": ["sysex", "bogus"]})).unwrap();
|
||||
assert_eq!(r["filtered"], json!(["sysex"]));
|
||||
@@ -597,6 +674,45 @@ mod tests {
|
||||
assert_eq!(r["samples"], 3200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_engine_does_not_pretend_the_device_changed() {
|
||||
let mut e = Engine::default();
|
||||
let r = e.execute("setup.playback_engine", &json!({})).unwrap();
|
||||
assert_eq!(r["applied"], false);
|
||||
assert!(r["buffer_size"].is_null(), "no invented default buffer: {r}");
|
||||
assert!(r["device"].is_null());
|
||||
assert_eq!(r["delay_compensation"], true, "the session default, not a device claim");
|
||||
|
||||
let r = e.execute("setup.hardware", &json!({"buffer_size": 300, "device": "Interface"})).unwrap();
|
||||
assert_eq!(r["applied"], false);
|
||||
assert_eq!(r["buffer_size"], 300, "kept, not rounded to a power of two");
|
||||
assert_eq!(r["device"], "Interface");
|
||||
assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0);
|
||||
|
||||
let r = e.execute("setup.playback_engine", &json!({"delay_compensation": false})).unwrap();
|
||||
assert_eq!(r["applied"], false);
|
||||
assert_eq!(r["delay_compensation"], false);
|
||||
assert_eq!(r["buffer_size"], 300, "delay compensation does not clear a stored buffer");
|
||||
assert!(!e.session().edit.delay_compensation);
|
||||
|
||||
let err = e.execute("setup.playback_engine", &json!({"buffer_size": 8, "delay_compensation": true, "device": "Other"})).unwrap_err();
|
||||
assert!(err.to_string().contains("16..=8192"), "{err}");
|
||||
assert!(!e.session().edit.delay_compensation, "an invalid buffer changes nothing");
|
||||
assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0);
|
||||
assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface"));
|
||||
assert!(e.execute("setup.hardware", &json!({"buffer_size": "big"})).is_err());
|
||||
|
||||
let err = e.execute("setup.playback_engine", &json!({"device": 42, "delay_compensation": true})).unwrap_err();
|
||||
assert!(err.to_string().contains("`device` must be a string or null"), "{err}");
|
||||
assert!(!e.session().edit.delay_compensation, "an invalid device changes nothing");
|
||||
assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface"));
|
||||
|
||||
let r = e.execute("setup.hardware", &json!({"buffer_size": null, "device": ""})).unwrap();
|
||||
assert_eq!(r["applied"], false);
|
||||
assert!(r["buffer_size"].is_null());
|
||||
assert!(r["device"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_session_data_brings_tracks_and_audio() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-import-{}", std::process::id()));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Strip Silence Rectangles: the ranges Strip Silence keeps, which the Strip Silence window draws.
|
||||
use super::{CommandSpec, f32_or, f64_or, has_range, range_param, tracks_required};
|
||||
use soundcraft_model::TrackId;
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
query "edit.strip_silence_preview",
|
||||
"Strip Silence Rectangles",
|
||||
[],
|
||||
None,
|
||||
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)",
|
||||
has_range,
|
||||
strip_silence_preview
|
||||
)]
|
||||
}
|
||||
|
||||
/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads.
|
||||
pub(super) type StripSettings = (Vec<TrackId>, soundcraft_time::Range, f32, Samples, (Samples, Samples));
|
||||
|
||||
/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the
|
||||
/// command in errors.
|
||||
pub(super) fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result<StripSettings> {
|
||||
let tracks = tracks_required(e, cmd, p)?;
|
||||
let r = range_param(e, cmd, p)?;
|
||||
let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0);
|
||||
let sr = e.session().sample_rate;
|
||||
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
|
||||
Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0))))
|
||||
}
|
||||
|
||||
/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit
|
||||
/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a
|
||||
/// worker thread outside the web build, and on the frame thread in the web build or when no thread
|
||||
/// can be started.
|
||||
pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result<crate::io::StripScan> {
|
||||
let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?;
|
||||
Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() })
|
||||
}
|
||||
|
||||
fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let found = strip_silence_scan(e, p)?.run()?;
|
||||
let tracks: Vec<Value> =
|
||||
found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::<Vec<_>>()})).collect();
|
||||
Ok(json!({"tracks": tracks}))
|
||||
}
|
||||
@@ -590,6 +590,20 @@ fn owned_folder_bus(s: &Session, folder: TrackId) -> Option<soundcraft_model::Bu
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a bus is still routed through by a track that survives deleting
|
||||
/// `deleted`, ignoring the output slots of `folder`'s own members, which the
|
||||
/// delete reroutes to the parent folder's bus. A folder owns its input bus
|
||||
/// only when this is false: `track.input` or a session file can point a
|
||||
/// folder at a bus that other tracks already use (#914).
|
||||
fn bus_used_beyond_folder(s: &Session, bus: soundcraft_model::BusId, deleted: &[TrackId], folder: TrackId) -> bool {
|
||||
let route = Route::Bus(bus);
|
||||
s.tracks.iter().filter(|track| !deleted.contains(&track.id)).any(|track| {
|
||||
track.mixer.input == route
|
||||
|| track.mixer.sends.iter().flatten().any(|send| send.target == route)
|
||||
|| (track.mixer.output == route && !in_folder(s, track.id, folder))
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep only the selected roots so selecting a folder and one of its children preserves the subtree.
|
||||
fn root_tracks(s: &Session, tracks: Vec<TrackId>) -> Vec<TrackId> {
|
||||
tracks.iter().copied().filter(|track| !tracks.iter().any(|root| root != track && in_folder(s, *track, *root))).collect()
|
||||
@@ -708,7 +722,11 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
.filter_map(|folder| {
|
||||
let bus = owned_folder_bus(s, *folder)?;
|
||||
let parent = surviving_parent(s, &tracks, *folder);
|
||||
Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main)))
|
||||
let replacement = parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main);
|
||||
// A bus that surviving tracks route through is shared, not the
|
||||
// folder's own: keep it, and every route to it, when the folder
|
||||
// goes (#914).
|
||||
(!bus_used_beyond_folder(s, bus, &tracks, *folder)).then_some((bus, replacement))
|
||||
})
|
||||
.collect();
|
||||
// Promote children of deleted folders and repair only routes that used the deleted
|
||||
@@ -1129,6 +1147,26 @@ mod tests {
|
||||
assert!(e.session().bus(outer_bus).is_some(), "the surviving parent folder's bus must remain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_folder_keeps_a_bus_other_tracks_still_use() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
// #914: the demo session's Verb bus is shared: an aux takes it as its input
|
||||
// and several tracks send to it. Point a new folder's input at it, the way
|
||||
// `track.input` or a session file can, then delete the folder.
|
||||
let verb = e.session().bus_by_name("Verb").unwrap().id;
|
||||
let folder = e.execute("track.new", &json!({"kind": "folder", "name": "Shared"})).unwrap();
|
||||
let folder = TrackId(folder["tracks"][0].as_u64().unwrap());
|
||||
e.execute("track.input", &json!({"tracks": [folder.0], "input": "Verb"})).unwrap();
|
||||
assert_eq!(e.session().track(folder).unwrap().mixer.input, Route::Bus(verb));
|
||||
e.execute("track.delete", &json!({"tracks": [folder.0]})).unwrap();
|
||||
assert!(e.session().track(folder).is_none(), "the folder itself is deleted");
|
||||
assert!(e.session().bus(verb).is_some(), "a bus other tracks route through must survive");
|
||||
let aux = e.session().track_by_name("Verb").unwrap();
|
||||
assert_eq!(aux.mixer.input, Route::Bus(verb), "the aux input must keep the bus");
|
||||
let snare = e.session().track_by_name("Snare").unwrap();
|
||||
assert!(snare.mixer.sends.iter().flatten().any(|send| send.target == Route::Bus(verb)), "sends to the shared bus must survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heights_take_points_and_step_through_the_presets() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Borrowed document equality for the saved-state indicator.
|
||||
|
||||
use soundcraft_model::{EditState, Session};
|
||||
|
||||
/// Equal apart from the selection, playhead and zoom, without copying document data.
|
||||
pub(super) fn same_document(saved: &Session, doc: &Session) -> bool {
|
||||
document_values(saved, &saved.edit) == document_values(doc, &saved.edit)
|
||||
}
|
||||
|
||||
fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl PartialEq + 'a {
|
||||
// No `..`: adding a document field must also decide how it affects saved-state equality.
|
||||
let Session {
|
||||
format_version,
|
||||
name,
|
||||
sample_rate,
|
||||
bit_depth,
|
||||
frame_rate,
|
||||
timecode_start,
|
||||
tempo,
|
||||
key_signatures,
|
||||
tracks,
|
||||
sources,
|
||||
videos,
|
||||
busses,
|
||||
outputs,
|
||||
markers,
|
||||
groups,
|
||||
auto_names,
|
||||
edit,
|
||||
next_id,
|
||||
comments,
|
||||
midi_originals,
|
||||
pool,
|
||||
folder,
|
||||
} = s;
|
||||
// Tuples implement PartialEq up to twelve elements. These contain only borrowed fields.
|
||||
(
|
||||
(format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, tracks, sources, videos, busses),
|
||||
(outputs, markers, groups, auto_names, edit_values(edit, navigation), next_id, comments, midi_originals, pool, folder),
|
||||
)
|
||||
}
|
||||
|
||||
fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl PartialEq + 'a {
|
||||
let EditState {
|
||||
tool,
|
||||
edit_mode,
|
||||
selection: _,
|
||||
selected_tracks: _,
|
||||
selected_clips: _,
|
||||
selected_sources,
|
||||
playhead: _,
|
||||
grid,
|
||||
nudge,
|
||||
grid_lines,
|
||||
main_counter,
|
||||
sub_counter,
|
||||
zoom: _,
|
||||
zoom_toggle,
|
||||
rulers,
|
||||
link_timeline_edit,
|
||||
link_track_edit,
|
||||
insertion_follows_playback,
|
||||
loop_playback,
|
||||
pre_post_roll,
|
||||
pre_roll,
|
||||
post_roll,
|
||||
tab_to_transient,
|
||||
automation_follows_edit,
|
||||
markers_follow_edit,
|
||||
layered_editing,
|
||||
mirrored_midi,
|
||||
click,
|
||||
countoff,
|
||||
countoff_bars,
|
||||
midi_merge,
|
||||
record_mode,
|
||||
loop_record,
|
||||
quickpunch,
|
||||
scrolling,
|
||||
solo_mode,
|
||||
pre_fader_metering,
|
||||
delay_compensation,
|
||||
keyboard_focus,
|
||||
timeline_selection: _,
|
||||
flags,
|
||||
values,
|
||||
} = edit;
|
||||
// Borrow the saved navigation on both sides instead of overwriting a document clone.
|
||||
// Keeping these values in the comparison also preserves PartialEq for non-finite zooms.
|
||||
(
|
||||
(
|
||||
tool,
|
||||
edit_mode,
|
||||
&navigation.selection,
|
||||
&navigation.selected_tracks,
|
||||
&navigation.selected_clips,
|
||||
selected_sources,
|
||||
&navigation.playhead,
|
||||
grid,
|
||||
nudge,
|
||||
grid_lines,
|
||||
main_counter,
|
||||
sub_counter,
|
||||
),
|
||||
(
|
||||
&navigation.zoom,
|
||||
zoom_toggle,
|
||||
rulers,
|
||||
link_timeline_edit,
|
||||
link_track_edit,
|
||||
insertion_follows_playback,
|
||||
loop_playback,
|
||||
pre_post_roll,
|
||||
pre_roll,
|
||||
post_roll,
|
||||
tab_to_transient,
|
||||
automation_follows_edit,
|
||||
),
|
||||
(
|
||||
markers_follow_edit,
|
||||
layered_editing,
|
||||
mirrored_midi,
|
||||
click,
|
||||
countoff,
|
||||
countoff_bars,
|
||||
midi_merge,
|
||||
record_mode,
|
||||
loop_record,
|
||||
quickpunch,
|
||||
scrolling,
|
||||
solo_mode,
|
||||
),
|
||||
(pre_fader_metering, delay_compensation, keyboard_focus, &navigation.timeline_selection, flags, values),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_model::{ChannelFormat, Clip, ClipId, SourceId, TrackId, TrackKind, ZoomToggle};
|
||||
use soundcraft_time::{Range, TimeFormat};
|
||||
use std::sync::Arc;
|
||||
|
||||
// The previous comparison is an oracle for preserving the saved-state policy.
|
||||
fn previous_same_document(saved: &Session, doc: &Session) -> bool {
|
||||
let mut doc = doc.clone();
|
||||
let (from, to) = (&saved.edit, &mut doc.edit);
|
||||
to.selection = from.selection;
|
||||
to.timeline_selection = from.timeline_selection;
|
||||
to.selected_tracks.clone_from(&from.selected_tracks);
|
||||
to.selected_clips.clone_from(&from.selected_clips);
|
||||
to.playhead = from.playhead;
|
||||
to.zoom = from.zoom;
|
||||
*saved == doc
|
||||
}
|
||||
|
||||
fn check(saved: &Session, doc: &Session, expected: bool, field: &str) {
|
||||
assert_eq!(same_document(saved, doc), expected, "{field}");
|
||||
assert_eq!(same_document(doc, saved), expected, "{field}, reversed");
|
||||
assert_eq!(same_document(saved, doc), previous_same_document(saved, doc), "{field}, previous policy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_fields_are_ignored() {
|
||||
let saved = Session::default();
|
||||
let mut doc = saved.clone();
|
||||
doc.edit.selection = Range::new(100, 200);
|
||||
doc.edit.timeline_selection = Range::new(300, 400);
|
||||
doc.edit.selected_tracks = vec![TrackId(50)];
|
||||
doc.edit.selected_clips = vec![ClipId(60)];
|
||||
doc.edit.playhead = 500;
|
||||
doc.edit.zoom.samples_per_px = 64.0;
|
||||
doc.edit.zoom.scroll = 600;
|
||||
doc.edit.zoom.waveform_zoom = 2.0;
|
||||
doc.edit.zoom.presets = [1.0; 5];
|
||||
check(&saved, &doc, true, "navigation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_edit_settings_still_change_document_equality() {
|
||||
let saved = Session::default();
|
||||
let edits: &[(&str, fn(&mut EditState))] = &[
|
||||
("tool", |e| e.tool = soundcraft_model::Tool::Grabber),
|
||||
("edit_mode", |e| e.edit_mode = soundcraft_model::EditMode::Grid),
|
||||
("selected_sources", |e| e.selected_sources.push(SourceId(1))),
|
||||
("grid", |e| e.grid = soundcraft_time::GridValue::Seconds(2.0)),
|
||||
("nudge", |e| e.nudge = soundcraft_time::GridValue::Seconds(2.0)),
|
||||
("grid_lines", |e| e.grid_lines = !e.grid_lines),
|
||||
("main_counter", |e| e.main_counter = TimeFormat::Samples),
|
||||
("sub_counter", |e| e.sub_counter = None),
|
||||
("zoom_toggle", |e| e.zoom_toggle = Some(ZoomToggle { samples_per_px: 64.0, scroll: 0, heights: Vec::new() })),
|
||||
("rulers", |e| e.rulers.clear()),
|
||||
("link_timeline_edit", |e| e.link_timeline_edit = !e.link_timeline_edit),
|
||||
("link_track_edit", |e| e.link_track_edit = !e.link_track_edit),
|
||||
("insertion_follows_playback", |e| e.insertion_follows_playback = !e.insertion_follows_playback),
|
||||
("loop_playback", |e| e.loop_playback = !e.loop_playback),
|
||||
("pre_post_roll", |e| e.pre_post_roll = !e.pre_post_roll),
|
||||
("pre_roll", |e| e.pre_roll += 1),
|
||||
("post_roll", |e| e.post_roll += 1),
|
||||
("tab_to_transient", |e| e.tab_to_transient = !e.tab_to_transient),
|
||||
("automation_follows_edit", |e| e.automation_follows_edit = !e.automation_follows_edit),
|
||||
("markers_follow_edit", |e| e.markers_follow_edit = !e.markers_follow_edit),
|
||||
("layered_editing", |e| e.layered_editing = !e.layered_editing),
|
||||
("mirrored_midi", |e| e.mirrored_midi = !e.mirrored_midi),
|
||||
("click", |e| e.click = !e.click),
|
||||
("countoff", |e| e.countoff = !e.countoff),
|
||||
("countoff_bars", |e| e.countoff_bars += 1),
|
||||
("midi_merge", |e| e.midi_merge = !e.midi_merge),
|
||||
("record_mode", |e| e.record_mode = "destructive".into()),
|
||||
("loop_record", |e| e.loop_record = !e.loop_record),
|
||||
("quickpunch", |e| e.quickpunch = !e.quickpunch),
|
||||
("scrolling", |e| e.scrolling = "continuous".into()),
|
||||
("solo_mode", |e| e.solo_mode = "afl".into()),
|
||||
("pre_fader_metering", |e| e.pre_fader_metering = !e.pre_fader_metering),
|
||||
("delay_compensation", |e| e.delay_compensation = !e.delay_compensation),
|
||||
("keyboard_focus", |e| e.keyboard_focus = "midi".into()),
|
||||
("flags", |e| {
|
||||
e.flags.insert("test.flag".into());
|
||||
}),
|
||||
("values", |e| {
|
||||
e.values.insert("test.value".into(), 0.5);
|
||||
}),
|
||||
];
|
||||
for &(field, edit) in edits {
|
||||
let mut doc = saved.clone();
|
||||
edit(&mut doc.edit);
|
||||
check(&saved, &doc, false, field);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_and_media_changes_still_change_document_equality() {
|
||||
let mut saved = Session::default();
|
||||
let track = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let clip = Clip::audio(saved.new_clip_id(), "Clip", SourceId(900), 0, 0, 100);
|
||||
saved.track_mut(track).unwrap().playlist_mut().unwrap().clips.push(clip);
|
||||
let audio =
|
||||
Arc::new(soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 100]] }));
|
||||
saved.pool.insert(SourceId(900), audio);
|
||||
check(&saved, &saved.clone(), true, "shared decoded audio");
|
||||
let mut doc = saved.clone();
|
||||
doc.track_mut(track).unwrap().playlist_mut().unwrap().clips[0].gain_db = -6.0;
|
||||
check(&saved, &doc, false, "clip gain");
|
||||
let mut doc = saved.clone();
|
||||
doc.pool.remove(SourceId(900));
|
||||
check(&saved, &doc, false, "decoded audio removed");
|
||||
let mut doc = saved.clone();
|
||||
doc.folder = Some(std::path::PathBuf::from("session"));
|
||||
check(&saved, &doc, false, "session folder");
|
||||
let mut doc = saved.clone();
|
||||
doc.edit.values.insert("test.nan".into(), f64::NAN);
|
||||
assert_eq!(same_document(&doc, &doc), previous_same_document(&doc, &doc), "NaN keeps the previous PartialEq behavior");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_saved_zoom_keeps_the_previous_partial_eq_behavior() {
|
||||
let mut saved = Session::default();
|
||||
let doc = saved.clone();
|
||||
saved.edit.zoom.waveform_zoom = f32::NAN;
|
||||
assert!(!same_document(&saved, &doc));
|
||||
assert!(same_document(&doc, &saved));
|
||||
assert_eq!(same_document(&saved, &doc), previous_same_document(&saved, &doc));
|
||||
assert_eq!(same_document(&doc, &saved), previous_same_document(&doc, &saved));
|
||||
}
|
||||
}
|
||||
@@ -238,13 +238,16 @@ pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], lengt
|
||||
make_room(s, track, at, length)?;
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
let mut placed = Vec::new();
|
||||
for c in clips {
|
||||
let mut nc = c.clone();
|
||||
nc.id = s.new_clip_id();
|
||||
nc.start += at;
|
||||
ids.push(nc.id);
|
||||
place_clip(s, track, nc);
|
||||
placed.push((track, nc));
|
||||
}
|
||||
// Clear every destination first, so pasted clips that overlap (crossfades) keep each other.
|
||||
put_clips(s, placed);
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
@@ -523,8 +526,10 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac
|
||||
}
|
||||
}
|
||||
let n = moving.len();
|
||||
// Limit the shift once, so the group stops at zero together and keeps its spacing.
|
||||
let delta = delta.max(moving.iter().map(|(_, c)| c.start.saturating_neg()).max().unwrap_or(0));
|
||||
let placed = moving.into_iter().map(|(tid, mut c)| {
|
||||
c.start = c.start.saturating_add(delta).max(0);
|
||||
c.start = c.start.saturating_add(delta);
|
||||
(lands(dest, tid), c)
|
||||
});
|
||||
put_clips(s, placed.collect());
|
||||
|
||||
@@ -343,3 +343,24 @@ fn delete_fades_leaves_unrelated_overlaps_alone() {
|
||||
let c = s.track(t).unwrap().clips();
|
||||
assert_eq!((c[1].fade_in.len, c[2].fade_out.len, c[3].fade_in.len), (0, 100, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_keeps_crossfade_overlap() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let (a, b) = (s.new_clip_id(), s.new_clip_id());
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips.clear();
|
||||
let mut ca = Clip::audio(a, "a", SourceId(500), 0, 1000, 2000);
|
||||
ca.fade_out = Fade { len: 500, shape: FadeShape::Linear, curvature: 0.0 };
|
||||
let mut cb = Clip::audio(b, "b", SourceId(500), 0, 2500, 2500);
|
||||
cb.fade_in = Fade { len: 500, shape: FadeShape::Linear, curvature: 0.0 };
|
||||
pl.clips.push(ca);
|
||||
pl.clips.push(cb);
|
||||
pl.sort();
|
||||
}
|
||||
let copied = copy_range(&s, t, Range::new(0, 6000));
|
||||
paste(&mut s, t, 20_000, &copied, 6000, false).unwrap();
|
||||
let pasted: Vec<_> =
|
||||
s.track(t).unwrap().clips().iter().filter(|c| c.start >= 20_000).map(|c| (c.start, c.length, c.fade_out.len, c.fade_in.len)).collect();
|
||||
assert_eq!(pasted, vec![(21_000, 2000, 500, 0), (22_500, 2500, 0, 500)]);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ pub fn session_text(e: &Engine) -> String {
|
||||
out.push_str(&format!("SESSION NAME:\t{}\nSAMPLE RATE:\t{}\nBIT DEPTH:\t{:?}\nTIMECODE FORMAT:\t{}\n# OF AUDIO TRACKS:\t{}\n# OF AUDIO CLIPS:\t{}\n# OF AUDIO FILES:\t{}\n\n",
|
||||
s.name, s.sample_rate.hz(), s.bit_depth, s.frame_rate.label(),
|
||||
s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).count(),
|
||||
s.tracks.iter().map(|t| t.clips().len()).sum::<usize>(), s.sources.len()));
|
||||
s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).map(|t| t.clips().len()).sum::<usize>(), s.sources.len()));
|
||||
out.push_str("F I L E S I N S E S S I O N\nFilename\tLocation\n");
|
||||
for src in &s.sources {
|
||||
out.push_str(&format!("{}\t{}\n", src.name, src.path));
|
||||
@@ -156,6 +156,19 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn session_text_counts_only_audio_track_clips() {
|
||||
let mut e = Engine::default();
|
||||
let t = e.execute("track.new", &json!({"kind": "midi"})).unwrap()["tracks"][0].clone();
|
||||
e.execute("midi.new_clip", &json!({"track": t, "start": 0, "length": 48000})).unwrap();
|
||||
let text = session_text(&e);
|
||||
assert!(text.contains("# OF AUDIO CLIPS:\t0\n"), "{text}");
|
||||
|
||||
let demo = crate::demo::demo_engine();
|
||||
let n: usize = demo.session().tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).map(|t| t.clips().len()).sum();
|
||||
assert!(session_text(&demo).contains(&format!("# OF AUDIO CLIPS:\t{n}\n")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_report_whether_audio_is_loaded() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -598,6 +598,7 @@ fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
let mut src = std::fs::File::open(from)?;
|
||||
let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?;
|
||||
if let Err(err) = std::io::copy(&mut src, &mut dst) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
drop(dst);
|
||||
let _ = std::fs::remove_file(to);
|
||||
return Err(err);
|
||||
|
||||
+137
-17
@@ -11,6 +11,7 @@ pub mod catalog_more;
|
||||
pub mod clip_group_file;
|
||||
pub mod cmd;
|
||||
pub mod demo;
|
||||
mod dirty;
|
||||
pub mod edit;
|
||||
pub mod file_access;
|
||||
pub mod inspect;
|
||||
@@ -23,9 +24,17 @@ use soundcraft_time::Samples;
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use dirty::same_document;
|
||||
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use soundcraft_model as model;
|
||||
|
||||
/// Upper bound on journaled commands, so a long session cannot grow memory without limit.
|
||||
const MAX_JOURNAL_ENTRIES: usize = 10_000;
|
||||
/// Upper bound on a single journaled entry's params (serialized), so junk fields from MCP or the
|
||||
/// control channel cannot keep a huge blob alive for the life of the process.
|
||||
const MAX_JOURNAL_ENTRY_BYTES: usize = 64 * 1024; // 64 KiB
|
||||
|
||||
/// Shuts the third-party plugin hosts down, running the module exit of every plugin binary they
|
||||
/// loaded. Call it at the end of `main`, once every plugin instance (mixer, player) is gone: a
|
||||
/// plugin that has been used can crash when its module exit only runs from an exit handler.
|
||||
@@ -173,8 +182,9 @@ pub struct Engine {
|
||||
pub clipboard: Clipboard,
|
||||
pub transport_requests: Vec<TransportRequest>,
|
||||
pub transport: TransportStatus,
|
||||
/// Command journal (id, params) for macro recording / agents.
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// Command journal (id, params) for macro recording / agents. Ring buffer: capped at
|
||||
/// [`MAX_JOURNAL_ENTRIES`] entries of up to [`MAX_JOURNAL_ENTRY_BYTES`] params each.
|
||||
pub journal: std::collections::VecDeque<(String, Value)>,
|
||||
/// Path of the session file, if saved.
|
||||
pub path: Option<String>,
|
||||
/// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it).
|
||||
@@ -216,7 +226,7 @@ impl Engine {
|
||||
clipboard: Clipboard::default(),
|
||||
transport_requests: Vec::new(),
|
||||
transport: TransportStatus::default(),
|
||||
journal: Vec::new(),
|
||||
journal: std::collections::VecDeque::new(),
|
||||
path: None,
|
||||
revision: 1,
|
||||
dirty: false,
|
||||
@@ -419,7 +429,7 @@ impl Engine {
|
||||
self.push_undo(spec.label, before);
|
||||
}
|
||||
if spec.journal {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
self.journal_append(id, params);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
@@ -486,6 +496,16 @@ impl Engine {
|
||||
self.merge_key = None;
|
||||
}
|
||||
|
||||
/// Append a journaled command, clamping its params to [`MAX_JOURNAL_ENTRY_BYTES`] and the
|
||||
/// buffer to [`MAX_JOURNAL_ENTRIES`] entries (oldest are dropped).
|
||||
fn journal_append(&mut self, id: &str, params: &Value) {
|
||||
let clamped = (id.to_string(), journal_params(params));
|
||||
self.journal.push_back(clamped);
|
||||
if self.journal.len() > MAX_JOURNAL_ENTRIES {
|
||||
self.journal.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: execute with `{}` params.
|
||||
pub fn run(&mut self, id: &str) -> Result<Value> {
|
||||
self.execute(id, &json!({}))
|
||||
@@ -511,19 +531,6 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Equal apart from what changes without editing: the selection, the playhead and the zoom.
|
||||
fn same_document(saved: &Session, doc: &Session) -> bool {
|
||||
let mut doc = doc.clone();
|
||||
let (from, to) = (&saved.edit, &mut doc.edit);
|
||||
to.selection = from.selection;
|
||||
to.timeline_selection = from.timeline_selection;
|
||||
to.selected_tracks.clone_from(&from.selected_tracks);
|
||||
to.selected_clips.clone_from(&from.selected_clips);
|
||||
to.playhead = from.playhead;
|
||||
to.zoom = from.zoom;
|
||||
*saved == doc
|
||||
}
|
||||
|
||||
fn install_panic_hook() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
@@ -539,6 +546,92 @@ fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
|
||||
p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
/// The params to keep in the journal for a command: the value itself when it serializes within
|
||||
/// [`MAX_JOURNAL_ENTRY_BYTES`], or a truncation marker when it is too large or unparseable.
|
||||
///
|
||||
/// Serializes into a [`std::io::Write`] sink that fails as soon as the cap is exceeded, so an
|
||||
/// oversized `params` (a multi-MiB line from MCP or the control channel) is never fully
|
||||
/// serialized, cloned, or allocated just to be discarded.
|
||||
fn journal_params(params: &Value) -> Value {
|
||||
// Write through a bounded sink: serialization stops (WriteZero) the moment the cap is passed,
|
||||
// so an oversized value costs at most ~1× the cap, not its full serialized size.
|
||||
struct Bounded(Vec<u8>);
|
||||
impl std::io::Write for Bounded {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let room = MAX_JOURNAL_ENTRY_BYTES.saturating_sub(self.0.len());
|
||||
if room == 0 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap"));
|
||||
}
|
||||
let take = buf.len().min(room);
|
||||
self.0.extend_from_slice(&buf[..take]);
|
||||
if take < buf.len() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap"));
|
||||
}
|
||||
Ok(take)
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut sink = Bounded(Vec::new());
|
||||
// Fails with WriteZero as soon as the cap is exceeded, so the allocation stays bounded even
|
||||
// for a multi-MiB params.
|
||||
let ok = serde_json::to_writer(&mut sink, params).is_ok();
|
||||
if !ok {
|
||||
return json!({"__truncated__": true});
|
||||
}
|
||||
params.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod journal_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn journal_is_bounded_to_max_entries() {
|
||||
let mut e = Engine::default();
|
||||
// Calling the private append helper directly exercises the ring buffer without the cost
|
||||
// of cloning the whole document on 10,000+ `track.new` calls.
|
||||
for i in 0..MAX_JOURNAL_ENTRIES + 500 {
|
||||
let n = format!("t{i}");
|
||||
e.journal_append("track.new", &json!({"name": n}));
|
||||
}
|
||||
assert_eq!(e.journal.len(), MAX_JOURNAL_ENTRIES);
|
||||
// The oldest entries were dropped, the newest kept: the first remaining is the 501st pushed,
|
||||
// and the newest still there.
|
||||
let first = e.journal.front().map(|(_, p)| p["name"].as_str().unwrap_or_default());
|
||||
assert_eq!(first, Some("t500"));
|
||||
let last = e.journal.back().map(|(_, p)| p["name"].as_str().unwrap_or_default());
|
||||
let last_name = format!("t{}", MAX_JOURNAL_ENTRIES + 499);
|
||||
assert_eq!(last, Some(last_name.as_str()));
|
||||
assert_eq!(e.journal.back().map(|(id, _)| id.as_str()), Some("track.new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_params_are_truncated_in_the_journal() {
|
||||
let big = "x".repeat(MAX_JOURNAL_ENTRY_BYTES * 4);
|
||||
assert_eq!(journal_params(&json!({"name": big})), json!({"__truncated__": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_params_are_kept_whole() {
|
||||
let p = json!({"track": "Kick", "db": -6.0});
|
||||
assert_eq!(journal_params(&p), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_boundary_is_exact() {
|
||||
// A value just under the cap is kept whole; one just over is truncated. "Just" means the
|
||||
// bounded writer made at most ~two allocations of cap size, not a full multi-MiB buffer.
|
||||
// `{"n":"…"}` adds 9 bytes around the string.
|
||||
let small_str = MAX_JOURNAL_ENTRY_BYTES - 64;
|
||||
let small = json!({"n": "x".repeat(small_str)});
|
||||
assert_eq!(journal_params(&small), small);
|
||||
let big = json!({"n": "x".repeat(MAX_JOURNAL_ENTRY_BYTES)});
|
||||
assert_eq!(journal_params(&big), json!({"__truncated__": true}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod merge_tests {
|
||||
use super::*;
|
||||
@@ -607,6 +700,33 @@ mod dirty_tests {
|
||||
e.undo();
|
||||
assert!(!e.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_state_follows_save_undo_and_redo_after_navigation() {
|
||||
let mut e = Engine::default();
|
||||
let track = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
|
||||
e.mark_clean();
|
||||
e.execute("mix.volume", &json!({"track": track.0, "db": -6.0})).unwrap();
|
||||
assert!(e.is_dirty());
|
||||
// A repeated check uses the revision cache.
|
||||
assert!(e.is_dirty());
|
||||
e.mark_clean();
|
||||
assert!(!e.is_dirty());
|
||||
e.execute("edit.select", &json!({"tracks": [track.0], "start": 100, "end": 200})).unwrap();
|
||||
e.execute("transport.locate", &json!({"at": 300})).unwrap();
|
||||
e.execute("view.zoom_in", &json!({})).unwrap();
|
||||
assert!(!e.is_dirty());
|
||||
assert!(e.undo());
|
||||
assert!(e.is_dirty(), "undoing away from the saved fader value is dirty");
|
||||
assert!(e.redo());
|
||||
assert!(!e.is_dirty(), "redoing to the saved fader value is clean despite navigation changes");
|
||||
e.execute("mix.volume", &json!({"track": track.0, "db": -12.0})).unwrap();
|
||||
assert!(e.is_dirty());
|
||||
assert!(e.undo());
|
||||
assert!(!e.is_dirty());
|
||||
assert!(e.redo());
|
||||
assert!(e.is_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -214,7 +214,13 @@ fn spell(pitch: u8) -> (char, i32, i32) {
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
s.chars()
|
||||
.filter(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}'))
|
||||
.collect::<String>()
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn note_type(units: u32) -> (&'static str, bool) {
|
||||
@@ -558,4 +564,19 @@ mod tests {
|
||||
let s = svg(&parts, &setup);
|
||||
assert!(s.starts_with("<svg") && s.contains("<ellipse"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_drop_characters_xml_forbids() {
|
||||
let e = crate::demo::demo_engine();
|
||||
let mut parts = layout(e.session(), &[]);
|
||||
parts[0].name = "Lead\u{1}1\u{FFFE}".to_string();
|
||||
let mut setup = ScoreSetup::from_session(e.session());
|
||||
setup.title = "T\u{2}itle".to_string();
|
||||
setup.composer = "C\u{3}omp".to_string();
|
||||
for out in [musicxml(&parts, &setup), svg(&parts, &setup)] {
|
||||
let bad: Vec<char> =
|
||||
out.chars().filter(|c| (*c < ' ' && !matches!(c, '\n' | '\t' | '\r')) || matches!(c, '\u{FFFE}' | '\u{FFFF}')).collect();
|
||||
assert!(bad.is_empty(), "forbidden characters: {bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,3 +413,30 @@ fn shuffle_lock_and_snap_rules_hold_when_a_call_names_tracks() {
|
||||
run(&mut e, "edit.mode", json!({"mode": "grid"}));
|
||||
assert!(e.execute("edit.snap_to_grid", &json!({"tracks": [t.0]})).is_err());
|
||||
}
|
||||
|
||||
fn selection_marker(e: &mut Engine, start: i64, end: i64) -> usize {
|
||||
run(e, "markers.add", json!({"kind": "selection", "start": start, "end": end}));
|
||||
e.session().markers.len() - 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_and_insert_time_move_the_end_of_a_spanning_selection_memory_location() {
|
||||
let (mut e, _, _) = engine("slip");
|
||||
let a = selection_marker(&mut e, 1000, 5000);
|
||||
let b = selection_marker(&mut e, 1000, 2500);
|
||||
run(&mut e, "event.cut_time", json!({"start": 2000, "end": 3000}));
|
||||
let m = &e.session().markers;
|
||||
assert_eq!((m[a].start, m[a].end), (1000, 4000));
|
||||
assert_eq!((m[b].start, m[b].end), (1000, 2000));
|
||||
run(&mut e, "event.insert_time", json!({"start": 2000, "length": 1000}));
|
||||
let m = &e.session().markers;
|
||||
assert_eq!((m[a].start, m[a].end), (1000, 5000));
|
||||
assert_eq!((m[b].start, m[b].end), (1000, 2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_group_left_past_zero_keeps_its_spacing() {
|
||||
let (mut e, t, ids) = long("slip", &[(500, 100, 0), (2000, 100, 0)]);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": -1000}));
|
||||
assert_eq!(clips(&e, t), [(0, 100), (1500, 100)]);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ mod hostile_params;
|
||||
mod hostile_sessions;
|
||||
mod long_renders;
|
||||
mod midi_offset;
|
||||
mod min_secs_with_timecode_counter;
|
||||
mod mix_required_params;
|
||||
mod random_edits;
|
||||
mod recording;
|
||||
mod session_transport;
|
||||
mod split_into_mono;
|
||||
mod time_key_signatures;
|
||||
mod track_name_collisions;
|
||||
mod trim_to_selection;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::Engine;
|
||||
|
||||
#[test]
|
||||
fn min_secs_string_is_not_misread_as_timecode_when_the_counter_is_timecode() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.main_counter", &json!({"format": "timecode"})).unwrap();
|
||||
e.execute("edit.select", &json!({"start": "0:12.500", "end": "0:20.000"})).unwrap();
|
||||
let sel = e.session().edit.selection;
|
||||
assert_eq!((sel.start, sel.end), (600_000, 960_000));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_model::{ChannelFormat, Session, TrackKind};
|
||||
|
||||
fn engine_with_keys() -> Engine {
|
||||
let mut session = Session::default();
|
||||
session.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let mut engine = Engine::new(session);
|
||||
for (at, key) in [(10, "C major"), (48000, "D major"), (100000, "E major")] {
|
||||
engine.execute("event.add_key_change", &json!({"key": key, "at": at})).unwrap();
|
||||
}
|
||||
engine
|
||||
}
|
||||
|
||||
fn keys(engine: &Engine) -> Vec<(u64, String)> {
|
||||
engine.session().key_signatures.iter().map(|(at, k)| (*at as u64, k.clone())).collect()
|
||||
}
|
||||
|
||||
fn k(at: u64, key: &str) -> (u64, String) {
|
||||
(at, key.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_time_moves_key_signatures() {
|
||||
let mut engine = engine_with_keys();
|
||||
let original = keys(&engine);
|
||||
engine.execute("event.insert_time", &json!({"start": 48000, "length": 1000})).unwrap();
|
||||
assert_eq!(keys(&engine), vec![k(10, "C major"), k(49000, "D major"), k(101000, "E major")]);
|
||||
engine.undo();
|
||||
assert_eq!(keys(&engine), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_time_removes_and_moves_key_signatures() {
|
||||
let mut engine = engine_with_keys();
|
||||
let original = keys(&engine);
|
||||
engine.execute("event.cut_time", &json!({"start": 20, "end": 60000})).unwrap();
|
||||
assert_eq!(keys(&engine), vec![k(10, "C major"), k(40020, "E major")]);
|
||||
engine.undo();
|
||||
assert_eq!(keys(&engine), original);
|
||||
}
|
||||
@@ -178,9 +178,10 @@ pub fn change_velocity_with_seed(notes: &mut [Note], op: VelocityOp, seed: u64)
|
||||
VelocityOp::Ramp { from, to } => {
|
||||
let first = notes.iter().map(|n| n.start).min().unwrap_or(0);
|
||||
let last = notes.iter().map(|n| n.start).max().unwrap_or(0);
|
||||
let span = last.saturating_sub(first) as f64;
|
||||
let span = (i128::from(last) - i128::from(first)) as f64;
|
||||
for n in notes.iter_mut() {
|
||||
let t = if span > 0.0 { n.start.saturating_sub(first) as f64 / span } else { 0.0 };
|
||||
let offset = (i128::from(n.start) - i128::from(first)) as f64;
|
||||
let t = if span > 0.0 { offset / span } else { 0.0 };
|
||||
let v = f64::from(from) + (f64::from(to) - f64::from(from)) * t;
|
||||
n.velocity = clamp_vel(round_i64(v));
|
||||
}
|
||||
|
||||
@@ -106,6 +106,13 @@ fn transpose_clamps() {
|
||||
assert!(notes.iter().all(|n| n.pitch == 127));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ramp_full_tick_range() {
|
||||
let mut notes = vec![n(60, i64::MIN, 1), n(60, 0, 1), n(60, i64::MAX, 1)];
|
||||
change_velocity(&mut notes, VelocityOp::Ramp { from: 1, to: 127 });
|
||||
assert_eq!(notes.iter().map(|n| n.velocity).collect::<Vec<_>>(), vec![1, 64, 127]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn velocity_ops() {
|
||||
let base = vec![n(60, 0, 1), n(60, 100, 1), n(60, 200, 1)];
|
||||
|
||||
@@ -1187,3 +1187,15 @@ fn apply_matrix_span_matches_the_plain_per_sample_sum() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,13 @@ pub fn parse_position(
|
||||
let v = sr.samples(m * 60.0 + s);
|
||||
Ok(if neg { -v } else { v })
|
||||
}
|
||||
TimeFormat::Timecode => Ok(Timecode::parse(t)?.to_samples(rate, sr).saturating_sub(tc_start)),
|
||||
TimeFormat::Timecode => {
|
||||
let tc = Timecode::parse(t)?;
|
||||
if tc.minutes >= 60 || tc.seconds >= 60 || tc.frames >= rate.nominal().max(1) {
|
||||
return Err(err());
|
||||
}
|
||||
Ok(tc.to_samples(rate, sr).saturating_sub(tc_start))
|
||||
}
|
||||
TimeFormat::FeetFrames => {
|
||||
let (ft, fr) = t.split_once('+').ok_or_else(err)?;
|
||||
let ft: i64 = ft.trim().parse().map_err(|_| err())?;
|
||||
|
||||
@@ -301,7 +301,7 @@ impl TempoMap {
|
||||
pub fn tick_at_bar_beat(&self, bb: BarBeat) -> i64 {
|
||||
let start = self.bar_start_tick(bb.bar);
|
||||
let m = self.meter_at_tick(start);
|
||||
start.saturating_add((bb.beat - 1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick)
|
||||
start.saturating_add(bb.beat.saturating_sub(1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick)
|
||||
}
|
||||
|
||||
pub fn bar_beat_at(&self, s: Samples, sr: SampleRate) -> BarBeat {
|
||||
@@ -344,6 +344,12 @@ mod tests {
|
||||
assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_beat_is_clamped_without_overflow() {
|
||||
let m = TempoMap::default();
|
||||
assert_eq!(m.tick_at_bar_beat(BarBeat { bar: 1, beat: i64::MIN, tick: 0 }), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_beat_at_min_tick_does_not_overflow() {
|
||||
let m = TempoMap::default();
|
||||
|
||||
@@ -176,7 +176,6 @@ impl SoundApp {
|
||||
if ctx.current_pass_index() == 0 {
|
||||
bounce::step(self, ctx);
|
||||
import::step(self, ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::tick(self, ctx);
|
||||
}
|
||||
if !self.pointer_down {
|
||||
@@ -340,7 +339,6 @@ impl SoundApp {
|
||||
score_editor::show(self, &ctx);
|
||||
palette::show(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::show(self, &ctx);
|
||||
// The sampler's drop rects are current now, so route any captured drop in this pass.
|
||||
self.route_dropped_files();
|
||||
|
||||
@@ -455,6 +455,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
audiosuite_window(app, ctx);
|
||||
crate::bounce::show(app, ctx);
|
||||
crate::import::show(app, ctx);
|
||||
// Whichever window shows, closing Strip Silence drops its rectangles and stops their scan.
|
||||
if !matches!(app.dialogs.open, Some(Dialog::StripSilence { .. } | Dialog::StripSilenceRename { .. })) {
|
||||
app.dialogs.strip_rectangles = StripRectangles::default();
|
||||
}
|
||||
let Some(mut d) = app.dialogs.open.take() else { return };
|
||||
let mut keep = true;
|
||||
let mut action: Option<(String, Value)> = None;
|
||||
|
||||
@@ -42,10 +42,7 @@ pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> Stri
|
||||
let values = match &app.dialogs.open {
|
||||
Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) => [*threshold, *min_ms, *pre_ms, *post_ms],
|
||||
Some(Dialog::StripSilenceRename { strip, .. }) => *strip,
|
||||
_ => {
|
||||
app.dialogs.strip_rectangles = StripRectangles::default();
|
||||
return Arc::default();
|
||||
}
|
||||
_ => return Arc::default(),
|
||||
};
|
||||
let key: StripKey = (app.engine.revision, values.map(f64::to_bits));
|
||||
let r = &mut app.dialogs.strip_rectangles;
|
||||
|
||||
@@ -518,6 +518,11 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
if id == "ui.audio_device" {
|
||||
return Some(crate::windows::audio_device_command(app, p));
|
||||
}
|
||||
// Menu clicks never get here: Setup › Playback Engine / Hardware open the window
|
||||
// (`MENU_WINDOWS`). A programmatic call applies the open device instead.
|
||||
if matches!(id, "setup.playback_engine" | "setup.hardware") {
|
||||
return Some(crate::windows::setup_audio_command(app, id, p));
|
||||
}
|
||||
let toggle = |v: &mut bool| {
|
||||
*v = p.get("value").and_then(Value::as_bool).unwrap_or(!*v);
|
||||
json!({"value": *v})
|
||||
|
||||
@@ -517,6 +517,8 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
number_combo(ui, "pe-rate", "Sample rate", &mut draft.sample_rate, out.map_or(&[][..], |d| &d.sample_rates), "Hz");
|
||||
ui.end_row();
|
||||
ui.label("Buffer size");
|
||||
// The combo lists standard power-of-two sizes. `setup.playback_engine` does not round
|
||||
// onto this list: it keeps any integer 16..=8192, the same as `ui.audio_device`.
|
||||
let buffers = out.map(DeviceInfo::buffer_sizes).unwrap_or_else(|| soundcraft_playback::devices::STANDARD_BUFFERS.to_vec());
|
||||
number_combo(ui, "pe-buffer", "Buffer size", &mut draft.buffer_size, &buffers, "samples");
|
||||
ui.end_row();
|
||||
@@ -623,6 +625,53 @@ pub fn audio_device_command(app: &mut SoundApp, p: &serde_json::Value) -> Result
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Programmatic `setup.playback_engine` / `setup.hardware`. `device` is the output name and
|
||||
/// `buffer_size` is any integer 16..=8192 or null (not rounded to the window's power-of-two list);
|
||||
/// both go through [`audio_device_command`]. Delay compensation still goes through the engine
|
||||
/// command, and only after the device change has been accepted. The result is the open device:
|
||||
/// its name, the buffer actually in use (`device_buffer`, null for the device default), its sample
|
||||
/// rate, and delay compensation. `applied` is true only when an audio device is open.
|
||||
pub fn setup_audio_command(app: &mut SoundApp, id: &str, p: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
use serde_json::Value;
|
||||
let mut audio = serde_json::Map::new();
|
||||
if let Some(device) = p.get("device") {
|
||||
if !device.is_null() && !device.is_string() {
|
||||
return Err("`device` must be a string or null".into());
|
||||
}
|
||||
audio.insert("output".to_string(), device.clone());
|
||||
}
|
||||
if let Some(buffer) = p.get("buffer_size") {
|
||||
audio.insert("buffer_size".to_string(), buffer.clone());
|
||||
}
|
||||
if !audio.is_empty() {
|
||||
audio_device_command(app, &Value::Object(audio))?;
|
||||
}
|
||||
if id == "setup.playback_engine"
|
||||
&& let Some(on) = p.get("delay_compensation").and_then(Value::as_bool)
|
||||
{
|
||||
app.engine.execute("setup.playback_engine", &json!({"delay_compensation": on})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let applied = app.player.as_ref().is_some_and(|player| !player.silent);
|
||||
let (device, buffer_size, sample_rate) = match app.player.as_ref() {
|
||||
Some(player) => {
|
||||
let buffer = match player.device_buffer {
|
||||
Some(n) => json!(n),
|
||||
None => Value::Null,
|
||||
};
|
||||
(json!(player.device_name), buffer, json!(player.device_rate))
|
||||
}
|
||||
None => (Value::Null, Value::Null, json!(app.engine.session().sample_rate.hz())),
|
||||
};
|
||||
Ok(json!({
|
||||
"applied": applied,
|
||||
"device": device,
|
||||
"buffer_size": buffer_size,
|
||||
"sample_rate": sample_rate,
|
||||
"bit_depth": app.engine.session().bit_depth,
|
||||
"delay_compensation": app.engine.session().edit.delay_compensation,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Setup › I/O: busses and output paths.
|
||||
pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let mut open = app.ui.show_io_setup;
|
||||
|
||||
@@ -1515,9 +1515,15 @@ fn editor_gesture_polling_survives_discarded_and_hidden_host_iterations() {
|
||||
}
|
||||
|
||||
/// The engine's revision, undo history and journal are as they were.
|
||||
fn assert_history_unchanged(app: &SoundApp, revision: u64, undo: usize, history: &[String], journal: &[(String, serde_json::Value)]) {
|
||||
fn assert_history_unchanged(
|
||||
app: &SoundApp,
|
||||
revision: u64,
|
||||
undo: usize,
|
||||
history: &[String],
|
||||
journal: &std::collections::VecDeque<(String, serde_json::Value)>,
|
||||
) {
|
||||
assert_eq!(app.engine.revision, revision);
|
||||
assert_eq!(app.engine.undo_history().len(), undo);
|
||||
assert_eq!(app.engine.undo_history(), history);
|
||||
assert_eq!(app.engine.journal, journal);
|
||||
assert_eq!(&app.engine.journal, journal);
|
||||
}
|
||||
|
||||
@@ -25,3 +25,79 @@ fn the_audio_device_choice_is_kept_only_when_it_is_applied() {
|
||||
assert_eq!(r["config"]["buffer_size"], 256);
|
||||
assert!(r["device"].is_object(), "{r}");
|
||||
}
|
||||
|
||||
fn new_app() -> SoundApp {
|
||||
SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_engine_refuses_an_invalid_or_untimely_device_choice_without_side_effects() {
|
||||
let mut app = new_app();
|
||||
let before = app.ui.audio.clone();
|
||||
let bit_depth = app.engine.session().bit_depth;
|
||||
assert!(!app.ui.show_playback_engine);
|
||||
|
||||
let r = app.run("setup.hardware", json!({})).unwrap();
|
||||
assert_eq!(r["applied"], false, "without a player there is no open device");
|
||||
assert_eq!(r["bit_depth"], json!(bit_depth));
|
||||
|
||||
assert!(app.engine.session().edit.delay_compensation, "delay compensation starts on");
|
||||
let err = app.run("setup.playback_engine", json!({"buffer_size": 8, "delay_compensation": false})).unwrap_err();
|
||||
assert!(err.contains("16..=8192"), "{err}");
|
||||
assert_eq!(app.ui.audio, before, "an invalid buffer changes nothing");
|
||||
assert!(app.engine.session().edit.delay_compensation, "an invalid buffer does not apply delay compensation");
|
||||
|
||||
app.engine.transport.recording = true;
|
||||
let err = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap_err();
|
||||
assert!(err.contains("stop recording"), "{err}");
|
||||
assert_eq!(app.ui.audio, before);
|
||||
assert!(app.engine.session().edit.delay_compensation, "a refused device change does not apply delay compensation");
|
||||
app.engine.transport.recording = false;
|
||||
|
||||
let err = app.run("setup.playback_engine", json!({"device": 42, "delay_compensation": false})).unwrap_err();
|
||||
assert!(err.contains("`device` must be a string or null"), "{err}");
|
||||
assert_eq!(app.ui.audio, before, "an invalid device changes nothing");
|
||||
assert!(app.engine.session().edit.delay_compensation, "an invalid device does not apply delay compensation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_engine_applies_the_device_choice_and_reports_the_open_device() {
|
||||
let mut app = new_app();
|
||||
let bit_depth = app.engine.session().bit_depth;
|
||||
let r = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap();
|
||||
assert_eq!(app.ui.audio.buffer_size, Some(256));
|
||||
assert!(!app.engine.session().edit.delay_compensation);
|
||||
assert!(!app.ui.show_playback_engine, "a programmatic call does not open the window");
|
||||
assert_eq!(r["delay_compensation"], false);
|
||||
let player = app.player.as_ref().unwrap();
|
||||
assert_eq!(r["applied"], !player.silent);
|
||||
assert_eq!(r["device"], json!(player.device_name));
|
||||
assert_eq!(r["buffer_size"], json!(player.device_buffer));
|
||||
assert_eq!(r["sample_rate"], player.device_rate);
|
||||
assert_eq!(r["bit_depth"], json!(bit_depth));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_engine_applies_the_same_device_choice_as_the_audio_command() {
|
||||
let mut app = new_app();
|
||||
app.run("setup.playback_engine", json!({"delay_compensation": false})).unwrap();
|
||||
|
||||
// 300 is in range and is not rounded onto the window's power-of-two list.
|
||||
let mut via_window_command = new_app();
|
||||
via_window_command.run("ui.audio_device", json!({"buffer_size": 300, "output": "Elsewhere"})).unwrap();
|
||||
app.run("setup.hardware", json!({"buffer_size": 300, "device": "Elsewhere"})).unwrap();
|
||||
assert_eq!(app.ui.audio.buffer_size, Some(300));
|
||||
assert_eq!(app.ui.audio.buffer_size, via_window_command.ui.audio.buffer_size);
|
||||
assert_eq!(app.ui.audio.output, via_window_command.ui.audio.output);
|
||||
assert!(!app.ui.show_playback_engine);
|
||||
|
||||
app.run("setup.playback_engine", json!({"buffer_size": null, "device": null})).unwrap();
|
||||
assert_eq!(app.ui.audio.buffer_size, None);
|
||||
assert_eq!(app.ui.audio.output, None);
|
||||
assert!(!app.engine.session().edit.delay_compensation, "clearing the device leaves delay compensation");
|
||||
|
||||
let cfg = app.ui.audio.clone();
|
||||
soundcraft_ui_egui::menus::invoke_menu(&mut app, "setup.playback_engine", "Setup > Playback Engine...");
|
||||
assert!(app.ui.show_playback_engine, "the menu still opens the Playback Engine window");
|
||||
assert_eq!(app.ui.audio, cfg, "opening the window does not change the device");
|
||||
}
|
||||
|
||||
@@ -25,8 +25,6 @@ const KNOWN_UNREACHED: &[&str] = &[
|
||||
"mix.solo_safe",
|
||||
"options.post_roll",
|
||||
"options.pre_roll",
|
||||
"setup.hardware", // menu opens the Playback Engine window, which doesn't run it
|
||||
"setup.playback_engine", // same
|
||||
"setup.reset_edit_state",
|
||||
"track.load_preset",
|
||||
"track.pin",
|
||||
@@ -93,6 +91,13 @@ fn every_command_has_a_ui_path() {
|
||||
assert!(stale.is_empty(), "now reachable, remove from KNOWN_UNREACHED: {stale:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_engine_menu_opens_the_window_instead_of_running_the_command() {
|
||||
let extra = menus::ui_aliases();
|
||||
assert_eq!(catalog::implemented_by("Setup > Playback Engine...", &extra).as_deref(), Some("window.playback_engine"));
|
||||
assert_eq!(catalog::implemented_by("Setup > Hardware...", &extra).as_deref(), Some("window.playback_engine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_midi_clip_is_in_the_clip_menu() {
|
||||
let extra = menus::ui_aliases();
|
||||
|
||||
@@ -109,3 +109,18 @@ fn the_rectangles_stay_while_rename_is_open_and_go_when_the_window_closes() {
|
||||
assert_eq!(rectangles(&h), 0);
|
||||
assert_eq!(clips(&h, track), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_window_while_the_mix_window_shows_stops_the_scan() {
|
||||
let (mut h, _) = strip_silence_dialog();
|
||||
if let Some(Dialog::StripSilence { threshold, .. }) = &mut h.state_mut().dialogs.open {
|
||||
*threshold = -30.0;
|
||||
}
|
||||
h.run_steps(1);
|
||||
assert!(h.state().dialogs.strip_scan_running(), "a scan for the new threshold started");
|
||||
h.state_mut().run("window.mix", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.state_mut().dialogs.open = None;
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().dialogs.strip_scan_running(), "the scan still runs after the window closed");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! NAL unit header and slice header parsing (7.3.1, 7.3.3).
|
||||
|
||||
use crate::bitstream::BitReader;
|
||||
use crate::h264::error::{Result, ensure};
|
||||
use crate::h264::error::{Error, Result, ensure};
|
||||
use crate::h264::params::{Pps, Sps};
|
||||
|
||||
/// NAL unit types used by the decoder.
|
||||
@@ -199,9 +199,9 @@ impl SliceHeader {
|
||||
let mut num_ref_idx_active = [pps.num_ref_idx_l0_default_active, pps.num_ref_idx_l1_default_active];
|
||||
if !slice_type.is_intra() {
|
||||
if r.read_flag()? {
|
||||
num_ref_idx_active[0] = r.read_ue()? + 1;
|
||||
num_ref_idx_active[0] = plus_one(r.read_ue()?)?;
|
||||
if slice_type.is_b() {
|
||||
num_ref_idx_active[1] = r.read_ue()? + 1;
|
||||
num_ref_idx_active[1] = plus_one(r.read_ue()?)?;
|
||||
}
|
||||
}
|
||||
let max = if field_pic { 32 } else { 16 };
|
||||
@@ -223,7 +223,7 @@ impl SliceHeader {
|
||||
ensure!(cabac_init_idc <= 2, "cabac_init_idc out of range");
|
||||
}
|
||||
let slice_qp_delta = r.read_se()?;
|
||||
let qp = pps.pic_init_qp + slice_qp_delta;
|
||||
let qp = pps.pic_init_qp.checked_add(slice_qp_delta).ok_or_else(|| Error::Invalid("slice QP out of range".to_string()))?;
|
||||
ensure!((0..=51).contains(&qp), "slice QP {qp} out of range");
|
||||
let mut sp_for_switch = false;
|
||||
let mut slice_qs_delta = 0;
|
||||
@@ -308,6 +308,11 @@ struct RefFields {
|
||||
mmcos: Vec<Mmco>,
|
||||
}
|
||||
|
||||
/// `num_ref_idx_active_minus1 + 1`, or the error its range check gives.
|
||||
fn plus_one(v: u32) -> Result<u32> {
|
||||
v.checked_add(1).ok_or_else(|| Error::Invalid("num_ref_idx_active out of range".to_string()))
|
||||
}
|
||||
|
||||
fn parse_reference_fields(
|
||||
r: &mut BitReader<'_>,
|
||||
nal: NalHeader,
|
||||
@@ -549,3 +554,88 @@ impl PocState {
|
||||
self.prev_frame_num = frame_num;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bitstream::BitWriter;
|
||||
|
||||
fn sps_pps() -> (Sps, Pps) {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(66, 8);
|
||||
w.write_bits(0xC0, 8);
|
||||
w.write_bits(30, 8);
|
||||
w.write_ue(0);
|
||||
w.write_ue(0);
|
||||
w.write_ue(2);
|
||||
w.write_ue(1);
|
||||
w.write_bit(false);
|
||||
w.write_ue(10);
|
||||
w.write_ue(8);
|
||||
w.write_bit(true);
|
||||
w.write_bit(true);
|
||||
w.write_bit(false);
|
||||
w.write_bit(false);
|
||||
w.rbsp_trailing();
|
||||
let sps = Sps::parse(&w.finish()).unwrap();
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(0);
|
||||
w.write_ue(0);
|
||||
w.write_bit(false);
|
||||
w.write_bit(false);
|
||||
w.write_ue(0);
|
||||
w.write_ue(0);
|
||||
w.write_ue(0);
|
||||
w.write_bit(false);
|
||||
w.write_bits(0, 2);
|
||||
w.write_se(0);
|
||||
w.write_se(0);
|
||||
w.write_se(0);
|
||||
w.write_bit(false);
|
||||
w.write_bit(false);
|
||||
w.write_bit(false);
|
||||
w.rbsp_trailing();
|
||||
let pps = Pps::parse(&w.finish(), &[Some(sps.clone())]).unwrap();
|
||||
(sps, pps)
|
||||
}
|
||||
|
||||
/// Parse a non-IDR slice header of `slice_type` whose override flag is set with the given counts.
|
||||
fn parse_override(slice_type: u32, l0_minus1: u32, l1_minus1: u32, qp_delta: i32) -> Result<SliceHeader> {
|
||||
let (sps, pps) = sps_pps();
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(0);
|
||||
w.write_ue(slice_type);
|
||||
w.write_ue(0);
|
||||
w.write_bits(0, 4);
|
||||
if slice_type == 1 {
|
||||
w.write_bit(true); // direct_spatial_mv_pred
|
||||
}
|
||||
w.write_bit(true); // num_ref_idx_active_override_flag
|
||||
w.write_ue(l0_minus1);
|
||||
if slice_type == 1 {
|
||||
w.write_ue(l1_minus1);
|
||||
}
|
||||
w.write_bit(false); // ref_pic_list_modification_flag_l0
|
||||
if slice_type == 1 {
|
||||
w.write_bit(false);
|
||||
}
|
||||
w.write_bit(false); // adaptive_ref_pic_marking_mode_flag
|
||||
w.write_se(qp_delta);
|
||||
w.rbsp_trailing();
|
||||
let nal = NalHeader { nal_ref_idc: 1, nal_unit_type: 1 };
|
||||
SliceHeader::parse(&w.finish(), nal, |_| Ok((&pps, &sps))).map(|(h, _, _)| h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_num_ref_idx_override_is_invalid() {
|
||||
assert!(matches!(parse_override(0, u32::MAX, 0, 0), Err(crate::h264::Error::Invalid(_))));
|
||||
assert!(matches!(parse_override(1, 0, u32::MAX, 0), Err(crate::h264::Error::Invalid(_))));
|
||||
assert!(matches!(parse_override(0, 16, 0, 0), Err(crate::h264::Error::Invalid(_))));
|
||||
assert_eq!(parse_override(0, 15, 0, 0).unwrap().num_ref_idx_active, [16, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_slice_qp_delta_is_invalid() {
|
||||
assert!(matches!(parse_override(0, 0, 0, i32::MAX), Err(crate::h264::Error::Invalid(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,7 +485,7 @@ impl Movie {
|
||||
let s = st.next;
|
||||
st.next += 1;
|
||||
let pts = self.rank.get(s).copied().unwrap_or(0) as i64;
|
||||
let data = match self.mp4.read_sample(&*self.src, self.track, s) {
|
||||
let data = match self.read(s) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
last_err = Some(e.to_string());
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
//! `image` encoder, muxed with the bundled MP4/MOV writer. When `ffmpeg` with libx264 is on PATH,
|
||||
//! real High-profile streams with B-frames are checked too (skipped otherwise).
|
||||
|
||||
use crate::isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use crate::isobmff::{Brand, ByteSource, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use crate::synth::{frame_color, h264_mp4, mjpeg_mov, prores_mov};
|
||||
use crate::{Movie, VideoError};
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn expected_rgb(i: usize) -> [u8; 3] {
|
||||
let [y, cb, cr] = frame_color(i);
|
||||
@@ -118,6 +120,47 @@ fn prores_frame_size_is_checked_before_decoding() {
|
||||
assert!(matches!(m.frame(0), Err(VideoError::Decode(e)) if e.contains("56x48 frame in a 64x48 track")));
|
||||
}
|
||||
|
||||
/// In-memory movie bytes, zero-padded up to `len` without storing the padding. Records the
|
||||
/// largest read asked of it and refuses reads over 1 MiB (the test movies are much smaller).
|
||||
struct PaddedSource {
|
||||
bytes: Vec<u8>,
|
||||
len: u64,
|
||||
largest: AtomicUsize,
|
||||
}
|
||||
|
||||
impl ByteSource for PaddedSource {
|
||||
fn len(&self) -> u64 {
|
||||
self.len
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
|
||||
self.largest.fetch_max(buf.len(), Ordering::Relaxed);
|
||||
if buf.len() > 1 << 20 {
|
||||
return Err(std::io::Error::other("read too large for the test source"));
|
||||
}
|
||||
let start = usize::try_from(offset).unwrap_or(usize::MAX);
|
||||
for (i, b) in buf.iter_mut().enumerate() {
|
||||
*b = self.bytes.get(start.saturating_add(i)).copied().unwrap_or(0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_sample_reads_are_capped() {
|
||||
// One frame whose `stsz` entry claims 300 MiB, in a source long enough to hold it.
|
||||
let mut bytes = h264_mp4(1, 1).unwrap();
|
||||
let at = bytes.windows(4).position(|w| w == b"stsz").unwrap();
|
||||
// After the type: version and flags, the constant size (0: one size per sample), the count.
|
||||
let field = if bytes[at + 8..at + 12] == [0; 4] { at + 16 } else { at + 8 };
|
||||
bytes[field..field + 4].copy_from_slice(&(300u32 << 20).to_be_bytes());
|
||||
let src = Arc::new(PaddedSource { bytes, len: 1 << 30, largest: AtomicUsize::new(0) });
|
||||
let mut m = Movie::open_source(src.clone()).unwrap();
|
||||
let r = m.frame(0);
|
||||
assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("sample larger than 256 MiB")), "{r:?}");
|
||||
let largest = src.largest.load(Ordering::Relaxed);
|
||||
assert!(largest <= 256 << 20, "a {largest}-byte read reached the source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_codec_opens_but_does_not_decode() {
|
||||
let mut mw = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
|
||||
@@ -35,6 +35,11 @@ pub(crate) struct Layout {
|
||||
pub event_input: bool,
|
||||
}
|
||||
|
||||
/// `"input"` or `"output"`, for error messages.
|
||||
fn dir_name(dir: sv::BusDirections) -> &'static str {
|
||||
if dir == sv::BusDirections_::kInput { "input" } else { "output" }
|
||||
}
|
||||
|
||||
pub(super) fn apply_transport(c: &mut sv::ProcessContext, t: &Transport) {
|
||||
use sv::ProcessContext_::StatesAndFlags_::{
|
||||
kBarPositionValid, kContTimeValid, kPlaying, kProjectTimeMusicValid, kRecording, kTempoValid, kTimeSigValid,
|
||||
@@ -248,7 +253,17 @@ impl Instance {
|
||||
|
||||
fn bus_count(&self, media: sv::MediaTypes, dir: sv::BusDirections) -> i32 {
|
||||
// SAFETY: valid component.
|
||||
unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.clamp(0, MAX_BUSES)
|
||||
unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.max(0)
|
||||
}
|
||||
|
||||
/// The plugin's audio buses in one direction. More than `MAX_BUSES` is an error: we never
|
||||
/// hand a plugin fewer buses than it advertised.
|
||||
fn audio_bus_count(&self, dir: sv::BusDirections) -> Result<i32, Vst3Error> {
|
||||
let n = self.bus_count(sv::MediaTypes_::kAudio, dir);
|
||||
if n > MAX_BUSES {
|
||||
return Err(Vst3Error::Process(format!("{n} audio {} buses (at most {MAX_BUSES} supported)", dir_name(dir))));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn arrangement(&self, dir: sv::BusDirections, index: i32) -> Option<sv::SpeakerArrangement> {
|
||||
@@ -258,30 +273,41 @@ impl Instance {
|
||||
ok(unsafe { p.getBusArrangement(dir as sv::BusDirection, index, &mut arr) }).then_some(arr)
|
||||
}
|
||||
|
||||
fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> u32 {
|
||||
if let Some(arr) = self.arrangement(dir, index) {
|
||||
return arr.count_ones().min(MAX_BUS_CHANNELS);
|
||||
}
|
||||
// SAFETY: plain data; all-zero is valid.
|
||||
let mut info: sv::BusInfo = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: valid component; index below the bus count; `info` is writable.
|
||||
if ok(unsafe { self.component.getBusInfo(sv::MediaTypes_::kAudio as sv::MediaType, dir as sv::BusDirection, index, &mut info) }) {
|
||||
u32::try_from(info.channelCount).unwrap_or(0).min(MAX_BUS_CHANNELS)
|
||||
/// Channels of one audio bus. More than `MAX_BUS_CHANNELS` is an error, as for the bus count.
|
||||
fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> Result<u32, Vst3Error> {
|
||||
let channels = if let Some(arr) = self.arrangement(dir, index) {
|
||||
arr.count_ones()
|
||||
} else {
|
||||
0
|
||||
// SAFETY: plain data; all-zero is valid.
|
||||
let mut info: sv::BusInfo = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: valid component; index below the bus count; `info` is writable.
|
||||
if ok(unsafe { self.component.getBusInfo(sv::MediaTypes_::kAudio as sv::MediaType, dir as sv::BusDirection, index, &mut info) }) {
|
||||
u32::try_from(info.channelCount).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
if channels > MAX_BUS_CHANNELS {
|
||||
return Err(Vst3Error::Process(format!(
|
||||
"audio {} bus {index} has {channels} channels (at most {MAX_BUS_CHANNELS} supported)",
|
||||
dir_name(dir)
|
||||
)));
|
||||
}
|
||||
Ok(channels)
|
||||
}
|
||||
|
||||
/// Stops processing, deactivates, negotiates the bus layout (main buses mono or stereo as
|
||||
/// asked, falling back to stereo and then to the plugin's own choice), activates the main
|
||||
/// buses, sets up 32-bit processing and activates the component.
|
||||
/// buses, sets up 32-bit processing and activates the component. A layout beyond
|
||||
/// `MAX_BUSES` buses or `MAX_BUS_CHANNELS` channels per bus is an error, and the component
|
||||
/// stays inactive.
|
||||
pub fn configure(&mut self, sample_rate: f64, max_frames: usize, mono: bool) -> Result<Layout, Vst3Error> {
|
||||
self.shutdown();
|
||||
let audio = sv::MediaTypes_::kAudio;
|
||||
let event = sv::MediaTypes_::kEvent;
|
||||
let (din, dout) = (sv::BusDirections_::kInput, sv::BusDirections_::kOutput);
|
||||
let nin = self.bus_count(audio, din);
|
||||
let nout = self.bus_count(audio, dout);
|
||||
let nin = self.audio_bus_count(din)?;
|
||||
let nout = self.audio_bus_count(dout)?;
|
||||
let current = |s: &Self, dir: sv::BusDirections, n: i32| -> Vec<sv::SpeakerArrangement> {
|
||||
(0..n).map(|i| s.arrangement(dir, i).unwrap_or(sv::SpeakerArr::kStereo)).collect()
|
||||
};
|
||||
@@ -311,8 +337,8 @@ impl Instance {
|
||||
log::debug!("vst3: plugin refused mono/stereo bus arrangements; using its own");
|
||||
}
|
||||
let layout = Layout {
|
||||
inputs: (0..nin).map(|i| self.bus_channels(din, i)).collect(),
|
||||
outputs: (0..nout).map(|i| self.bus_channels(dout, i)).collect(),
|
||||
inputs: (0..nin).map(|i| self.bus_channels(din, i)).collect::<Result<_, _>>()?,
|
||||
outputs: (0..nout).map(|i| self.bus_channels(dout, i)).collect::<Result<_, _>>()?,
|
||||
event_input: self.bus_count(event, din) > 0,
|
||||
};
|
||||
// Main buses on, aux buses off (VST3 still gets buffers for every bus).
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn fixture_dir() -> &'static Path {
|
||||
let bin = dir.join("vendor/Fixture.vst3/Contents").join(folder);
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::copy(&built, bin.join(exe)).unwrap();
|
||||
assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 2);
|
||||
assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 5);
|
||||
dir
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Test-only VST3 plugin for soundcraft-vst3-host (two audio module classes):
|
||||
//! Test-only VST3 plugin for soundcraft-vst3-host (five audio module classes):
|
||||
//!
|
||||
//! - "Fixture Gain" (`Fx`): stereo or mono in → out × gain, with a separate edit controller
|
||||
//! (connected through IConnectionPoint, synced through the component state). Params: 7 "Gain"
|
||||
@@ -11,6 +11,11 @@
|
||||
//! its edit controller). No audio input, one stereo output, one event input; every output
|
||||
//! sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and
|
||||
//! MIDI CC 7 through `IMidiMapping` (hidden parameter 1), set it to their value too.
|
||||
//! - "Fixture Wide 16x64", "Fixture Wide 17 buses", "Fixture Wide 65 channels" (`Fx`): effects
|
||||
//! advertising wide layouts (16 buses of 64 channels, 17 buses of 2 channels, one bus of 65
|
||||
//! channels per direction), through `getBusInfo` only. `process` writes 1.0 into every channel
|
||||
//! of output bus 0 when the host's buffers match the advertised layout, -1.0 when they do not.
|
||||
//! Report 3 samples of latency.
|
||||
//!
|
||||
//! The module checks the host's module lifecycle the way real plugins depend on it (see "module
|
||||
//! lifecycle" below) and aborts the process when it is broken.
|
||||
@@ -28,6 +33,9 @@ use vst3::{Class, ComRef, ComWrapper, uid};
|
||||
const GAIN_CID: TUID = uid(0x5C0A1D00, 0x11112222, 0x33334444, 0x55556666);
|
||||
const GAIN_CTRL_CID: TUID = uid(0x5C0A1D01, 0x11112222, 0x33334444, 0x55556666);
|
||||
const SYNTH_CID: TUID = uid(0x5C0A1D02, 0x11112222, 0x33334444, 0x55556666);
|
||||
const WIDE16_CID: TUID = uid(0x5C0A1D03, 0x11112222, 0x33334444, 0x55556666);
|
||||
const WIDE17_CID: TUID = uid(0x5C0A1D04, 0x11112222, 0x33334444, 0x55556666);
|
||||
const WIDE65_CID: TUID = uid(0x5C0A1D05, 0x11112222, 0x33334444, 0x55556666);
|
||||
|
||||
fn copy_c8(src: &str, dst: &mut [c_char]) {
|
||||
for (d, s) in dst.iter_mut().zip(src.bytes().chain(std::iter::once(0))) {
|
||||
@@ -626,6 +634,98 @@ impl IEditControllerTrait for Synth {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- wide layouts --------------------------------------------------------------------------
|
||||
|
||||
struct Wide {
|
||||
buses: i32,
|
||||
channels: i32,
|
||||
_live: Live,
|
||||
}
|
||||
|
||||
impl Class for Wide {
|
||||
type Interfaces = (IComponent, IAudioProcessor);
|
||||
}
|
||||
|
||||
impl IPluginBaseTrait for Wide {
|
||||
unsafe fn initialize(&self, _context: *mut FUnknown) -> tresult {
|
||||
kResultOk
|
||||
}
|
||||
unsafe fn terminate(&self) -> tresult {
|
||||
kResultOk
|
||||
}
|
||||
}
|
||||
|
||||
impl IComponentTrait for Wide {
|
||||
unsafe fn getControllerClassId(&self, _class_id: *mut TUID) -> tresult {
|
||||
kNotImplemented
|
||||
}
|
||||
unsafe fn setIoMode(&self, _mode: IoMode) -> tresult {
|
||||
kResultOk
|
||||
}
|
||||
unsafe fn getBusCount(&self, media: MediaType, _dir: BusDirection) -> i32 {
|
||||
if media == MediaTypes_::kAudio as MediaType { self.buses } else { 0 }
|
||||
}
|
||||
unsafe fn getBusInfo(&self, media: MediaType, dir: BusDirection, index: i32, bus: *mut BusInfo) -> tresult {
|
||||
if media != MediaTypes_::kAudio as MediaType || !(0..self.buses).contains(&index) {
|
||||
return kInvalidArgument;
|
||||
}
|
||||
audio_bus(bus, dir, "Wide");
|
||||
unsafe { (*bus).channelCount = self.channels };
|
||||
kResultOk
|
||||
}
|
||||
unsafe fn getRoutingInfo(&self, _i: *mut RoutingInfo, _o: *mut RoutingInfo) -> tresult {
|
||||
kNotImplemented
|
||||
}
|
||||
unsafe fn activateBus(&self, _m: MediaType, _d: BusDirection, _i: i32, _s: TBool) -> tresult {
|
||||
kResultOk
|
||||
}
|
||||
unsafe fn setActive(&self, _state: TBool) -> tresult {
|
||||
kResultOk
|
||||
}
|
||||
unsafe fn setState(&self, _state: *mut IBStream) -> tresult {
|
||||
kResultOk
|
||||
}
|
||||
unsafe fn getState(&self, _state: *mut IBStream) -> tresult {
|
||||
kResultOk
|
||||
}
|
||||
}
|
||||
|
||||
impl IAudioProcessorTrait for Wide {
|
||||
unsafe fn setBusArrangements(&self, _i: *mut SpeakerArrangement, _n_in: i32, _o: *mut SpeakerArrangement, _n_out: i32) -> tresult {
|
||||
kResultFalse
|
||||
}
|
||||
unsafe fn getBusArrangement(&self, _dir: BusDirection, _index: i32, _arr: *mut SpeakerArrangement) -> tresult {
|
||||
kNotImplemented
|
||||
}
|
||||
unsafe fn canProcessSampleSize(&self, size: i32) -> tresult {
|
||||
if size == SymbolicSampleSizes_::kSample32 as i32 { kResultOk } else { kNotImplemented }
|
||||
}
|
||||
unsafe fn getLatencySamples(&self) -> u32 {
|
||||
3
|
||||
}
|
||||
unsafe fn setupProcessing(&self, _setup: *mut ProcessSetup) -> tresult {
|
||||
kResultOk
|
||||
}
|
||||
unsafe fn setProcessing(&self, _state: TBool) -> tresult {
|
||||
kResultOk
|
||||
}
|
||||
unsafe fn process(&self, data: *mut ProcessData) -> tresult {
|
||||
let d = unsafe { &*data };
|
||||
let fits =
|
||||
|bufs: *mut AudioBusBuffers, n: i32| n == self.buses && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.numChannels == self.channels);
|
||||
let ok = fits(d.inputs, d.numInputs) && fits(d.outputs, d.numOutputs);
|
||||
if d.numOutputs > 0 {
|
||||
for ch in unsafe { channels(&*d.outputs, d.numSamples.max(0) as usize) } {
|
||||
ch.fill(if ok { 1.0 } else { -1.0 });
|
||||
}
|
||||
}
|
||||
kResultOk
|
||||
}
|
||||
unsafe fn getTailSamples(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
// ---- factory -------------------------------------------------------------------------------
|
||||
|
||||
struct Factory {
|
||||
@@ -636,10 +736,13 @@ impl Class for Factory {
|
||||
type Interfaces = (IPluginFactory2,);
|
||||
}
|
||||
|
||||
const CLASSES: [(TUID, &str, &str, &str); 3] = [
|
||||
const CLASSES: [(TUID, &str, &str, &str); 6] = [
|
||||
(GAIN_CID, "Audio Module Class", "Fixture Gain", "Fx"),
|
||||
(GAIN_CTRL_CID, "Component Controller Class", "Fixture Gain", ""),
|
||||
(SYNTH_CID, "Audio Module Class", "Fixture Synth", "Instrument|Synth"),
|
||||
(WIDE16_CID, "Audio Module Class", "Fixture Wide 16x64", "Fx"),
|
||||
(WIDE17_CID, "Audio Module Class", "Fixture Wide 17 buses", "Fx"),
|
||||
(WIDE65_CID, "Audio Module Class", "Fixture Wide 65 channels", "Fx"),
|
||||
];
|
||||
|
||||
impl IPluginFactoryTrait for Factory {
|
||||
@@ -682,6 +785,10 @@ impl IPluginFactoryTrait for Factory {
|
||||
.to_com_ptr::<FUnknown>()
|
||||
} else if cid == SYNTH_CID {
|
||||
ComWrapper::new(Synth { level: AtomicU32::new(0), _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
|
||||
} else if let Some((buses, channels)) =
|
||||
[(WIDE16_CID, (16, 64)), (WIDE17_CID, (17, 2)), (WIDE65_CID, (1, 65))].into_iter().find_map(|(c, layout)| (c == cid).then_some(layout))
|
||||
{
|
||||
ComWrapper::new(Wide { buses, channels, _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ fn id_of(name: &str) -> String {
|
||||
fn scan_lists_both_audio_modules_but_not_the_controller_class() {
|
||||
let dir = fixture_dir();
|
||||
let found = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]);
|
||||
assert_eq!(found.len(), 2, "{found:?}");
|
||||
assert_eq!(found.len(), 5, "{found:?}");
|
||||
let g = found.iter().find(|d| d.name == "Fixture Gain").unwrap();
|
||||
// `uid()` lays out the first 8 bytes differently on Windows (COM GUID order).
|
||||
let want = if cfg!(windows) { "vst3:001D0A5C111122223333444455556666" } else { "vst3:5C0A1D00111122223333444455556666" };
|
||||
@@ -311,6 +311,26 @@ fn host_set_controller_param_survives_state_round_trip() {
|
||||
assert!((r.param("12").unwrap() - 200.0).abs() < 0.01, "a loaded state replaces earlier edits: {:?}", r.param("12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
|
||||
// Control: exactly at the limits (16 buses of 64 channels) activates and gets its full layout.
|
||||
let mut p = soundcraft_vst3_host::create(&id_of("Fixture Wide 16x64")).unwrap();
|
||||
p.prepare(44_100.0, 64, 2);
|
||||
assert_eq!(p.latency(), 3);
|
||||
let mut io = vec![vec![0.5f32; 64]; 2];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io.iter().flatten().all(|&x| x == 1.0), "16x64 layout delivered as advertised: {:?}", &io[0][..4]);
|
||||
// 17 buses, or a 65-channel bus: never activated, never processed with a cut-down layout.
|
||||
for name in ["Fixture Wide 17 buses", "Fixture Wide 65 channels"] {
|
||||
let mut p = soundcraft_vst3_host::create(&id_of(name)).unwrap();
|
||||
p.prepare(44_100.0, 64, 2);
|
||||
assert_eq!(p.latency(), 0, "{name}: not activated");
|
||||
let mut io = vec![vec![0.5f32; 64]; 2];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io.iter().flatten().all(|&x| x == 0.5), "{name}: the plugin never ran: {:?}", &io[0][..4]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_view_is_probed_and_edits_come_back_as_plain_values() {
|
||||
let id = id_of("Fixture Gain");
|
||||
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
# Where SoundCraft falls short of Pro Tools
|
||||
|
||||
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-25 · **Change:** trivial (G-04: Clip Looping crossfade and loop count; previously trivial: G-08 and G-20: built-in plugin count, the sampler shipped) · **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**
|
||||
@@ -46,11 +46,16 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t
|
||||
device, sample rate and device buffer size (kept with the preferences, per machine; `ui.audio_device`
|
||||
for agents); f32 and 16/32-bit integer devices; ASIO behind the opt-in `asio` build feature
|
||||
(verified on Windows with an SSL 2+ at 128 samples).
|
||||
- **Done (2026-10-25):** `setup.playback_engine` and `setup.hardware` apply `device` (the output
|
||||
name) and `buffer_size` in the desktop app on the same path as the Playback Engine window
|
||||
(`ui.audio_device`) and return the open device. `buffer_size` is any integer 16..=8192 and is
|
||||
not rounded onto the window's power-of-two list. Headless (CLI, no app) still stores the request
|
||||
and returns `applied: false`, with `buffer_size` null when unset; playback does not read those
|
||||
session values.
|
||||
- **Missing:** routing to physical channels (I/O Setup), one duplex stream on one clock (input and
|
||||
output are still separate cpal streams), latency reporting and record-offset compensation, ASIO
|
||||
in official builds (owner decision: Steinberg's SDK is proprietary or GPLv3), JACK, and
|
||||
verification on macOS and Linux. The mix still runs in fixed 512-sample blocks.
|
||||
`setup.playback_engine` still stores `device`/`buffer_size` in the session, unused.
|
||||
- **Evidence:** code above; Playback Engine window says "Mix block size: 512 samples"; issue #42
|
||||
(no input device selection, forced monitoring feedback with virtual routing).
|
||||
- **Impact:** blocks tracking with any multichannel interface; latency makes overdubs hard.
|
||||
@@ -226,6 +231,7 @@ missing. Out of scope unless the owner decides otherwise.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained |
|
||||
| 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# SoundCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (built-in plugin count; the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** trivial (`setup.playback_engine` applies the device buffer in the desktop app) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
Forward-looking plan. Status and numbers: [`../ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the ranked work list: [`gaps.md`](gaps.md).
|
||||
@@ -23,7 +23,7 @@ main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are th
|
||||
|
||||
| Workflow | Works end to end? | Evidence | Hours to pass |
|
||||
|---|---|---|---:|
|
||||
| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Default output and input devices only. Fixed 512-sample block. Separate input and output streams. `setup.playback_engine` params ignored by playback. Takes held in RAM until stop (PR #84). No input device choice (#42). | 35–55 |
|
||||
| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Device, rate and buffer are chosen in the Playback Engine window and, in the desktop app, by `setup.playback_engine` (headless returns `applied: false`). The mix still runs in fixed 512-sample blocks. Separate input and output streams. Takes held in RAM until stop (PR #84). | 35–55 |
|
||||
| Record MIDI from a controller and play it back through an instrument | **no** | Playback of drawn and imported MIDI works. No MIDI device layer at all, so nothing can be recorded and instruments can't be played live (G-02). | 20–35 |
|
||||
| Edit: select, trim, fade, comp playlists, Slip/Shuffle/Grid | yes (rough) | Every Edit menu command; property-tested with undo; open bugs #3, #5, #140 don't block the workflow | — |
|
||||
| Mix with built-in and third-party plugins, sends, busses, automation | partial (passes on CLAP/AU) | 27 built-in processors; CLAP and AU host on the signed macOS build, but its missing entitlement blocks third-party VST3 (#153). No AU plugin windows yet; VST3 windows on macOS only | 1–2 (entitlement) |
|
||||
@@ -89,6 +89,7 @@ There is no `cargo xtask scorecard` in this repo; the measured number is `cargo
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | trivial | Record-workflow evidence: `setup.playback_engine` applies the device and buffer in the desktop app; headless returns `applied: false` |
|
||||
| 2026-10-24 | trivial | 27 built-in processors, as the plugin registry holds; the sampler (#108) is no longer listed under plugin essentials |
|
||||
| 2026-10-14 | trivial | M8: reject oversized MCP batches before execution (#460) |
|
||||
| 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) |
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and package SoundCraft for FreeBSD:
|
||||
#
|
||||
# $DIST/soundcraft-<version>-freebsd-x86_64.tar.gz a /usr/local-style tree:
|
||||
# soundcraft-<version>-freebsd-x86_64/{bin, man/man1, share/applications, share/icons,
|
||||
# $DIST/soundcraft-<version>-freebsd-<arch>.tar.gz a /usr/local-style tree (<arch> is x86_64, or
|
||||
# aarch64 when CARGO_BUILD_TARGET=aarch64-unknown-freebsd):
|
||||
# soundcraft-<version>-freebsd-<arch>/{bin, man/man1, share/applications, share/icons,
|
||||
# share/mime, share/metainfo, share/doc/soundcraft, share/{bash-completion,zsh,fish}}
|
||||
#
|
||||
# Install by copying the tree's contents into /usr/local, e.g.
|
||||
@@ -23,7 +24,7 @@ SKIP_BUILD=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
-h | --help) sed -n '2,15p' "$0"; exit 0 ;;
|
||||
-h | --help) sed -n '2,16p' "$0"; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -23,6 +23,8 @@ finish-args:
|
||||
- --socket=fallback-x11
|
||||
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
|
||||
- --share=ipc
|
||||
# Update checks reach GitHub (Help > Check for Updates and the startup check).
|
||||
- --share=network
|
||||
# GPU access for the wgpu UI (Vulkan, or GL as a fallback).
|
||||
- --device=dri
|
||||
# Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its
|
||||
|
||||
@@ -28,6 +28,8 @@ finish-args:
|
||||
- --socket=fallback-x11
|
||||
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
|
||||
- --share=ipc
|
||||
# Update checks reach GitHub (Help > Check for Updates and the startup check).
|
||||
- --share=network
|
||||
# GPU access for the wgpu UI (Vulkan, or GL as a fallback).
|
||||
- --device=dri
|
||||
# Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its
|
||||
|
||||
Reference in New Issue
Block a user