mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-11 16:35:42 -06:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e0e034ee | ||
|
|
eeb5d653a5 | ||
|
|
c25b12ba46 | ||
|
|
296bfbb2e1 | ||
|
|
f7a5adb474 | ||
|
|
23242338a6 | ||
|
|
e01ea00cdd | ||
|
|
659ec7a8e2 | ||
|
|
90e069f626 | ||
|
|
7a4f9c911d | ||
|
|
60f97a99a5 | ||
|
|
d255ca4fe9 | ||
|
|
1fe1d06073 | ||
|
|
10e4bc7b87 | ||
|
|
1d696bb3ef | ||
|
|
9f342bf403 | ||
|
|
0e511b1259 | ||
|
|
927c4f7b72 | ||
|
|
69af50aac2 | ||
|
|
e86d09ab2a | ||
|
|
f780dcab37 | ||
|
|
2c042c599f | ||
|
|
e94a7875d3 | ||
|
|
654deabd54 |
@@ -27,6 +27,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# A nightly dry run on main: every release build except macOS, nothing signed or released
|
||||
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
|
||||
schedule:
|
||||
- cron: "36 2 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -30,6 +34,7 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
dry-run: ${{ github.event_name == 'schedule' }}
|
||||
app-name: soundcraft
|
||||
app-title: SoundCraft
|
||||
version: ${{ inputs.version }}
|
||||
|
||||
Generated
+19
-18
@@ -4331,7 +4331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -4355,7 +4355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4365,7 +4365,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4377,7 +4377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
@@ -4390,7 +4390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4403,7 +4403,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -4421,7 +4421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4432,7 +4432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -4450,12 +4450,13 @@ dependencies = [
|
||||
"soundcraft-video",
|
||||
"soundcraft-vst3-host",
|
||||
"thiserror 2.0.21",
|
||||
"winsafe",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4465,7 +4466,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -4481,7 +4482,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4494,7 +4495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -4509,7 +4510,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4519,7 +4520,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -4549,7 +4550,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4561,7 +4562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4577,7 +4578,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6225,7 +6226,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,20 +87,27 @@ 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)
|
||||
.map_err(|r| EngineError::Io(format!("{rel}: {}", r.message("too large to import"))))?;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
|
||||
+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.
|
||||
|
||||
+217
-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,146 @@ fn apply_matrix_span_matches_the_plain_per_sample_sum() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A volume lane through `points` (sample, dB).
|
||||
fn volume_lane(points: &[(i64, f32)]) -> soundcraft_model::AutomationLane {
|
||||
let mut l = soundcraft_model::AutomationLane::new(AutoParam::Volume);
|
||||
for &(at, db) in points {
|
||||
l.set_point(at, db);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// Volume automation is read at every sample: a breakpoint acts on its own sample, and between
|
||||
/// breakpoints the gain follows the lane (linear in dB), whatever size the render blocks are. A
|
||||
/// 480-sample crossfade used to be resolved once per block, so it moved by up to a block and left
|
||||
/// a hole or not depending on where the block edge fell.
|
||||
#[test]
|
||||
fn gain_ramp_matches_converting_both_ends_every_sample() {
|
||||
for (db0, db1, n) in [(-6.0f32, 3.0f32, 512usize), (0.0, 0.0, 1), (-144.0, 0.0, 300), (2.5, -60.0, 0), (f32::NAN, -3.0, 64), (-200.0, 12.0, 7)] {
|
||||
let ramp = crate::automation::GainRamp::new(db0, db1, n);
|
||||
let (g0, g1) = (db_to_gain(db0), db_to_gain(db1));
|
||||
for i in 0..=n {
|
||||
let want = if n == 0 { g0 } else { g0 + (g1 - g0) * (i as f32 / n as f32) };
|
||||
assert_eq!(ramp.at(i).to_bits(), want.to_bits(), "{db0} -> {db1} dB, sample {i} of {n}");
|
||||
fn volume_automation_follows_its_breakpoints_in_any_block_size() {
|
||||
let (mut s, a) = session_with_dc(0.5, 8192);
|
||||
let b = add_dc_track(&mut s, 0.5, 8192);
|
||||
// A falls from 0 to -144 dB across 2760..3240 while B rises, and back across 5760..6240.
|
||||
let fall = volume_lane(&[(0, 0.0), (2760, 0.0), (3240, -144.0), (5760, -144.0), (6240, 0.0)]);
|
||||
let rise = volume_lane(&[(0, -144.0), (2760, -144.0), (3240, 0.0), (5760, 0.0), (6240, -144.0)]);
|
||||
// One track's level at 0 dB, through the pan law.
|
||||
let per_track = render_range(&s, Range::new(0, 64), 64)[0][0] / 2.0;
|
||||
s.track_mut(a).unwrap().automation.push(fall.clone());
|
||||
s.track_mut(b).unwrap().automation.push(rise.clone());
|
||||
let want = |i: i64| per_track * (db_to_gain(fall.value_at(i, 0.0)) + db_to_gain(rise.value_at(i, 0.0)));
|
||||
for block in [1024, 512, 333, 64] {
|
||||
let out = render_range(&s, Range::new(0, 8192), block);
|
||||
for (i, &x) in out[0].iter().enumerate() {
|
||||
let w = want(i as i64);
|
||||
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "block {block}, sample {i}: {x} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A master fader's breakpoint acts on its own sample too.
|
||||
#[test]
|
||||
fn master_volume_automation_follows_its_breakpoints_in_any_block_size() {
|
||||
let (mut s, _) = session_with_dc(0.5, 4096);
|
||||
let dry = render_range(&s, Range::new(0, 64), 64)[0][0];
|
||||
let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);
|
||||
let lane = volume_lane(&[(0, 0.0), (1500, 0.0), (1501, -144.0), (2600, -144.0), (2700, -6.0)]);
|
||||
s.track_mut(m).unwrap().automation.push(lane.clone());
|
||||
for block in [1024, 256, 100] {
|
||||
let out = render_range(&s, Range::new(0, 4096), block);
|
||||
for (i, &x) in out[0].iter().enumerate() {
|
||||
let w = dry * db_to_gain(lane.value_at(i as i64, 0.0));
|
||||
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "block {block}, sample {i}: {x} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `scale` gives what `db_to_gain` gives sample by sample, from a ramp's first sample to its last:
|
||||
/// silence at or below -144 dB, the cap, NaN, extreme values and one- or two-sample ramps.
|
||||
#[test]
|
||||
fn stepped_fader_ramps_match_converting_every_sample() {
|
||||
let cases: &[(&str, f32, f32, usize, f32)] = &[
|
||||
("down to silence", 0.0, -144.0, 480, 12.0),
|
||||
("up from silence", -144.0, 0.0, 480, 12.0),
|
||||
("across the cap", 6.0, 18.0, 300, 12.0),
|
||||
("below the floor and back", -200.0, -20.0, 512, 12.0),
|
||||
("flat", -6.0, -6.0, 1024, 12.0),
|
||||
("one sample", -6.0, 3.0, 1, 12.0),
|
||||
("two samples", -6.0, 3.0, 2, 12.0),
|
||||
("empty", 0.0, -6.0, 0, 12.0),
|
||||
("NaN start", f32::NAN, -3.0, 64, 12.0),
|
||||
("long ramp in one block", 0.0, -60.0, 16_384, 12.0),
|
||||
("recover from deep silence", -10_000.0, 0.0, 1024, 12.0),
|
||||
("descend from extreme trim", 10_000.0, -100.0, 1024, 12.0),
|
||||
("no cap", -12.0, 6.0, 1000, f32::INFINITY),
|
||||
("gain helper ceiling", 300.0, 0.0, 1024, f32::INFINITY),
|
||||
("extreme exact endpoint", -1e20, -20.0, 64, 12.0),
|
||||
("negative infinite start", f32::NEG_INFINITY, -20.0, 64, 12.0),
|
||||
("positive infinite end", -20.0, f32::INFINITY, 64, 12.0),
|
||||
("opposite infinities", f32::NEG_INFINITY, f32::INFINITY, 64, 12.0),
|
||||
];
|
||||
for &(name, d0, d1, n, max_db) in cases {
|
||||
let mut x = vec![1.0f32; n];
|
||||
crate::automation::scale(&mut x, d0, d1, max_db);
|
||||
for (k, &got) in x.iter().enumerate() {
|
||||
let at = if k == 0 {
|
||||
d0
|
||||
} else if k + 1 == n {
|
||||
d1
|
||||
} else {
|
||||
let t = k as f64 / (n - 1) as f64;
|
||||
(f64::from(d0) * (1.0 - t) + f64::from(d1) * t) as f32
|
||||
};
|
||||
let want = if d0.is_nan() || d1.is_nan() || at.is_nan() { 0.0 } else { db_to_gain(at.min(max_db)) };
|
||||
assert!((got - want).abs() <= 1e-5 * want.max(1e-6) || (got == 0.0 && want == 0.0), "{name}, sample {k} of {n}: {got} vs {want}");
|
||||
}
|
||||
if d0.is_nan() || d1.is_nan() {
|
||||
assert!(x.iter().all(|&g| g == 0.0), "{name}: a NaN end is silence");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(x.first().copied().unwrap_or(0.0), if n == 0 { 0.0 } else { db_to_gain(d0.min(max_db)) }, "{name}: first sample exact");
|
||||
if n > 1 {
|
||||
assert_eq!(x[n - 1], db_to_gain(d1.min(max_db)), "{name}: last sample exact");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume, trim and VCA lanes with breakpoints at different samples add up at every sample, a
|
||||
/// muted track stays silent, and a render that starts inside a ramp or has breakpoints on block
|
||||
/// edges reads the same curve.
|
||||
#[test]
|
||||
fn summed_fader_lanes_are_read_at_every_sample() {
|
||||
let (mut s, a) = session_with_dc(0.5, 12_288);
|
||||
let muted = add_dc_track(&mut s, 0.5, 12_288);
|
||||
let vca = s.add_track(TrackKind::Vca, ChannelFormat::Mono, None);
|
||||
let one_track = render_range(&s, Range::new(0, 64), 64)[0][0] / 2.0;
|
||||
s.track_mut(muted).unwrap().mixer.mute = true;
|
||||
let vol = volume_lane(&[(0, 0.0), (1024, 0.0), (2048, -30.0), (4096, -6.0)]);
|
||||
let mut trim = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: u8::MAX, param: "trim".into() });
|
||||
for (at, db) in [(0, 0.0), (1500, 0.0), (3000, 6.0)] {
|
||||
trim.set_point(at, db);
|
||||
}
|
||||
let vca_lane = volume_lane(&[(0, 0.0), (5120, 0.0), (7000, -12.0)]);
|
||||
let tr = s.track_mut(a).unwrap();
|
||||
tr.automation.push(vol.clone());
|
||||
tr.automation.push(trim.clone());
|
||||
tr.mixer.vca = Some(vca.0);
|
||||
s.track_mut(vca).unwrap().automation.push(vca_lane.clone());
|
||||
// dB at `i`, from the breakpoints themselves.
|
||||
let lin = |pts: &[(i64, f32)], i: i64| match pts.iter().position(|p| p.0 > i) {
|
||||
None => pts[pts.len() - 1].1,
|
||||
Some(0) => pts[0].1,
|
||||
Some(k) => pts[k - 1].1 + (pts[k].1 - pts[k - 1].1) * ((i - pts[k - 1].0) as f32 / (pts[k].0 - pts[k - 1].0) as f32),
|
||||
};
|
||||
let want = |i: i64| {
|
||||
let db = lin(&[(0, 0.0), (1024, 0.0), (2048, -30.0), (4096, -6.0)], i)
|
||||
+ lin(&[(0, 0.0), (1500, 0.0), (3000, 6.0)], i)
|
||||
+ lin(&[(0, 0.0), (5120, 0.0), (7000, -12.0)], i);
|
||||
one_track * db_to_gain(db.min(12.0))
|
||||
};
|
||||
for (start, block) in [(0i64, 1024), (0, 512), (1700, 333), (1024, 1024), (2047, 64)] {
|
||||
let out = render_range(&s, Range::new(start, 12_288), block);
|
||||
for (k, &x) in out[0].iter().enumerate() {
|
||||
let i = start + k as i64;
|
||||
let w = want(i);
|
||||
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "start {start}, block {block}, sample {i}: {x} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Release and source Flatpaks must allow release checks and host update helpers.
|
||||
fn finish_args(manifest: &str) -> Vec<&str> {
|
||||
manifest
|
||||
.split("finish-args:")
|
||||
.nth(1)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
|
||||
.filter_map(|line| line.trim().strip_prefix("- "))
|
||||
.map(|arg| arg.trim_matches(['\'', '"']))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatpak_update_permissions() {
|
||||
let manifests = [
|
||||
include_str!("../../../packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml"),
|
||||
include_str!("../../../packaging/linux/flatpak/ai.storyteller.soundcraft.yml"),
|
||||
];
|
||||
let args = manifests.map(finish_args);
|
||||
assert_eq!(args[0], args[1], "source and release permissions must agree");
|
||||
for permissions in args {
|
||||
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
|
||||
assert!(permissions.contains(&required), "missing update permission: {required}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -18,6 +18,8 @@ runtime-version: '25.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: soundcraft
|
||||
finish-args:
|
||||
# The shared updater inspects and updates this installation via flatpak-spawn --host.
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
|
||||
@@ -23,6 +23,8 @@ sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
command: soundcraft
|
||||
finish-args:
|
||||
# The shared updater inspects and updates this installation via flatpak-spawn --host.
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
|
||||
Reference in New Issue
Block a user