mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-09 20:00:25 -06:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0857ab5465 | ||
|
|
d0519a2640 | ||
|
|
17d3f00700 | ||
|
|
1c350101bc |
@@ -222,10 +222,33 @@ mod tests {
|
||||
assert!(plugin_info("reverb").is_none());
|
||||
}
|
||||
|
||||
/// A new directory that this test alone created, readable only by its user on Unix. Creation
|
||||
/// fails if the path already exists: the test then loads native libraries from it, so it must
|
||||
/// never reuse a directory someone else could have put a library in. It lives next to the test
|
||||
/// binary (in the build directory), not under the temp dir: `TMPDIR`/`TEMP` can name a shared
|
||||
/// directory without a sticky bit or with inheritable write access, where another user could
|
||||
/// rename or populate it. Anyone who can write to the build directory can already replace the
|
||||
/// test binary itself, so this parent adds no new trust.
|
||||
fn fresh_private_dir(prefix: &str) -> std::path::PathBuf {
|
||||
let exe = std::env::current_exe().expect("test binary path");
|
||||
let parent = exe.parent().expect("test binary has a parent directory");
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |t| t.as_nanos());
|
||||
let d = parent.join(format!("{prefix}-{}-{nanos}", std::process::id()));
|
||||
#[cfg(unix)]
|
||||
let builder = {
|
||||
let mut b = std::fs::DirBuilder::new();
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
|
||||
b
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let builder = std::fs::DirBuilder::new();
|
||||
builder.create(&d).unwrap_or_else(|e| panic!("cannot create a fresh test directory {}: {e}", d.display()));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_files_are_errors_not_crashes() {
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-clap-garbage-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let d = fresh_private_dir("soundcraft-clap-garbage");
|
||||
std::fs::create_dir_all(d.join("Bundle.clap/Contents/MacOS")).unwrap();
|
||||
std::fs::write(d.join("garbage.clap"), b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
|
||||
std::fs::write(d.join("empty.clap"), b"").unwrap();
|
||||
|
||||
@@ -87,16 +87,23 @@ fn decode_group_audio(
|
||||
cmd_id: &str,
|
||||
) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
|
||||
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
|
||||
// The folder as the file system knows it, so a link among the group's files can't lead out.
|
||||
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
|
||||
let root = crate::io::canonical_path(dir)?;
|
||||
for (old, rel) in audio_entries {
|
||||
let full = dir.join(rel);
|
||||
let escapes = |rel: &str| EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}"));
|
||||
if !full.starts_with(dir)
|
||||
|| Path::new(rel)
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_)))
|
||||
{
|
||||
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
|
||||
return Err(escapes(rel));
|
||||
}
|
||||
let key = crate::io::canonical_path(&full)?;
|
||||
if !key.starts_with(&root) {
|
||||
return Err(escapes(rel));
|
||||
}
|
||||
let key = crate::io::file_key(&full);
|
||||
let n = match index.get(&key) {
|
||||
Some(n) => *n,
|
||||
None => {
|
||||
@@ -268,9 +275,59 @@ mod tests {
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "absolute paths are refused: {r:?}");
|
||||
// A link inside the folder that leads out of it is refused once resolved.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let outside = std::env::temp_dir().join(format!("sc-grp-outside-{}.wav", std::process::id()));
|
||||
std::fs::copy(dir.join("Wait... what_0.wav"), &outside).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.join("link.wav")).unwrap();
|
||||
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
|
||||
*rel = json!("link.wav");
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "links out of the folder are refused: {r:?}");
|
||||
let _ = std::fs::remove_file(&outside);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Under a read root, a link in the group folder to a folder outside the root is refused the same way
|
||||
/// whether or not the file asked for exists there, so the group cannot be used to probe for names.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_out_of_the_read_root_are_refused_alike_for_existing_and_missing_files() {
|
||||
let base = std::env::temp_dir().join(format!("sc-grp-root-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (dir, outside) = (base.join("group"), base.join("outside"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let path = dir.join("drums.scgrp").to_string_lossy().to_string();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let kick = e.session().tracks.iter().find(|t| t.name == "Kick").map(|t| t.id).unwrap();
|
||||
let ids: Vec<_> = e.session().track(kick).unwrap().clips().iter().take(1).map(|c| c.id.0).collect();
|
||||
e.execute("file.export_clip_groups", &json!({"path": path, "clips": ids})).unwrap();
|
||||
let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
let exported = file["audio"].as_object().unwrap().values().next().unwrap().as_str().unwrap().to_string();
|
||||
std::fs::copy(dir.join(&exported), outside.join("real.wav")).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.join("out")).unwrap();
|
||||
let roots = crate::file_access::AutomationRoots::new(&[&dir], &[]).unwrap().unwrap();
|
||||
let mut refusals = Vec::new();
|
||||
for target in ["out/real.wav", "out/missing.wav"] {
|
||||
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
|
||||
*rel = json!(target);
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = crate::file_access::confine(Some(&roots), || {
|
||||
e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}))
|
||||
});
|
||||
refusals.push(r.unwrap_err().to_string());
|
||||
}
|
||||
assert!(refusals[0].contains("outside the read root"), "{}", refusals[0]);
|
||||
assert_eq!(refusals[0].replace("real.wav", "X"), refusals[1].replace("missing.wav", "X"), "existing and missing names are refused alike");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_clips_carry_no_auto_rename_scheme() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
|
||||
|
||||
+110
-9
@@ -85,6 +85,17 @@ pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
|
||||
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))
|
||||
}
|
||||
|
||||
/// `path` with its links followed, inside the automation read root when there is one. The root is
|
||||
/// checked on the path as named (links followed, a missing tail resolved by its nearest folder)
|
||||
/// before anything is resolved, so a name that leads out of the root is refused the same way
|
||||
/// whether or not it exists there.
|
||||
pub(crate) fn canonical_path(path: impl AsRef<Path>) -> Result<PathBuf> {
|
||||
let path = path.as_ref();
|
||||
let checked = if path.as_os_str().is_empty() { Path::new(".") } else { path };
|
||||
crate::file_access::check_read(checked).map_err(EngineError::Io)?;
|
||||
std::fs::canonicalize(path).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
|
||||
}
|
||||
|
||||
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
|
||||
pub(crate) fn write_path(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
@@ -329,7 +340,7 @@ impl Out {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Ok(Out::Memory(std::io::Cursor::new(Vec::new())));
|
||||
}
|
||||
Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, std::fs::File::create(part)?)))
|
||||
Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, create_new(part)?)))
|
||||
}
|
||||
|
||||
/// Puts the finished file at `path` and returns its size: the part file is synced and renamed
|
||||
@@ -462,14 +473,27 @@ pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) ->
|
||||
let stem = sanitize_name(&c.name);
|
||||
let mut path = Path::new(dir).join(format!("{stem}.wav"));
|
||||
let mut k = 2u32;
|
||||
while path.exists() {
|
||||
// The name is taken by creating the file, so whatever entry already has it (a link
|
||||
// to elsewhere included) is left alone rather than written through.
|
||||
let mut file = loop {
|
||||
// An occupied name is taken whatever it is (a link whose target is outside the write
|
||||
// root too): it is only looked at, never resolved, so the root check does not stop the
|
||||
// search for a free name. The folder itself was checked above.
|
||||
if std::fs::symlink_metadata(&path).is_err() {
|
||||
crate::file_access::check_write(&path).map_err(EngineError::Io)?;
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(f) => break f,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
|
||||
}
|
||||
}
|
||||
path = Path::new(dir).join(format!("{stem}-{k}.wav"));
|
||||
k = k.saturating_add(1);
|
||||
if k > 10_000 {
|
||||
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
|
||||
}
|
||||
}
|
||||
write_path(&path, bytes)?;
|
||||
};
|
||||
std::io::Write::write_all(&mut file, &bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
@@ -506,13 +530,13 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
{
|
||||
src.path = f.join(&src.path).to_string_lossy().into_owned();
|
||||
}
|
||||
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
|
||||
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && file_found(dir.join(&src.path));
|
||||
let shared = shared_paths.get(i).copied().unwrap_or(false);
|
||||
if exists_rel && !src.unsaved && !shared {
|
||||
continue;
|
||||
}
|
||||
let in_folder = Path::new(&src.path).strip_prefix(&audio_dir).ok().and_then(Path::to_str).map(str::to_string);
|
||||
if let Some(f) = in_folder.filter(|_| !src.unsaved && Path::new(&src.path).is_file()) {
|
||||
if let Some(f) = in_folder.filter(|_| !src.unsaved && file_found(&src.path)) {
|
||||
src.path = format!("Audio Files/{f}");
|
||||
continue;
|
||||
}
|
||||
@@ -528,7 +552,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
if overwrite {
|
||||
// One file per source id so autosaves never collide or pile up.
|
||||
stem = format!("{stem}_{}", src.id.0);
|
||||
if audio_dir.join(format!("{stem}.wav")).exists() {
|
||||
if file_found(audio_dir.join(format!("{stem}.wav"))) {
|
||||
src.path = format!("Audio Files/{stem}.wav");
|
||||
src.unsaved = false;
|
||||
files.insert(Arc::as_ptr(audio), src.path.clone());
|
||||
@@ -588,11 +612,22 @@ fn sync_dir(dir: &Path) {
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::File::create(path)?;
|
||||
let mut f = create_new(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
/// Creates `path` as a new file. A leftover of that name (a part file from an interrupted write)
|
||||
/// is removed first, but an existing entry is never opened: a link planted at a predictable name
|
||||
/// in the destination folder can't redirect the write elsewhere.
|
||||
fn create_new(path: &Path) -> std::io::Result<std::fs::File> {
|
||||
match std::fs::remove_file(path) {
|
||||
Err(err) if err.kind() != std::io::ErrorKind::NotFound => return Err(err),
|
||||
_ => {}
|
||||
}
|
||||
std::fs::OpenOptions::new().write(true).create_new(true).open(path)
|
||||
}
|
||||
|
||||
/// Copies `from` to `to`, which must not exist yet; an existing entry, even a link, is never followed.
|
||||
fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
let mut src = std::fs::File::open(from)?;
|
||||
@@ -687,7 +722,12 @@ fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
|
||||
return Ok((name, true));
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
if std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64) && std::fs::read(&path).is_ok_and(|b| b == bytes) {
|
||||
// Reused only when it may be read: whether it equals the audio must not be
|
||||
// learnt about a file the roots allow writing beside but not reading.
|
||||
if crate::file_access::check_read(&path).is_ok()
|
||||
&& std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64)
|
||||
&& std::fs::read(&path).is_ok_and(|b| b == bytes)
|
||||
{
|
||||
return Ok((name, false));
|
||||
}
|
||||
}
|
||||
@@ -785,6 +825,67 @@ pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod exclusive_tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-io-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_planted_link_at_the_part_name_is_not_written_through() {
|
||||
let dir = scratch("part-link");
|
||||
let (target, part) = (dir.join("elsewhere"), dir.join("session.scraft.tmp"));
|
||||
std::os::unix::fs::symlink(&target, &part).unwrap();
|
||||
write_synced(&part, b"new").unwrap();
|
||||
assert!(!target.exists(), "the link's target was created");
|
||||
assert_eq!(std::fs::read(&part).unwrap(), b"new", "the link itself was replaced by the file");
|
||||
// A leftover regular part file is still replaced.
|
||||
write_synced(&part, b"newer").unwrap();
|
||||
assert_eq!(std::fs::read(&part).unwrap(), b"newer");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn export_skips_a_name_held_by_a_dangling_link() {
|
||||
let dir = scratch("export-link");
|
||||
let e = crate::demo::demo_engine();
|
||||
let (t, c) = e.session().tracks.iter().find_map(|t| t.clips().iter().find(|c| c.is_audio()).map(|c| (t.id, c.clone()))).unwrap();
|
||||
let _ = t;
|
||||
let target = dir.join("elsewhere.wav");
|
||||
std::os::unix::fs::symlink(&target, dir.join(format!("{}.wav", sanitize_name(&c.name)))).unwrap();
|
||||
assert_eq!(export_clips(&e, &[c.id], &dir.to_string_lossy()).unwrap(), 1);
|
||||
assert!(!target.exists(), "the export followed the link");
|
||||
assert!(dir.join(format!("{}-2.wav", sanitize_name(&c.name))).is_file(), "the next free name was used");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// With a write root, a link out of it that holds the name is still just an occupied name.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn export_skips_a_link_out_of_the_write_root_too() {
|
||||
let dir = scratch("export-root-link");
|
||||
let e = crate::demo::demo_engine();
|
||||
let c = e.session().tracks.iter().find_map(|t| t.clips().iter().find(|c| c.is_audio()).cloned()).unwrap();
|
||||
let elsewhere = scratch("export-root-elsewhere");
|
||||
let target = elsewhere.join("target.wav");
|
||||
std::os::unix::fs::symlink(&target, dir.join(format!("{}.wav", sanitize_name(&c.name)))).unwrap();
|
||||
let roots = crate::file_access::AutomationRoots::new(&[], &[&dir]).unwrap().unwrap();
|
||||
let n = crate::file_access::confine(Some(&roots), || export_clips(&e, &[c.id], &dir.to_string_lossy())).unwrap();
|
||||
assert_eq!(n, 1);
|
||||
assert!(!target.exists(), "the export followed the link out of the root");
|
||||
assert!(dir.join(format!("{}-2.wav", sanitize_name(&c.name))).is_file(), "the next free name was used");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&elsewhere);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stem_tests {
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
@@ -108,14 +110,28 @@ impl Sequence {
|
||||
|
||||
self.ctrls.sort_by_key(|c| c.tick);
|
||||
let mut kept: Vec<CtrlEvent> = Vec::with_capacity(self.ctrls.len());
|
||||
// The latest tick and `kept` index of each channel+kind, so a same-tick repeat is found
|
||||
// without rescanning the tick's events (a tick can hold many thousands of distinct keys).
|
||||
// An entry from an earlier tick counts as absent; keeping the tick instead of clearing the
|
||||
// map per tick keeps the work per event constant after a dense tick.
|
||||
let mut last_at: HashMap<(u8, (u8, u8)), (i64, usize)> = HashMap::new();
|
||||
for c in self.ctrls.drain(..) {
|
||||
// Within a tick, replace an earlier event of the same channel+kind.
|
||||
// Only events at the same tick (the tail of `kept`) can match.
|
||||
let slot =
|
||||
kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && kind_key(k.kind) == kind_key(c.kind));
|
||||
match slot {
|
||||
Some(slot) => *slot = c,
|
||||
None => kept.push(c),
|
||||
match last_at.entry((c.channel, kind_key(c.kind))) {
|
||||
Entry::Occupied(mut e) => {
|
||||
let (tick, idx) = *e.get();
|
||||
match kept.get_mut(idx) {
|
||||
Some(slot) if tick == c.tick => *slot = c,
|
||||
_ => {
|
||||
e.insert((c.tick, kept.len()));
|
||||
kept.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
e.insert((c.tick, kept.len()));
|
||||
kept.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.ctrls = kept;
|
||||
|
||||
@@ -222,3 +222,72 @@ fn sequence_serde_round_trip() {
|
||||
let json = serde_json::to_string(&seq).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Sequence>(&json).unwrap(), seq);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_handles_a_large_same_tick_controller_group() {
|
||||
// Every channel x controller number at one tick, twice: the second copy of each wins. A
|
||||
// rescan of the tick's kept events per incoming event would make this quadratic.
|
||||
let keys: Vec<(u8, u8)> = (0..=255u8).flat_map(|ch| (0..=255u8).map(move |cc| (ch, cc))).collect();
|
||||
let ctrls = [0, 1]
|
||||
.iter()
|
||||
.flat_map(|&pass| keys.iter().map(move |&(channel, cc)| CtrlEvent { tick: 7, channel, kind: CtrlKind::Cc(cc), value: pass }))
|
||||
.collect();
|
||||
let mut seq = Sequence { notes: Vec::new(), ctrls };
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(seq.remove_duplicates(), keys.len());
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
assert_eq!(seq.ctrls.len(), keys.len());
|
||||
assert!(seq.ctrls.iter().all(|c| c.value == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_matches_a_per_tick_rescan_on_mixed_ticks() {
|
||||
// The straightforward per-tick rescan the indexed version replaced, kept as an oracle.
|
||||
fn reference(mut ctrls: Vec<CtrlEvent>) -> Vec<CtrlEvent> {
|
||||
ctrls.sort_by_key(|c| c.tick);
|
||||
let key = |k: CtrlKind| match k {
|
||||
CtrlKind::Cc(n) => (0, n),
|
||||
CtrlKind::PitchBend => (1, 0),
|
||||
CtrlKind::ChannelPressure => (2, 0),
|
||||
CtrlKind::PolyPressure(p) => (3, p),
|
||||
CtrlKind::Program => (4, 0),
|
||||
};
|
||||
let mut kept: Vec<CtrlEvent> = Vec::new();
|
||||
for c in ctrls {
|
||||
match kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && key(k.kind) == key(c.kind)) {
|
||||
Some(slot) => *slot = c,
|
||||
None => kept.push(c),
|
||||
}
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
// Few ticks, channels and kinds, so the same key recurs both within a tick (interleaved with
|
||||
// other keys) and across ticks; unsorted input from a fixed xorshift stream.
|
||||
let mut state = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
let kinds = |r: u64| match r % 5 {
|
||||
0 => CtrlKind::Cc((r >> 8) as u8 % 4),
|
||||
1 => CtrlKind::PitchBend,
|
||||
2 => CtrlKind::ChannelPressure,
|
||||
3 => CtrlKind::PolyPressure((r >> 8) as u8 % 4),
|
||||
_ => CtrlKind::Program,
|
||||
};
|
||||
for round in 0..200 {
|
||||
let mut ctrls = Vec::new();
|
||||
for i in 0..(next() % 64) {
|
||||
let r = next();
|
||||
ctrls.push(CtrlEvent { tick: (r % 6) as i64 - 2, channel: (r >> 16) as u8 % 3, kind: kinds(r >> 24), value: (round * 100 + i) as i32 });
|
||||
}
|
||||
let mut seq = Sequence { notes: Vec::new(), ctrls: ctrls.clone() };
|
||||
let removed = seq.remove_duplicates();
|
||||
let expected = reference(ctrls.clone());
|
||||
assert_eq!(seq.ctrls, expected, "round {round}");
|
||||
assert_eq!(removed, ctrls.len() - expected.len(), "round {round}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +105,6 @@ impl ClipFx {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the EQ or compressor is on: only they carry parameters that settle (a gain does not).
|
||||
pub(crate) fn has_smoothed_modules(&self) -> bool {
|
||||
self.eq.is_some() || self.comp.is_some()
|
||||
}
|
||||
pub(crate) fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
if let Some(eq) = &mut self.eq {
|
||||
eq.process(io, frames);
|
||||
|
||||
@@ -131,85 +131,33 @@ pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec<f32>]) -> Vec<Vec<f32>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Block size for clip effects in [`render_clips`].
|
||||
const CLIP_FX_BLOCK: usize = 4096;
|
||||
/// Most a clip effect keeps rendering past its clip's end in [`render_clips`], in seconds.
|
||||
const MAX_CLIP_FX_TAIL_SECONDS: f64 = 10.0;
|
||||
/// Silent lead-in run before a clip with effects so smoothed parameters (EQ) settle, in seconds
|
||||
/// (the smoothers settle within ~0.3 s).
|
||||
const CLIP_FX_SETTLE_SECONDS: f64 = 1.0;
|
||||
/// Frames an effect advances its smoothed parameters by at a time (the EQ's chunk). Processing starts on a
|
||||
/// multiple of it from the selection's start, as a render of the whole selection would, so the smoothers
|
||||
/// step at the same samples.
|
||||
const CLIP_FX_CHUNK: usize = 32;
|
||||
/// A tail block whose peak is below this (-180 dBFS) ends a clip effect's tail.
|
||||
const CLIP_FX_TAIL_FLOOR: f32 = 1e-9;
|
||||
|
||||
/// Render only one track's clips (no mixer processing) — Consolidate.
|
||||
pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
|
||||
let Some(t) = s.track(track) else { return Vec::new() };
|
||||
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / t.channels().max(1));
|
||||
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
|
||||
let mut scratch = Vec::new();
|
||||
// One reusable block for every clip with effects.
|
||||
let mut block: Vec<Vec<f32>> = Vec::new();
|
||||
let max_tail = s.sample_rate.samples(MAX_CLIP_FX_TAIL_SECONDS).max(0);
|
||||
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
|
||||
match clip_fx_params(s, c.id.0) {
|
||||
None => render_clip(s, c, range.start, len, &mut out, &mut scratch),
|
||||
Some(params) => {
|
||||
if block.len() != out.len() {
|
||||
block = vec![vec![0.0f32; CLIP_FX_BLOCK]; out.len()];
|
||||
}
|
||||
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, out.len());
|
||||
let mut tmp = vec![vec![0.0f32; len]; out.len()];
|
||||
render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
|
||||
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, tmp.len());
|
||||
fx.configure(params);
|
||||
// The effects start from silence, so nothing comes out before the clip starts:
|
||||
// process from there through the clip and a bounded tail, not the whole selection.
|
||||
let offset = |at: Samples| usize::try_from(at.saturating_sub(range.start).max(0)).unwrap_or(usize::MAX).min(len);
|
||||
let first = offset(c.start);
|
||||
let clip_end = offset(c.end());
|
||||
let tail_end = offset(c.end().saturating_add(max_tail));
|
||||
// Smoothed parameters start at their defaults; settle them on silence, as the
|
||||
// full-span render did, so the clip opens at the configured setting.
|
||||
// (the clip's own samples start inside the first chunk when `first` is not on the grid)
|
||||
let grid = first - first % CLIP_FX_CHUNK;
|
||||
let mut lead = grid.min(usize::try_from(s.sample_rate.samples(CLIP_FX_SETTLE_SECONDS).max(0)).unwrap_or(0));
|
||||
lead -= lead % CLIP_FX_CHUNK;
|
||||
// a gain-only effect has no smoother to settle
|
||||
if !fx.has_smoothed_modules() {
|
||||
lead = 0;
|
||||
}
|
||||
while lead > 0 {
|
||||
let n = lead.min(CLIP_FX_BLOCK);
|
||||
for ch in &mut block {
|
||||
ch.fill(0.0);
|
||||
}
|
||||
fx.process(&mut block, n);
|
||||
lead -= n;
|
||||
}
|
||||
let mut done = grid;
|
||||
let mut done = 0;
|
||||
while done < len {
|
||||
let n = (len - done).min(CLIP_FX_BLOCK);
|
||||
for ch in &mut block {
|
||||
ch.fill(0.0);
|
||||
}
|
||||
if done < clip_end {
|
||||
render_clip(s, c, range.start.saturating_add(done as i64), n, &mut block, &mut scratch);
|
||||
}
|
||||
let n = (len - done).min(4096);
|
||||
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch.get(done..done + n).map(<[f32]>::to_vec).unwrap_or_default()).collect();
|
||||
fx.process(&mut block, n);
|
||||
let mut peak = 0.0f32;
|
||||
for (o, b) in out.iter_mut().zip(block.iter()) {
|
||||
if let Some(dst) = o.get_mut(done..done + n) {
|
||||
for (x, y) in dst.iter_mut().zip(b.iter()) {
|
||||
*x += *y;
|
||||
peak = peak.max(y.abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
done += n;
|
||||
if done >= clip_end && (done >= tail_end || peak < CLIP_FX_TAIL_FLOOR) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1208,95 +1208,3 @@ fn gain_ramp_matches_converting_both_ends_every_sample() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Consolidate's clip-effect pass, processing every clip's effects across the whole selection
|
||||
/// (what `render_clips` did before it limited them to the clip and its tail).
|
||||
fn render_clips_full_span(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
|
||||
let t = s.track(track).unwrap();
|
||||
let len = range.len() as usize;
|
||||
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
|
||||
let mut scratch = Vec::new();
|
||||
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
|
||||
let params = crate::clips::clip_fx_params(s, c.id.0).unwrap();
|
||||
let mut tmp = vec![vec![0.0f32; len]; out.len()];
|
||||
crate::clips::render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
|
||||
let mut fx = crate::clips::ClipFx::new(48_000.0, tmp.len());
|
||||
fx.configure(params);
|
||||
for done in (0..len).step_by(4096) {
|
||||
let n = (len - done).min(4096);
|
||||
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch[done..done + n].to_vec()).collect();
|
||||
fx.process(&mut block, n);
|
||||
for (o, b) in out.iter_mut().zip(&block) {
|
||||
o[done..done + n].iter_mut().zip(b).for_each(|(x, y)| *x += y);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidate_clip_effects_cover_the_clip_and_its_tail() {
|
||||
let fx_sets: [&[(&str, f64)]; 3] =
|
||||
[&[("comp.lookahead", 5.0), ("comp.threshold", -20.0), ("gain", -6.0)], &[("eq.hpf_on", 1.0), ("gain", 3.0)], &[("eq.low_shelf_gain", 12.0)]];
|
||||
for (k, params) in fx_sets.iter().enumerate() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })));
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(900), 0, 20_000, 4800));
|
||||
for (param, value) in params.iter() {
|
||||
s.edit.values.insert(format!("clip_fx.{}.{param}", cid.0), *value);
|
||||
}
|
||||
let range = Range::new(5_000, 100_000);
|
||||
let got = render_clips(&s, t, range);
|
||||
let want = render_clips_full_span(&s, t, range);
|
||||
assert_eq!(got.len(), want.len());
|
||||
assert!(want[0].iter().any(|x| x.abs() > 0.05), "the fixture should sound");
|
||||
if k == 0 {
|
||||
// The compressor's lookahead output after the clip's end is kept.
|
||||
assert!(got[0][20_000 + 4800 - 5_000 + 10].abs() > 1e-3);
|
||||
}
|
||||
for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() {
|
||||
assert!((a - b).abs() < 1e-5, "set {k}, sample {i}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip that starts a few frames into the selection, while the EQ's smoothers are still settling: the
|
||||
/// effect's 32-frame chunks must line up with the full-span render's.
|
||||
#[test]
|
||||
fn consolidate_clip_effects_keep_the_reference_chunk_grid() {
|
||||
for offset in [1i64, 31, 32, 33, 1000] {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })));
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(900), 0, offset, 4800));
|
||||
s.edit.values.insert(format!("clip_fx.{}.eq.low_shelf_gain", cid.0), 12.0);
|
||||
let range = Range::new(0, 20_000);
|
||||
let got = render_clips(&s, t, range);
|
||||
let want = render_clips_full_span(&s, t, range);
|
||||
for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() {
|
||||
assert!((a - b).abs() < 1e-5, "clip at {offset}, sample {i}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidate_many_tiny_effect_clips_stays_fast() {
|
||||
// Thousands of one-sample clips with effects across a long selection: each one is processed
|
||||
// over its own span, not the whole selection.
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 1]] })));
|
||||
for i in 0..2000i64 {
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "x", SourceId(900), 0, i * 1000, 1));
|
||||
s.edit.values.insert(format!("clip_fx.{}.gain", cid.0), -6.0);
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
let out = render_clips(&s, t, Range::new(0, 4_000_000));
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
assert!((out[0][1000] - 0.5 * db_to_gain(-6.0)).abs() < 1e-6);
|
||||
assert_eq!(out[0][1001], 0.0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user