mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-12 00:44:57 -06:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28ec09b7b1 | ||
|
|
c25b12ba46 | ||
|
|
296bfbb2e1 | ||
|
|
f7a5adb474 | ||
|
|
23242338a6 | ||
|
|
e01ea00cdd | ||
|
|
659ec7a8e2 | ||
|
|
90e069f626 | ||
|
|
7a4f9c911d | ||
|
|
60f97a99a5 | ||
|
|
d255ca4fe9 | ||
|
|
1fe1d06073 | ||
|
|
10e4bc7b87 | ||
|
|
1d696bb3ef | ||
|
|
9f342bf403 | ||
|
|
17649aa693 | ||
|
|
0e511b1259 | ||
|
|
927c4f7b72 | ||
|
|
69af50aac2 | ||
|
|
e86d09ab2a | ||
|
|
ad34dbf87e | ||
|
|
f780dcab37 | ||
|
|
ff4be04936 | ||
|
|
1483dc833b | ||
|
|
2c042c599f | ||
|
|
e94a7875d3 | ||
|
|
dc4f82ea9f | ||
|
|
df67089ccd | ||
|
|
44e80dcc24 | ||
|
|
e1b22258bc | ||
|
|
61becd52c5 | ||
|
|
654deabd54 | ||
|
|
b93a182ef7 | ||
|
|
bf2a6b52de | ||
|
|
b8e1e69134 | ||
|
|
b6946d3a94 |
@@ -23,39 +23,5 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/git-sources.yml@main
|
||||
|
||||
@@ -27,6 +27,10 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
Generated
+8
-6
@@ -1064,17 +1064,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1097,7 +1097,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -1105,7 +1105,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1126,7 +1126,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1136,6 +1136,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"rustix 1.1.5",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4449,6 +4450,7 @@ dependencies = [
|
||||
"soundcraft-video",
|
||||
"soundcraft-vst3-host",
|
||||
"thiserror 2.0.21",
|
||||
"winsafe",
|
||||
"zip",
|
||||
]
|
||||
|
||||
|
||||
+4
-4
@@ -50,15 +50,15 @@ zip = { version = "9", default-features = false }
|
||||
cpal = "0.18"
|
||||
|
||||
# Which file paths stored in a session may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["serde"] }
|
||||
egui = { version = "0.37" }
|
||||
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
use crate::pcm::{be_u32, be_u64};
|
||||
use crate::{AudioError, Result};
|
||||
|
||||
/// The most `stts` entries a sound track may have. Symphonia finds each packet's timestamp by
|
||||
/// scanning the table from its start, so a table of one-sample runs makes decoding quadratic in
|
||||
/// the packet count. Audio encoders write one run per packet length (a handful of entries);
|
||||
/// this bound keeps the worst case at a few hundred million steps.
|
||||
const MAX_AUDIO_STTS_ENTRIES: u32 = 1 << 14;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Level {
|
||||
Top,
|
||||
@@ -11,7 +17,11 @@ enum Level {
|
||||
Trak,
|
||||
Mdia,
|
||||
Minf,
|
||||
Stbl,
|
||||
/// `audio`: the sample entry is an audio format, so symphonia will decode its packets (whatever the
|
||||
/// handler says: it builds codec parameters from `stsd` alone).
|
||||
Stbl {
|
||||
audio: bool,
|
||||
},
|
||||
Udta,
|
||||
Meta,
|
||||
Ilst,
|
||||
@@ -55,13 +65,20 @@ fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
|
||||
(Level::Moov, b"udta") => atoms(b, body, inner, Level::Udta)?,
|
||||
(Level::Trak, b"mdia") => atoms(b, body, inner, Level::Mdia)?,
|
||||
(Level::Mdia, b"minf") => atoms(b, body, inner, Level::Minf)?,
|
||||
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl)?,
|
||||
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl { audio: entry_is_audio(b, body, inner) })?,
|
||||
(Level::Meta, b"ilst") => atoms(b, body, inner, Level::Ilst)?,
|
||||
(Level::Ilst, _) => atoms(b, body, inner, Level::Tag)?,
|
||||
(Level::Stbl, b"stts" | b"co64") => table(b, body + 4, 8, inner)?,
|
||||
(Level::Stbl, b"stsc") => table(b, body + 4, 12, inner)?,
|
||||
(Level::Stbl, b"stco") => table(b, body + 4, 4, inner)?,
|
||||
(Level::Stbl, b"stsz") => sample_sizes(b, body, inner)?,
|
||||
(Level::Stbl { audio }, b"stts") => {
|
||||
let fits = table(b, body + 4, 8, inner)?;
|
||||
if audio && be_u32(b, body + 4).is_some_and(|n| n > MAX_AUDIO_STTS_ENTRIES) {
|
||||
return Err(AudioError::Malformed("MP4 audio timing table has more runs than packets could need".into()));
|
||||
}
|
||||
fits
|
||||
}
|
||||
(Level::Stbl { .. }, b"co64") => table(b, body + 4, 8, inner)?,
|
||||
(Level::Stbl { .. }, b"stsc") => table(b, body + 4, 12, inner)?,
|
||||
(Level::Stbl { .. }, b"stco") => table(b, body + 4, 4, inner)?,
|
||||
(Level::Stbl { .. }, b"stsz") => sample_sizes(b, body, inner)?,
|
||||
(Level::Mdia, b"hdlr") | (Level::Tag, b"data" | b"name" | b"mean") if len > (b.len() - pos) as u64 => {
|
||||
return Err(malformed(&format!("`{}` atom", String::from_utf8_lossy(kind))));
|
||||
}
|
||||
@@ -75,6 +92,38 @@ fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The sample entry formats symphonia reads as audio (the list `stsd` is matched against in
|
||||
/// symphonia-format-isomp4 0.6); every other format, video and subtitles included, has no audio packets.
|
||||
const AUDIO_FORMATS: [&[u8; 4]; 18] = [
|
||||
b"mp4a", b"alac", b"ac-3", b"ec-3", b"fLaC", b"Opus", b".mp3", b"lpcm", b"wave", b"alaw", b"ulaw", b"raw ", b"sowt", b"twos", b"in24", b"in32",
|
||||
b"fl32", b"fl64",
|
||||
];
|
||||
|
||||
/// Whether the `stbl` atom whose children lie in `pos..end` describes audio: its `stsd` may come before
|
||||
/// or after the timing tables, so the children are walked once more for it. Symphonia keeps the last
|
||||
/// `stsd` it reads, so the last one decides.
|
||||
fn entry_is_audio(b: &[u8], mut pos: usize, end: usize) -> bool {
|
||||
let mut audio = false;
|
||||
while pos < end {
|
||||
let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return audio };
|
||||
let head = if size == 1 { 16 } else { 8 };
|
||||
if kind == b"stsd" {
|
||||
// Version and flags, the entry count, then the first entry's size and format.
|
||||
audio = b.get(pos + head + 12..pos + head + 16).is_some_and(|f| AUDIO_FORMATS.iter().any(|a| a.as_slice() == f));
|
||||
}
|
||||
let len = match size {
|
||||
0 => return audio,
|
||||
1 => be_u64(b, pos + 8).unwrap_or(0),
|
||||
n => u64::from(n),
|
||||
};
|
||||
match usize::try_from(len).ok().filter(|&n| n >= head).and_then(|n| pos.checked_add(n)) {
|
||||
Some(next) => pos = next,
|
||||
None => return audio,
|
||||
}
|
||||
}
|
||||
audio
|
||||
}
|
||||
|
||||
/// A sample table's entries, counted at `count`, must fit in the file: symphonia reserves room for
|
||||
/// all of them first. False if they run past the table's atom (`end`), where symphonia stops.
|
||||
fn table(b: &[u8], count: usize, entry: u64, end: usize) -> Result<bool> {
|
||||
|
||||
@@ -22,6 +22,11 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat
|
||||
/// takes its frame length from the file (encoders use 4096).
|
||||
const MAX_PACKET_FRAMES: u64 = 1 << 20;
|
||||
|
||||
/// The most samples (frames × channels) a packet may declare: the PCM decoder allocates that
|
||||
/// many for its output up front, so a CAF with thousands of channels and a long packet could
|
||||
/// otherwise claim gigabytes before a sample is decoded (64 MiB of f32).
|
||||
const MAX_PACKET_SAMPLES: u64 = 1 << 24;
|
||||
|
||||
/// How far [`first_marker`] looks for the first format marker: the same 1 MiB a default symphonia
|
||||
/// probe scans.
|
||||
const MAX_PREFIX_SCAN: usize = 1 << 20;
|
||||
@@ -508,7 +513,9 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
}
|
||||
let params = audio_params(track).cloned().ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
|
||||
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0));
|
||||
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
|
||||
let ch = u64::from(info.channels);
|
||||
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES || n.saturating_mul(ch) > MAX_PACKET_SAMPLES)
|
||||
{
|
||||
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
|
||||
}
|
||||
let mut decoder = symphonia::default::get_codecs().make_audio_decoder(¶ms, &decoder_options()).map_err(map_err)?;
|
||||
@@ -531,6 +538,13 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
if packet.track_id != track_id {
|
||||
continue;
|
||||
}
|
||||
// The declared frames per packet are not the packet's size: a container supplies that on its
|
||||
// own, and the PCM decoder sizes its output by the bytes it is given.
|
||||
if let Some(bytes_per_sample) = params.bits_per_coded_sample.map(|b| u64::from(b / 8)).filter(|&n| n > 0)
|
||||
&& packet.data.len() as u64 / bytes_per_sample > MAX_PACKET_SAMPLES
|
||||
{
|
||||
return Err(AudioError::Malformed("a packet holds far more samples than any codec uses".into()));
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(d) => d,
|
||||
Err(SymError::DecodeError(m)) => {
|
||||
|
||||
@@ -321,7 +321,7 @@ fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec<u8>) -> Vec<u8>) -
|
||||
};
|
||||
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
|
||||
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
|
||||
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
|
||||
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| i.wrapping_mul(40).to_le_bytes()).collect();
|
||||
[ftyp, moov(offset), atom(b"mdat", &pcm)].concat()
|
||||
}
|
||||
|
||||
@@ -373,6 +373,63 @@ fn packets_longer_than_any_codec_uses_are_refused() {
|
||||
for entry in [lpcm(1 << 21), alac(1 << 21)] {
|
||||
assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets")));
|
||||
}
|
||||
// Frames and channels multiply into the decoder's packet buffer; an ordinary stereo stream
|
||||
// with long packets stays well inside the bound.
|
||||
let wide = |channels: u32, frames: u32| {
|
||||
let fields = [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[channels, 0x7f00_0000, 16, 4, 2 * channels, frames])].concat();
|
||||
sample_entry(b"lpcm", 2, &fields, &[])
|
||||
};
|
||||
assert!(decode(&mp4(480, &wide(2, 1 << 13), |_, a| a), None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_audio_timing_table_with_a_run_per_sample_is_bounded() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
// One stts run per sample: symphonia scans the runs from the start for every packet.
|
||||
let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat());
|
||||
let frames = 1 << 14;
|
||||
let (_, buf) = decode(&mp4(frames, &pcm, |k, a| if k == b"stts" { runs(frames) } else { a }), None).unwrap();
|
||||
assert_eq!(buf.frames(), frames as usize, "a table at the bound still decodes");
|
||||
let f = mp4(frames + 1, &pcm, |k, a| if k == b"stts" { runs(frames + 1) } else { a });
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "one run past the bound is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_audio_timing_bound_follows_the_sample_entry_not_the_handler() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat());
|
||||
// an audio sample entry under a video handler is still decoded as audio by symphonia
|
||||
let video_handler = atom(b"hdlr", &[0u32, 0, u32::from_be_bytes(*b"vide"), 0, 0, 0, 0].map(u32::to_be_bytes).concat());
|
||||
let frames = (1 << 14) + 1;
|
||||
let f = mp4(frames, &pcm, |k, a| match k {
|
||||
b"stts" => runs(frames),
|
||||
b"hdlr" => video_handler.clone(),
|
||||
_ => a,
|
||||
});
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "the handler does not exempt an audio entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_sample_entry_decides_whether_the_timing_bound_applies() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
let video = sample_entry(b"avc1", 0, &[], &[]);
|
||||
let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat());
|
||||
let audio_stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &pcm].concat());
|
||||
let frames = (1 << 14) + 1;
|
||||
// A video `stsd` first and an audio one after it: symphonia keeps the last, so it decodes audio.
|
||||
let f = mp4(frames, &video, |k, a| if k == b"stts" { [audio_stsd.clone(), runs(frames)].concat() } else { a });
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "the later audio entry counts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pcm_packet_longer_than_its_declared_frames_is_refused_before_decoding() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
// one `stsz` sample of 2 * 2^24 + 2 bytes (just over the packet bound) in a file that holds it
|
||||
let big = 2 * (1usize << 24) + 2;
|
||||
let stsz = atom(b"stsz", &[0u32, big as u32, 1].map(u32::to_be_bytes).concat());
|
||||
let mut f = mp4(1, &pcm, |k, a| if k == b"stsz" { stsz.clone() } else { a });
|
||||
f.resize(f.len() + big, 0);
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("packet holds far more samples")));
|
||||
}
|
||||
|
||||
/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.
|
||||
|
||||
@@ -310,6 +310,36 @@ fn links_out_of_the_roots_are_refused() {
|
||||
ok(&mut via_alias, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn exports_refuse_hard_links_without_changing_either_name() {
|
||||
let f = fixture("hard-links");
|
||||
let midi = f.write.join("linked.mid");
|
||||
let preset = f.write.join("linked.json");
|
||||
std::fs::hard_link(f.outside.join("secret.mid"), &midi).unwrap();
|
||||
std::fs::hard_link(f.outside.join("secret.json"), &preset).unwrap();
|
||||
let mut srv = demo_server(Some(&f.read), Some(&f.write));
|
||||
let before = listing(&f.write);
|
||||
|
||||
// Refusal is the safe alternative allowed by #1359: neither the outside inode nor its
|
||||
// in-root alias may be truncated, and no replacement or temporary file may be left behind.
|
||||
refused(&mut srv, "file.export_midi", json!({"path": s(&midi)}), "hard-linked");
|
||||
refused(&mut srv, "track.save_preset", json!({"path": s(&preset), "tracks": ["Kick"]}), "hard-linked");
|
||||
for path in [&midi, &preset, &f.outside.join("secret.mid"), &f.outside.join("secret.json")] {
|
||||
assert_eq!(std::fs::read(path).unwrap(), SECRET.as_bytes());
|
||||
}
|
||||
assert_eq!(listing(&f.write), before);
|
||||
|
||||
// The error offers a new export path; both commands must still work at ordinary paths.
|
||||
let fresh_midi = f.write.join("fresh.mid");
|
||||
ok(&mut srv, "file.export_midi", json!({"path": s(&fresh_midi)}));
|
||||
assert!(std::fs::read(fresh_midi).unwrap().starts_with(b"MThd"));
|
||||
let fresh_preset = f.write.join("fresh.json");
|
||||
ok(&mut srv, "track.save_preset", json!({"path": s(&fresh_preset), "tracks": ["Kick"]}));
|
||||
let preset_json: Value = serde_json::from_slice(&std::fs::read(fresh_preset).unwrap()).unwrap();
|
||||
assert_eq!(preset_json["name"], "Kick");
|
||||
}
|
||||
|
||||
/// A session file is hostile input too: media it names outside the read root is not loaded,
|
||||
/// and the refusal is listed with the other missing media.
|
||||
#[test]
|
||||
|
||||
+209
-16
@@ -10,6 +10,10 @@ use std::sync::atomic::Ordering;
|
||||
const MAX_BLOCK: usize = 1 << 16;
|
||||
/// Most channels a host strip may ask for.
|
||||
const MAX_CHANNELS: usize = 64;
|
||||
/// Queue slots that only releases from `note_off` may take: one per key, so a queue that
|
||||
/// parameter changes, note-ons or other MIDI filled still has room to release every held key on
|
||||
/// a reset (#1464).
|
||||
const RELEASE_RESERVE: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum NoteMode {
|
||||
@@ -79,14 +83,104 @@ impl ClapPlugin {
|
||||
self.queue.retain(|e| !matches!(e, Event::Param { .. }));
|
||||
}
|
||||
|
||||
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
|
||||
/// allocates on the audio thread).
|
||||
/// Queues an event other than a note release; see [`push_event`].
|
||||
fn push(&mut self, e: Event) {
|
||||
if self.queue.len() >= self.queue.capacity() {
|
||||
return;
|
||||
push_event(&mut self.queue, e, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
|
||||
/// allocates on the audio thread). Only a `release` may take the last [`RELEASE_RESERVE`] slots.
|
||||
/// Returns whether the event was queued.
|
||||
fn push_event(queue: &mut Vec<Event>, e: Event, release: bool) -> bool {
|
||||
let room = if release { queue.capacity() } else { queue.capacity().saturating_sub(RELEASE_RESERVE) };
|
||||
if queue.len() >= room {
|
||||
return false;
|
||||
}
|
||||
let pos = queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
|
||||
queue.insert(pos, e);
|
||||
true
|
||||
}
|
||||
|
||||
/// Queues the release of `key` at `time`. The key stays held when the queue has no room, so a
|
||||
/// later reset still releases it (#1464).
|
||||
fn queue_release(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode, time: u32, key: u8) {
|
||||
if let Some(e) = release_event(notes, time, key)
|
||||
&& push_event(queue, e, true)
|
||||
&& let Some(h) = held.get_mut(usize::from(key))
|
||||
{
|
||||
*h = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The release of `key` in the note port's dialect (`None` without a note port).
|
||||
fn release_event(notes: NoteMode, time: u32, key: u8) -> Option<Event> {
|
||||
match notes {
|
||||
NoteMode::Clap => Some(Event::NoteOff { time, key }),
|
||||
NoteMode::Midi => Some(Event::Midi { time, data: [0x80, key, 0] }),
|
||||
NoteMode::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The queue after a reset: [`keep_releases`], then a release at the start of the next block for
|
||||
/// every key still `held` that has none yet, so each key is released once. Clears `held`.
|
||||
///
|
||||
/// It never runs out of room: events other than `note_off` releases fill at most
|
||||
/// `capacity - RELEASE_RESERVE` slots, and the releases left are one per key.
|
||||
fn release_on_reset(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode) {
|
||||
keep_releases(queue);
|
||||
for e in queue.iter() {
|
||||
let key = match *e {
|
||||
Event::NoteOff { key, .. } => Some(key),
|
||||
// Channel 1, where `note_off` sends them.
|
||||
Event::Midi { data: [status, key, velocity], .. } if status & 0x0F == 0 && is_midi_note_off(status, velocity) => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(h) = key.and_then(|k| held.get_mut(usize::from(k))) {
|
||||
*h = false;
|
||||
}
|
||||
let pos = self.queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
|
||||
self.queue.insert(pos, e);
|
||||
}
|
||||
for key in 0..128u8 {
|
||||
if held.get(usize::from(key)).copied().unwrap_or(false)
|
||||
&& let Some(e) = release_event(notes, 0, key)
|
||||
{
|
||||
let _ = push_event(queue, e, true);
|
||||
}
|
||||
}
|
||||
*held = [false; 128];
|
||||
}
|
||||
|
||||
/// What a reset keeps of the pending events: parameter changes, and the releases (CLAP note-offs
|
||||
/// and MIDI note-offs, one per channel and key), all moved to the start of the next block. Note-ons
|
||||
/// and other MIDI belong to the position being left. One release per key leaves room in the queue
|
||||
/// for `all_notes_off`.
|
||||
fn keep_releases(queue: &mut Vec<Event>) {
|
||||
let mut clap_off = [false; 128];
|
||||
let mut midi_off = [[false; 128]; 16];
|
||||
queue.retain(|e| match *e {
|
||||
Event::Param { .. } => true,
|
||||
Event::NoteOff { key, .. } => first_release(clap_off.get_mut(usize::from(key))),
|
||||
Event::Midi { data: [status, key, velocity], .. } if is_midi_note_off(status, velocity) => {
|
||||
first_release(midi_off.get_mut(usize::from(status & 0x0F)).and_then(|c| c.get_mut(usize::from(key))))
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
for e in queue.iter_mut() {
|
||||
e.set_time(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a key's release as seen; true only the first time (false for a key out of range).
|
||||
fn first_release(seen: Option<&mut bool>) -> bool {
|
||||
seen.is_some_and(|s| !std::mem::replace(s, true))
|
||||
}
|
||||
|
||||
/// A MIDI 1.0 note-off: status 0x8n, or a note-on (0x9n) with velocity 0.
|
||||
fn is_midi_note_off(status: u8, velocity: u8) -> bool {
|
||||
match status & 0xF0 {
|
||||
0x80 => true,
|
||||
0x90 => velocity == 0,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +279,10 @@ impl Plugin for ClapPlugin {
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.inst.reset();
|
||||
self.queue.retain(|e| matches!(e, Event::Param { .. }));
|
||||
self.held = [false; 128];
|
||||
// Keep pending releases and release whatever is still held: CLAP asks `reset` to kill
|
||||
// voices, but some plugins' doesn't (Surge XT's), and they would sound on with nothing
|
||||
// left to release them (#1464).
|
||||
release_on_reset(&mut self.queue, &mut self.held, self.notes);
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: &str, value: f32) -> bool {
|
||||
@@ -287,14 +383,7 @@ impl Plugin for ClapPlugin {
|
||||
return;
|
||||
}
|
||||
let time = u32::try_from(offset).unwrap_or(u32::MAX);
|
||||
match self.notes {
|
||||
NoteMode::Clap => self.push(Event::NoteOff { time, key: note }),
|
||||
NoteMode::Midi => self.push(Event::Midi { time, data: [0x80, note, 0] }),
|
||||
NoteMode::None => return,
|
||||
}
|
||||
if let Some(h) = self.held.get_mut(note as usize) {
|
||||
*h = false;
|
||||
}
|
||||
queue_release(&mut self.queue, &mut self.held, self.notes, time, note);
|
||||
}
|
||||
|
||||
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
|
||||
@@ -383,3 +472,107 @@ impl PluginEditor for ClapEditor {
|
||||
self.link.host().dirty.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A reset keeps parameter changes and one release per key, at the start of the next block,
|
||||
/// and drops note-ons and other MIDI.
|
||||
#[test]
|
||||
fn reset_keeps_params_and_one_release_per_key() {
|
||||
let mut q = vec![
|
||||
Event::Param { time: 0, id: 4, value: 0.5 },
|
||||
Event::NoteOn { time: 3, key: 60, velocity: 1.0 },
|
||||
Event::NoteOff { time: 5, key: 60 },
|
||||
Event::NoteOff { time: 9, key: 60 },
|
||||
Event::NoteOff { time: 12, key: 64 },
|
||||
Event::Midi { time: 20, data: [0x90, 62, 90] },
|
||||
Event::Midi { time: 21, data: [0xB0, 64, 127] },
|
||||
Event::Midi { time: 22, data: [0x80, 62, 0] },
|
||||
Event::Midi { time: 23, data: [0x80, 62, 40] },
|
||||
Event::Midi { time: 24, data: [0x83, 62, 0] },
|
||||
Event::Midi { time: 25, data: [0x91, 67, 0] },
|
||||
Event::Midi { time: 26, data: [0x80, 200, 0] },
|
||||
];
|
||||
keep_releases(&mut q);
|
||||
assert_eq!(
|
||||
q,
|
||||
vec![
|
||||
Event::Param { time: 0, id: 4, value: 0.5 },
|
||||
Event::NoteOff { time: 0, key: 60 },
|
||||
Event::NoteOff { time: 0, key: 64 },
|
||||
Event::Midi { time: 0, data: [0x80, 62, 0] },
|
||||
Event::Midi { time: 0, data: [0x83, 62, 0] },
|
||||
Event::Midi { time: 0, data: [0x91, 67, 0] },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A queue that parameter changes filled still releases every held key on a reset, once
|
||||
/// each, and keeps the parameter changes.
|
||||
#[test]
|
||||
fn a_full_queue_still_releases_every_held_key_on_reset() {
|
||||
for notes in [NoteMode::Clap, NoteMode::Midi] {
|
||||
let mut q = Vec::with_capacity(EVENT_CAP);
|
||||
for id in 0..EVENT_CAP as u32 {
|
||||
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
|
||||
}
|
||||
assert_eq!(q.len(), EVENT_CAP - RELEASE_RESERVE, "parameter changes leave the reserve free");
|
||||
// Keys 0..4 already have a release queued, and are held again after it.
|
||||
for key in 0..4u8 {
|
||||
let _ = push_event(&mut q, release_event(notes, 7, key).unwrap(), true);
|
||||
}
|
||||
let mut held = [true; 128];
|
||||
release_on_reset(&mut q, &mut held, notes);
|
||||
assert_eq!(held, [false; 128]);
|
||||
assert_eq!(q.iter().filter(|e| matches!(e, Event::Param { .. })).count(), EVENT_CAP - RELEASE_RESERVE, "{notes:?}");
|
||||
for key in 0..128u8 {
|
||||
let want = release_event(notes, 0, key).unwrap();
|
||||
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
|
||||
}
|
||||
assert_eq!(q.len(), EVENT_CAP);
|
||||
}
|
||||
}
|
||||
|
||||
/// A release that finds the queue full (parameter changes plus duplicate releases) leaves its
|
||||
/// key held, so the reset that follows still releases it, as the mix's `all_notes_off`
|
||||
/// then `reset` does on Stop.
|
||||
#[test]
|
||||
fn a_release_the_full_queue_drops_is_sent_by_the_next_reset() {
|
||||
for notes in [NoteMode::Clap, NoteMode::Midi] {
|
||||
let mut q = Vec::with_capacity(EVENT_CAP);
|
||||
let mut held = [false; 128];
|
||||
for id in 0..EVENT_CAP as u32 {
|
||||
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
|
||||
}
|
||||
// Key 0 pressed and released over and over fills the reserve with its releases.
|
||||
for t in 0..RELEASE_RESERVE as u32 {
|
||||
held[0] = true;
|
||||
queue_release(&mut q, &mut held, notes, t, 0);
|
||||
}
|
||||
assert_eq!(q.len(), EVENT_CAP, "{notes:?}: the queue is full");
|
||||
held[5] = true;
|
||||
queue_release(&mut q, &mut held, notes, 0, 5);
|
||||
assert!(held[5], "{notes:?}: a release that was not queued leaves its key held");
|
||||
release_on_reset(&mut q, &mut held, notes);
|
||||
for key in [0u8, 5] {
|
||||
let want = release_event(notes, 0, key).unwrap();
|
||||
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
|
||||
}
|
||||
assert_eq!(held, [false; 128]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A queue full of releases still has room to release every held key after a reset.
|
||||
#[test]
|
||||
fn reset_keeps_room_to_release_every_held_key() {
|
||||
let mut q = Vec::with_capacity(EVENT_CAP);
|
||||
for i in 0..EVENT_CAP {
|
||||
q.push(Event::NoteOff { time: i as u32, key: (i % 4) as u8 });
|
||||
}
|
||||
keep_releases(&mut q);
|
||||
let want: Vec<Event> = (0..4u8).map(|key| Event::NoteOff { time: 0, key }).collect();
|
||||
assert_eq!(q, want, "one release per key remains");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! (stepped enum A/B/C), hidden param 9, latency 3 samples.
|
||||
//! - `org.soundcraft.test.synth`: instrument, stereo out, CLAP note port; outputs a constant equal
|
||||
//! to the velocity of the held note (0 when none), switching at the event's sample offset. A
|
||||
//! MIDI CC 7 (volume) sets the constant to its value / 127.
|
||||
//! MIDI CC 7 (volume) sets the constant to its value / 127. `reset` leaves the note sounding.
|
||||
//!
|
||||
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
|
||||
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
|
||||
@@ -166,9 +166,9 @@ unsafe extern "C" fn stop_processing(p: *const clap_plugin) {
|
||||
unsafe { fx(p) }.processing = false;
|
||||
}
|
||||
|
||||
unsafe extern "C" fn reset(p: *const clap_plugin) {
|
||||
unsafe { fx(p) }.level = 0.0;
|
||||
}
|
||||
/// Leaves the synth's note sounding. CLAP asks `reset` to kill voices, but some synths' doesn't
|
||||
/// (Surge XT's), so the host must send the releases itself.
|
||||
unsafe extern "C" fn reset(_p: *const clap_plugin) {}
|
||||
|
||||
fn apply(f: &mut Fx, h: &clap_event_header) {
|
||||
if h.space_id != CLAP_CORE_EVENT_SPACE_ID {
|
||||
|
||||
@@ -109,6 +109,48 @@ fn synth_plays_notes_at_their_offsets() {
|
||||
assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset");
|
||||
}
|
||||
|
||||
/// Renders one 64-frame stereo block of `p` and returns its left channel.
|
||||
fn render_block(p: &mut dyn soundcraft_dsp::Plugin) -> Vec<f32> {
|
||||
let mut io = vec![vec![0.0f32; 64], vec![0.0f32; 64]];
|
||||
p.process(&mut io, 64);
|
||||
io.swap_remove(0)
|
||||
}
|
||||
|
||||
/// Stop, Play and Locate run `all_notes_off` then `reset`: the releases reach a synth whose own
|
||||
/// reset leaves its voices sounding, and a later block is silent (#1464).
|
||||
#[test]
|
||||
fn reset_delivers_the_releases_of_held_notes() {
|
||||
fixture_dir();
|
||||
let mut p = soundcraft_clap_host::create(SYNTH).unwrap();
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
let silent = |b: &[f32]| b.iter().all(|x| x.abs() < 1e-6);
|
||||
|
||||
// As the mix engine does on Stop: release the held note, then reset.
|
||||
p.note_on(0, 60, 127);
|
||||
assert!(render_block(p.as_mut()).iter().all(|&x| (x - 1.0).abs() < 1e-6), "the note sounds");
|
||||
p.all_notes_off();
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "all_notes_off's release was dropped by reset");
|
||||
|
||||
// A reset alone releases what is still held.
|
||||
p.note_on(0, 61, 127);
|
||||
render_block(p.as_mut());
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "reset did not release the held note");
|
||||
|
||||
// A release queued for a block that never ran is still delivered.
|
||||
p.note_on(0, 62, 127);
|
||||
render_block(p.as_mut());
|
||||
p.note_off(30, 62);
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "the pending release was dropped by reset");
|
||||
|
||||
// A note-on queued for the position being left is dropped, not played after the reset.
|
||||
p.note_on(10, 63, 127);
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "a note-on from before the reset sounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
|
||||
fixture_dir();
|
||||
|
||||
@@ -25,6 +25,9 @@ log.workspace = true
|
||||
zip.workspace = true
|
||||
craft-docpath.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winsafe = { version = "0.0.29", features = ["kernel"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
|
||||
@@ -454,6 +454,32 @@ pub fn create_folder(dir: &Path) -> std::io::Result<()> {
|
||||
builder.create(dir)
|
||||
}
|
||||
|
||||
/// A new folder directly in `parent`, named `{prefix}-` and 32 random hex digits. It is made
|
||||
/// exclusively (it is created by this call or the call fails), so a folder or link another account
|
||||
/// put there first is never used, and the name cannot be guessed ahead. On Unix it is owner-only
|
||||
/// (`0o700`); on other systems it has the mode `parent` gives it.
|
||||
pub fn create_unique_folder(parent: &Path, prefix: &str) -> std::io::Result<PathBuf> {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
// One plain name: a prefix like `../x` would make a sibling of `parent`, not a child.
|
||||
let mut parts = Path::new(prefix).components();
|
||||
if !matches!((parts.next(), parts.next()), (Some(std::path::Component::Normal(_)), None)) || prefix.contains(['/', '\\']) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the folder prefix must be a single name"));
|
||||
}
|
||||
for _ in 0..16 {
|
||||
let random = |_| std::collections::hash_map::RandomState::new().build_hasher().finish();
|
||||
let dir = parent.join(format!("{prefix}-{:016x}{:016x}", random(0), random(1)));
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
match builder.create(&dir) {
|
||||
Ok(()) => return Ok(dir),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no unused folder name"))
|
||||
}
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = std::fs::File::create(path)?;
|
||||
f.write_all(bytes)?;
|
||||
@@ -476,6 +502,25 @@ fn io_error(path: &Path, err: std::io::Error) -> EngineError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::io::open_session;
|
||||
|
||||
#[test]
|
||||
fn unique_folders_are_new_and_owner_only() {
|
||||
let parent = std::env::temp_dir().join(format!("soundcraft-unique-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&parent);
|
||||
std::fs::create_dir_all(&parent).unwrap();
|
||||
let (a, b) = (create_unique_folder(&parent, "T").unwrap(), create_unique_folder(&parent, "T").unwrap());
|
||||
assert_ne!(a, b);
|
||||
assert!(a.is_dir() && a.parent() == Some(parent.as_path()));
|
||||
assert_eq!(a.file_name().unwrap().len(), "T-".len() + 32);
|
||||
#[cfg(unix)]
|
||||
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&a).unwrap().permissions()) & 0o777, 0o700);
|
||||
assert!(create_unique_folder(&parent.join("missing"), "T").is_err(), "no parents are made");
|
||||
for bad in ["../outside", "a/b", "a\\b", "..", ".", "", "/abs"] {
|
||||
assert!(create_unique_folder(&parent, bad).is_err(), "{bad:?} is not a single name");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&parent);
|
||||
}
|
||||
|
||||
use serde_json::json;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
|
||||
|
||||
@@ -87,23 +87,30 @@ 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 => {
|
||||
let bytes = crate::io::read_file(&full)?;
|
||||
let bytes = crate::io::read_file_inside(&full, &root)?;
|
||||
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
|
||||
let buf = crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES)
|
||||
.ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?;
|
||||
.map_err(|r| EngineError::Io(format!("{rel}: {}", r.message("too large to import"))))?;
|
||||
decoded.push((rel.clone(), buf));
|
||||
index.insert(key, decoded.len() - 1);
|
||||
decoded.len() - 1
|
||||
@@ -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()));
|
||||
|
||||
@@ -154,6 +154,7 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
|
||||
for t in ids {
|
||||
crate::edit::separate_all_at(s, t, r.start);
|
||||
crate::edit::insert_silence(s, t, r);
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
for l in &mut tr.automation {
|
||||
@@ -185,6 +186,8 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
|
||||
for t in ids {
|
||||
crate::edit::separate_all_at(s, t, r.start);
|
||||
crate::edit::separate_all_at(s, t, r.end);
|
||||
crate::edit::clear_range(s, t, r, false);
|
||||
crate::edit::shift_from(s, t, r.end, -r.len());
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
|
||||
@@ -94,9 +94,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
|
||||
let r = range_param(e, "file.bounce_stems", p)?;
|
||||
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
|
||||
let format = str_param(p, "format")
|
||||
.and_then(soundcraft_audio_io::FileFormat::from_write_extension)
|
||||
.unwrap_or(soundcraft_audio_io::FileFormat::Wav);
|
||||
let format_name = match p.get("format") {
|
||||
None => "wav",
|
||||
Some(value) => value.as_str().ok_or_else(|| bad("file.bounce_stems", "`format` must be a string"))?,
|
||||
};
|
||||
let format = soundcraft_audio_io::FileFormat::from_write_extension(format_name).ok_or_else(|| {
|
||||
let supported = soundcraft_audio_io::FileFormat::writable_extensions().join(", ");
|
||||
bad("file.bounce_stems", format!("cannot write `{format_name}` files (supported: {supported})"))
|
||||
})?;
|
||||
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
|
||||
Some("16") => soundcraft_audio_io::BitDepth::Int16,
|
||||
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
|
||||
|
||||
@@ -118,6 +118,71 @@ fn import_audio_honours_new_tracks() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_stems_rejects_unsupported_formats_before_writing() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-stems-unsupported-format-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let stems = dir.join("stems");
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
|
||||
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "not-a-format", "start": 0, "end": 1}));
|
||||
let created_output = stems.exists();
|
||||
// Own this directory exclusively; remove any baseline output before the assertion can fail.
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let err = result.map(|_| ()).unwrap_err();
|
||||
assert!(err.to_string().contains("cannot write `not-a-format`"), "{err}");
|
||||
assert!(err.to_string().contains("supported: wav"), "{err}");
|
||||
assert!(!created_output, "an unsupported format must not create the output directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_stems_rejects_non_string_formats_before_writing() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-stems-format-type-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
|
||||
let results: Vec<_> = [json!(null), json!(123), json!(false), json!([]), json!({})]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, format)| {
|
||||
let stems = dir.join(index.to_string());
|
||||
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": format, "start": 0, "end": 1}));
|
||||
(result, stems.exists())
|
||||
})
|
||||
.collect();
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
for (result, created_output) in results {
|
||||
let err = result.map(|_| ()).unwrap_err();
|
||||
assert!(err.to_string().contains("`format` must be a string"), "{err}");
|
||||
assert!(!created_output, "a non-string format must not create the output directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_stems_accepts_explicit_wav_format() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-stems-explicit-wav-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let mut e = Engine::default();
|
||||
let wav = tone_wav(&dir, "tone.wav");
|
||||
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
|
||||
let stems = dir.join("stems");
|
||||
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "wav", "start": 0, "end": 32, "dither": false})).unwrap();
|
||||
let files = result["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
let path = std::path::Path::new(files[0].as_str().unwrap());
|
||||
assert_eq!(path.parent(), Some(stems.as_path()));
|
||||
assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("wav"));
|
||||
let (info, audio) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap();
|
||||
assert_eq!(info.format, soundcraft_audio_io::FileFormat::Wav);
|
||||
assert_eq!(audio.frames(), 32);
|
||||
assert_eq!(audio.num_channels(), e.session().main_format().channels());
|
||||
assert!(audio.channels.iter().flatten().any(|sample| sample.abs() > 0.01));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_refuses_formats_it_cannot_write() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -75,7 +75,7 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path:
|
||||
return Err(EngineError::Io(format!("{name}: no audio")));
|
||||
}
|
||||
let buf = crate::io::at_rate(buf, e.session().sample_rate.hz(), MAX_SAMPLE_VALUES)
|
||||
.ok_or_else(|| EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")))?;
|
||||
.map_err(|r| EngineError::Io(format!("{name}: {}", r.message("too long for a sample (ten minutes of 192 kHz stereo at most)"))))?;
|
||||
let channels = buf.num_channels();
|
||||
let s = e.session_mut();
|
||||
let src = crate::io::add_source(s, name, buf, path, format.format);
|
||||
|
||||
@@ -78,9 +78,9 @@ fn rebase_midi_start(clip: &mut Clip, old_start: Samples, t: TimeCtx<'_>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split every clip on a track's active playlist that spans `at`. The piece before `at` keeps the
|
||||
/// clip's name. The piece after it is a new clip, and takes the next name of the clip's Auto Rename
|
||||
/// scheme when it has one.
|
||||
/// Split every clip on a track's active playlist that spans `at`, except time- or edit-locked ones.
|
||||
/// The piece before `at` keeps the clip's name. The piece after it is a new clip, and takes the next
|
||||
/// name of the clip's Auto Rename scheme when it has one.
|
||||
pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
separate_at_if(s, track, at, |_| true)
|
||||
}
|
||||
@@ -88,11 +88,21 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
/// [`separate_at`], giving the piece after `at` the next Auto Rename name only when `auto_name`
|
||||
/// returns true for it. Any other piece keeps the clip's name, and its scheme does not count up.
|
||||
pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool) -> usize {
|
||||
split_at(s, track, at, auto_name, true)
|
||||
}
|
||||
|
||||
/// [`separate_at`], splitting time- and edit-locked clips too. For the Event time operations, which
|
||||
/// move the whole timeline, locked clips included.
|
||||
pub fn separate_all_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
split_at(s, track, at, |_| true, false)
|
||||
}
|
||||
|
||||
fn split_at(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool, skip_locked: bool) -> usize {
|
||||
let tc_tempo = s.tempo.clone();
|
||||
let sr = s.sample_rate;
|
||||
let t = TimeCtx { tempo: &tc_tempo, sr };
|
||||
let mut ids = Vec::new();
|
||||
let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end()).count());
|
||||
let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end() && !(skip_locked && locked(c))).count());
|
||||
for _ in 0..n_split {
|
||||
ids.push(s.new_clip_id());
|
||||
}
|
||||
@@ -106,6 +116,7 @@ pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: i
|
||||
for c in pl.clips.drain(..) {
|
||||
if at > c.start
|
||||
&& at < c.end()
|
||||
&& !(skip_locked && locked(&c))
|
||||
&& let Some(id) = ids.pop()
|
||||
&& let Some((l, mut r)) = split_clip(&c, at, id, t)
|
||||
{
|
||||
@@ -125,6 +136,45 @@ pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: i
|
||||
count
|
||||
}
|
||||
|
||||
/// Split every clip on a track's active playlist at each of `points`, in one pass over the
|
||||
/// playlist: [`separate_at_if`] once per point with `|_| false` (no piece takes an Auto Rename
|
||||
/// name here), without rebuilding the playlist per point. Returns the number of splits.
|
||||
/// `points` are sorted and deduplicated first, so the source offsets of stretched audio can differ
|
||||
/// from per-point calls given in another order (each trim rounds its own adjustment).
|
||||
pub fn separate_at_points(s: &mut Session, track: TrackId, points: &[Samples]) -> usize {
|
||||
let mut pts = points.to_vec();
|
||||
pts.sort_unstable();
|
||||
pts.dedup();
|
||||
let tc_tempo = s.tempo.clone();
|
||||
let t = TimeCtx { tempo: &tc_tempo, sr: s.sample_rate };
|
||||
// The points strictly inside a clip.
|
||||
let within = |c: &Clip| {
|
||||
let (lo, hi) = (pts.partition_point(|p| *p <= c.start), pts.partition_point(|p| *p < c.end()));
|
||||
lo..hi.max(lo)
|
||||
};
|
||||
let n_split: usize = s.track(track).map_or(0, |tr| tr.clips().iter().map(|c| within(c).len()).sum());
|
||||
let mut ids: Vec<ClipId> = (0..n_split).map(|_| s.new_clip_id()).collect();
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut out = Vec::with_capacity(pl.clips.len() + n_split);
|
||||
for c in pl.clips.drain(..) {
|
||||
let mut rest = c;
|
||||
for p in &pts[within(&rest)] {
|
||||
let Some(id) = ids.pop() else { break };
|
||||
match split_clip(&rest, *p, id, t) {
|
||||
Some((l, r)) => {
|
||||
out.push(l);
|
||||
rest = r;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
out.push(rest);
|
||||
}
|
||||
pl.clips = out;
|
||||
pl.sort();
|
||||
n_split
|
||||
}
|
||||
|
||||
/// Gives `c` the next name of its Auto Rename scheme, when it has one, and counts the scheme up.
|
||||
fn take_auto_name(schemes: &mut std::collections::BTreeMap<u64, soundcraft_model::AutoName>, c: &mut Clip) {
|
||||
if let Some(scheme) = c.auto_name.and_then(|k| schemes.get_mut(&k)) {
|
||||
@@ -427,6 +477,10 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
for c in &mut pl.clips {
|
||||
// Locked clips keep their extent, like they don't move for nudge.
|
||||
if locked(c) {
|
||||
continue;
|
||||
}
|
||||
if let Some(i) = c.range().intersect(&range)
|
||||
&& i != c.range()
|
||||
{
|
||||
@@ -448,6 +502,9 @@ pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
for c in &mut pl.clips {
|
||||
if locked(c) {
|
||||
continue;
|
||||
}
|
||||
if c.range().contains(at) && at > c.start {
|
||||
let old = c.start;
|
||||
c.trim_start_to(at);
|
||||
@@ -458,10 +515,14 @@ pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
n
|
||||
}
|
||||
|
||||
/// Trim the end of clips after `at` to `at` (Trim End to Insertion).
|
||||
pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
for c in &mut pl.clips {
|
||||
if locked(c) {
|
||||
continue;
|
||||
}
|
||||
if at > c.start && at < c.end() {
|
||||
c.trim_end_to(at);
|
||||
n += 1;
|
||||
|
||||
+457
-10
@@ -65,6 +65,69 @@ fn read_file_within(path: &Path, session: bool) -> Result<Vec<u8>> {
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// `O_NONBLOCK` of the platforms that have a FIFO to wait on (std has no name for it, and this crate has no unsafe
|
||||
/// code or libc); 0 elsewhere, where a FIFO open still waits for a writer.
|
||||
#[cfg(unix)]
|
||||
const O_NONBLOCK: i32 = if cfg!(all(
|
||||
any(target_os = "linux", target_os = "android"),
|
||||
not(any(
|
||||
target_arch = "mips",
|
||||
target_arch = "mips32r6",
|
||||
target_arch = "mips64",
|
||||
target_arch = "mips64r6",
|
||||
target_arch = "sparc",
|
||||
target_arch = "sparc64"
|
||||
))
|
||||
)) {
|
||||
0x800
|
||||
} else if cfg!(any(
|
||||
target_os = "macos",
|
||||
target_os = "ios",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "dragonfly"
|
||||
)) {
|
||||
0x4
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
/// [`read_file`] for a file that must lie inside `root` (a canonical folder): the file is opened
|
||||
/// once, and the bytes come from that handle only after the handle is shown to be the file the
|
||||
/// path resolves to inside `root`. A file swapped in for the path after a check by name is
|
||||
/// refused instead of read. Where the handle's identity can't be read (non-Unix targets) the
|
||||
/// resolved path is checked against `root` alone.
|
||||
pub(crate) fn read_file_inside(path: &Path, root: &Path) -> Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
crate::file_access::check_read(path).map_err(EngineError::Io)?;
|
||||
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
|
||||
// opened without waiting for a FIFO's writer (the type is checked on the opened handle, below)
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, O_NONBLOCK);
|
||||
let mut file = options.open(path).map_err(err)?;
|
||||
if !file.metadata().map_err(err)?.is_file() {
|
||||
return Err(EngineError::Io(format!("{}: not a regular file", path.display())));
|
||||
}
|
||||
let key = canonical_path(path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let (a, b) = (file.metadata().map_err(err)?, std::fs::metadata(&key).map_err(err)?);
|
||||
if (a.dev(), a.ino()) != (b.dev(), b.ino()) {
|
||||
return Err(EngineError::Io(format!("{}: changed while it was opened", path.display())));
|
||||
}
|
||||
}
|
||||
if !key.starts_with(root) {
|
||||
return Err(EngineError::Io(format!("{}: outside the folder it must stay in", path.display())));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes).map_err(err)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// [`read_file`] as UTF-8 text.
|
||||
pub fn read_text(path: impl AsRef<Path>) -> Result<String> {
|
||||
let path = path.as_ref();
|
||||
@@ -192,6 +255,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();
|
||||
@@ -446,7 +520,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
|
||||
@@ -578,8 +652,28 @@ pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) ->
|
||||
let audio = soundcraft_mix::render_clips(s, t, c.range());
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
|
||||
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let path = clip_file(dir, &sanitize_name(&c.name), &mut names, Path::exists)?;
|
||||
write_file(&path.to_string_lossy(), &bytes)?;
|
||||
let stem = sanitize_name(&c.name);
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
let path = clip_file(dir, &stem, &mut names, Path::exists)?;
|
||||
write_file(&path.to_string_lossy(), &bytes)?;
|
||||
continue;
|
||||
}
|
||||
// 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. 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.
|
||||
let (mut file, path) = loop {
|
||||
let path = clip_file(dir, &stem, &mut names, |p| std::fs::symlink_metadata(p).is_ok())?;
|
||||
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, path),
|
||||
// taken since it was looked at: `names` has it now, so the next name is tried
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
|
||||
}
|
||||
};
|
||||
std::io::Write::write_all(&mut file, &bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
|
||||
}
|
||||
Ok(names.taken.len())
|
||||
}
|
||||
@@ -644,13 +738,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;
|
||||
}
|
||||
@@ -672,8 +766,11 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
let mut stem = sanitize_name(&src.name);
|
||||
if overwrite {
|
||||
// One file per source id so autosaves never collide or pile up.
|
||||
// (With only an automation write root, `file_found` cannot read an existing file, so each save
|
||||
// writes a new numbered WAV: reuse never reads a file the roots do not allow, which keeps a save
|
||||
// from revealing whether an unreadable file matches the audio.)
|
||||
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());
|
||||
@@ -756,11 +853,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)?;
|
||||
@@ -840,7 +948,8 @@ fn same_path(a: &Path, b: &Path) -> bool {
|
||||
|
||||
/// Store `bytes` as the first of `<stem>.wav`, `<stem>_01.wav`, … in `dir` that is free or already
|
||||
/// holds exactly these bytes, so saving never replaces audio; returns the name and whether it was
|
||||
/// written.
|
||||
/// written. Where the automation roots allow writing in `dir` but not reading (only a write root),
|
||||
/// an existing file is never compared, so each save writes a new numbered WAV.
|
||||
fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
|
||||
use std::io::Write;
|
||||
for k in 0..10_000u32 {
|
||||
@@ -855,7 +964,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));
|
||||
}
|
||||
}
|
||||
@@ -891,7 +1005,99 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
return deliver(name, bytes).map_err(EngineError::Io);
|
||||
}
|
||||
crate::file_access::check_write(path).map_err(EngineError::Io)?;
|
||||
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
|
||||
// Follow existing symlinks, and refuse dangling links even without automation roots.
|
||||
let named = Path::new(path);
|
||||
let path = match std::fs::symlink_metadata(named) {
|
||||
Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(named).map_err(|err| EngineError::Io(format!("{path}: {err}")))?,
|
||||
Ok(_) => named.to_path_buf(),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => named.to_path_buf(),
|
||||
Err(err) => return Err(EngineError::Io(format!("{path}: {err}"))),
|
||||
};
|
||||
crate::file_access::check_write(&path).map_err(EngineError::Io)?;
|
||||
let metadata = match std::fs::metadata(&path) {
|
||||
Ok(m) if m.is_file() => Some(m),
|
||||
Ok(_) => return Err(EngineError::Io(format!("{}: not a regular file", path.display()))),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
|
||||
};
|
||||
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
|
||||
if metadata.as_ref().is_some_and(|m| m.permissions().readonly()) {
|
||||
return Err(err(std::io::ErrorKind::PermissionDenied.into()));
|
||||
}
|
||||
write_export_checked(&path, bytes).map_err(err)
|
||||
}
|
||||
|
||||
/// Keep the original file and its security metadata. Refuse hard links before truncation: a
|
||||
/// link inside an automation root can name a file outside it (#1359).
|
||||
#[cfg(unix)]
|
||||
fn write_export_checked(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write as _;
|
||||
use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _};
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(false).mode(0o600).open(path)?;
|
||||
let opened = file.metadata()?;
|
||||
if !opened.is_file() {
|
||||
return Err(std::io::Error::other("not a regular file"));
|
||||
}
|
||||
if opened.nlink() > 1 {
|
||||
return Err(hard_link_export_error());
|
||||
}
|
||||
file.set_len(0)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
fn hard_link_export_error() -> std::io::Error {
|
||||
std::io::Error::other("refusing to overwrite a hard-linked file; choose a new export path")
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn write_export_checked(_path: &Path, _bytes: &[u8]) -> std::io::Result<()> {
|
||||
Err(std::io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
|
||||
/// Windows' stable std metadata API does not expose the link count. Use a safe kernel wrapper
|
||||
/// and the same handle for the check and write; no replacement means the destination DACL stays.
|
||||
#[cfg(windows)]
|
||||
fn write_export_checked(path: &Path, mut bytes: &[u8]) -> std::io::Result<()> {
|
||||
use winsafe::{HFILE, co};
|
||||
// Let std retain its Windows filename normalization and create missing destinations without
|
||||
// truncation. Canonicalization then supplies a verbatim path, including long and UNC paths,
|
||||
// to the raw Win32 wrapper. The handle below is checked before any existing bytes change.
|
||||
let _opened = std::fs::OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
|
||||
let absolute = std::fs::canonicalize(path)?;
|
||||
let path = absolute.to_str().filter(|p| !p.contains('\0')).ok_or(std::io::ErrorKind::InvalidInput)?;
|
||||
let io_error = |err: winsafe::co::ERROR| std::io::Error::other(err.to_string());
|
||||
let (file, _) = HFILE::CreateFile(
|
||||
path,
|
||||
co::GENERIC::WRITE,
|
||||
Some(co::FILE_SHARE::READ | co::FILE_SHARE::WRITE | co::FILE_SHARE::DELETE),
|
||||
None,
|
||||
co::DISPOSITION::OPEN_EXISTING,
|
||||
co::FILE_ATTRIBUTE::NORMAL,
|
||||
Some(co::FILE_FLAG::WRITE_THROUGH),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(io_error)?;
|
||||
if file.GetFileType().map_err(io_error)? != co::FILE_TYPE::DISK {
|
||||
return Err(std::io::Error::other("not a regular file"));
|
||||
}
|
||||
match file.GetFileInformationByHandle().map_err(io_error)?.nNumberOfLinks {
|
||||
1 => {}
|
||||
0 => return Err(std::io::ErrorKind::Unsupported.into()),
|
||||
_ => return Err(hard_link_export_error()),
|
||||
}
|
||||
file.SetEndOfFile().map_err(io_error)?;
|
||||
while !bytes.is_empty() {
|
||||
// Win32 takes a DWORD length. Small bounded writes also handle short writes correctly.
|
||||
let count = file.WriteFile(&bytes[..bytes.len().min(64 * 1024)]).map_err(io_error)? as usize;
|
||||
if count == 0 {
|
||||
return Err(std::io::ErrorKind::WriteZero.into());
|
||||
}
|
||||
bytes = &bytes[count..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
|
||||
@@ -962,6 +1168,89 @@ 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 a_file_read_within_a_root_must_resolve_inside_it() {
|
||||
let dir = scratch("within");
|
||||
let (inside, outside) = (dir.join("group"), dir.join("elsewhere"));
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
std::fs::write(inside.join("a.wav"), b"a").unwrap();
|
||||
std::fs::write(outside.join("b.wav"), b"b").unwrap();
|
||||
std::os::unix::fs::symlink(outside.join("b.wav"), inside.join("link.wav")).unwrap();
|
||||
let root = canonical_path(&inside).unwrap();
|
||||
assert_eq!(read_file_inside(&inside.join("a.wav"), &root).unwrap(), b"a");
|
||||
assert!(read_file_inside(&inside.join("link.wav"), &root).is_err());
|
||||
// a FIFO with no writer is refused, not waited on (Linux: the coreutils tool makes it)
|
||||
#[cfg(target_os = "linux")]
|
||||
if std::process::Command::new("mkfifo").arg(inside.join("pipe.wav")).status().is_ok_and(|s| s.success()) {
|
||||
let err = read_file_inside(&inside.join("pipe.wav"), &root).unwrap_err().to_string();
|
||||
assert!(err.contains("not a regular file"), "{err}");
|
||||
}
|
||||
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]
|
||||
@@ -1019,6 +1308,164 @@ mod stem_tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod export_replacement_tests {
|
||||
use super::*;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-export-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
#[test]
|
||||
fn export_refuses_hard_links_before_truncating_and_refuses_nonfiles() {
|
||||
let dir = scratch("hard-links");
|
||||
let original = dir.join("original.mid");
|
||||
let write_root = dir.join("write");
|
||||
std::fs::create_dir(&write_root).unwrap();
|
||||
let destination = write_root.join("export.mid");
|
||||
std::fs::write(&original, b"old").unwrap();
|
||||
std::fs::hard_link(&original, &destination).unwrap();
|
||||
for confined in [false, true] {
|
||||
let roots = if confined { crate::file_access::AutomationRoots::new(&[], &[&write_root]).unwrap() } else { None };
|
||||
let result = crate::file_access::confine(roots.as_ref(), || write_file(destination.to_str().unwrap(), b"new"));
|
||||
assert!(result.unwrap_err().to_string().contains("hard-linked"));
|
||||
}
|
||||
assert_eq!(std::fs::read(&original).unwrap(), b"old");
|
||||
assert_eq!(std::fs::read(&destination).unwrap(), b"old");
|
||||
let folder = dir.join("folder");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
assert!(write_file(folder.to_str().unwrap(), b"new").is_err());
|
||||
assert!(folder.is_dir());
|
||||
assert!(!std::fs::read_dir(&dir).unwrap().any(|e| e.unwrap().file_name().to_string_lossy().starts_with(".soundcraft-")));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn export_supports_paths_longer_than_max_path() {
|
||||
let root = scratch("long-path");
|
||||
let mut dir = root.clone();
|
||||
while dir.as_os_str().len() < 280 {
|
||||
dir.push("long-folder-component");
|
||||
}
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("export.mid");
|
||||
write_file(path.to_str().unwrap(), b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
write_file(path.to_str().unwrap(), b"replacement").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"replacement");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn export_writes_all_chunks_and_truncates_an_existing_file() {
|
||||
let dir = scratch("chunks");
|
||||
let path = dir.join("export.mid");
|
||||
let bytes = vec![0x5a; 128 * 1024 + 17];
|
||||
write_file(path.to_str().unwrap(), &bytes).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
write_file(path.to_str().unwrap(), b"").unwrap();
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn export_refuses_readonly_files_without_changing_their_bytes() {
|
||||
let dir = scratch("readonly");
|
||||
let path = dir.join("readonly.mid");
|
||||
std::fs::write(&path, b"old contents").unwrap();
|
||||
let original = std::fs::metadata(&path).unwrap().permissions();
|
||||
let mut readonly = original.clone();
|
||||
readonly.set_readonly(true);
|
||||
std::fs::set_permissions(&path, readonly).unwrap();
|
||||
let result = write_file(path.to_str().unwrap(), b"new");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
std::fs::set_permissions(&path, original).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(bytes, b"old contents");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn export_preserves_the_existing_file_identity_and_security_metadata() {
|
||||
let dir = scratch("identity");
|
||||
let path = dir.join("existing.mid");
|
||||
std::fs::write(&path, b"long old contents").unwrap();
|
||||
#[cfg(unix)]
|
||||
let identity = |p: &Path| {
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let m = std::fs::metadata(p).unwrap();
|
||||
(m.dev(), m.ino(), m.uid(), m.gid())
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let identity = |p: &Path| {
|
||||
use winsafe::{HFILE, co};
|
||||
let (file, _) = HFILE::CreateFile(
|
||||
p.to_str().unwrap(),
|
||||
co::GENERIC::READ,
|
||||
None,
|
||||
None,
|
||||
co::DISPOSITION::OPEN_EXISTING,
|
||||
co::FILE_ATTRIBUTE::NORMAL,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let i = file.GetFileInformationByHandle().unwrap();
|
||||
(i.dwVolumeSerialNumber, i.nFileIndex())
|
||||
};
|
||||
let before = identity(&path);
|
||||
write_file(path.to_str().unwrap(), b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
// No inode/file-record replacement: owner, group and access-control lists stay attached
|
||||
// to the same file rather than being inherited anew from the parent directory.
|
||||
assert_eq!(identity(&path), before);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn export_replacement_preserves_existing_permissions_and_new_files_are_private() {
|
||||
let dir = scratch("permissions");
|
||||
for mode in [0o600, 0o640] {
|
||||
let path = dir.join(format!("{mode:o}.mid"));
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
|
||||
write_file(path.to_str().unwrap(), b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, mode);
|
||||
}
|
||||
let path = dir.join("new.mid");
|
||||
write_file(path.to_str().unwrap(), b"new").unwrap();
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o077, 0);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn export_replacement_preserves_a_symlink_and_refuses_a_dangling_one() {
|
||||
let dir = scratch("symlinks");
|
||||
let target = dir.join("target.mid");
|
||||
let link = dir.join("link.mid");
|
||||
std::fs::write(&target, b"old").unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
symlink("target.mid", &link).unwrap();
|
||||
for confined in [false, true] {
|
||||
let roots = if confined { crate::file_access::AutomationRoots::new(&[], &[&dir]).unwrap() } else { None };
|
||||
crate::file_access::confine(roots.as_ref(), || write_file(link.to_str().unwrap(), b"new")).unwrap();
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"new");
|
||||
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
let missing = dir.join("missing.mid");
|
||||
let dangling = dir.join("dangling.mid");
|
||||
symlink(&missing, &dangling).unwrap();
|
||||
assert!(write_file(dangling.to_str().unwrap(), b"new").is_err());
|
||||
assert!(std::fs::symlink_metadata(&dangling).unwrap().file_type().is_symlink());
|
||||
assert!(!missing.exists());
|
||||
assert!(!std::fs::read_dir(&dir).unwrap().any(|e| e.unwrap().file_name().to_string_lossy().starts_with(".soundcraft-")));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clip_tests {
|
||||
|
||||
@@ -7,18 +7,48 @@ use super::{
|
||||
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
|
||||
pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30;
|
||||
|
||||
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples: a file with a
|
||||
/// tiny sample rate grows by orders of magnitude.
|
||||
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> Option<AudioBuffer> {
|
||||
/// The most a file's audio may grow when converted to the session rate. Sessions run at 8 kHz to
|
||||
/// 768 kHz (96×); past this a file is lying about its rate, and converting a few kilobytes would
|
||||
/// otherwise allocate and filter hundreds of megabytes.
|
||||
const MAX_RATE_EXPANSION: u32 = 128;
|
||||
|
||||
/// Why [`at_rate`] refused a conversion.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum RateError {
|
||||
/// The converted audio would hold more samples than the limit.
|
||||
TooLarge,
|
||||
/// The file's rate is so far below the session's that converting would grow it past [`MAX_RATE_EXPANSION`].
|
||||
Ratio { from: u32, to: u32 },
|
||||
}
|
||||
|
||||
impl RateError {
|
||||
/// The refusal as a sentence: `size` says what a size refusal means to the caller.
|
||||
pub(crate) fn message(&self, size: &str) -> String {
|
||||
match self {
|
||||
RateError::TooLarge => size.to_string(),
|
||||
RateError::Ratio { from, to } => {
|
||||
format!("its {from} Hz sample rate is too low to convert to the session's {to} Hz (at most {MAX_RATE_EXPANSION}-fold)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples, or grow more
|
||||
/// than [`MAX_RATE_EXPANSION`]-fold: a file with a tiny sample rate grows by orders of magnitude.
|
||||
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> std::result::Result<AudioBuffer, RateError> {
|
||||
let from = buf.sample_rate.max(1);
|
||||
// widened integers: dividing first would truncate 128.86-fold down to 128
|
||||
if u64::from(rate) > u64::from(from) * u64::from(MAX_RATE_EXPANSION) {
|
||||
return Err(RateError::Ratio { from, to: rate });
|
||||
}
|
||||
if buf.frames() as f64 * buf.num_channels() as f64 * f64::from(rate) / f64::from(from) > limit as f64 {
|
||||
return None;
|
||||
return Err(RateError::TooLarge);
|
||||
}
|
||||
if from != rate {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, from, rate);
|
||||
buf.sample_rate = rate;
|
||||
}
|
||||
Some(buf)
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// A relative `Audio Files/` path no other source in `s` already uses.
|
||||
@@ -115,7 +145,7 @@ pub fn decode_import(path: &str, rate: u32) -> Result<DecodedImport> {
|
||||
fn decode_import_bytes(name: &str, bytes: &[u8], rate: u32) -> Result<(soundcraft_audio_io::AudioInfo, Arc<SourceAudio>)> {
|
||||
let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase);
|
||||
let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
|
||||
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| EngineError::Io(format!("{name}: {}", e.message("file is too large to import"))))?;
|
||||
if buf.frames() == 0 {
|
||||
return Err(EngineError::Io(format!("{name}: no audio")));
|
||||
}
|
||||
@@ -215,8 +245,22 @@ mod rate_tests {
|
||||
#[test]
|
||||
fn a_tiny_sample_rate_is_refused_before_it_is_converted() {
|
||||
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
|
||||
assert!(at_rate(buf(1), 48_000, 1_000_000).is_none());
|
||||
assert!(at_rate(buf(1), 48_000, 1_000_000).is_err());
|
||||
let ok = at_rate(buf(8_000), 48_000, 1_000_000).unwrap();
|
||||
assert_eq!((ok.sample_rate, ok.frames()), (48_000, 600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extreme_rate_expansion_is_refused_whatever_the_sample_budget() {
|
||||
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
|
||||
// 100 frames at 300 Hz would fit the budget as 128 000 samples, but grow 640-fold.
|
||||
assert_eq!(at_rate(buf(300), 192_000, usize::MAX).unwrap_err(), RateError::Ratio { from: 300, to: 192_000 });
|
||||
assert!(at_rate(buf(1_500), 192_000, usize::MAX).is_ok(), "128-fold is the supported extreme");
|
||||
assert!(at_rate(buf(8_000), 768_000, usize::MAX).is_ok(), "the slowest file at the fastest session");
|
||||
// 128.86-fold: integer division would truncate it to 128 and let it through
|
||||
assert_eq!(at_rate(buf(1_490), 192_000, usize::MAX).unwrap_err(), RateError::Ratio { from: 1_490, to: 192_000 });
|
||||
// a short file that is only over the sample budget is a size refusal, not a rate one
|
||||
assert_eq!(at_rate(buf(48_000), 48_000, 10).unwrap_err(), RateError::TooLarge);
|
||||
assert!(RateError::Ratio { from: 300, to: 192_000 }.message("big").contains("300 Hz"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ pub(crate) fn open_session_linked(e: &mut Engine, path: &Path, links: &Path) ->
|
||||
/// Decode one source's audio file at the session rate.
|
||||
pub(super) fn decode_source(bytes: &[u8], ext: Option<&str>, rate: u32) -> std::result::Result<Arc<SourceAudio>, String> {
|
||||
let (_, buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?;
|
||||
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or("too large to load")?;
|
||||
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| e.message("too large to load"))?;
|
||||
Ok(Arc::new(SourceAudio::new(buf)))
|
||||
}
|
||||
|
||||
|
||||
@@ -117,9 +117,7 @@ pub fn strip_silence(
|
||||
}
|
||||
cut_points.push(r.end);
|
||||
let old: std::collections::HashSet<_> = s.track(t).map(|x| x.clips().iter().map(|c| c.id).collect()).unwrap_or_default();
|
||||
for p in &cut_points {
|
||||
crate::edit::separate_at_if(s, t, *p, |_| false);
|
||||
}
|
||||
crate::edit::separate_at_points(s, t, &cut_points);
|
||||
let kept = strip_pieces(s, t, r, &keep, mode);
|
||||
crate::edit::auto_name_new_clips(s, t, &old, |c| !(named && c.is_audio() && c.start >= r.start && c.end() <= r.end));
|
||||
Ok(kept)
|
||||
@@ -144,10 +142,24 @@ fn strip_pieces(s: &mut Session, t: TrackId, r: Range, keep: &[(Samples, Samples
|
||||
let audio: Vec<Range> = keep.iter().map(|(a, b)| Range::new(*a, *b)).collect();
|
||||
let remove = if mode == StripMode::Extract { &audio } else { &gaps };
|
||||
let inside = |c: &Clip, g: &Range| c.start >= g.start && c.end() <= g.end;
|
||||
// The range of `rs` holding `c`: by binary search when `rs` is ascending and disjoint (the
|
||||
// usual case, checked once by the caller), by scan otherwise.
|
||||
let holder = |rs: &[Range], sorted: bool, c: &Clip| {
|
||||
if sorted {
|
||||
let i = rs.partition_point(|g| g.end < c.end());
|
||||
rs.get(i).filter(|g| inside(c, g)).map(|_| i)
|
||||
} else {
|
||||
rs.iter().position(|g| inside(c, g))
|
||||
}
|
||||
};
|
||||
let ordered = |rs: &[Range]| rs.windows(2).all(|w| w[0].end <= w[1].start);
|
||||
let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) else { return if mode == StripMode::Extract { 0 } else { keep.len() } };
|
||||
pl.clips.retain(|c| !remove.iter().any(|g| inside(c, g)));
|
||||
let sorted = ordered(remove);
|
||||
pl.clips.retain(|c| holder(remove, sorted, c).is_none());
|
||||
if mode == StripMode::Extract {
|
||||
return gaps.iter().filter(|g| pl.clips.iter().any(|c| inside(c, g))).count();
|
||||
let sorted = ordered(&gaps);
|
||||
let held: std::collections::HashSet<usize> = pl.clips.iter().filter_map(|c| holder(&gaps, sorted, c)).collect();
|
||||
return held.len();
|
||||
}
|
||||
keep.len()
|
||||
}
|
||||
@@ -294,6 +306,57 @@ mod strip_tests {
|
||||
(r, e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutting_at_many_points_in_one_pass_equals_cutting_at_each() {
|
||||
let spans = |e: &Engine, t: TrackId| {
|
||||
let mut v: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
};
|
||||
let points = [30_000, 10, 5_000, 30_000, 120_000, 0, 20_000, 1_000_000];
|
||||
let (mut one, t) = tones(&[0, 20_000, 100_000], 40_000);
|
||||
let (mut each, _) = tones(&[0, 20_000, 100_000], 40_000);
|
||||
crate::edit::separate_at_points(one.session_mut(), t, &points);
|
||||
for p in points {
|
||||
crate::edit::separate_at_if(each.session_mut(), t, p, |_| false);
|
||||
}
|
||||
assert_eq!(spans(&one, t), spans(&each, t));
|
||||
assert!(spans(&one, t).len() > 3);
|
||||
let ids: std::collections::HashSet<_> = one.session().track(t).unwrap().clips().iter().map(|c| c.id).collect();
|
||||
assert_eq!(ids.len(), spans(&one, t).len(), "new pieces have distinct ids");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutting_stretched_audio_at_ascending_points_matches_cutting_at_each() {
|
||||
let pieces = |e: &Engine, t: TrackId| {
|
||||
let mut v: Vec<_> = e
|
||||
.session()
|
||||
.track(t)
|
||||
.unwrap()
|
||||
.clips()
|
||||
.iter()
|
||||
.map(|c| (c.start, c.end(), if let soundcraft_model::ClipContent::Audio { offset, .. } = c.content { offset } else { -1 }))
|
||||
.collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
};
|
||||
let stretched = || {
|
||||
let (mut e, t) = tones(&[0], 40_000);
|
||||
for c in &mut e.session_mut().track_mut(t).unwrap().playlist_mut().unwrap().clips {
|
||||
c.stretch = 2.0;
|
||||
}
|
||||
(e, t)
|
||||
};
|
||||
let points = [1, 2, 3_001, 12_345];
|
||||
let ((mut one, t), (mut each, _)) = (stretched(), stretched());
|
||||
crate::edit::separate_at_points(one.session_mut(), t, &points);
|
||||
for p in points {
|
||||
crate::edit::separate_at_if(each.session_mut(), t, p, |_| false);
|
||||
}
|
||||
assert_eq!(pieces(&one, t), pieces(&each, t));
|
||||
assert_eq!(pieces(&one, t).len(), points.len() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_keeps_the_audio_and_removes_the_silence() {
|
||||
let (r, clips) = strip_mode("strip");
|
||||
|
||||
@@ -579,3 +579,48 @@ fn moving_a_group_left_past_zero_keeps_its_spacing() {
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": -1000}));
|
||||
assert_eq!(clips(&e, t), [(0, 100), (1500, 100)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_and_range_edits_leave_locked_clips_whole() {
|
||||
let edits = [
|
||||
("edit.separate", json!({"at": 24_000})),
|
||||
("edit.separate", json!({"start": 12_000, "end": 36_000})),
|
||||
("edit.separate_on_grid", json!({"start": 12_000, "end": 36_000})),
|
||||
("edit.clear", json!({"start": 12_000, "end": 36_000})),
|
||||
("edit.insert_silence", json!({"start": 24_000, "end": 25_000})),
|
||||
];
|
||||
for lock in [None, Some("clip.edit_lock"), Some("clip.time_lock")] {
|
||||
for (cmd, p) in &edits {
|
||||
let (mut e, t, ids) = long("slip", &[(0, 48_000, 0)]);
|
||||
if let Some(lock) = lock {
|
||||
run(&mut e, lock, json!({"clips": [ids[0].0]}));
|
||||
}
|
||||
let mut p = p.clone();
|
||||
p["tracks"] = json!([t.0]);
|
||||
run(&mut e, cmd, p);
|
||||
if lock.is_some() {
|
||||
assert_eq!(clips(&e, t), vec![(0, 48_000)], "{lock:?} {cmd}");
|
||||
} else {
|
||||
assert_ne!(clips(&e, t), vec![(0, 48_000)], "unlocked control {cmd}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Event time edits move the entire timeline, including clips protected from ordinary edits.
|
||||
#[test]
|
||||
fn event_time_edits_split_and_move_locked_clips() {
|
||||
for lock in ["clip.edit_lock", "clip.time_lock"] {
|
||||
for (command, expected) in [
|
||||
("event.insert_time", vec![(0, 12_000, 0), (36_000, 36_000, 12_000)]),
|
||||
("event.cut_time", vec![(0, 12_000, 0), (12_000, 12_000, 36_000)]),
|
||||
] {
|
||||
let (mut e, t, ids) = long("slip", &[(0, 48_000, 0)]);
|
||||
run(&mut e, lock, json!({"clips": [ids[0].0]}));
|
||||
run(&mut e, command, json!({"start": 12_000, "end": 36_000}));
|
||||
let actual: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect();
|
||||
assert_eq!(actual, expected, "{lock} {command}");
|
||||
assert!(e.session().track(t).unwrap().clips().iter().all(soundcraft_engine::edit::locked));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,3 +18,68 @@ fn trim_to_selection_keeps_clips_sorted() {
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(long, 0, 30), (short, 5, 5)]);
|
||||
}
|
||||
|
||||
fn three_clip_session() -> (Session, soundcraft_model::TrackId, [soundcraft_model::ClipId; 3]) {
|
||||
let mut session = Session::default();
|
||||
let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let ids = [session.new_clip_id(), session.new_clip_id(), session.new_clip_id()];
|
||||
session.track_mut(track).unwrap().playlist_mut().unwrap().clips = vec![
|
||||
Clip::audio(ids[0], "free", SourceId(1), 0, 0, 100),
|
||||
Clip::audio(ids[1], "edit-locked", SourceId(1), 0, 100, 100),
|
||||
Clip::audio(ids[2], "time-locked", SourceId(1), 0, 200, 100),
|
||||
];
|
||||
let pl = session.track_mut(track).unwrap().playlist_mut().unwrap();
|
||||
pl.clips[1].edit_locked = true;
|
||||
pl.clips[2].time_locked = true;
|
||||
(session, track, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_to_selection_leaves_locked_clips_whole() {
|
||||
let (session, track, ids) = three_clip_session();
|
||||
let mut engine = Engine::new(session);
|
||||
let result = engine.execute("edit.trim_to_selection", &json!({"tracks": [track.0], "start": 50, "end": 250})).unwrap();
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(ids[0], 50, 50), (ids[1], 100, 100), (ids[2], 200, 100)]);
|
||||
assert_eq!(result["trimmed"], json!(1));
|
||||
engine.undo();
|
||||
assert_eq!(engine.session().track(track).unwrap().clips()[0].length, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_trim_skips_locked_clips() {
|
||||
let (session, track, ids) = three_clip_session();
|
||||
let mut engine = Engine::new(session);
|
||||
engine.execute("edit.trim_start_to_insertion", &json!({"tracks": [track.0], "at": 150})).unwrap();
|
||||
engine.execute("edit.trim_end_to_insertion", &json!({"tracks": [track.0], "at": 250})).unwrap();
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
assert_eq!(
|
||||
clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(),
|
||||
vec![(ids[0], 0, 100), (ids[1], 100, 100), (ids[2], 200, 100)],
|
||||
"only unlocked clips trim; the edit-locked and time-locked ones keep their extent"
|
||||
);
|
||||
}
|
||||
|
||||
/// A lock on one clip must not suppress eligible unlocked clips on the same track.
|
||||
#[test]
|
||||
fn insertion_trims_still_trim_unlocked_clips_beside_both_lock_kinds() {
|
||||
for (command, expected_start) in [("edit.trim_start_to_insertion", 50), ("edit.trim_end_to_insertion", 0)] {
|
||||
let (mut session, track, ids) = three_clip_session();
|
||||
for clip in &mut session.track_mut(track).unwrap().playlist_mut().unwrap().clips {
|
||||
clip.start = 0;
|
||||
}
|
||||
let before = session.clone();
|
||||
let mut engine = Engine::new(session);
|
||||
let result = engine.execute(command, &json!({"tracks": [track.0], "at": 50})).unwrap();
|
||||
assert_eq!(result["trimmed"], json!(1), "{command}");
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
let free = clips.iter().find(|c| c.id == ids[0]).unwrap();
|
||||
assert_eq!((free.start, free.length), (expected_start, 50), "{command}");
|
||||
for id in &ids[1..] {
|
||||
let locked = clips.iter().find(|c| c.id == *id).unwrap();
|
||||
assert_eq!((locked.start, locked.length), (0, 100), "{command}");
|
||||
}
|
||||
assert!(engine.undo());
|
||||
assert_eq!(engine.session(), &before, "{command} undo");
|
||||
}
|
||||
}
|
||||
|
||||
+136
-21
@@ -1,4 +1,6 @@
|
||||
use super::{AutoParam, Samples, Session, TICKS_PER_QUARTER, Track, Transport, db_to_gain};
|
||||
use soundcraft_dsp::MIN_DB;
|
||||
use soundcraft_model::AutomationLane;
|
||||
|
||||
pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording: bool) -> Transport {
|
||||
let sr = s.sample_rate;
|
||||
@@ -21,15 +23,146 @@ pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording:
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn volume_db_at(t: &Track, pos: Samples) -> f32 {
|
||||
/// One term of a fader's level in dB: a fixed value, or an automation lane (with its default).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum Level<'a> {
|
||||
Fixed(f32),
|
||||
Lane(&'a AutomationLane, f32),
|
||||
}
|
||||
|
||||
impl Level<'_> {
|
||||
fn at(&self, pos: Samples) -> f32 {
|
||||
match self {
|
||||
Level::Fixed(v) => *v,
|
||||
Level::Lane(l, default) => l.value_at(pos, *default),
|
||||
}
|
||||
}
|
||||
|
||||
/// The lane's first breakpoint after `pos`. Up to it, the level is linear in time.
|
||||
fn next_point(&self, pos: Samples) -> Option<Samples> {
|
||||
match self {
|
||||
Level::Fixed(_) => None,
|
||||
Level::Lane(l, _) => l.points.get(l.points.partition_point(|p| p.at <= pos)).map(|p| p.at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A track's volume: its automation lane when the mode reads it, else the fader.
|
||||
pub(crate) fn volume_level(t: &Track) -> Level<'_> {
|
||||
if t.mixer.automation_mode.reads()
|
||||
&& t.mixer.automation_mode != soundcraft_model::AutomationMode::Write
|
||||
&& let Some(l) = t.lane(&AutoParam::Volume)
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
return l.value_at(pos, t.mixer.volume_db);
|
||||
return Level::Lane(l, t.mixer.volume_db);
|
||||
}
|
||||
t.mixer.volume_db
|
||||
Level::Fixed(t.mixer.volume_db)
|
||||
}
|
||||
|
||||
/// Calls `f(i, j, d0, d1)` for each stretch `i..=j` of the block at `pos` over which the sum of
|
||||
/// `levels` is linear in dB, with its values at `i` and `j`. Lanes are linear between breakpoints,
|
||||
/// so a stretch ends at the next breakpoint or at the block's last frame.
|
||||
fn for_each_segment(levels: &[Level<'_>], pos: Samples, frames: usize, mut f: impl FnMut(usize, usize, f32, f32)) {
|
||||
let db = |at: Samples| levels.iter().map(|l| l.at(at)).sum::<f32>();
|
||||
let last = frames.saturating_sub(1);
|
||||
let mut i = 0usize;
|
||||
while i < frames {
|
||||
let at = pos.saturating_add(i as i64);
|
||||
let j = levels
|
||||
.iter()
|
||||
.filter_map(|l| l.next_point(at))
|
||||
.min()
|
||||
.map_or(last, |k| usize::try_from(k.saturating_sub(pos)).map_or(last, |j| j.min(last)));
|
||||
f(i, j, db(at), db(pos.saturating_add(j as i64)));
|
||||
i = j + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Multiplies `x` by a gain that goes from `d0` dB at its first sample to `d1` dB at its last,
|
||||
/// linearly in dB, capped at `max_db` and silent at or below [`MIN_DB`] (or NaN), as `db_to_gain`
|
||||
/// would give it sample by sample. Linear in dB is geometric in gain, so the ramp steps by one ratio
|
||||
/// instead of converting every sample; the ends are converted exactly.
|
||||
pub(crate) fn scale(x: &mut [f32], d0: f32, d1: f32, max_db: f32) {
|
||||
let Some(n) = x.len().checked_sub(1) else { return };
|
||||
// Match db_to_gain's ceiling even when the caller requests no fader cap.
|
||||
let max_db = max_db.min(200.0);
|
||||
if d0.is_nan() || d1.is_nan() {
|
||||
x.fill(0.0);
|
||||
return;
|
||||
}
|
||||
if d0 == d1 || n == 0 {
|
||||
let g = gain(d0, max_db);
|
||||
x.iter_mut().for_each(|v| *v *= g);
|
||||
return;
|
||||
}
|
||||
// Extreme finite trim values can underflow or overflow the geometric recurrence.
|
||||
// Convert those rare ramps directly so an inaudible prefix can recover normally.
|
||||
if !(-1000.0..=1000.0).contains(&d0) || !(-1000.0..=1000.0).contains(&d1) {
|
||||
for (i, v) in x.iter_mut().enumerate() {
|
||||
let t = i as f64 / n as f64;
|
||||
let db = if i == 0 {
|
||||
f64::from(d0)
|
||||
} else if i == n {
|
||||
f64::from(d1)
|
||||
} else {
|
||||
f64::from(d0) * (1.0 - t) + f64::from(d1) * t
|
||||
};
|
||||
*v *= gain(db as f32, max_db);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let step = (f64::from(d1) - f64::from(d0)) / n as f64;
|
||||
let ratio = 10f64.powf(step / 20.0);
|
||||
let (floor, cap) = (f64::from(MIN_DB), f64::from(max_db));
|
||||
let gcap = f64::from(db_to_gain(max_db));
|
||||
let (mut d, mut g) = (f64::from(d0), 10f64.powf(f64::from(d0) / 20.0));
|
||||
if let Some((first, rest)) = x.split_first_mut() {
|
||||
*first *= gain(d0, max_db);
|
||||
if let Some((last, mid)) = rest.split_last_mut() {
|
||||
for v in mid {
|
||||
d += step;
|
||||
g *= ratio;
|
||||
let gd = if d.is_nan() || d <= floor {
|
||||
0.0
|
||||
} else if d >= cap {
|
||||
gcap
|
||||
} else {
|
||||
g
|
||||
};
|
||||
*v *= gd as f32;
|
||||
}
|
||||
*last *= gain(d1, max_db);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The gain of `db`, at most `max_db`.
|
||||
fn gain(db: f32, max_db: f32) -> f32 {
|
||||
if db.is_nan() { 0.0 } else { db_to_gain(db.min(max_db)) }
|
||||
}
|
||||
|
||||
/// Fader gain over the block at `pos`, read at every frame: multiplies `buf`'s channels by the
|
||||
/// gain of the sum of `levels` (dB, at most `max_db`).
|
||||
pub(crate) fn apply_gain(levels: &[Level<'_>], pos: Samples, frames: usize, max_db: f32, buf: &mut [Vec<f32>]) {
|
||||
for_each_segment(levels, pos, frames, |i, j, d0, d1| {
|
||||
for c in buf.iter_mut() {
|
||||
if let Some(x) = c.get_mut(i..=j) {
|
||||
scale(x, d0, d1, max_db);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// [`apply_gain`] from `src` into `dst`.
|
||||
pub(crate) fn copy_with_gain(levels: &[Level<'_>], pos: Samples, frames: usize, max_db: f32, src: &[Vec<f32>], dst: &mut [Vec<f32>]) {
|
||||
for_each_segment(levels, pos, frames, |i, j, d0, d1| {
|
||||
for (d, s) in dst.iter_mut().zip(src) {
|
||||
if let (Some(d), Some(s)) = (d.get_mut(i..=j), s.get(i..=j)) {
|
||||
d.copy_from_slice(s);
|
||||
scale(d, d0, d1, max_db);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 {
|
||||
@@ -69,21 +202,3 @@ pub(crate) fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct GainRamp {
|
||||
g0: f32,
|
||||
g1: f32,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl GainRamp {
|
||||
pub(crate) fn new(db0: f32, db1: f32, n: usize) -> Self {
|
||||
GainRamp { g0: db_to_gain(db0), g1: db_to_gain(db1), n }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn at(&self, i: usize) -> f32 {
|
||||
if self.n == 0 { self.g0 } else { self.g0 + (self.g1 - self.g0) * (i as f32 / self.n as f32) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ impl ClipFx {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the EQ or compressor is on: only they carry parameters that settle (a gain does not).
|
||||
pub(crate) fn has_smoothed_modules(&self) -> bool {
|
||||
self.eq.is_some() || self.comp.is_some()
|
||||
}
|
||||
pub(crate) fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
if let Some(eq) = &mut self.eq {
|
||||
eq.process(io, frames);
|
||||
|
||||
+3
-16
@@ -71,7 +71,7 @@ mod routing;
|
||||
mod strip;
|
||||
mod threads;
|
||||
|
||||
use automation::{GainRamp, pan_at, pan_segment, send_level, transport_at, volume_db_at};
|
||||
use automation::{copy_with_gain, pan_at, pan_segment, send_level, transport_at, volume_level};
|
||||
pub use instances::{INSTRUMENT_SLOT, InstanceSpec, NewInstance, PluginFailures, PreparedInstance, create_instance, instance_specs, is_third_party};
|
||||
pub use meter::StripMeter;
|
||||
use midi::sync_sample;
|
||||
@@ -539,21 +539,8 @@ impl MixEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Match track faders: the next block's first sample must not ramp this one.
|
||||
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
|
||||
let v0 = volume_db_at(t, pos);
|
||||
let v1 = volume_db_at(t, last);
|
||||
let ramp = GainRamp::new(v0, v1, frames.saturating_sub(1));
|
||||
for (ch, src) in strip.buf.iter().enumerate().take(self.main.len()) {
|
||||
if let Some(m) = self.main.get_mut(ch) {
|
||||
for i in 0..frames {
|
||||
let g = ramp.at(i);
|
||||
if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) {
|
||||
*d = x * g;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Read at every sample, like track faders: a breakpoint acts on its own sample.
|
||||
copy_with_gain(&[volume_level(t)], pos, frames, f32::INFINITY, &strip.buf, &mut self.main);
|
||||
let m = self.meters.entry(t.id).or_default();
|
||||
m.measure(&self.main, frames, 0.0);
|
||||
m.plugin_failures = strip.failures;
|
||||
|
||||
@@ -132,33 +132,85 @@ pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec<f32>]) -> Vec<Vec<f32>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Block size for clip effects in [`render_clips`].
|
||||
const CLIP_FX_BLOCK: usize = 4096;
|
||||
/// Most a clip effect keeps rendering past its clip's end in [`render_clips`], in seconds.
|
||||
const MAX_CLIP_FX_TAIL_SECONDS: f64 = 10.0;
|
||||
/// Silent lead-in run before a clip with effects so smoothed parameters (EQ) settle, in seconds
|
||||
/// (the smoothers settle within ~0.3 s).
|
||||
const CLIP_FX_SETTLE_SECONDS: f64 = 1.0;
|
||||
/// Frames an effect advances its smoothed parameters by at a time (the EQ's chunk). Processing starts on a
|
||||
/// multiple of it from the selection's start, as a render of the whole selection would, so the smoothers
|
||||
/// step at the same samples.
|
||||
const CLIP_FX_CHUNK: usize = 32;
|
||||
/// A tail block whose peak is below this (-180 dBFS) ends a clip effect's tail.
|
||||
const CLIP_FX_TAIL_FLOOR: f32 = 1e-9;
|
||||
|
||||
/// Render only one track's clips (no mixer processing) — Consolidate.
|
||||
pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
|
||||
let Some(t) = s.track(track) else { return Vec::new() };
|
||||
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / t.channels().max(1));
|
||||
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
|
||||
let mut scratch = Vec::new();
|
||||
// One reusable block for every clip with effects.
|
||||
let mut block: Vec<Vec<f32>> = Vec::new();
|
||||
let max_tail = s.sample_rate.samples(MAX_CLIP_FX_TAIL_SECONDS).max(0);
|
||||
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
|
||||
match clip_fx_params(s, c.id.0) {
|
||||
None => render_clip(s, c, range.start, len, &mut out, &mut scratch),
|
||||
Some(params) => {
|
||||
let mut tmp = vec![vec![0.0f32; len]; out.len()];
|
||||
render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
|
||||
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, tmp.len());
|
||||
if block.len() != out.len() {
|
||||
block = vec![vec![0.0f32; CLIP_FX_BLOCK]; out.len()];
|
||||
}
|
||||
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, out.len());
|
||||
fx.configure(params);
|
||||
let mut done = 0;
|
||||
while done < len {
|
||||
let n = (len - done).min(4096);
|
||||
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch.get(done..done + n).map(<[f32]>::to_vec).unwrap_or_default()).collect();
|
||||
// The effects start from silence, so nothing comes out before the clip starts:
|
||||
// process from there through the clip and a bounded tail, not the whole selection.
|
||||
let offset = |at: Samples| usize::try_from(at.saturating_sub(range.start).max(0)).unwrap_or(usize::MAX).min(len);
|
||||
let first = offset(c.start);
|
||||
let clip_end = offset(c.end());
|
||||
let tail_end = offset(c.end().saturating_add(max_tail));
|
||||
// Smoothed parameters start at their defaults; settle them on silence, as the
|
||||
// full-span render did, so the clip opens at the configured setting.
|
||||
// (the clip's own samples start inside the first chunk when `first` is not on the grid)
|
||||
let grid = first - first % CLIP_FX_CHUNK;
|
||||
let mut lead = grid.min(usize::try_from(s.sample_rate.samples(CLIP_FX_SETTLE_SECONDS).max(0)).unwrap_or(0));
|
||||
lead -= lead % CLIP_FX_CHUNK;
|
||||
// a gain-only effect has no smoother to settle
|
||||
if !fx.has_smoothed_modules() {
|
||||
lead = 0;
|
||||
}
|
||||
while lead > 0 {
|
||||
let n = lead.min(CLIP_FX_BLOCK);
|
||||
for ch in &mut block {
|
||||
ch.fill(0.0);
|
||||
}
|
||||
fx.process(&mut block, n);
|
||||
lead -= n;
|
||||
}
|
||||
let mut done = grid;
|
||||
while done < len {
|
||||
let n = (len - done).min(CLIP_FX_BLOCK);
|
||||
for ch in &mut block {
|
||||
ch.fill(0.0);
|
||||
}
|
||||
if done < clip_end {
|
||||
render_clip(s, c, range.start.saturating_add(done as i64), n, &mut block, &mut scratch);
|
||||
}
|
||||
fx.process(&mut block, n);
|
||||
let mut peak = 0.0f32;
|
||||
for (o, b) in out.iter_mut().zip(block.iter()) {
|
||||
if let Some(dst) = o.get_mut(done..done + n) {
|
||||
for (x, y) in dst.iter_mut().zip(b.iter()) {
|
||||
*x += *y;
|
||||
peak = peak.max(y.abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
done += n;
|
||||
if done >= clip_end && (done >= tail_end || peak < CLIP_FX_TAIL_FLOOR) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-22
@@ -1,4 +1,4 @@
|
||||
use super::automation::{GainRamp, transport_at, volume_db_at};
|
||||
use super::automation::{Level, apply_gain, transport_at, volume_level};
|
||||
use super::clips::{ClipFx, clip_fx_params, render_clip};
|
||||
use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance_for, is_third_party};
|
||||
use super::midi::{chase_controllers, schedule_notes};
|
||||
@@ -334,7 +334,7 @@ fn render_instrument(s: &Session, t: &Track, strip: &mut Strip, blk: Block, tran
|
||||
fn run_inserts(t: &Track, strip: &mut Strip, blk: Block, transport: &Transport) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
let mut gr = 0.0f32;
|
||||
let mut latency = 0usize;
|
||||
let mut latency = strip.instrument.as_ref().filter(|i| !i.failed).map_or(0, |i| i.plugin.latency());
|
||||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||||
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
|
||||
if !ins.active || ins.bypass || slot.failed {
|
||||
@@ -356,41 +356,28 @@ fn run_inserts(t: &Track, strip: &mut Strip, blk: Block, transport: &Transport)
|
||||
strip.latency = latency.min(MAX_LATENCY);
|
||||
}
|
||||
|
||||
/// Fader gain (volume, trim and VCA automation), ramped across the block.
|
||||
/// Fader gain (volume, trim and VCA automation), read at every sample of the block.
|
||||
fn apply_fader(s: &Session, t: &Track, strip: &mut Strip, blk: Block) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
// Endpoints are the first and last sample of this block. The next block starts on its own
|
||||
// first sample, so a breakpoint there does not ramp the block before it.
|
||||
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
|
||||
let mut v0 = volume_db_at(t, pos);
|
||||
let mut v1 = volume_db_at(t, last);
|
||||
// Trim automation (an offset on top of the volume curve).
|
||||
if let Some(l) = t
|
||||
let trim = t
|
||||
.automation
|
||||
.iter()
|
||||
.find(|l| matches!(&l.param, AutoParam::Plugin { slot: u8::MAX, param } if param == "trim"))
|
||||
.filter(|l| !l.points.is_empty())
|
||||
{
|
||||
v0 += l.value_at(pos, 0.0);
|
||||
v1 += l.value_at(last, 0.0);
|
||||
}
|
||||
.map_or(Level::Fixed(0.0), |l| Level::Lane(l, 0.0));
|
||||
// VCA master: its fader offsets the member's, and its mute mutes the member.
|
||||
let mut vca_level = Level::Fixed(0.0);
|
||||
if let Some(vid) = t.mixer.vca
|
||||
&& let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca)
|
||||
{
|
||||
v0 += volume_db_at(vca, pos);
|
||||
v1 += volume_db_at(vca, last);
|
||||
vca_level = volume_level(vca);
|
||||
if vca.mixer.mute {
|
||||
strip.muted = true;
|
||||
}
|
||||
}
|
||||
let (g0, g1) = if strip.muted { (-144.0, -144.0) } else { (v0.min(12.0), v1.min(12.0)) };
|
||||
let ramp = GainRamp::new(g0, g1, frames.saturating_sub(1));
|
||||
for c in strip.buf.iter_mut() {
|
||||
for (i, x) in c.iter_mut().enumerate().take(frames) {
|
||||
*x *= ramp.at(i);
|
||||
}
|
||||
}
|
||||
let levels = if strip.muted { [Level::Fixed(-144.0), Level::Fixed(0.0), Level::Fixed(0.0)] } else { [volume_level(t), trim, vca_level] };
|
||||
apply_gain(&levels, pos, frames, 12.0, &mut strip.buf);
|
||||
}
|
||||
|
||||
/// Track mute gates the audio and its pre-fader tap without splitting processor blocks.
|
||||
|
||||
+309
-7
@@ -941,6 +941,84 @@ fn a_plugins_bogus_latency_is_capped() {
|
||||
assert_eq!((eng.track_latency, eng.latency), (MAX_LATENCY, 2 * MAX_LATENCY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hosted_instruments_latency_is_compensated() {
|
||||
// #1474: a synth that reports latency plays late. Its delay must count toward the mix
|
||||
// latency and align, the way an insert's does.
|
||||
struct LatentSynth {
|
||||
inner: Box<dyn Plugin>,
|
||||
n: usize,
|
||||
t: usize,
|
||||
pending: Option<usize>,
|
||||
}
|
||||
impl Plugin for LatentSynth {
|
||||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||||
self.inner.info()
|
||||
}
|
||||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||||
self.inner.prepare(sr, mb, ch);
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||||
self.inner.set_param(id, v)
|
||||
}
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.inner.param(id)
|
||||
}
|
||||
fn note_on(&mut self, offset: usize, _: u8, _: u8) {
|
||||
self.pending = Some(self.t + offset + self.n);
|
||||
}
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
for c in io.iter_mut() {
|
||||
for (i, x) in c.iter_mut().take(frames).enumerate() {
|
||||
*x = if Some(self.t + i) == self.pending { 0.25 } else { 0.0 };
|
||||
}
|
||||
}
|
||||
self.t += frames;
|
||||
}
|
||||
fn latency(&self) -> usize {
|
||||
self.n
|
||||
}
|
||||
}
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
let n = 500;
|
||||
let mut s = Session::default();
|
||||
s.edit.delay_compensation = true;
|
||||
let audio = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
let mut click = vec![0.0f32; 48_000];
|
||||
click[24_000] = 0.25;
|
||||
s.pool.insert(SourceId(1474), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![click.clone(), click] })));
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(audio).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "click", SourceId(1474), 0, 0, 48_000));
|
||||
let inst = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
|
||||
let mut seq = Sequence::default();
|
||||
seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 960, length: 240 });
|
||||
let mcid = s.new_clip_id();
|
||||
s.track_mut(inst).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(mcid, "note", 0, 48_000, seq));
|
||||
s.track_mut(inst).unwrap().instrument = Some(Insert::new("clap:test.latent"));
|
||||
let mut eng = MixEngine::new(48_000.0, 1024);
|
||||
eng.set_external_instances(true);
|
||||
eng.adopt(vec![PreparedInstance {
|
||||
track: inst,
|
||||
slot: INSTRUMENT_SLOT,
|
||||
id: "clap:test.latent".into(),
|
||||
ch: 2,
|
||||
plugin: Box::new(LatentSynth { inner: soundcraft_dsp::create("gain").unwrap(), n, t: 0, pending: None }),
|
||||
}]);
|
||||
let mut out = vec![vec![0.0; 1024]; 2];
|
||||
let mut rendered = Vec::new();
|
||||
for b in 0..48 {
|
||||
eng.render(&s, b * 1024, 1024, &mut out);
|
||||
rendered.extend_from_slice(&out[0]);
|
||||
}
|
||||
assert_eq!(eng.latency, n, "the hosted instrument's latency is part of the mix latency");
|
||||
let impulses: Vec<_> = rendered.iter().enumerate().filter(|(_, v)| v.abs() > 1e-6).map(|(i, _)| i).collect();
|
||||
assert_eq!(impulses, vec![24_000 + n], "both the audio click and instrument impulse align");
|
||||
assert!((rendered[24_000 + n] - 0.5).abs() < 1e-6, "the aligned impulses add, neither is missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_monitoring_replaces_clips() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
@@ -1197,14 +1275,238 @@ fn apply_matrix_span_matches_the_plain_per_sample_sum() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A volume lane through `points` (sample, dB).
|
||||
fn volume_lane(points: &[(i64, f32)]) -> soundcraft_model::AutomationLane {
|
||||
let mut l = soundcraft_model::AutomationLane::new(AutoParam::Volume);
|
||||
for &(at, db) in points {
|
||||
l.set_point(at, db);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// Volume automation is read at every sample: a breakpoint acts on its own sample, and between
|
||||
/// breakpoints the gain follows the lane (linear in dB), whatever size the render blocks are. A
|
||||
/// 480-sample crossfade used to be resolved once per block, so it moved by up to a block and left
|
||||
/// a hole or not depending on where the block edge fell.
|
||||
#[test]
|
||||
fn gain_ramp_matches_converting_both_ends_every_sample() {
|
||||
for (db0, db1, n) in [(-6.0f32, 3.0f32, 512usize), (0.0, 0.0, 1), (-144.0, 0.0, 300), (2.5, -60.0, 0), (f32::NAN, -3.0, 64), (-200.0, 12.0, 7)] {
|
||||
let ramp = crate::automation::GainRamp::new(db0, db1, n);
|
||||
let (g0, g1) = (db_to_gain(db0), db_to_gain(db1));
|
||||
for i in 0..=n {
|
||||
let want = if n == 0 { g0 } else { g0 + (g1 - g0) * (i as f32 / n as f32) };
|
||||
assert_eq!(ramp.at(i).to_bits(), want.to_bits(), "{db0} -> {db1} dB, sample {i} of {n}");
|
||||
fn volume_automation_follows_its_breakpoints_in_any_block_size() {
|
||||
let (mut s, a) = session_with_dc(0.5, 8192);
|
||||
let b = add_dc_track(&mut s, 0.5, 8192);
|
||||
// A falls from 0 to -144 dB across 2760..3240 while B rises, and back across 5760..6240.
|
||||
let fall = volume_lane(&[(0, 0.0), (2760, 0.0), (3240, -144.0), (5760, -144.0), (6240, 0.0)]);
|
||||
let rise = volume_lane(&[(0, -144.0), (2760, -144.0), (3240, 0.0), (5760, 0.0), (6240, -144.0)]);
|
||||
// One track's level at 0 dB, through the pan law.
|
||||
let per_track = render_range(&s, Range::new(0, 64), 64)[0][0] / 2.0;
|
||||
s.track_mut(a).unwrap().automation.push(fall.clone());
|
||||
s.track_mut(b).unwrap().automation.push(rise.clone());
|
||||
let want = |i: i64| per_track * (db_to_gain(fall.value_at(i, 0.0)) + db_to_gain(rise.value_at(i, 0.0)));
|
||||
for block in [1024, 512, 333, 64] {
|
||||
let out = render_range(&s, Range::new(0, 8192), block);
|
||||
for (i, &x) in out[0].iter().enumerate() {
|
||||
let w = want(i as i64);
|
||||
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "block {block}, sample {i}: {x} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A master fader's breakpoint acts on its own sample too.
|
||||
#[test]
|
||||
fn master_volume_automation_follows_its_breakpoints_in_any_block_size() {
|
||||
let (mut s, _) = session_with_dc(0.5, 4096);
|
||||
let dry = render_range(&s, Range::new(0, 64), 64)[0][0];
|
||||
let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);
|
||||
let lane = volume_lane(&[(0, 0.0), (1500, 0.0), (1501, -144.0), (2600, -144.0), (2700, -6.0)]);
|
||||
s.track_mut(m).unwrap().automation.push(lane.clone());
|
||||
for block in [1024, 256, 100] {
|
||||
let out = render_range(&s, Range::new(0, 4096), block);
|
||||
for (i, &x) in out[0].iter().enumerate() {
|
||||
let w = dry * db_to_gain(lane.value_at(i as i64, 0.0));
|
||||
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "block {block}, sample {i}: {x} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `scale` gives what `db_to_gain` gives sample by sample, from a ramp's first sample to its last:
|
||||
/// silence at or below -144 dB, the cap, NaN, extreme values and one- or two-sample ramps.
|
||||
#[test]
|
||||
fn stepped_fader_ramps_match_converting_every_sample() {
|
||||
let cases: &[(&str, f32, f32, usize, f32)] = &[
|
||||
("down to silence", 0.0, -144.0, 480, 12.0),
|
||||
("up from silence", -144.0, 0.0, 480, 12.0),
|
||||
("across the cap", 6.0, 18.0, 300, 12.0),
|
||||
("below the floor and back", -200.0, -20.0, 512, 12.0),
|
||||
("flat", -6.0, -6.0, 1024, 12.0),
|
||||
("one sample", -6.0, 3.0, 1, 12.0),
|
||||
("two samples", -6.0, 3.0, 2, 12.0),
|
||||
("empty", 0.0, -6.0, 0, 12.0),
|
||||
("NaN start", f32::NAN, -3.0, 64, 12.0),
|
||||
("long ramp in one block", 0.0, -60.0, 16_384, 12.0),
|
||||
("recover from deep silence", -10_000.0, 0.0, 1024, 12.0),
|
||||
("descend from extreme trim", 10_000.0, -100.0, 1024, 12.0),
|
||||
("no cap", -12.0, 6.0, 1000, f32::INFINITY),
|
||||
("gain helper ceiling", 300.0, 0.0, 1024, f32::INFINITY),
|
||||
("extreme exact endpoint", -1e20, -20.0, 64, 12.0),
|
||||
("negative infinite start", f32::NEG_INFINITY, -20.0, 64, 12.0),
|
||||
("positive infinite end", -20.0, f32::INFINITY, 64, 12.0),
|
||||
("opposite infinities", f32::NEG_INFINITY, f32::INFINITY, 64, 12.0),
|
||||
];
|
||||
for &(name, d0, d1, n, max_db) in cases {
|
||||
let mut x = vec![1.0f32; n];
|
||||
crate::automation::scale(&mut x, d0, d1, max_db);
|
||||
for (k, &got) in x.iter().enumerate() {
|
||||
let at = if k == 0 {
|
||||
d0
|
||||
} else if k + 1 == n {
|
||||
d1
|
||||
} else {
|
||||
let t = k as f64 / (n - 1) as f64;
|
||||
(f64::from(d0) * (1.0 - t) + f64::from(d1) * t) as f32
|
||||
};
|
||||
let want = if d0.is_nan() || d1.is_nan() || at.is_nan() { 0.0 } else { db_to_gain(at.min(max_db)) };
|
||||
assert!((got - want).abs() <= 1e-5 * want.max(1e-6) || (got == 0.0 && want == 0.0), "{name}, sample {k} of {n}: {got} vs {want}");
|
||||
}
|
||||
if d0.is_nan() || d1.is_nan() {
|
||||
assert!(x.iter().all(|&g| g == 0.0), "{name}: a NaN end is silence");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(x.first().copied().unwrap_or(0.0), if n == 0 { 0.0 } else { db_to_gain(d0.min(max_db)) }, "{name}: first sample exact");
|
||||
if n > 1 {
|
||||
assert_eq!(x[n - 1], db_to_gain(d1.min(max_db)), "{name}: last sample exact");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume, trim and VCA lanes with breakpoints at different samples add up at every sample, a
|
||||
/// muted track stays silent, and a render that starts inside a ramp or has breakpoints on block
|
||||
/// edges reads the same curve.
|
||||
#[test]
|
||||
fn summed_fader_lanes_are_read_at_every_sample() {
|
||||
let (mut s, a) = session_with_dc(0.5, 12_288);
|
||||
let muted = add_dc_track(&mut s, 0.5, 12_288);
|
||||
let vca = s.add_track(TrackKind::Vca, ChannelFormat::Mono, None);
|
||||
let one_track = render_range(&s, Range::new(0, 64), 64)[0][0] / 2.0;
|
||||
s.track_mut(muted).unwrap().mixer.mute = true;
|
||||
let vol = volume_lane(&[(0, 0.0), (1024, 0.0), (2048, -30.0), (4096, -6.0)]);
|
||||
let mut trim = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: u8::MAX, param: "trim".into() });
|
||||
for (at, db) in [(0, 0.0), (1500, 0.0), (3000, 6.0)] {
|
||||
trim.set_point(at, db);
|
||||
}
|
||||
let vca_lane = volume_lane(&[(0, 0.0), (5120, 0.0), (7000, -12.0)]);
|
||||
let tr = s.track_mut(a).unwrap();
|
||||
tr.automation.push(vol.clone());
|
||||
tr.automation.push(trim.clone());
|
||||
tr.mixer.vca = Some(vca.0);
|
||||
s.track_mut(vca).unwrap().automation.push(vca_lane.clone());
|
||||
// dB at `i`, from the breakpoints themselves.
|
||||
let lin = |pts: &[(i64, f32)], i: i64| match pts.iter().position(|p| p.0 > i) {
|
||||
None => pts[pts.len() - 1].1,
|
||||
Some(0) => pts[0].1,
|
||||
Some(k) => pts[k - 1].1 + (pts[k].1 - pts[k - 1].1) * ((i - pts[k - 1].0) as f32 / (pts[k].0 - pts[k - 1].0) as f32),
|
||||
};
|
||||
let want = |i: i64| {
|
||||
let db = lin(&[(0, 0.0), (1024, 0.0), (2048, -30.0), (4096, -6.0)], i)
|
||||
+ lin(&[(0, 0.0), (1500, 0.0), (3000, 6.0)], i)
|
||||
+ lin(&[(0, 0.0), (5120, 0.0), (7000, -12.0)], i);
|
||||
one_track * db_to_gain(db.min(12.0))
|
||||
};
|
||||
for (start, block) in [(0i64, 1024), (0, 512), (1700, 333), (1024, 1024), (2047, 64)] {
|
||||
let out = render_range(&s, Range::new(start, 12_288), block);
|
||||
for (k, &x) in out[0].iter().enumerate() {
|
||||
let i = start + k as i64;
|
||||
let w = want(i);
|
||||
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "start {start}, block {block}, sample {i}: {x} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Consolidate's clip-effect pass, processing every clip's effects across the whole selection
|
||||
/// (what `render_clips` did before it limited them to the clip and its tail).
|
||||
fn render_clips_full_span(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
|
||||
let t = s.track(track).unwrap();
|
||||
let len = range.len() as usize;
|
||||
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
|
||||
let mut scratch = Vec::new();
|
||||
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
|
||||
let params = crate::clips::clip_fx_params(s, c.id.0).unwrap();
|
||||
let mut tmp = vec![vec![0.0f32; len]; out.len()];
|
||||
crate::clips::render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
|
||||
let mut fx = crate::clips::ClipFx::new(48_000.0, tmp.len());
|
||||
fx.configure(params);
|
||||
for done in (0..len).step_by(4096) {
|
||||
let n = (len - done).min(4096);
|
||||
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch[done..done + n].to_vec()).collect();
|
||||
fx.process(&mut block, n);
|
||||
for (o, b) in out.iter_mut().zip(&block) {
|
||||
o[done..done + n].iter_mut().zip(b).for_each(|(x, y)| *x += y);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidate_clip_effects_cover_the_clip_and_its_tail() {
|
||||
let fx_sets: [&[(&str, f64)]; 3] =
|
||||
[&[("comp.lookahead", 5.0), ("comp.threshold", -20.0), ("gain", -6.0)], &[("eq.hpf_on", 1.0), ("gain", 3.0)], &[("eq.low_shelf_gain", 12.0)]];
|
||||
for (k, params) in fx_sets.iter().enumerate() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })));
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(900), 0, 20_000, 4800));
|
||||
for (param, value) in params.iter() {
|
||||
s.edit.values.insert(format!("clip_fx.{}.{param}", cid.0), *value);
|
||||
}
|
||||
let range = Range::new(5_000, 100_000);
|
||||
let got = render_clips(&s, t, range);
|
||||
let want = render_clips_full_span(&s, t, range);
|
||||
assert_eq!(got.len(), want.len());
|
||||
assert!(want[0].iter().any(|x| x.abs() > 0.05), "the fixture should sound");
|
||||
if k == 0 {
|
||||
// The compressor's lookahead output after the clip's end is kept.
|
||||
assert!(got[0][20_000 + 4800 - 5_000 + 10].abs() > 1e-3);
|
||||
}
|
||||
for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() {
|
||||
assert!((a - b).abs() < 1e-5, "set {k}, sample {i}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip that starts a few frames into the selection, while the EQ's smoothers are still settling: the
|
||||
/// effect's 32-frame chunks must line up with the full-span render's.
|
||||
#[test]
|
||||
fn consolidate_clip_effects_keep_the_reference_chunk_grid() {
|
||||
for offset in [1i64, 31, 32, 33, 1000] {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })));
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(900), 0, offset, 4800));
|
||||
s.edit.values.insert(format!("clip_fx.{}.eq.low_shelf_gain", cid.0), 12.0);
|
||||
let range = Range::new(0, 20_000);
|
||||
let got = render_clips(&s, t, range);
|
||||
let want = render_clips_full_span(&s, t, range);
|
||||
for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() {
|
||||
assert!((a - b).abs() < 1e-5, "clip at {offset}, sample {i}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidate_many_tiny_effect_clips_stays_fast() {
|
||||
// Thousands of one-sample clips with effects across a long selection: each one is processed
|
||||
// over its own span, not the whole selection.
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 1]] })));
|
||||
for i in 0..2000i64 {
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "x", SourceId(900), 0, i * 1000, 1));
|
||||
s.edit.values.insert(format!("clip_fx.{}.gain", cid.0), -6.0);
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
let out = render_clips(&s, t, Range::new(0, 4_000_000));
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
assert!((out[0][1000] - 0.5 * db_to_gain(-6.0)).abs() < 1e-6);
|
||||
assert_eq!(out[0][1001], 0.0);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,10 @@ impl AutomationLane {
|
||||
// Widen so far-apart breakpoints cannot overflow i64.
|
||||
let span = i128::from(b.at) - i128::from(a.at);
|
||||
let off = i128::from(at) - i128::from(a.at);
|
||||
a.value + (b.value - a.value) * (off as f64 / span as f64) as f32
|
||||
// Interpolate in f64: the f32 difference overflows for
|
||||
// opposite-sign values near f32::MAX.
|
||||
let (av, bv) = (f64::from(a.value), f64::from(b.value));
|
||||
(av + (bv - av) * (off as f64 / span as f64)) as f32
|
||||
}
|
||||
(Some(a), _) => a.value,
|
||||
_ => default,
|
||||
@@ -281,6 +284,17 @@ mod tests {
|
||||
assert!(w.is_finite() && (-10.0..=0.0).contains(&w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_at_opposite_sign_extremes_stays_finite() {
|
||||
let mut l = AutomationLane::new(AutoParam::Plugin { slot: 0, param: "Cutoff".into() });
|
||||
l.set_point(0, -3.0e38);
|
||||
l.set_point(1000, 3.0e38);
|
||||
let v = l.value_at(500, 0.0);
|
||||
assert!(v.is_finite() && v.abs() < 1.0e32, "mid value {v}");
|
||||
let w = l.value_at(1, 0.0);
|
||||
assert!(w.is_finite() && w < -2.9e38, "early value {w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_label_max_does_not_overflow() {
|
||||
assert_eq!(AutoParam::Pan(255).label(), "pan 256");
|
||||
|
||||
@@ -46,6 +46,7 @@ impl SoundApp {
|
||||
services,
|
||||
native_menu_bar: false,
|
||||
autosave_dir: None,
|
||||
fallback_take_dir: None,
|
||||
autosaver: None,
|
||||
autosaved_rev: 0,
|
||||
leftovers: Vec::new(),
|
||||
|
||||
@@ -155,15 +155,23 @@ impl SoundApp {
|
||||
/// autosave folder. Returns the takes it records.
|
||||
fn arm_recorder(&mut self) -> Result<Vec<soundcraft_engine::io::Take>, String> {
|
||||
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
|
||||
let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name));
|
||||
// Without a settings folder the takes go under the temporary folder, in a folder of this
|
||||
// run's own that nobody else could have made first.
|
||||
let unsaved_dir = match &self.autosave_dir {
|
||||
Some(d) => d.join(name),
|
||||
// A saved session records beside its file: `plan_takes` never uses this folder.
|
||||
None if self.engine.path.is_some() => std::path::PathBuf::new(),
|
||||
None => self.fallback_take_dir()?,
|
||||
};
|
||||
let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) };
|
||||
let plan = soundcraft_engine::io::plan_takes(&self.engine, &unsaved_dir, r.channels);
|
||||
if plan.is_empty() {
|
||||
return Err("no audio track is record-enabled".into());
|
||||
}
|
||||
// The takes are unsaved work: the folder they go into under the autosave root is
|
||||
// owner-only, like the autosaves, before the recorder makes anything in it.
|
||||
if self.engine.path.is_none() && self.autosave_dir.is_some() {
|
||||
// The takes are unsaved work: on Unix the folder they go into is owner-only, like the
|
||||
// autosaves, before the recorder makes anything in it (other systems get the folder's
|
||||
// inherited access: no ACLs are set, so the temporary folder's own per-user access applies).
|
||||
if self.engine.path.is_none() {
|
||||
for dir in plan.iter().filter_map(|(_, _, path)| path.parent()) {
|
||||
soundcraft_engine::autosave::create_folder(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
@@ -176,6 +184,19 @@ impl SoundApp {
|
||||
Ok(takes)
|
||||
}
|
||||
|
||||
/// The folder for the takes of a session never saved when there is no autosave folder, made on
|
||||
/// first use: exclusively and under a name nobody can guess, so another account cannot have
|
||||
/// made it first, with open permissions or as a link.
|
||||
fn fallback_take_dir(&mut self) -> Result<std::path::PathBuf, String> {
|
||||
if let Some(dir) = &self.fallback_take_dir {
|
||||
return Ok(dir.clone());
|
||||
}
|
||||
let dir = soundcraft_engine::autosave::create_unique_folder(&std::env::temp_dir(), "SoundCraft")
|
||||
.map_err(|e| format!("temporary take folder: {e}"))?;
|
||||
self.fallback_take_dir = Some(dir.clone());
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn finish_recording(&mut self) {
|
||||
self.engine.transport.recording = false;
|
||||
if let Some(p) = &self.player {
|
||||
@@ -453,3 +474,18 @@ impl SoundApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_fallback_take_folder_is_made_once_under_the_temporary_folder() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default());
|
||||
let a = app.fallback_take_dir().unwrap();
|
||||
assert_eq!(a.parent(), Some(std::env::temp_dir().as_path()));
|
||||
assert!(a.is_dir());
|
||||
assert_eq!(app.fallback_take_dir().unwrap(), a, "the same folder for the rest of the run");
|
||||
let _ = std::fs::remove_dir_all(&a);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +291,8 @@ pub struct SoundApp {
|
||||
pub native_menu_bar: bool,
|
||||
/// Folder for crash-recovery autosaves (native apps set it).
|
||||
pub autosave_dir: Option<std::path::PathBuf>,
|
||||
/// Where the takes of a session never saved go without an autosave folder: made once per run, under the temporary folder.
|
||||
fallback_take_dir: Option<std::path::PathBuf>,
|
||||
/// Writes the open session's autosaves in the background, once there is something to write.
|
||||
autosaver: Option<soundcraft_engine::autosave::Autosaver>,
|
||||
/// The document revision last handed to the autosaver (0 once that autosave is stale).
|
||||
|
||||
+36
-26
@@ -298,32 +298,34 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
|
||||
let extra = ui_aliases();
|
||||
for root in tree() {
|
||||
ui.menu_button(&root.label, |ui| {
|
||||
ui.add_enabled_ui(menu_entries_enabled, |ui| {
|
||||
for c in &root.children {
|
||||
menu_node(app, ui, c, &extra);
|
||||
if c.path == "File > Open" {
|
||||
crate::recent::menu(app, ui);
|
||||
scrolling(ui, |ui| {
|
||||
ui.add_enabled_ui(menu_entries_enabled, |ui| {
|
||||
for c in &root.children {
|
||||
menu_node(app, ui, c, &extra);
|
||||
if c.path == "File > Open" {
|
||||
crate::recent::menu(app, ui);
|
||||
}
|
||||
if c.path == "Track > New..." {
|
||||
instrument_menu(app, ui);
|
||||
}
|
||||
}
|
||||
if c.path == "Track > New..." {
|
||||
instrument_menu(app, ui);
|
||||
if root.path == "AudioSuite" {
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
|
||||
}
|
||||
}
|
||||
if root.path == "AudioSuite" {
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
|
||||
}
|
||||
if root.path == "Window" {
|
||||
ui.separator();
|
||||
let mut separate = app.ui.mix_separate;
|
||||
if ui
|
||||
.checkbox(&mut separate, "Mix in Separate Window")
|
||||
.on_hover_text("Show Edit and Mix at once, e.g. one per monitor")
|
||||
.changed()
|
||||
{
|
||||
let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));
|
||||
ui.close();
|
||||
if root.path == "Window" {
|
||||
ui.separator();
|
||||
let mut separate = app.ui.mix_separate;
|
||||
if ui
|
||||
.checkbox(&mut separate, "Mix in Separate Window")
|
||||
.on_hover_text("Show Edit and Mix at once, e.g. one per monitor")
|
||||
.changed()
|
||||
{
|
||||
let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -341,6 +343,12 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
|
||||
);
|
||||
}
|
||||
|
||||
/// A menu taller than the window scrolls instead of running off its bottom edge (#1482).
|
||||
fn scrolling<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let max_h = (ui.ctx().content_rect().height() - 40.0).max(120.0);
|
||||
egui::ScrollArea::vertical().max_height(max_h).show(ui, add).inner
|
||||
}
|
||||
|
||||
/// Track › Instrument: the instrument picker for the selected instrument track (#374).
|
||||
fn instrument_menu(app: &mut SoundApp, ui: &mut egui::Ui) {
|
||||
match instrument_track(app.engine.session()) {
|
||||
@@ -361,9 +369,11 @@ fn instrument_track(s: &soundcraft_model::Session) -> Option<soundcraft_model::T
|
||||
fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str, &str)]) {
|
||||
if !n.children.is_empty() {
|
||||
ui.menu_button(&n.label, |ui| {
|
||||
for c in &n.children {
|
||||
menu_node(app, ui, c, extra);
|
||||
}
|
||||
scrolling(ui, |ui| {
|
||||
for c in &n.children {
|
||||
menu_node(app, ui, c, extra);
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,34 +25,45 @@ pub(crate) fn route_popup(app: &mut SoundApp, resp: &egui::Response, id: TrackId
|
||||
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, input));
|
||||
}
|
||||
|
||||
/// Input or output menu: no route and buses on both, plus the main output (outputs) or inputs 1-8 (inputs).
|
||||
/// Input or output menu: no route and buses on both, plus the main output (outputs) or inputs 1-8
|
||||
/// (inputs). Its search field takes typing at once and finds any of them, not just the buses.
|
||||
fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
|
||||
let key = if input { "input" } else { "output" };
|
||||
let cmd = if input { "track.input" } else { "track.output" };
|
||||
if ui.button(if input { "no input" } else { "no output" }).clicked() {
|
||||
let _ = app.run(cmd, json!({"track": id.0, key: "none"}));
|
||||
ui.close();
|
||||
}
|
||||
let hint = if input { "Search inputs…" } else { "Search outputs…" };
|
||||
let search = MenuSearch::show(ui, egui::Id::unique(("route-search", id.0, input)), hint);
|
||||
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
|
||||
let main_name = app.engine.session().outputs.first().map_or_else(|| "Out 1-2".to_string(), |o| o.name.clone());
|
||||
if !input && ui.button(main_name).clicked() {
|
||||
let _ = app.run(cmd, json!({"track": id.0, key: "main"}));
|
||||
ui.close();
|
||||
}
|
||||
// (label, the route the command is given)
|
||||
let mut fixed = vec![(if input { "no input" } else { "no output" }.to_string(), "none".to_string())];
|
||||
if input {
|
||||
for i in 1..=8 {
|
||||
if ui.button(format!("In {i}")).clicked() {
|
||||
let _ = app.run(cmd, json!({"track": id.0, key: format!("In {i}")}));
|
||||
ui.close();
|
||||
}
|
||||
fixed.extend((1..=8).map(|i| (format!("In {i}"), format!("In {i}"))));
|
||||
} else {
|
||||
fixed.push((main_name, "main".to_string()));
|
||||
}
|
||||
if search.typing() {
|
||||
let visible: Vec<(&String, &String)> =
|
||||
fixed.iter().map(|(l, v)| (l, v)).chain(busses.iter().map(|b| (b, b))).filter(|(l, _)| search.matches(l)).collect();
|
||||
let picked = search.rows(ui, visible.len(), 420.0, |ui, i, highlighted| visible.get(i).is_some_and(|(l, _)| menu_row(ui, highlighted, l)));
|
||||
if let Some((_, value)) = picked.and_then(|i| visible.get(i)) {
|
||||
let _ = app.run(cmd, json!({"track": id.0, key: value}));
|
||||
ui.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (label, value) in &fixed {
|
||||
if ui.button(label).clicked() {
|
||||
let _ = app.run(cmd, json!({"track": id.0, key: value}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
|
||||
ui.strong("Bus");
|
||||
let search = MenuSearch::show(ui, egui::Id::unique(("route-bus-search", id.0, input)), "Search buses…");
|
||||
let visible: Vec<&String> = busses.iter().filter(|b| search.matches(b)).collect();
|
||||
if let Some(i) = search.rows(ui, visible.len(), 420.0, |ui, i, highlighted| menu_row(ui, highlighted, visible[i])) {
|
||||
let _ = app.run(cmd, json!({"track": id.0, key: visible[i]}));
|
||||
if let Some(bus) = search
|
||||
.rows(ui, busses.len(), 420.0, |ui, i, highlighted| busses.get(i).is_some_and(|b| menu_row(ui, highlighted, b)))
|
||||
.and_then(|i| busses.get(i))
|
||||
{
|
||||
let _ = app.run(cmd, json!({"track": id.0, key: bus}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("new bus...").clicked() {
|
||||
|
||||
@@ -265,20 +265,42 @@ fn draw_roll_background(painter: &egui::Painter, g: &Roll<'_>) {
|
||||
}
|
||||
}
|
||||
// Grid.
|
||||
let step = grid_stride(grid_ticks, px_per_tick);
|
||||
let mut tick = 0;
|
||||
while tick <= len_ticks {
|
||||
let bar = tick % (TICKS_PER_QUARTER * 4) == 0;
|
||||
let x = g.x_of(tick);
|
||||
if grid_ticks as f32 * px_per_tick > 4.0 || bar {
|
||||
painter.line_segment(
|
||||
[pos2(x, roll.min.y), pos2(x, roll.max.y)],
|
||||
Stroke::new(1.0, if bar { Color32::from_rgb(80, 80, 86) } else { Color32::from_rgb(50, 50, 54) }),
|
||||
);
|
||||
}
|
||||
tick += grid_ticks;
|
||||
painter.line_segment(
|
||||
[pos2(x, roll.min.y), pos2(x, roll.max.y)],
|
||||
Stroke::new(1.0, if bar { Color32::from_rgb(80, 80, 86) } else { Color32::from_rgb(50, 50, 54) }),
|
||||
);
|
||||
tick = tick.saturating_add(step);
|
||||
}
|
||||
}
|
||||
|
||||
/// Closest lines the grid draws, in points.
|
||||
const MIN_GRID_PX: f32 = 4.0;
|
||||
|
||||
/// Ticks between grid lines: the grid when its lines are more than [`MIN_GRID_PX`] apart, else
|
||||
/// as few common multiples of the grid and a bar as keep that spacing (so every line is a snap
|
||||
/// position and falls on a bar). The lines drawn are then bounded by the roll's width, however long
|
||||
/// the clip, instead of by its length.
|
||||
fn grid_stride(grid_ticks: i64, px_per_tick: f32) -> i64 {
|
||||
let bar_ticks = TICKS_PER_QUARTER * 4;
|
||||
if grid_ticks as f32 * px_per_tick > MIN_GRID_PX {
|
||||
return grid_ticks.max(1);
|
||||
}
|
||||
let grid = grid_ticks.max(1);
|
||||
let (mut a, mut b) = (grid, bar_ticks);
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
let common = (grid / a).saturating_mul(bar_ticks);
|
||||
// `as` saturates a huge or NaN quotient; a zero `px_per_tick` gives one common multiple.
|
||||
let n = (MIN_GRID_PX / (common as f32 * px_per_tick)).ceil().max(1.0) as i64;
|
||||
common.saturating_mul(n)
|
||||
}
|
||||
|
||||
/// The notes, with a drag in progress applied.
|
||||
fn draw_notes(app: &SoundApp, painter: &egui::Painter, g: &Roll<'_>, base: Color32) {
|
||||
let Roll { sequence, grid_ticks, .. } = *g;
|
||||
@@ -478,6 +500,24 @@ fn velocity_lane(app: &mut SoundApp, ui: &mut Ui, g: &Roll<'_>, cid: ClipId, bas
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grid_lines_are_bounded_by_the_roll_width_not_the_clip_length() {
|
||||
let sixteenth = TICKS_PER_QUARTER / 4;
|
||||
// A 1000 pt roll showing a 4-bar clip: every sixteenth is 16 pt apart and drawn.
|
||||
assert_eq!(grid_stride(sixteenth, 1000.0 / (16.0 * TICKS_PER_QUARTER as f32)), sixteenth);
|
||||
// The same roll over a clip a billion ticks long: at most a line every 4 pt.
|
||||
let (len, px_per_tick) = (1_000_000_000i64, 1000.0 / 1_000_000_000.0f32);
|
||||
let step = grid_stride(sixteenth, px_per_tick);
|
||||
assert_eq!(step % (TICKS_PER_QUARTER * 4), 0, "lines fall on bars");
|
||||
assert!((len / step) <= 251, "{} lines", len / step);
|
||||
// a dotted quarter grid (1440 ticks): every line is also a grid position
|
||||
let dotted = TICKS_PER_QUARTER * 3 / 2;
|
||||
let step = grid_stride(dotted, px_per_tick);
|
||||
assert_eq!((step % dotted, step % (TICKS_PER_QUARTER * 4)), (0, 0), "lines are snap positions on bars: {step}");
|
||||
assert!((len / step) <= 251, "{} lines", len / step);
|
||||
assert!(grid_stride(0, 0.0) >= TICKS_PER_QUARTER * 4, "a zero grid or width still advances");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_clip_drops_selection_and_gestures() {
|
||||
let mut st = MidiEditorState { selected: vec![0, 2], clip: Some(ClipId(1)), band: Some(pos2(1.0, 2.0)), ..Default::default() };
|
||||
|
||||
@@ -115,6 +115,22 @@ fn first_movie_aspect(s: &soundcraft_model::Session) -> Option<f32> {
|
||||
s.videos.first().map(|v| v.aspect() as f32)
|
||||
}
|
||||
|
||||
/// Most thumbnails one clip draws in the Video Universe strip: 8 pt each, so more than the
|
||||
/// widest strip holds.
|
||||
const MAX_UNIVERSE_THUMBS: usize = 1 << 12;
|
||||
|
||||
/// Left edges of the thumbnails of a clip starting at `clip_x` (each `tw` wide) that lie in
|
||||
/// `shown_min..shown_max`. The index and position are worked out in `f64`: an `f32` this far from
|
||||
/// the origin (a clip starting a billion points left) cannot step by a thumbnail's width.
|
||||
fn thumb_xs(clip_x: f32, shown_min: f32, shown_max: f32, tw: f32) -> impl Iterator<Item = f32> {
|
||||
let (origin, tw) = (f64::from(clip_x), f64::from(tw));
|
||||
// Nothing of the clip is in view when the shown span is empty (or inverted).
|
||||
let empty = shown_max <= shown_min;
|
||||
let first = ((f64::from(shown_min) - origin) / tw).floor().max(0.0);
|
||||
let count = if empty { 0 } else { ((f64::from(shown_max) - origin) / tw - first).ceil().max(0.0) as usize };
|
||||
(0..count.min(MAX_UNIVERSE_THUMBS)).map(move |i| (origin + (first + i as f64) * tw) as f32)
|
||||
}
|
||||
|
||||
fn universe(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
app.video.poll(ctx);
|
||||
let s = app.engine.session_arc();
|
||||
@@ -145,8 +161,10 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
}
|
||||
let tw = (cr.height() * v.aspect() as f32).max(8.0);
|
||||
let hpx = ((cr.height() * ppp / 16.0).ceil() * 16.0).clamp(16.0, 256.0) as u32;
|
||||
let mut x = cr.min.x;
|
||||
while x < cr.max.x {
|
||||
// Only the thumbnails inside the strip, counted rather than stepped: a clip starting
|
||||
// far left of it has an edge so large that adding a thumbnail's width wouldn't move it.
|
||||
let shown = cr.intersect(r);
|
||||
for x in thumb_xs(cr.min.x, shown.min.x, shown.max.x, tw) {
|
||||
let secs = movie_secs(&s, c.start, offset, strip_position(x, r.min.x, r.width(), end, c.start)).max(0.0);
|
||||
if let Some(tex) = app.video.thumb(ctx, key, &v.path, secs, hpx) {
|
||||
let tr = Rect::from_min_size(pos2(x, cr.min.y), vec2(tw, cr.height()));
|
||||
@@ -154,7 +172,6 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((vis.width() / tw).clamp(0.0, 1.0), 1.0));
|
||||
painter.image(tex.id(), vis, uv, Color32::WHITE);
|
||||
}
|
||||
x += tw;
|
||||
}
|
||||
}
|
||||
// The Edit window's visible range and the playhead.
|
||||
@@ -182,7 +199,21 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::strip_position;
|
||||
use super::{strip_position, thumb_xs};
|
||||
|
||||
#[test]
|
||||
fn thumbnails_of_a_clip_far_left_of_the_strip_are_evenly_spaced() {
|
||||
// 2^30 points left of the strip with 8 pt thumbnails: f32 steps of `first + i` here are 16 apart
|
||||
let xs: Vec<f32> = thumb_xs(-1_073_741_824.0, 0.0, 80.0, 8.0).collect();
|
||||
assert_eq!(xs.len(), 10);
|
||||
assert!(xs.windows(2).all(|w| w[1] - w[0] == 8.0), "{xs:?}");
|
||||
assert_eq!(xs[0], 0.0);
|
||||
// a clip inside the strip starts at its own edge
|
||||
assert_eq!(thumb_xs(10.0, 10.0, 34.0, 8.0).collect::<Vec<_>>(), vec![10.0, 18.0, 26.0]);
|
||||
// a clip ending left of the strip (the shown span is empty or inverted) draws none
|
||||
assert_eq!(thumb_xs(-10.0, 0.0, -1.0, 8.0).count(), 0);
|
||||
assert_eq!(thumb_xs(-10.0, 0.0, 0.0, 8.0).count(), 0);
|
||||
}
|
||||
|
||||
// A 1024 px strip over 1_024_000 samples: 1000 samples per point, exact in f32.
|
||||
const LEFT: f32 = 100.0;
|
||||
|
||||
@@ -90,6 +90,13 @@ impl Worker {
|
||||
}
|
||||
|
||||
fn open_movie(path: &str) -> Result<Movie, String> {
|
||||
// The last check before the file system: the path came from a session file, so the read
|
||||
// roots apply, and only a regular file is opened (a pipe would hold the video worker
|
||||
// forever).
|
||||
soundcraft_engine::file_access::check_read(path)?;
|
||||
if !std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.is_file() {
|
||||
return Err(format!("{path}: not a regular file"));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
@@ -491,3 +498,24 @@ pub fn draw_lane(app: &mut SoundApp, painter: &Painter, s: &Session, track: &Tra
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::open_movie;
|
||||
|
||||
#[test]
|
||||
fn only_a_regular_movie_file_is_opened() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-open-movie-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (folder, missing, junk, movie) = (dir.to_string_lossy().to_string(), dir.join("none.mp4"), dir.join("junk.mp4"), dir.join("a.mp4"));
|
||||
assert!(open_movie(&folder).err().unwrap_or_default().contains("not a regular file"));
|
||||
assert!(open_movie(&missing.to_string_lossy()).is_err());
|
||||
std::fs::write(&junk, b"not a movie").unwrap();
|
||||
let err = open_movie(&junk.to_string_lossy()).err().unwrap_or_default();
|
||||
assert!(!err.is_empty() && !err.contains("not a regular file"), "{err}");
|
||||
std::fs::write(&movie, soundcraft_video::synth::h264_mp4(2, 2).unwrap()).unwrap();
|
||||
assert!(open_movie(&movie.to_string_lossy()).is_ok());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,19 +151,19 @@ fn the_views_menu_stays_open_until_a_click_elsewhere() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_io_columns_route_menus_stay_open_while_searching_buses() {
|
||||
fn the_io_columns_route_menus_stay_open_while_searching() {
|
||||
let (mut h, _, row) = harness("Kick", "medium", "view.edit_io");
|
||||
click(&mut h, pos2(row[0] + HEADER_W + 40.0, row[1] + 29.5));
|
||||
let bus = h.get_by_label("Bus").rect();
|
||||
let first = h.get_by_label("no output").rect();
|
||||
let search = h
|
||||
.query_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.map(|node| node.rect())
|
||||
.filter(|r| r.min.y >= bus.max.y)
|
||||
.min_by(|a, b| a.min.y.total_cmp(&b.min.y))
|
||||
.filter(|r| r.max.y <= first.min.y && r.x_range().contains(first.center().x))
|
||||
.max_by(|a, b| a.min.y.total_cmp(&b.min.y))
|
||||
.unwrap()
|
||||
.center();
|
||||
click(&mut h, search);
|
||||
assert!(h.query_by_label("no output").is_some(), "a click in the bus search closed the Output menu");
|
||||
assert!(h.query_by_label("no output").is_some(), "a click in the menu's search closed the Output menu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! A main menu taller than the window scrolls, so its last items can be reached (#1482).
|
||||
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, PointerButton, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, menus};
|
||||
|
||||
const SIZE: egui::Vec2 = vec2(1200.0, 420.0);
|
||||
|
||||
fn step(h: &mut Harness<'_, SoundApp>) {
|
||||
let mut raw = std::mem::take(h.input_mut());
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().raw_input_hook(&ctx, &mut raw);
|
||||
*h.input_mut() = raw;
|
||||
h.step();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_main_menu_scrolls_to_its_last_item() {
|
||||
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let mut h = Harness::builder().with_size(SIZE).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
for _ in 0..4 {
|
||||
step(&mut h);
|
||||
}
|
||||
let options = menus::tree().into_iter().find(|r| r.path == "Options").unwrap();
|
||||
// The menu must scroll all the way to its last entry, a leaf like "Low Latency Monitoring".
|
||||
let last = options.children.last().unwrap().label.clone();
|
||||
|
||||
let at = h.get_by_label("Options").rect().center();
|
||||
let s = &mut h.state_mut().synthetic;
|
||||
s.push(Event::PointerMoved(at));
|
||||
s.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
s.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
for _ in 0..6 {
|
||||
step(&mut h);
|
||||
}
|
||||
let bottom = |h: &Harness<'_, SoundApp>| h.get_by_label_contains(&last).rect().bottom();
|
||||
assert!(bottom(&h) > SIZE.y, "the Options menu is taller than the window, so `{last}` starts out of view");
|
||||
|
||||
// Over the top of the open menu, which drops down below its title.
|
||||
h.state_mut().synthetic.push(Event::PointerMoved(at + vec2(80.0, 120.0)));
|
||||
step(&mut h);
|
||||
for _ in 0..40 {
|
||||
h.state_mut().synthetic.push(Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Line,
|
||||
delta: vec2(0.0, -3.0),
|
||||
modifiers: Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Wheel,
|
||||
});
|
||||
step(&mut h);
|
||||
}
|
||||
for _ in 0..10 {
|
||||
step(&mut h);
|
||||
}
|
||||
assert!(bottom(&h) <= SIZE.y, "scrolling the menu brings `{last}` into view (bottom at {})", bottom(&h));
|
||||
}
|
||||
@@ -22,6 +22,7 @@ mod group_dialog;
|
||||
mod hide_floating;
|
||||
mod idle;
|
||||
mod key_families;
|
||||
mod long_menu_scroll;
|
||||
mod marker_rename;
|
||||
mod menu_search;
|
||||
mod midi_double_click;
|
||||
|
||||
@@ -169,6 +169,28 @@ fn send_and_output_menus_search_buses_as_soon_as_they_open() {
|
||||
assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::Bus(bus(&h, "Verb")));
|
||||
}
|
||||
|
||||
/// The I/O menus' search finds the hardware inputs, the main output and "no input/output", not just buses.
|
||||
#[test]
|
||||
fn io_menus_search_every_route_not_just_buses() {
|
||||
let mut h = harness(vox());
|
||||
open(&mut h, "Input for Vox");
|
||||
type_text(&mut h, "in 3");
|
||||
assert!(h.query_by_label("In 4").is_none(), "only the matches are listed");
|
||||
press(&mut h, Key::Enter);
|
||||
assert_eq!(h.state().engine.session().tracks[0].mixer.input, Route::Hardware("In 3".into()));
|
||||
|
||||
open(&mut h, "Output for Vox");
|
||||
type_text(&mut h, "no out");
|
||||
press(&mut h, Key::Enter);
|
||||
assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::None);
|
||||
|
||||
let main = h.state().engine.session().outputs.first().map_or_else(|| "Out 1-2".to_string(), |o| o.name.clone());
|
||||
open(&mut h, "Output for Vox");
|
||||
type_text(&mut h, &main.to_lowercase());
|
||||
press(&mut h, Key::Enter);
|
||||
assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::Main);
|
||||
}
|
||||
|
||||
/// Scripted typing and Enter reach a menu search even when the host window is unfocused.
|
||||
#[test]
|
||||
fn scripted_keys_reach_the_search_with_the_window_in_the_background() {
|
||||
|
||||
@@ -380,6 +380,11 @@ impl Movie {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Size the sample table declares for `sample` (0 when there is none).
|
||||
fn declared_size(&self, sample: usize) -> usize {
|
||||
self.mp4.tracks.get(self.track).and_then(|t| t.samples.get(sample)).map_or(0, |s| s.size as usize)
|
||||
}
|
||||
|
||||
fn read(&self, sample: usize) -> Result<Vec<u8>> {
|
||||
let t = self.mp4.tracks.get(self.track).ok_or(VideoError::NoVideoTrack)?;
|
||||
let size = t.samples.get(sample).map_or(0, |s| s.size);
|
||||
@@ -415,6 +420,13 @@ impl Movie {
|
||||
Ok(Frame { width: f.width, height: f.height, rgba, index, time })
|
||||
}
|
||||
Codec::Jpeg => {
|
||||
// The frame's own size, from its header, before a pixel is decoded: a frame may differ from
|
||||
// the track's size (as before), but not exceed `MAX_PIXELS`.
|
||||
let reader = image::ImageReader::with_format(std::io::Cursor::new(&data), image::ImageFormat::Jpeg);
|
||||
let (w, h) = reader.into_dimensions().map_err(|e| VideoError::Decode(e.to_string()))?;
|
||||
if u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
return Err(VideoError::Decode("picture too large".into()));
|
||||
}
|
||||
let img = image::load_from_memory_with_format(&data, image::ImageFormat::Jpeg).map_err(|e| VideoError::Decode(e.to_string()))?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (width, height) = rgba.dimensions();
|
||||
@@ -457,6 +469,14 @@ impl Movie {
|
||||
Ok(self.h264_to_frame(p, index))
|
||||
}
|
||||
|
||||
/// Most sample bytes one frame request feeds the decoder (eight samples at the 256 MiB cap):
|
||||
/// a request decodes from the previous keyframe, and a sample table whose entries all name
|
||||
/// one large payload would otherwise read and parse it once per sample.
|
||||
const MAX_REQUEST_BYTES: usize = 2 << 30;
|
||||
/// Most samples one frame request feeds without a picture coming out: the reorder depth is
|
||||
/// at most 16, so a stream that produces nothing for this long will produce nothing.
|
||||
const MAX_SAMPLES_WITHOUT_PICTURE: usize = 256;
|
||||
|
||||
fn decode_h264(&mut self, index: usize) -> Result<Arc<Frame>> {
|
||||
let Codec::H264 { avcc } = &self.codec else { return Err(VideoError::Unsupported("not H.264".into())) };
|
||||
let target = self.order.get(index).copied().ok_or(VideoError::OutOfRange)?;
|
||||
@@ -471,6 +491,7 @@ impl Movie {
|
||||
}
|
||||
let total = self.order.len();
|
||||
let mut last_err: Option<String> = None;
|
||||
let (mut fed, mut without_picture) = (0usize, 0usize);
|
||||
// Bounded: each iteration feeds one sample, or flushes once at the end.
|
||||
for _ in 0..=total.saturating_add(1) {
|
||||
let Some(st) = self.h264.as_mut() else { break };
|
||||
@@ -485,13 +506,29 @@ impl Movie {
|
||||
let s = st.next;
|
||||
st.next += 1;
|
||||
let pts = self.rank.get(s).copied().unwrap_or(0) as i64;
|
||||
// The declared size is checked before the read allocates it. An error leaves no decoder
|
||||
// behind that has stepped past this sample: the next request starts from its keyframe.
|
||||
if fed.saturating_add(self.declared_size(s)) > Self::MAX_REQUEST_BYTES {
|
||||
self.h264 = None;
|
||||
return Err(VideoError::Decode(format!("frame {index} needs more than {} MiB of samples", Self::MAX_REQUEST_BYTES >> 20)));
|
||||
}
|
||||
let data = match self.read(s) {
|
||||
Ok(d) => d,
|
||||
// an unreadable sample is skipped, as before, and counts as a sample without a picture: the
|
||||
// request's byte and no-picture budgets bound the walk
|
||||
Err(e) => {
|
||||
last_err = Some(e.to_string());
|
||||
without_picture += 1;
|
||||
if without_picture > Self::MAX_SAMPLES_WITHOUT_PICTURE {
|
||||
return Err(VideoError::Decode(format!(
|
||||
"no picture in {} samples before frame {index}",
|
||||
Self::MAX_SAMPLES_WITHOUT_PICTURE
|
||||
)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
fed = fed.saturating_add(data.len());
|
||||
let Some(st) = self.h264.as_mut() else { break };
|
||||
match st.dec.decode(&data, pts) {
|
||||
Ok(p) => p,
|
||||
@@ -506,6 +543,10 @@ impl Movie {
|
||||
st.dec.flush()
|
||||
};
|
||||
let Some(st) = self.h264.as_mut() else { break };
|
||||
without_picture = if pics.is_empty() { without_picture + 1 } else { 0 };
|
||||
if without_picture > Self::MAX_SAMPLES_WITHOUT_PICTURE {
|
||||
return Err(VideoError::Decode(format!("no picture in {} samples before frame {index}", Self::MAX_SAMPLES_WITHOUT_PICTURE)));
|
||||
}
|
||||
for p in pics {
|
||||
let q = usize::try_from(p.pts).unwrap_or(usize::MAX);
|
||||
st.last_out = Some(q);
|
||||
|
||||
@@ -121,7 +121,7 @@ fn prores_frame_size_is_checked_before_decoding() {
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// largest read asked of it and refuses reads over 32 MiB (the test movies are much smaller).
|
||||
struct PaddedSource {
|
||||
bytes: Vec<u8>,
|
||||
len: u64,
|
||||
@@ -134,7 +134,7 @@ impl ByteSource for PaddedSource {
|
||||
}
|
||||
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 {
|
||||
if buf.len() > 32 << 20 {
|
||||
return Err(std::io::Error::other("read too large for the test source"));
|
||||
}
|
||||
let start = usize::try_from(offset).unwrap_or(usize::MAX);
|
||||
@@ -161,6 +161,80 @@ fn h264_sample_reads_are_capped() {
|
||||
assert!(largest <= 256 << 20, "a {largest}-byte read reached the source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_frame_request_reads_a_bounded_amount_of_samples() {
|
||||
// 65 frames whose `stsz` entries each claim 32 MiB: under the per-sample cap, but together
|
||||
// past the 2 GiB one request may feed the decoder.
|
||||
let mut bytes = h264_mp4(65, 65).unwrap();
|
||||
let at = bytes.windows(4).position(|w| w == b"stsz").unwrap();
|
||||
if bytes[at + 8..at + 12] == [0; 4] {
|
||||
// one size per sample
|
||||
for i in 0..65 {
|
||||
let field = at + 16 + 4 * i;
|
||||
bytes[field..field + 4].copy_from_slice(&(32u32 << 20).to_be_bytes());
|
||||
}
|
||||
} else {
|
||||
// the writer stored one size for all the (equally large) samples
|
||||
bytes[at + 8..at + 12].copy_from_slice(&(32u32 << 20).to_be_bytes());
|
||||
}
|
||||
let src = Arc::new(PaddedSource { bytes, len: 3 << 30, largest: AtomicUsize::new(0) });
|
||||
let mut m = Movie::open_source(src).unwrap();
|
||||
let r = m.frame(64);
|
||||
assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("needs more than 2048 MiB of samples")), "{r:?}");
|
||||
// the sample that was refused is not skipped: asking again fails the same way instead of decoding past it
|
||||
let again = m.frame(64);
|
||||
assert!(matches!(&again, Err(VideoError::Decode(e)) if e.contains("needs more than 2048 MiB of samples")), "{again:?}");
|
||||
}
|
||||
|
||||
/// A source that, once `failing` is set, cannot be read at all.
|
||||
struct UnreadableSamples {
|
||||
bytes: Vec<u8>,
|
||||
failing: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl ByteSource for UnreadableSamples {
|
||||
fn len(&self) -> u64 {
|
||||
self.bytes.len() as u64
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
|
||||
let start = usize::try_from(offset).unwrap_or(usize::MAX);
|
||||
if self.failing.load(Ordering::Relaxed) || start.saturating_add(buf.len()) > self.bytes.len() {
|
||||
return Err(std::io::Error::other("unreadable"));
|
||||
}
|
||||
buf.copy_from_slice(&self.bytes[start..start + buf.len()]);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_over_unreadable_samples_stops_at_the_no_picture_budget() {
|
||||
// 400 frames of one GOP, none of whose samples can be read once the movie is open: the walk gives up after 256 skipped samples
|
||||
let bytes = h264_mp4(400, 400).unwrap();
|
||||
let src = Arc::new(UnreadableSamples { bytes, failing: false.into() });
|
||||
let mut m = Movie::open_source(src.clone()).unwrap();
|
||||
src.failing.store(true, Ordering::Relaxed);
|
||||
let r = m.frame(399);
|
||||
assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("no picture in 256 samples")), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mjpeg_frame_size_is_capped_from_its_header_but_may_differ_from_the_track() {
|
||||
// A frame whose header differs from the track's size still decodes (within the dimension cap, as before)...
|
||||
let mut bytes = mjpeg_mov(2).unwrap();
|
||||
let sof = bytes.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap();
|
||||
bytes[sof + 5..sof + 9].copy_from_slice(&[0, 8, 0, 8]);
|
||||
let mut m = Movie::open(bytes).unwrap();
|
||||
assert!(m.frame(0).is_ok());
|
||||
// ...but one claiming 16 000 x 16 000 is refused from the header, not decoded.
|
||||
let mut bytes = mjpeg_mov(2).unwrap();
|
||||
let sof = bytes.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap();
|
||||
bytes[sof + 5..sof + 9].copy_from_slice(&[0x3E, 0x80, 0x3E, 0x80]);
|
||||
let mut m = Movie::open(bytes).unwrap();
|
||||
let r = m.frame(0);
|
||||
assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("picture too large")), "{r:?}");
|
||||
assert!(m.frame(1).is_ok(), "the untouched frame still decodes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_codec_opens_but_does_not_decode() {
|
||||
let mut mw = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
# Where SoundCraft falls short of Pro Tools
|
||||
|
||||
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** trivial (G-01: `setup.playback_engine` applies the device buffer in the desktop app; G-04: Clip Looping crossfade and loop count) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-11-04 · **Change:** trivial (G-04: volume automation read at every sample, #1354; previously trivial: G-01: `setup.playback_engine` applies the device buffer in the desktop app; G-04: Clip Looping crossfade and loop count) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing
|
||||
real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**
|
||||
@@ -92,7 +92,9 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t
|
||||
**Fixed (2026-10-16):** Aux, folder and master latency calculations now skip inserts already
|
||||
render block, matching track faders. A breakpoint at the next block no longer ramps the preceding
|
||||
block, and ramps reach their last-sample value. Regression tests cover nonzero render starts,
|
||||
one-sample callbacks and several block sizes. Interior automation breakpoints remain block-dependent.
|
||||
one-sample callbacks and several block sizes. Since #1354, track and master volume automation is read at
|
||||
every sample, so its interior breakpoints no longer depend on the block; send levels and plugin parameters
|
||||
are still read once per block.
|
||||
|
||||
disabled for producing NaN or infinity. Subsequent blocks compensate only for healthy inserts;
|
||||
regression tests verify rendered impulse timing, retained healthy latency and reset/retry at
|
||||
@@ -230,6 +232,7 @@ missing. Out of scope unless the owner decides otherwise.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | trivial | G-04: track and master volume automation read at every sample, so breakpoints act on their own sample in any block size (#1354); estimates unchanged |
|
||||
| 2026-10-25 | trivial | G-04: Clip Looping (#1030) lacks the loop crossfade option, and Number of Loops stops at 512 while length and fill make up to 1000 loops |
|
||||
| 2026-10-25 | trivial | G-01: `setup.playback_engine` and `setup.hardware` apply the device and buffer in the desktop app; headless returns `applied: false` |
|
||||
| 2026-10-24 | trivial | G-08: 27 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing |
|
||||
|
||||
@@ -22,7 +22,7 @@ FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
TEST=1
|
||||
OCI=0
|
||||
TARBALL=""
|
||||
while [ $# -gt 0 ]; do
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-test) TEST=0; shift ;;
|
||||
--oci) OCI=1; shift ;;
|
||||
@@ -40,7 +40,7 @@ case "$ARCH" in
|
||||
esac
|
||||
BASENAME="soundcraft-$VERSION-linux-$ARCH"
|
||||
TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}"
|
||||
[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
|
||||
[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
|
||||
for tool in flatpak flatpak-builder; do
|
||||
command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
|
||||
done
|
||||
@@ -51,7 +51,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/stage"
|
||||
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
|
||||
[ -x "$WORK/stage/bin/soundcraft" ] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; }
|
||||
[[ -x "$WORK/stage/bin/soundcraft" ]] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; }
|
||||
cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
|
||||
|
||||
flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
|
||||
@@ -70,7 +70,7 @@ if [[ "$OCI" = 1 ]]; then
|
||||
echo "wrote OCI layout to $OUT_OCI"
|
||||
fi
|
||||
|
||||
if [ "$TEST" = 1 ]; then
|
||||
if [[ "$TEST" = 1 ]]; then
|
||||
flatpak install --user -y --noninteractive --reinstall "$OUT"
|
||||
flatpak info --user "$APP_ID"
|
||||
flatpak run --command=soundcraft-cli "$APP_ID" --version
|
||||
|
||||
Reference in New Issue
Block a user