mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-30 05:26:52 -06:00
Compare commits
29
Commits
m/land-995
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7902d59b47 | ||
|
|
92e74f6e00 | ||
|
|
072e548ec8 | ||
|
|
98fae00688 | ||
|
|
d2079bf09d | ||
|
|
5447c94cb6 | ||
|
|
3276358bef | ||
|
|
081f43b90a | ||
|
|
b4412929a7 | ||
|
|
ca27f0f8b4 | ||
|
|
5b69a11d89 | ||
|
|
5075da2f3e | ||
|
|
af4970e388 | ||
|
|
9e768d659b | ||
|
|
4e9d778734 | ||
|
|
c2f14dff49 | ||
|
|
f39413033e | ||
|
|
19f4b695e2 | ||
|
|
17d553a7a7 | ||
|
|
57ae0a8b5d | ||
|
|
d9c293a92b | ||
|
|
7c029c40ce | ||
|
|
75e5b90a86 | ||
|
|
e294aa48c0 | ||
|
|
021caab499 | ||
|
|
fa77e03e16 | ||
|
|
ccd3fe7ca2 | ||
|
|
dab64379cb | ||
|
|
9d5f5784d5 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,3 +65,6 @@ sync_github_name = true
|
||||
[people.luigiPinna]
|
||||
real_name = "Luigi Pinna"
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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, ¬es);
|
||||
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, ¬es);
|
||||
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, ¬es);
|
||||
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:?}");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user