Compare commits

..
29 Commits
Author SHA1 Message Date
Dylan M. Taylor 7902d59b47 UPD.1: honor app pretend versions in update checks (#1199) 2026-10-30 02:31:43 -04:00
Dylan M. Taylor 92e74f6e00 Update prompt shows the app icon and accent color instead of a letter (#1198)
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#1fa5c9, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).

craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
2026-10-30 01:28:31 -04:00
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 Thomas 5075da2f3e Merge remote-tracking branch 'origin/main' into land-993
# Conflicts:
#	crates/engine/src/score.rs
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
Samuel Bacaro e294aa48c0 score: keep the bar duration when the item cap drops notes
A 64/1 bar can hold 256 sixteenths, more than MAX_ITEMS_PER_MEASURE (128). The
cap used to truncate the item list and drop the remaining duration, so MusicXML
wrote an underfull measure and SVG divided positions by the shortened total,
moving the kept notes across the bar.

The discarded suffix is now replaced with representable rests. The cap shrinks
the retained prefix further when the rests would not fit, and a final truncate
still bounds a hostile meter whose whole-bar rests exceed the cap.

Regression test: item_cap_keeps_the_measure_duration (fails on the old
truncate-only code, passes with rests).
2026-10-25 09:55:14 -03:00
Samuel Bacaro 021caab499 score: make the MusicXML oversize test reach the measure check
The 1 KiB budget failed on the part-list check, so nothing exercised the MusicXML
per-measure budget. Measure the header (parts with no measures) and the full
document, pick a budget between them, and assert that the header fits while the
completed document is refused, so the test proves the refusal comes from the
measures. The 1 KiB run stays as the header-refusal case, and the SVG case is
unchanged.
2026-10-25 09:40:30 -03:00
Samuel Bacaro fa77e03e16 score: apply every byte-budget and per-part cap in the same place
Addresses the review threads on #993:

- MAX_NOTES_PER_PART: the layout loop stops collecting past 100k notes
  per part, so the raw note vector cannot grow unboundedly regardless
  of clip count.
- fill: a pitch the chord cap rejects no longer extends the onset end
  (e.0), so dropped pitches do not inflate chord duration; a retained
  pitch that reappears with a longer duration still does.
- musicxml now checks the budget before the per-measure loop (covers an
  oversized title/composer, escaped, even with zero measures), after the
  part-list, and at the end, so no growth path is unchecked.
- svg now checks every measure (rest-only or missing measures cannot
  grow the output untouched past the budget), every staff, every system
  and the final footer, so staff/clef/bracket growth is capped too.
- over_budget takes the budget as a parameter and the budgeted entry
  points stay separate so tests pass a tiny budget instead of building
  256 MiB. The oversize test shrank from ~256 MiB to ~64 KiB and the
  new header test checks the title/composer escape path directly.

Regression tests.
2026-10-25 09:23:40 -03:00
Samuel Bacaro ccd3fe7ca2 Bound Print Score and MusicXML output size
MAX_MEASURES limited the number of measures but not the items per
measure, pitches per chord, or total output bytes, so a hostile session
could produce an unbounded document (a 10M-note session became a ~20 GB
SVG and aborted).

- Limit items per measure and pitches per chord in layout/fill.
- Give svg() and musicxml() a 256 MiB byte budget; over it they return
  an error instead of growing without bound.
- write_score surfaces that error to the caller.

Fixes #921
2026-10-25 09:23:40 -03: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
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
22 changed files with 504 additions and 51 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
+12 -11
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",
]
@@ -1120,13 +1120,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -1533,7 +1534,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 +3245,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 +3881,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3937,7 +3938,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4839,10 +4840,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 +5736,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 +6360,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",
+2 -1
View File
@@ -54,7 +54,8 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
+1 -1
View File
@@ -185,7 +185,7 @@ fn app_icon() -> Option<egui::IconData> {
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::SOUNDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(result) = craft_update::self_update::finish(&soundcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so every start-up record (and the engine's panic hook, installed with the first
+3
View File
@@ -65,3 +65,6 @@ sync_github_name = true
[people.luigiPinna]
real_name = "Luigi Pinna"
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
+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 -1
View File
@@ -335,7 +335,11 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value>
return Err(bad(id, "no MIDI or instrument tracks to notate"));
}
let setup = crate::score::ScoreSetup::from_session(e.session());
let (text, ext) = if svg { (crate::score::svg(&parts, &setup), "svg") } else { (crate::score::musicxml(&parts, &setup), "musicxml") };
let (text, ext) = if svg {
(crate::score::svg(&parts, &setup).map_err(|m| bad(id, m))?, "svg")
} else {
(crate::score::musicxml(&parts, &setup).map_err(|m| bad(id, m))?, "musicxml")
};
let path = match str_param(p, "path") {
Some(p) => p.to_string(),
None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(),
+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);
}
+181 -12
View File
@@ -13,9 +13,26 @@ use std::fmt::Write as _;
const UNIT: i64 = TICKS_PER_QUARTER / 4;
/// Upper bound on engraved measures, so a hostile session cannot make a huge document.
const MAX_MEASURES: usize = 2000;
/// Upper bound on output size, so a hostile session cannot make a huge document.
const MAX_OUTPUT_BYTES: usize = 256 * 1024 * 1024; // 256 MiB
/// Upper bound on items per measure, so a hostile session cannot make a huge document.
const MAX_ITEMS_PER_MEASURE: usize = 128;
/// Upper bound on pitches per chord, so a hostile session cannot make a huge document.
const MAX_PITCHES_PER_CHORD: usize = 16;
/// Upper bound on notes collected per part, before `fill` builds onsets and spans, so a hostile
/// session cannot make `layout` accumulate an unbounded note vector.
const MAX_NOTES_PER_PART: usize = 100_000;
/// Note values a single notehead can show, in sixteenths (longest first).
const VALUES: [u32; 8] = [16, 12, 8, 6, 4, 3, 2, 1];
/// The output has grown past the byte budget: refuse to grow it further.
fn over_budget(buf: &str, budget: usize) -> Result<(), String> {
if buf.len() > budget {
return Err(format!("score too large: over {} bytes", budget));
}
Ok(())
}
/// Score Setup (File › Score Setup…). Stored in the session's edit flags/values.
#[derive(Debug, Clone, PartialEq)]
pub struct ScoreSetup {
@@ -93,6 +110,9 @@ pub fn layout(s: &Session, tracks: &[TrackId]) -> Vec<Part> {
let base = s.tempo.samples_to_ticks(c.start, sr);
let end = s.tempo.samples_to_ticks(c.start.saturating_add(c.length), sr);
for n in &sequence.notes {
if notes.len() >= MAX_NOTES_PER_PART {
break;
}
let st = base.saturating_add(n.start);
if st < base || st >= end || n.length <= 0 {
continue;
@@ -151,13 +171,21 @@ fn split_units(mut units: i64) -> Vec<u32> {
}
fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec<Measure> {
// Onsets → chords, each held until its longest note ends or the next onset.
// Onsets → chords, each held until its longest *retained* note ends or the next onset. A pitch
// the cap rejects must not extend the onset end.
let mut onsets: std::collections::BTreeMap<i64, (i64, Vec<u8>)> = std::collections::BTreeMap::new();
for (a, b, p) in notes {
let e = onsets.entry(*a).or_insert((*b, Vec::new()));
e.0 = e.0.max(*b);
if !e.1.contains(p) {
e.1.push(*p);
let (ends, pitches) = onsets.entry(*a).or_insert((*b, Vec::new()));
let new_pitch = !pitches.contains(p);
if new_pitch && pitches.len() >= MAX_PITCHES_PER_CHORD {
// Chord full and this pitch is absent: it is dropped and must not extend the onset end.
continue;
}
// A retained pitch (already in the chord, or one the cap accepts) may extend the onset
// end, even when it reappears with a longer duration.
*ends = (*ends).max(*b);
if new_pitch {
pitches.push(*p);
}
}
let starts: Vec<i64> = onsets.keys().copied().collect();
@@ -196,10 +224,33 @@ fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec<Measure>
}
emit(from, to, ps, *a < *ms, *b > me, &mut items);
cursor = to;
// Cap items per measure to avoid pathological memory use.
if items.len() > MAX_ITEMS_PER_MEASURE {
break;
}
}
if cursor < me {
emit(cursor, me, &[], false, false, &mut items);
}
// Cap the item count without shortening the measure: whatever suffix the cap discards is
// replaced with representable rests, so the bar's duration (and the SVG layout denominator)
// stays right. Shrinking the item list further makes room for those rests.
if items.len() > MAX_ITEMS_PER_MEASURE {
let bar_len = me.saturating_sub(*ms);
let mut keep = MAX_ITEMS_PER_MEASURE;
loop {
let kept: i64 = items.iter().take(keep).map(|i| i64::from(i.units)).sum();
let rests = split_units(bar_len.saturating_sub(kept));
if keep.saturating_add(rests.len()) <= MAX_ITEMS_PER_MEASURE || keep == 0 {
items.truncate(keep);
items.extend(rests.into_iter().map(|units| Item { pitches: Vec::new(), units, tie_start: false, tie_stop: false }));
break;
}
keep -= 1;
}
// Safety net for a hostile meter so wide that even whole-bar rests exceed the cap.
items.truncate(MAX_ITEMS_PER_MEASURE);
}
measures.push(Measure { numerator: *num, denominator: *den, items });
}
measures
@@ -237,11 +288,20 @@ fn note_type(units: u32) -> (&'static str, bool) {
}
/// MusicXML 4.0 (partwise) for `parts`.
pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String {
pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> Result<String, String> {
musicxml_budgeted(parts, setup, MAX_OUTPUT_BYTES)
}
/// [`musicxml`] with an explicit byte budget (kept separate so tests can pass a tiny budget
/// instead of building a 256 MiB score).
fn musicxml_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result<String, String> {
let mut x = String::new();
x.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n");
x.push_str("<!DOCTYPE score-partwise PUBLIC \"-//Recordare//DTD MusicXML 4.0 Partwise//EN\" \"http://www.musicxml.org/dtds/partwise.dtd\">\n");
x.push_str("<score-partwise version=\"4.0\">\n");
// The header (and an oversized title/composer, escaped) can itself exceed the budget even with
// no measures, so check before the per-measure loop too.
over_budget(&x, budget)?;
let _ = writeln!(x, " <work><work-title>{}</work-title></work>", xml_escape(&setup.title));
x.push_str(" <identification>\n");
if !setup.composer.is_empty() {
@@ -252,6 +312,7 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String {
let _ = writeln!(x, " <score-part id=\"P{}\"><part-name>{}</part-name></score-part>", i + 1, xml_escape(&p.name));
}
x.push_str(" </part-list>\n");
over_budget(&x, budget)?;
for (i, p) in parts.iter().enumerate() {
let _ = writeln!(x, " <part id=\"P{}\">", i + 1);
let mut meter = (0, 0);
@@ -318,11 +379,13 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String {
}
}
x.push_str(" </measure>\n");
over_budget(&x, budget)?;
}
x.push_str(" </part>\n");
}
x.push_str("</score-partwise>\n");
x
over_budget(&x, budget)?;
Ok(x)
}
/// Staff position of `pitch` in half-spaces above the bottom line (treble E4 / bass G2).
@@ -334,7 +397,13 @@ fn staff_step(pitch: u8, bass: bool) -> i32 {
}
/// An engraved score as a printable SVG page (A4 portrait width, as tall as needed).
pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result<String, String> {
svg_budgeted(parts, setup, MAX_OUTPUT_BYTES)
}
/// [`svg`] with an explicit byte budget (kept separate so tests can pass a tiny budget instead of
/// building a 256 MiB score).
fn svg_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result<String, String> {
const W: f64 = 794.0;
const MARGIN: f64 = 48.0;
const SP: f64 = 7.0; // staff space
@@ -356,7 +425,9 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
let _ = writeln!(o, "<text x=\"{}\" y=\"56\" font-size=\"24\" text-anchor=\"middle\">{}</text>", W / 2.0, xml_escape(&setup.title));
if !setup.composer.is_empty() {
let _ = writeln!(o, "<text x=\"{}\" y=\"84\" font-size=\"13\" text-anchor=\"end\">{}</text>", W - MARGIN, xml_escape(&setup.composer));
over_budget(&o, budget)?;
}
over_budget(&o, budget)?;
let bar_w = (staff_x1 - staff_x0) / per as f64;
for sys in 0..systems {
let y_sys = top + sys as f64 * sys_h;
@@ -468,7 +539,13 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
let _ = writeln!(o, "<path d=\"M{:.1} {y:.1} q 8 5 16 0\" fill=\"none\" stroke=\"black\" stroke-width=\"0.9\"/>", x + 3.0);
}
}
// Check each measure, so a run of rest-only or missing measures that only draws
// bar lines cannot grow the output past the budget untouched.
over_budget(&o, budget)?;
}
// The staff markup (lines, clef, track names) itself grows with the part count even
// without any items, so check once per staff too.
over_budget(&o, budget)?;
}
// System bracket.
let y_last = y_sys + (parts.len().max(1) - 1) as f64 * STAFF_GAP + 4.0 * SP;
@@ -478,9 +555,11 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
staff_x0 - 30.0,
staff_x0 - 30.0
);
over_budget(&o, budget)?;
}
o.push_str("</svg>\n");
o
over_budget(&o, budget)?;
Ok(o)
}
fn rest(o: &mut String, x: f64, y0: f64, sp: f64, units: u32) {
@@ -558,13 +637,103 @@ mod tests {
let parts = layout(e.session(), &[]);
assert!(!parts.is_empty(), "demo has MIDI tracks");
let setup = ScoreSetup::from_session(e.session());
let x = musicxml(&parts, &setup);
let x = musicxml(&parts, &setup).expect("demo musicxml fits");
assert!(x.contains("<score-partwise") && x.contains("<pitch>") && x.trim_end().ends_with("</score-partwise>"));
assert_eq!(x.matches("<measure ").count(), x.matches("</measure>").count());
let s = svg(&parts, &setup);
let s = svg(&parts, &setup).expect("demo svg fits");
assert!(s.starts_with("<svg") && s.contains("<ellipse"));
}
#[test]
fn output_is_bounded_by_byte_budget() {
// A part with many notes must not produce an unbounded document.
let bars: Vec<_> = (0..100).map(|i| (i * 16, 16, 4, 4)).collect();
let mut notes = Vec::new();
for i in 0..2000 {
notes.push((i * 2, i * 2 + 1, 60 + (i % 24) as u8));
}
let measures = fill(&bars, &notes);
let part = Part { name: "dense".into(), bass: false, measures };
let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 4, show_track_names: false };
let s = svg(&[part], &setup);
assert!(s.is_ok(), "dense but bounded score fits");
let s = s.unwrap();
assert!(s.len() <= MAX_OUTPUT_BYTES);
}
#[test]
fn oversize_score_is_refused_not_aborted() {
// Many dense parts drive the output past the byte budget: the call must return an error,
// not grow a multi-GB string or abort. Use the budgeted entry points with a tiny budget so
// the test exercises the over-budget path without allocating 256 MiB of score, and covers
// both the per-measure checks and the header/staff-only growth.
let bars: Vec<_> = (0..100).map(|i| (i * 16, 16, 4, 4)).collect();
let mut notes = Vec::new();
for i in 0..2000 {
let onset = i * 2;
notes.push((onset, onset + 1, 60 + (i % 24) as u8));
}
let measures = fill(&bars, &notes);
let part = Part { name: "dense".into(), bass: false, measures };
let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 1, show_track_names: true };
// Enough copies of the dense part to exceed a tiny budget.
let parts: Vec<Part> = (0..64).map(|i| Part { name: format!("part{i}"), ..part.clone() }).collect();
let tiny = 1024;
let err = svg_budgeted(&parts, &setup, tiny).unwrap_err();
assert!(err.contains("score too large"), "{err}");
// MusicXML needs a budget that clears the header/part-list but trips in the measures, so
// the per-measure check is what refuses the document (this test used to fail on the
// part-list check alone).
let boundless = usize::MAX;
// The header plus part-list exactly as `parse` writes it, but with no measures: that is
// the size a budget must clear before the per-measure check is reached.
let header_parts: Vec<Part> = parts.iter().map(|p| Part { name: p.name.clone(), bass: p.bass, measures: Vec::new() }).collect();
let header = musicxml_budgeted(&header_parts, &setup, boundless).map_or(0, |x| x.len());
let full = musicxml_budgeted(&parts, &setup, boundless).map_or(0, |x| x.len());
assert!(header > 0, "the bare header fits any budget");
assert!(full > header, "the measures add output ({full} vs {header})");
// A budget above the bare header but below the completed document must be refused by the
// per-measure check.
let between = header.saturating_add((full.saturating_sub(header)) / 2);
assert!(between > header && between < full, "budget sits between header and document");
// The header alone fits that budget, so the refusal below comes from the measures.
assert!(musicxml_budgeted(&header_parts, &setup, between).is_ok(), "header fits the mid budget");
let err = musicxml_budgeted(&parts, &setup, between).unwrap_err();
assert!(err.contains("score too large"), "{err}");
// And a header that does not fit is refused before any measure is built.
let err = musicxml_budgeted(&parts, &setup, tiny).unwrap_err();
assert!(err.contains("score too large"), "{err}");
}
#[test]
fn item_cap_keeps_the_measure_duration() {
// A 64/1 bar (256 sixteenths) with one note per sixteenth needs 256 items: over the cap.
// What the cap discards must come back as rests, so the bar still fills completely and the
// SVG layout denominator stays right.
let bars = vec![(0i64, 256i64, 64u32, 4u32)];
let notes: Vec<(i64, i64, u8)> = (0..256).map(|i| (i, i + 1, 60)).collect();
let measures = fill(&bars, &notes);
assert_eq!(measures.len(), 1);
let m = &measures[0];
assert!(m.items.len() <= MAX_ITEMS_PER_MEASURE, "capped at {MAX_ITEMS_PER_MEASURE}");
let total: u32 = m.items.iter().map(|i| i.units).sum();
assert_eq!(total, 256, "the bar still lasts 256 sixteenths");
assert!(m.items.iter().any(|i| i.pitches.is_empty()), "the discarded suffix became rests");
}
#[test]
fn oversized_title_is_refused_by_the_header_check() {
// An oversized part name/title must be caught by the header check even with no measures:
// the escaped copy alone exceeds the budget. (A 54 MiB title blows past 64 KiB of escaped
// text without touching the per-measure loop.)
let setup = ScoreSetup { title: "&".repeat(64 * 1024), composer: "c".repeat(64 * 1024), bars_per_system: 4, show_track_names: false };
let err = musicxml_budgeted(&[], &setup, 1024).unwrap_err();
assert!(err.contains("score too large"), "{err}");
let err = svg_budgeted(&[], &setup, 1024).unwrap_err();
assert!(err.contains("score too large"), "{err}");
}
#[test]
fn exports_drop_characters_xml_forbids() {
let e = crate::demo::demo_engine();
@@ -573,7 +742,7 @@ mod tests {
let mut setup = ScoreSetup::from_session(e.session());
setup.title = "T\u{2}itle".to_string();
setup.composer = "C\u{3}omp".to_string();
for out in [musicxml(&parts, &setup), svg(&parts, &setup)] {
for out in [musicxml(&parts, &setup).expect("musicxml fits"), svg(&parts, &setup).expect("svg fits")] {
let bad: Vec<char> =
out.chars().filter(|c| (*c < ' ' && !matches!(c, '\n' | '\t' | '\r')) || matches!(c, '\u{FFFE}' | '\u{FFFF}')).collect();
assert!(bad.is_empty(), "forbidden characters: {bad:?}");
+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));
+12
View File
@@ -4,6 +4,15 @@
//! executing commands. It can be replaced without touching anything below it.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// SoundCraft as craft-update knows it, with its 128 px icon and accent colour: the update
/// prompt shows both, and an AppImage's menu entry gets the icon.
#[cfg(not(target_arch = "wasm32"))]
pub const UPDATE_APP: craft_update::app::App = craft_update::app::App {
icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.soundcraft.png")),
accent: Some([0x1f, 0xa5, 0xc9]),
..craft_update::suite::SOUNDCRAFT
};
pub mod appearance;
pub mod bounce;
pub mod control;
@@ -235,6 +244,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 +394,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}),
+61
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());
+1 -1
View File
@@ -21,7 +21,7 @@ impl Default for Runtime {
fn default() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater::new(&craft_update::suite::SOUNDCRAFT, env!("CARGO_PKG_VERSION")),
updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
#[cfg(not(target_arch = "wasm32"))]
startup_check: false,
#[cfg(not(target_arch = "wasm32"))]
+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;