Compare commits

..
26 Commits
Author SHA1 Message Date
Dylan M. Taylor 072e548ec8 deps: bump tar, zip, roxmltree, rustls-webpki and symphonia to their latest versions (#1192)
- `zip`: 9.1.1 → 9.1.3

Lockfile only.
2026-10-29 22:21:27 -04:00
Dylan M. Taylor 98fae00688 CI: workflows only call craft-common-actions at @main (#1190)
AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call
craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and
link or open a craft-common-actions issue (a PR only for a small fix that is safe for
every app and does not increase CI time); legacy jobs
aren't added to and move only as their own task, staying until a shared workflow runs them.

windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on
craft-common-actions main and the workflow (and the actions it calls) is identical
there, so nothing changes in behaviour.
2026-10-29 22:11:16 -04:00
Brandon Thomas d2079bf09d Merge pull request #1194 from storytold/m/land-995
Bound the command journal (#995, rebased)
2026-10-29 22:04:22 -04:00
Brandon Thomas 5447c94cb6 Merge pull request #1193 from storytold/m/land-993
Bound Print Score and MusicXML output size (#993, rebased)
2026-10-29 22:04:18 -04:00
Brandon Thomas 3276358bef Merge pull request #1150 from ayozetr/fix-1146-send-fader-automation
Write send-level automation from the send faders (fixes #1146)
2026-10-29 22:03:28 -04:00
Brandon Thomas 081f43b90a Merge pull request #1180 from Silvertree2010/fix/h3951-h-264-picture-order-count-type-1
Use saturating arithmetic in H.264 POC type 1/2 computation
2026-10-29 22:03:24 -04:00
Brandon Thomas b4412929a7 Merge pull request #1109 from Silvertree2010/fix/h2757-audiosuite-processes-that-shorte
Clamp clip fades after AudioSuite processes that shorten a clip
2026-10-29 22:03:21 -04:00
Brandon Thomas ca27f0f8b4 Merge pull request #1057 from Silvertree2010/fix/h2373-trim-tool-lets-a-stretched-clip
Scale trim_clip file bounds by clip stretch
2026-10-29 22:03:18 -04:00
Brandon Thomas 5b69a11d89 Merge pull request #1020 from Silvertree2010/fix/h1998-clip-group-import-rejects-its-ow
Fix clip group import rejecting audio names containing ".."
2026-10-29 22:03:14 -04:00
Brandon ThomasandClaude Opus 5.5 81ab012724 Take the journal as a VecDeque in assert_history_unchanged
#995 makes the command journal a bounded VecDeque; the helper #1129 added
still took a slice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:00:11 -04:00
Brandon Thomas a62d955a39 Merge remote-tracking branch 'origin/main' into land-995 2026-10-29 21:59:42 -04:00
Brandon ThomasandClaude Opus 5.5 af4970e388 Saturate the reference POC LSB after a memory management op 5
A saturated top POC with a negative bottom field could still overflow
here, as CodeRabbit noted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:58:53 -04:00
Brandon Thomas 9e768d659b Merge remote-tracking branch 'origin/main' into fix1180 2026-10-29 21:58:16 -04:00
Brandon ThomasandClaude Opus 5.5 4e9d778734 Make the fade clamp test reach the fade-out bound too
A 10-sample fade-out was already shorter than the processed clip, so the
fade-out clamp went untested. Use 40, as CodeRabbit suggested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:58:10 -04:00
Brandon Thomas c2f14dff49 Merge remote-tracking branch 'origin/main' into fix1109 2026-10-29 21:57:47 -04:00
Brandon ThomasandClaude Opus 5.5 f39413033e Cover the start bound of trim_clip on a stretched clip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:57:36 -04:00
Brandon ThomasandClaude Opus 5.5 19f4b695e2 Merge origin/main into #1057; move its test to edit/tests.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:57:36 -04:00
Brandon ThomasandClaude Opus 5.5 17d553a7a7 Refuse absolute audio paths in clip group files
A bare group path leaves the folder empty, so an absolute audio path
passed the starts_with check. Reject root and prefix components too,
as CodeRabbit noted, and cover it in the regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:56:21 -04:00
Brandon Thomas 57ae0a8b5d Merge remote-tracking branch 'origin/main' into fix1020
# Conflicts:
#	crates/engine/src/clip_group_file.rs
2026-10-29 21:56:21 -04:00
NetterSilverandClaude Haiku 5.5 d9c293a92b Use saturating arithmetic in H.264 POC type 1/2 computation
Found while testing crates/video/src/h264/slice.rs:485-490 (PocState::compute, pic_order_cnt_type 1)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 12:05:31 +00:00
Ayoze Torres 7c029c40ce Write send-level automation from the send faders (#1146)
Moving a send's level with automation enabled wrote nothing: the live
pass in write_automation only recorded each track's main fader
(`volume`). Send levels have their own lanes (`send_a_level` …), which
playback reads, but nothing wrote them while playing.

The send's level control in the strip slot and its floating send window
now start a SendFader gesture, released like the main fader's. The pass
writes the send's level with the track's mode, the same rules as the
volume: Write throughout, Touch while the send is dragged, Latch and
Touch/Latch from the first drag until stop (touched sends are kept per
track and slot).
2026-10-27 22:14:20 +00:00
NetterSilverandClaude Haiku 5.5 75e5b90a86 Clamp clip fades after AudioSuite processes that shorten a clip
Found while testing crates/engine/src/cmd/audiosuite.rs:132-137

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 05:37:43 +00:00
NetterSilverandClaude Haiku 5.5 dab64379cb Scale trim_clip file bounds by clip stretch
Found while testing crates/engine/src/edit.rs:756-758,770 (trim_clip, file closure)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 02:58:32 +00:00
Samuel Bacaro 4f771268b0 journal: measure params size via a bounded writer before cloning
Addresses the review on #995: the previous fix serialized the full
params clone (up to a multi-MiB line from MCP or the control channel)
before applying the 64 KiB cap, so one oversized request still made
several large allocations.

- journal_params serializes through a bounded std::io::Write sink that
  fails with WriteZero the moment the cap is passed, so an oversized
  value costs at most ~1x the cap and is never fully serialized.
- Only when serialization fits is the value cloned for the journal.
- Tests exercise the journal helper directly (no 10,500 track.new
  calls), check ring-buffer eviction (oldest dropped, newest kept) and
  the cap boundary.

Refines the fix for #926.
2026-10-24 17:25:53 -03:00
NetterSilverandClaude Haiku 5.5 9d5f5784d5 Fix clip group import rejecting audio names containing ".."
Found while testing crates/engine/src/clip_group_file.rs:101

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:02:39 +00:00
Samuel Bacaro 2e057309b9 Bound the command journal
The command journal was a Vec that was never trimmed, so large params
(clips/indices arrays, junk fields from MCP or the control channel) were
kept for the life of the process.

- Store the journal as a VecDeque ring buffer capped at 10,000 entries
  (oldest are dropped).
- Cap a single entry's params at 64 KiB (serialized): a larger value is
  replaced by a {"__truncated__": true} marker before journaling.

Fixes #926
2026-10-23 18:59:00 -03:00
17 changed files with 415 additions and 40 deletions
+2 -2
View File
@@ -26,8 +26,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# 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/windows-arm64.yml@main
with:
app-name: soundcraft
app-dir: SoundCraft
+5
View File
@@ -89,3 +89,8 @@ Push to the `release` branch to build signed installers for macOS (universal DMG
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+5
View File
@@ -3,3 +3,8 @@ Read `AGENTS.md`: it is the entry point for agents in this repo.
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
before modules or other crate items; crate-level `//!` documentation may precede them.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+10 -10
View File
@@ -272,7 +272,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -1533,7 +1533,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3244,7 +3244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3880,7 +3880,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3937,7 +3937,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4839,10 +4839,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5735,7 +5735,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -6359,9 +6359,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.1"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"crc32fast",
"flate2",
+39 -1
View File
@@ -89,7 +89,11 @@ fn decode_group_audio(
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
for (old, rel) in audio_entries {
let full = dir.join(rel);
if !full.starts_with(dir) || rel.contains("..") {
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}")));
}
let key = crate::io::file_key(&full);
@@ -233,6 +237,40 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_accepts_audio_names_with_consecutive_dots_but_not_parent_dirs() {
let dir = std::env::temp_dir().join(format!("sc-grp-dots-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
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();
for (i, rel) in file["audio"].as_object_mut().unwrap().values_mut().enumerate() {
let old = dir.join(rel.as_str().unwrap());
let new = format!("Wait... what_{i}.wav");
std::fs::rename(&old, dir.join(&new)).unwrap();
*rel = json!(new);
}
std::fs::write(&path, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
assert!(r.is_ok(), "{r:?}");
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!("../escape.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(..))), "{r:?}");
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!(dir.join("Wait... what_0.wav").to_string_lossy());
}
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:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn imported_clips_carry_no_auto_rename_scheme() {
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
+16
View File
@@ -137,6 +137,7 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
nc.name = new_name;
nc.content = ClipContent::Audio { source: src, offset: 0 };
nc.length = i64::try_from(frames).unwrap_or(nc.length);
nc.clamp_fades();
// The render already has the clip's stretch applied; keeping it would stretch the new file again.
nc.stretch = 1.0;
// Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain,
@@ -246,6 +247,21 @@ mod tests {
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
}
#[test]
fn shortening_process_clamps_fades_to_the_new_length() {
let (mut e, id) = gained_clip();
{
let c = e.session_mut().find_clip_mut(id).unwrap();
c.fade_in.len = 60;
c.fade_out.len = 40;
}
e.execute("audiosuite.process", &json!({"process": "time_stretch", "params": {"ratio": 0.25}, "clips": [id.0]})).unwrap();
let c = e.session().tracks[0].clips()[0].clone();
assert!(c.length < 64, "{}", c.length);
assert!(c.fade_in.len <= c.length, "fade in {} > length {}", c.fade_in.len, c.length);
assert_eq!(c.fade_out.len, 40.min(c.length));
}
#[test]
fn processed_clip_does_not_keep_the_stretch_it_baked() {
for stretch in [0.5, 2.0] {
+5 -2
View File
@@ -799,8 +799,11 @@ pub fn trim_clip(s: &mut Session, id: ClipId, start: Option<Samples>, end: Optio
lo = lo.max(0);
}
let file = |c: &Clip| {
let start = c.start.saturating_sub(c.source_offset());
material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(n).max(c.end())))
// The mixer plays from `offset + rel / stretch`, so file samples span `stretch` times as many timeline samples.
let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 };
let scale = |n: Samples| if (stretch - 1.0).abs() >= 1e-9 { (n as f64 * stretch).round() as Samples } else { n };
let start = c.start.saturating_sub(scale(c.source_offset()));
material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(scale(n)).max(c.end())))
};
let t = TimeCtx::of(s);
let mut c = old.clone();
+36
View File
@@ -364,3 +364,39 @@ fn paste_keeps_crossfade_overlap() {
s.track(t).unwrap().clips().iter().filter(|c| c.start >= 20_000).map(|c| (c.start, c.length, c.fade_out.len, c.fade_in.len)).collect();
assert_eq!(pasted, vec![(21_000, 2000, 500, 0), (22_500, 2500, 0, 500)]);
}
#[test]
fn trim_clip_bounds_stretched_clip_to_its_file() {
for (stretch, length, want) in [(0.5, 5000, 5000), (2.0, 1000, 20_000)] {
let (mut s, t) = session_with_clip();
let id = {
let c = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()).unwrap();
c.stretch = stretch;
c.start = 0;
c.length = length;
if let ClipContent::Audio { offset, .. } = &mut c.content {
*offset = 0;
}
c.id
};
let c = trim_clip(&mut s, id, None, Some(99_999), false).unwrap();
assert_eq!(c.end(), want, "stretch {stretch}");
}
}
#[test]
fn trim_clip_start_bound_scales_the_offset_by_stretch() {
let (mut s, t) = session_with_clip();
let id = {
let c = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()).unwrap();
c.stretch = 2.0;
c.start = 10_000;
c.length = 1000;
if let ClipContent::Audio { offset, .. } = &mut c.content {
*offset = 1000;
}
c.id
};
let c = trim_clip(&mut s, id, Some(0), None, false).unwrap();
assert_eq!(c.start, 8000);
}
+107 -4
View File
@@ -29,6 +29,12 @@ use dirty::same_document;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use soundcraft_model as model;
/// Upper bound on journaled commands, so a long session cannot grow memory without limit.
const MAX_JOURNAL_ENTRIES: usize = 10_000;
/// Upper bound on a single journaled entry's params (serialized), so junk fields from MCP or the
/// control channel cannot keep a huge blob alive for the life of the process.
const MAX_JOURNAL_ENTRY_BYTES: usize = 64 * 1024; // 64 KiB
/// Shuts the third-party plugin hosts down, running the module exit of every plugin binary they
/// loaded. Call it at the end of `main`, once every plugin instance (mixer, player) is gone: a
/// plugin that has been used can crash when its module exit only runs from an exit handler.
@@ -176,8 +182,9 @@ pub struct Engine {
pub clipboard: Clipboard,
pub transport_requests: Vec<TransportRequest>,
pub transport: TransportStatus,
/// Command journal (id, params) for macro recording / agents.
pub journal: Vec<(String, Value)>,
/// Command journal (id, params) for macro recording / agents. Ring buffer: capped at
/// [`MAX_JOURNAL_ENTRIES`] entries of up to [`MAX_JOURNAL_ENTRY_BYTES`] params each.
pub journal: std::collections::VecDeque<(String, Value)>,
/// Path of the session file, if saved.
pub path: Option<String>,
/// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it).
@@ -219,7 +226,7 @@ impl Engine {
clipboard: Clipboard::default(),
transport_requests: Vec::new(),
transport: TransportStatus::default(),
journal: Vec::new(),
journal: std::collections::VecDeque::new(),
path: None,
revision: 1,
dirty: false,
@@ -422,7 +429,7 @@ impl Engine {
self.push_undo(spec.label, before);
}
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
self.journal_append(id, params);
}
Ok(v)
}
@@ -489,6 +496,16 @@ impl Engine {
self.merge_key = None;
}
/// Append a journaled command, clamping its params to [`MAX_JOURNAL_ENTRY_BYTES`] and the
/// buffer to [`MAX_JOURNAL_ENTRIES`] entries (oldest are dropped).
fn journal_append(&mut self, id: &str, params: &Value) {
let clamped = (id.to_string(), journal_params(params));
self.journal.push_back(clamped);
if self.journal.len() > MAX_JOURNAL_ENTRIES {
self.journal.pop_front();
}
}
/// Convenience: execute with `{}` params.
pub fn run(&mut self, id: &str) -> Result<Value> {
self.execute(id, &json!({}))
@@ -529,6 +546,92 @@ fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
/// The params to keep in the journal for a command: the value itself when it serializes within
/// [`MAX_JOURNAL_ENTRY_BYTES`], or a truncation marker when it is too large or unparseable.
///
/// Serializes into a [`std::io::Write`] sink that fails as soon as the cap is exceeded, so an
/// oversized `params` (a multi-MiB line from MCP or the control channel) is never fully
/// serialized, cloned, or allocated just to be discarded.
fn journal_params(params: &Value) -> Value {
// Write through a bounded sink: serialization stops (WriteZero) the moment the cap is passed,
// so an oversized value costs at most ~1× the cap, not its full serialized size.
struct Bounded(Vec<u8>);
impl std::io::Write for Bounded {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let room = MAX_JOURNAL_ENTRY_BYTES.saturating_sub(self.0.len());
if room == 0 {
return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap"));
}
let take = buf.len().min(room);
self.0.extend_from_slice(&buf[..take]);
if take < buf.len() {
return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap"));
}
Ok(take)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut sink = Bounded(Vec::new());
// Fails with WriteZero as soon as the cap is exceeded, so the allocation stays bounded even
// for a multi-MiB params.
let ok = serde_json::to_writer(&mut sink, params).is_ok();
if !ok {
return json!({"__truncated__": true});
}
params.clone()
}
#[cfg(test)]
mod journal_tests {
use super::*;
#[test]
fn journal_is_bounded_to_max_entries() {
let mut e = Engine::default();
// Calling the private append helper directly exercises the ring buffer without the cost
// of cloning the whole document on 10,000+ `track.new` calls.
for i in 0..MAX_JOURNAL_ENTRIES + 500 {
let n = format!("t{i}");
e.journal_append("track.new", &json!({"name": n}));
}
assert_eq!(e.journal.len(), MAX_JOURNAL_ENTRIES);
// The oldest entries were dropped, the newest kept: the first remaining is the 501st pushed,
// and the newest still there.
let first = e.journal.front().map(|(_, p)| p["name"].as_str().unwrap_or_default());
assert_eq!(first, Some("t500"));
let last = e.journal.back().map(|(_, p)| p["name"].as_str().unwrap_or_default());
let last_name = format!("t{}", MAX_JOURNAL_ENTRIES + 499);
assert_eq!(last, Some(last_name.as_str()));
assert_eq!(e.journal.back().map(|(id, _)| id.as_str()), Some("track.new"));
}
#[test]
fn oversized_params_are_truncated_in_the_journal() {
let big = "x".repeat(MAX_JOURNAL_ENTRY_BYTES * 4);
assert_eq!(journal_params(&json!({"name": big})), json!({"__truncated__": true}));
}
#[test]
fn small_params_are_kept_whole() {
let p = json!({"track": "Kick", "db": -6.0});
assert_eq!(journal_params(&p), p);
}
#[test]
fn cap_boundary_is_exact() {
// A value just under the cap is kept whole; one just over is truncated. "Just" means the
// bounded writer made at most ~two allocations of cap size, not a full multi-MiB buffer.
// `{"n":"…"}` adds 9 bytes around the string.
let small_str = MAX_JOURNAL_ENTRY_BYTES - 64;
let small = json!({"n": "x".repeat(small_str)});
assert_eq!(journal_params(&small), small);
let big = json!({"n": "x".repeat(MAX_JOURNAL_ENTRY_BYTES)});
assert_eq!(journal_params(&big), json!({"__truncated__": true}));
}
}
#[cfg(test)]
mod merge_tests {
use super::*;
+4 -3
View File
@@ -105,6 +105,7 @@ impl SoundApp {
quit_requested: false,
arrange_request: false,
touched: std::collections::HashSet::new(),
touched_sends: std::collections::HashSet::new(),
last_write_at: i64::MIN,
editor_states: Vec::new(),
editor_poll_due: true,
@@ -159,7 +160,7 @@ impl SoundApp {
if hidden {
self.pointer_down = false;
self.wants_keyboard = false;
if matches!(self.gesture, Some(Gesture::Fader { .. })) {
if matches!(self.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) {
self.gesture = None;
}
}
@@ -179,11 +180,11 @@ impl SoundApp {
updates_ui::tick(self, ctx);
}
if !self.pointer_down {
if matches!(self.gesture, Some(Gesture::Fader { .. })) {
if matches!(self.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) {
self.gesture = None;
}
// Keep a running automation pass merged until playback stops.
if !self.wants_keyboard && self.touched.is_empty() {
if !self.wants_keyboard && self.touched.is_empty() && self.touched_sends.is_empty() {
self.engine.end_merge();
}
}
+41 -4
View File
@@ -204,7 +204,8 @@ impl SoundApp {
}
/// While playing, record volume automation from fader positions for tracks in Write mode, and
/// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Each point replaces the
/// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Send levels follow the
/// same rules, per send, from its slot or its floating send window. Each point replaces the
/// automation since the previous one, and Stop writes a last point. A pass and whatever is
/// dragged during it, its fader included, are one undo step.
pub(crate) fn write_automation(&mut self) {
@@ -214,7 +215,7 @@ impl SoundApp {
let may_write =
playing && self.engine.session().tracks.iter().any(|t| matches!(t.mixer.automation_mode, M::Write | M::Touch | M::Latch | M::TouchLatch));
self.engine.hold_merge(may_write.then_some(PASS));
if !playing && self.touched.is_empty() {
if !playing && self.touched.is_empty() && self.touched_sends.is_empty() {
self.last_write_at = i64::MIN;
return;
}
@@ -230,6 +231,10 @@ impl SoundApp {
Some(Gesture::Fader { track, .. }) => Some(*track),
_ => None,
};
let send_fader = match &self.gesture {
Some(Gesture::SendFader { track, slot }) => Some((*track, *slot)),
_ => None,
};
let writes: Vec<(TrackId, f32)> = self
.engine
.session()
@@ -247,9 +252,39 @@ impl SoundApp {
write.then_some((t.id, t.mixer.volume_db))
})
.collect();
for (t, v) in writes {
let send_writes: Vec<(TrackId, usize, f32)> = self
.engine
.session()
.tracks
.iter()
.flat_map(|t| {
let m = t.mixer.automation_mode;
t.mixer.sends.iter().enumerate().filter_map(move |(slot, s)| s.as_ref().map(|s| (t.id, m, slot, s.level_db)))
})
.filter(|&(t, m, slot, _)| {
let touched_now = dragging && send_fader == Some((t, slot));
match m {
M::Write => true,
M::Touch => touched_now,
M::Latch | M::TouchLatch => touched_now || self.touched_sends.contains(&(t, slot)),
_ => false,
}
})
.map(|(t, _, slot, db)| (t, slot, db))
.collect();
let points: Vec<(TrackId, String, f32)> = writes
.iter()
.map(|&(t, v)| (t, "volume".to_string(), v))
.chain(send_writes.iter().map(|&(t, slot, v)| (t, format!("send_{}_level", (b'a' + slot as u8) as char), v)))
.collect();
for &(t, _) in &writes {
self.touched.insert(t);
let mut point = serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v});
}
for &(t, slot, _) in &send_writes {
self.touched_sends.insert((t, slot));
}
for (t, param, v) in points {
let mut point = serde_json::json!({"track": t.0, "param": param, "at": pos, "value": v});
if from != i64::MIN {
point["from"] = from.into();
}
@@ -257,6 +292,7 @@ impl SoundApp {
}
if !playing {
self.touched.clear();
self.touched_sends.clear();
self.engine.end_merge();
}
}
@@ -296,6 +332,7 @@ impl SoundApp {
self.punch = None;
self.gesture = None;
self.touched.clear();
self.touched_sends.clear();
self.last_write_at = i64::MIN;
self.edit_layout.follow_hold = false;
self.meters.clear();
+1 -1
View File
@@ -34,7 +34,7 @@ pub(super) fn fade_curve_drag_value(fade: soundcraft_model::Fade, anchor: (f32,
/// Runs before anything is painted, against the last frame's layout.
pub(super) fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) {
if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. })) {
if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) {
return;
}
let (s, tl) = (app.engine.session_arc(), rect(app.edit_layout.timeline));
+3
View File
@@ -235,6 +235,7 @@ pub enum Gesture {
FadeCurve { clip: soundcraft_model::ClipId, fade_in: bool, curvature: f32, anchor: (f32, f32), height: f32 },
Scrub { last: Samples },
Fader { track: TrackId, start_db: f32 },
SendFader { track: TrackId, slot: usize },
ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 },
Pencil { track: TrackId, points: Vec<(Samples, f32)> },
Breakpoint { track: TrackId, param: soundcraft_model::AutoParam, at: Samples },
@@ -384,6 +385,8 @@ pub struct SoundApp {
pub arrange_request: bool,
/// Tracks whose automation was touched during this pass (Touch/Latch writing).
touched: std::collections::HashSet<TrackId>,
/// Sends whose level was touched during this pass (Touch/Latch writing), as (track, slot).
touched_sends: std::collections::HashSet<(TrackId, usize)>,
/// The pass's last point; `i64::MIN` after a stop or a jump, so the next point erases nothing it skipped.
last_write_at: Samples,
/// Plugins changed in their editors whose state the session hasn't taken yet (see `editor_states_due`).
+2
View File
@@ -46,6 +46,7 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us
// applying it to the already-updated value made trackpad adjustment accelerate.
let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift));
let db = dragged_send_db(s.level_db, dy, fine);
app.gesture = Some(crate::Gesture::SendFader { track: id, slot });
let _ =
app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0));
}
@@ -120,6 +121,7 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) {
// Distinct from the strip's "Send A level for <track>" icon and from the slot's "Send level", so a
// label query while the window is open still picks one node.
if let Some(db) = fader(ui, fr, send.level_db, ui.scope_id().with("send_fader"), "Send level fader") {
app.gesture = Some(crate::Gesture::SendFader { track: id, slot });
let _ = app.engine.execute_merged(
"mix.send_level",
&json!({"track": id.0, "slot": slot, "db": db}),
+69 -2
View File
@@ -244,6 +244,67 @@ fn a_pass_and_the_fader_ride_driving_it_are_one_undo_step() {
assert_eq!(app.engine.undo_history().len(), steps + 1);
}
/// #1146: a send's level writes its own automation lane, by the track's mode like its fader: Touch
/// while the send is dragged, Latch from the first drag until stop, Write throughout. The track's
/// volume lane is left alone.
#[test]
fn a_dragged_send_level_writes_send_automation() {
use soundcraft_model::AutoParam;
let points = |app: &SoundApp, param: &AutoParam| app.engine.session().track_by_name("Lead").unwrap().lane(param).map_or(0, |l| l.points.len());
let level = |app: &SoundApp| app.engine.session().track_by_name("Lead").unwrap().lane(&AutoParam::SendLevel(0)).map(|l| l.points.clone());
let pass = |app: &mut SoundApp| {
app.handle_transport(0.1);
app.write_automation();
};
for mode in ["touch", "latch", "write"] {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let lead = app.engine.session().track_by_name("Lead").unwrap().id;
assert!(app.engine.session().track(lead).unwrap().mixer.sends[0].is_some(), "the demo's Lead sends to the reverb");
let volume = points(&app, &AutoParam::Volume);
app.run("mix.automation_mode", json!({"tracks": ["Lead"], "mode": mode})).unwrap();
app.run("transport.play", json!({})).unwrap();
for _ in 0..3 {
pass(&mut app);
}
let before_drag = points(&app, &AutoParam::SendLevel(0));
if mode == "write" {
assert!(before_drag > 0, "Write writes the send without a drag");
} else {
assert_eq!(before_drag, 0, "{mode}: nothing written before the send is touched");
}
// What the send's slot and its floating window do while the level is dragged.
app.pointer_down = true;
app.gesture = Some(super::Gesture::SendFader { track: lead, slot: 0 });
for frame in 0..4 {
pass(&mut app);
let db = -12.0 + frame as f32;
app.engine.execute_merged("mix.send_level", &json!({"track": lead.0, "slot": 0, "db": db}), &format!("send:{}:0", lead.0)).unwrap();
}
let dragged = points(&app, &AutoParam::SendLevel(0));
assert!(dragged >= before_drag + 3, "{mode}: the drag is written ({before_drag} -> {dragged})");
assert!(level(&app).unwrap().iter().any(|p| (p.value - (-10.0)).abs() < 1e-6), "{mode}: the points follow the send level");
// Released.
app.pointer_down = false;
app.gesture = None;
for _ in 0..3 {
pass(&mut app);
}
let released = points(&app, &AutoParam::SendLevel(0));
if mode == "touch" {
assert_eq!(released, dragged, "Touch stops writing when the send is let go");
} else {
assert!(released > dragged, "{mode}: keeps writing after the release");
}
app.run("transport.stop", json!({})).unwrap();
app.handle_transport(0.0);
app.write_automation();
if mode != "write" {
assert_eq!(points(&app, &AutoParam::Volume), volume, "{mode}: the track's volume lane is left alone");
}
assert!(app.touched_sends.is_empty(), "{mode}: stop ends the pass");
}
}
#[test]
fn plugin_states_read_during_a_drag_keep_it_one_undo_step() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
@@ -1515,9 +1576,15 @@ fn editor_gesture_polling_survives_discarded_and_hidden_host_iterations() {
}
/// The engine's revision, undo history and journal are as they were.
fn assert_history_unchanged(app: &SoundApp, revision: u64, undo: usize, history: &[String], journal: &[(String, serde_json::Value)]) {
fn assert_history_unchanged(
app: &SoundApp,
revision: u64,
undo: usize,
history: &[String],
journal: &std::collections::VecDeque<(String, serde_json::Value)>,
) {
assert_eq!(app.engine.revision, revision);
assert_eq!(app.engine.undo_history().len(), undo);
assert_eq!(app.engine.undo_history(), history);
assert_eq!(app.engine.journal, journal);
assert_eq!(&app.engine.journal, journal);
}
+59
View File
@@ -909,4 +909,63 @@ mod tests {
fn huge_pic_init_qp_is_an_error() {
assert_eq!(invalid_pps(pps(None, 0, i32::MAX)), "pic_init_qp_minus26 out of range");
}
#[test]
fn poc_extreme_offsets_do_not_overflow() {
use crate::h264::slice::{PocState, SliceHeader, SliceType};
let mut s = sps(SpsFields::default()).unwrap();
s.log2_max_frame_num = 16;
s.offset_for_ref_frame = vec![i32::MAX, i32::MAX];
s.offset_for_non_ref_pic = i32::MAX;
s.offset_for_top_to_bottom_field = i32::MAX;
// Drive FrameNumOffset up to the i32 limit by wrapping frame_num repeatedly.
let mut state = PocState::default();
for _ in 0..32_767 {
state.update_gap_frame(5, &s);
state.update_gap_frame(1, &s);
}
// prev_frame_num > frame_num below, so the next picture adds max_frame_num once more.
state.update_gap_frame(5, &s);
for (poc_type, nal_ref_idc, st) in [1u32, 2].into_iter().flat_map(|t| [1u8, 0].into_iter().flat_map(move |r| [(t, r, false), (t, r, true)])) {
s.pic_order_cnt_type = poc_type;
let state = if st { state.clone() } else { PocState::default() };
let sh = SliceHeader {
nal_ref_idc,
nal_unit_type: 1,
idr: false,
first_mb_in_slice: 0,
slice_type_raw: 0,
slice_type: SliceType::P,
pps_id: 0,
colour_plane_id: 0,
frame_num: 2,
field_pic: false,
bottom_field: false,
idr_pic_id: 0,
pic_order_cnt_lsb: 0,
delta_pic_order_cnt_bottom: 0,
delta_pic_order_cnt: [i32::MAX, i32::MAX],
redundant_pic_cnt: 0,
direct_spatial_mv_pred: false,
num_ref_idx_active: [1, 0],
ref_pic_list_mod: [Vec::new(), Vec::new()],
pred_weight_table: None,
no_output_of_prior_pics: false,
long_term_reference: false,
adaptive_ref_pic_marking: false,
mmcos: Vec::new(),
cabac_init_idc: 0,
slice_qp_delta: 0,
sp_for_switch: false,
slice_qs_delta: 0,
disable_deblocking_filter_idc: 0,
slice_alpha_c0_offset_div2: 0,
slice_beta_offset_div2: 0,
slice_group_change_cycle: 0,
header_bits: 0,
};
// Must not panic; the value itself is meaningless for such a stream.
let _ = state.compute(&sh, &s);
}
}
}
+11 -11
View File
@@ -480,7 +480,7 @@ impl PocState {
1 => {
let frame_num_offset = self.frame_num_offset(sh.idr, sh.frame_num, sps);
let n = sps.offset_for_ref_frame.len() as i32;
let mut abs_frame_num = if n != 0 { frame_num_offset + sh.frame_num as i32 } else { 0 };
let mut abs_frame_num = if n != 0 { frame_num_offset.saturating_add(sh.frame_num as i32) } else { 0 };
if sh.nal_ref_idc == 0 && abs_frame_num > 0 {
abs_frame_num -= 1;
}
@@ -488,17 +488,17 @@ impl PocState {
if abs_frame_num > 0 {
let cycle_cnt = (abs_frame_num - 1) / n;
let in_cycle = (abs_frame_num - 1) % n;
let delta_cycle: i32 = sps.offset_for_ref_frame.iter().sum();
expected = cycle_cnt.wrapping_mul(delta_cycle);
let delta_cycle: i32 = sps.offset_for_ref_frame.iter().fold(0i32, |a, &o| a.saturating_add(o));
expected = cycle_cnt.saturating_mul(delta_cycle);
for &o in &sps.offset_for_ref_frame[..=in_cycle as usize] {
expected += o;
expected = expected.saturating_add(o);
}
}
if sh.nal_ref_idc == 0 {
expected += sps.offset_for_non_ref_pic;
expected = expected.saturating_add(sps.offset_for_non_ref_pic);
}
let top = expected + sh.delta_pic_order_cnt[0];
let bottom = top + sps.offset_for_top_to_bottom_field + sh.delta_pic_order_cnt[1];
let top = expected.saturating_add(sh.delta_pic_order_cnt[0]);
let bottom = top.saturating_add(sps.offset_for_top_to_bottom_field).saturating_add(sh.delta_pic_order_cnt[1]);
Poc { top, bottom, msb: 0, frame_num_offset }
}
_ => {
@@ -506,9 +506,9 @@ impl PocState {
let temp = if sh.idr {
0
} else if sh.nal_ref_idc == 0 {
2 * (frame_num_offset + sh.frame_num as i32) - 1
2i32.saturating_mul(frame_num_offset.saturating_add(sh.frame_num as i32)).saturating_sub(1)
} else {
2 * (frame_num_offset + sh.frame_num as i32)
2i32.saturating_mul(frame_num_offset.saturating_add(sh.frame_num as i32))
};
Poc { top: temp, bottom: temp, msb: 0, frame_num_offset }
}
@@ -519,7 +519,7 @@ impl PocState {
if idr {
0
} else if self.prev_frame_num > frame_num {
self.prev_frame_num_offset + sps.max_frame_num() as i32
self.prev_frame_num_offset.saturating_add(sps.max_frame_num() as i32)
} else {
self.prev_frame_num_offset
}
@@ -532,7 +532,7 @@ impl PocState {
if sh.nal_ref_idc != 0 {
if mmco5 {
self.prev_ref_msb = 0;
self.prev_ref_lsb = poc.top - poc.frame();
self.prev_ref_lsb = poc.top.saturating_sub(poc.frame());
} else {
self.prev_ref_msb = poc.msb;
self.prev_ref_lsb = sh.pic_order_cnt_lsb as i32;