Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor af5796bb9a Merge branch 'main' into fix/sec-logging 2026-11-03 17:36:48 -05:00
FredApps b26ab2b25c Merge pull request #576 from colliderman/fix/edit-track-header-alignments
fix(ui): center track names in edit window and remove unnecessary spacing for Solo and Mute buttons in Master tracks
2026-11-03 21:01:19 +01:00
Cody Gilchrist 76f58fcb81 fix(ui): add hover tooltip for overflowing track names and assert exact center position in test 2026-11-03 12:29:37 -05:00
Cody Gilchrist 6a4c055094 fix(ui): preserve start of long track names in edit headers 2026-11-03 11:47:04 -05:00
CodyG_NeuralDSP d5564bce05 fix(ui): position master track view selector directly under track name 2026-11-03 11:00:53 -05:00
CodyG_NeuralDSP 87ad11cccc fix(ui): center track names in edit window track headers 2026-11-03 10:58:53 -05:00
Dylan M. Taylor 58dfbcd013 fix(log): escape control characters in log messages 2026-11-03 15:19:31 +00:00
Brandon Thomas b11ffe73ae Merge pull request #1280 from TheGalitube/fix/1252-tracks-nested-in-a-folder-are-no
Indent tracks nested in folders
2026-11-03 10:05:11 -05:00
Brandon Thomas f91b4173b6 Merge pull request #1281 from rahul05ranjan/toolbar-link-icons
Toolbar: distinct link icons (#1262) and an Insertion Follows Playback toggle with its own icon (#1263)
2026-11-03 10:05:06 -05:00
Brandon Thomas 98c3a307c3 Merge pull request #1316 from JonRC/fix/plugin-load-error-reason
Plugin hosts: keep the system's reason when a plugin binary fails to load
2026-11-03 10:05:01 -05:00
Brandon Thomas 6492b5f27e Merge pull request #1286 from Silvertree2010/fix/1260-zoom-in-t-doesn-t-use-the-edit-c
Keep the edit cursor as the zoom reference when it is off-screen
2026-11-03 10:04:56 -05:00
Brandon Thomas 9c202d6f17 Merge pull request #1290 from JonRC/fix/1289-offline-render-mode
Tell hosted plugins when they render offline (VST3 kOffline, clap.render)
2026-11-03 10:04:51 -05:00
Brandon Thomas 44b864ffaf Merge pull request #1305 from JonRC/fix/1304-cli-logger
soundcraft-cli: print logged warnings and errors to stderr
2026-11-03 10:04:47 -05:00
Brandon Thomas 035de82c84 Merge pull request #1278 from TheGalitube/fix/1257-alt-click-doesn-t-delete-a-marke
Delete markers on Alt-click
2026-11-03 10:04:42 -05:00
Brandon Thomas 181f329ec8 Merge pull request #1279 from TheGalitube/fix/1255-stereo-track-input-shows-a-mono
Show stereo input pairs for stereo tracks
2026-11-03 10:04:37 -05:00
Brandon Thomas 5ba9f9d7b8 Merge pull request #1291 from ljkx/new-send-opens-send-window
A new send opens its Send window, so its fader is on screen right away
2026-11-03 10:04:32 -05:00
Brandon Thomas c15682a088 Merge pull request #1302 from JonRC/fix/1301-audiosuite-hosted-ids
audiosuite.process: say it takes built-in plugin ids, and why hosted ones are refused
2026-11-03 10:04:28 -05:00
Brandon Thomas 0d253b6d8c Merge pull request #1300 from JonRC/fix/1296-bounce-bext
Stamp WAV bounces with their timecode position (BWF bext TimeReference)
2026-11-03 10:04:22 -05:00
Brandon Thomas c0f2f7dc87 Merge pull request #1314 from JonRC/fix/convert-keeps-bext
convert: keep a BWF source's bext chunk
2026-11-03 10:04:16 -05:00
Brandon ThomasandClaude Opus 5.5 f99407fb1f Leave Insertion Follows Playback's N to the Edit window's shortcut handler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:56:42 -05:00
Brandon ThomasandClaude Opus 5.5 636454e639 Name the missing entry point in the not-a-plugin errors on every platform
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:56:35 -05:00
Brandon ThomasandClaude Opus 5.5 7546ab992b Cap the folder indent at eight levels and test against the track's own folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:56:05 -05:00
Brandon ThomasandClaude Opus 5.5 f86b077cdf Test that an off-screen edit cursor lands on its fixed column
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:53:19 -05:00
Dylan M. Taylor aa250cc1d7 Clip gain to volume: index envelope lookups (#1309)
Converting clip gain to volume (and baking volume into clip gain)
evaluated the clip's gain envelope by walking it from the start for
every breakpoint being converted, so a large envelope in an opened or
imported session cost envelope points squared and blocked the engine.

Build a running-maximum index over the envelope once per clip and
binary-search it. It returns exactly what the walk returned, including
for envelopes stored out of order.
2026-11-03 07:44:31 -05:00
Dylan M. Taylor 351b9d3a55 Tempo map: binary-search tempo segments in tick/sample conversions (#1297)
* Tempo map: index tempo segments so tick/sample conversions are O(log n)

Every tick<->sample conversion walked all earlier tempo changes, so code
that converts many positions (MIDI import/export, the tempo ruler, score
layout, MIDI scheduling and controller chasing on the audio thread)
cost positions x tempo changes. A session or MIDI file with many tempo
changes and many notes or tracks could stall the engine or the audio
callback.

TempoMap now keeps the elapsed time at each tempo change (rebuilt on
every edit and on deserialize, never serialized) and binary-searches
the segment. MIDI scheduling also skips notes whose on and off both
fall outside the block before converting them.

* Tempo map: index a file's ticks with saturating subtraction
2026-11-03 06:18:44 -05:00
Dylan M. Taylor da02c19fcc Bundles: refuse entries that share their data (#1303)
Bundle entries are stored, so the files read out of one can only
outgrow the bundle when several entries point at the same bytes. A
small bundle naming one payload thousands of times was copied out once
per name, up to the 1 GiB total limit, in the browser. Check the
archive for overlapping entry data before reading any entry.
2026-11-02 23:33:19 -05:00
Dylan M. Taylor 64540f109b Mixer: compute cyclic aux depths in one pass per hop (#1306)
When the aux/folder routing has a cycle, processing_order sorted the
auxes by a bounded feeder depth recomputed inside the sort key: each
evaluation re-searched every aux's output and sends against a growing
list of inputs, for up to eight hops, on every comparison. A session
with many auxes sharing buses made that cubic during audio sync.

Compute the same capped depth for every track at once: per hop, collect
the buses written by auxes reached so far, then mark the tracks reading
them. The result matches the old search (tested against it on a mixed
graph with cycles) and the sort uses it by lookup.
2026-11-02 23:27:09 -05:00
Dylan M. Taylor 0af1ad909e Mixer: match automation lanes to insert parameters in one merge (#1299)
apply_params searched all of the track's automation lanes for every
stored insert parameter on every block, so a session with many
parameters and many lanes multiplied the two on the audio thread.
Collect the slot's lanes once per block, order them by parameter name
(keeping the first lane per name, the one the old search found) and
walk them alongside the sorted parameter map. The lane list reuses a
scratch buffer kept on the plugin slot.
2026-11-02 23:25:34 -05:00
JonRC 7587130d59 convert: keep a BWF source's bext chunk
convert wrote every file with bwf: None, so converting a Broadcast Wave file
dropped its bext: description, originator, dates and the time reference that
places it on a timeline. Pass the decoded bext on. The time reference counts
samples, so with --rate it moves to the output rate.
2026-11-03 01:19:13 -03:00
JonRC e4f0599a2a Plugin hosts: keep the system's reason when a plugin binary fails to load
libloading 0.9 prints only "dlopen failed" ("dlsym failed", "LoadLibraryExW
failed") and keeps the dlerror text or the Windows error as the error's source.
The CLAP and VST3 loaders formatted the error alone, so a plugin built for a
newer glibc was reported as "dlopen failed" with no reason. Append the source.
2026-11-03 01:19:13 -03:00
Dylan M. Taylor 17d3f00700 MIDI: index controller dedup by channel and kind (#1298)
* MIDI: index controller dedup by channel and kind

Remove Duplicate Notes rescanned every controller already kept at the
same tick for each incoming one. A session whose sequence holds one tick
with many distinct channel/controller pairs made that quadratic and
blocked editing. Track where each channel+kind sits at the current tick
in a map instead; the last event per key still wins.

* MIDI: tag dedup index entries with their tick instead of clearing per tick

Clearing the map on every tick change cost O(capacity) per tick after a
dense tick. Store the tick with each entry and treat entries from an
earlier tick as absent. Add an oracle test comparing against the
previous per-tick rescan on mixed-tick input.
2026-11-02 22:51:02 -05:00
JonRC f46ea7d70c soundcraft-cli: print logged warnings and errors to stderr
The CLI installed no logger, so every log::warn! and log::error! was
dropped: a plugin that failed to load just vanished from
engine.clap_plugins with no reason. A small logger now writes warnings
and errors to standard error as `warning: <message>` and
`error: <message>`, like the CLI's own errors. The level is fixed:
RUST_LOG stays a desktop app variable.

Fixes #1304.
2026-11-02 23:48:01 -03:00
JonRC 1403bbc4d3 audiosuite.process: say it takes built-in plugin ids
Its help said `<plugin id>`, but it only runs built-in plugins, and a
hosted id (clap:, vst3:, au:) failed as "unknown process". The help now
says built-in plugin id, and a hosted id gets an error saying AudioSuite
runs built-in plugins only.

Fixes #1301.
2026-11-02 23:42:08 -03:00
JonRC b0793ca875 Stamp WAV bounces with their timecode position (BWF bext)
Bounces passed no bwf to the encoder, so mix and stem WAVs had no bext
chunk and post tools placed them at zero. Bounce::new now stamps them
with TimeReference = the session's timecode start plus the bounce start,
in samples since midnight, as other DAWs do. A caller's own bwf wins;
AIFF and FLAC have no bext and are unchanged.

Fixes #1296.
2026-11-02 23:36:15 -03:00
Dylan M. Taylor 1c350101bc clap-host tests: load plugins only from a freshly created private temp directory (#1295)
* clap-host tests: scan a freshly created private directory for the garbage-file test

The test reused a PID-named directory under the shared temp dir after ignoring a
failed cleanup, then loaded every .clap candidate in it. Another local account
could pre-create that directory and plant a native library that the test runner
would load. Create the directory exclusively (mode 0700 on Unix) and fail
instead of reusing one that already exists.

* clap-host tests: create the garbage-file test directory beside the test binary, not under TMPDIR
2026-11-02 21:30:40 -05:00
ljkx 36d42e0136 Test a new send's Send window end to end, and tidy send_route_menu
An integration test creates send C while send A's window is open: the window moves to the new send
with its fader on screen, and undoing the send closes it. send_route_menu now opens the window only
after the send command succeeds, without the extra bool, and its comment says reassignment leaves
the window alone.
2026-11-03 01:55:52 +01:00
ljkx 04dab602c7 A new send opens its Send window, so its fader is on screen right away (#1269)
Picking a destination for an empty send slot, or making one with new bus..., now opens that send's Send window, as in Pro Tools. Moving an existing send to another bus leaves the window as it was.
2026-11-02 19:38:53 +01:00
JonRC 576ff36f61 Tell hosted plugins when they render offline
Bounces ran VST3 plugins with processMode kRealtime and never set
clap.render, so plugins that work differently offline never could.
Offline renders now create their plugins with Plugin::set_offline(true):
VST3 plugins get kOffline in setupProcessing and in every process call,
and CLAP plugins with clap.render are set to CLAP_RENDER_OFFLINE.
Playback stays realtime.

Fixes #1289.
2026-11-02 12:34:32 -03:00
NetterSilverandClaude Haiku 5.5 4b06407dae Keep the edit cursor as the zoom reference when it is off-screen
Fixes #1260

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 15:57:40 +01:00
rahul05ranjan 1a95692f50 Toolbar: distinct link icons, an Insertion Follows Playback toggle, and its N shortcut in the menu
The two link toggles shared the "link" icon (#1262), and Insertion Follows
Playback, although it works with N, had no toolbar button of its own and no
shortcut in the Options menu (#1263).

- icons: "link_track" draws the chain under two track rows so the track link
  reads apart from the timeline link; "insertion_follows" draws the insertion
  line and the playhead arrow it follows.
- toolbar: the option toggles move into option_toggles, which gains the
  insertion-follows entry between the links and Mirrored MIDI, as in Pro Tools.
- options: the Insertion Follows Playback menu item shows N, the key it uses.
- test: option_toggles_have_distinct_icons fails when any two option toggles
  share an icon (it fails on the old "link" pair).

Fixes #1262, fixes #1263

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
2026-11-02 12:15:55 +00:00
Max MittelstädtandGPT-5.6-Luna 7dfa7e470f Indent tracks nested in folders
Fixes #1252

Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
2026-11-02 08:25:43 +00:00
Max MittelstädtandGPT-5.6-Luna 8f7eb4d777 Show stereo input pairs for stereo tracks
Fixes #1255

Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
2026-11-02 08:23:34 +00:00
Max MittelstädtandGPT-5.6-Luna ffc035ba29 Delete markers on Alt-click
Fixes #1257

Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
2026-11-02 08:23:18 +00:00
13 changed files with 58 additions and 310 deletions
-1
View File
@@ -41,4 +41,3 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
Generated
+18 -18
View File
@@ -4297,7 +4297,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"chrono",
"clap",
@@ -4321,7 +4321,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"log",
"serde",
@@ -4331,7 +4331,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"log",
"proptest",
@@ -4343,7 +4343,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"clap",
"log",
@@ -4356,7 +4356,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4369,7 +4369,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"clap",
"clap_complete",
@@ -4387,7 +4387,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"log",
"proptest",
@@ -4398,7 +4398,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"craft-docpath",
"log",
@@ -4421,7 +4421,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4431,7 +4431,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"log",
"rayon",
@@ -4447,7 +4447,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4460,7 +4460,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"cpal",
"log",
@@ -4475,7 +4475,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4485,7 +4485,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -4514,7 +4514,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"craft-media",
"image",
@@ -4526,7 +4526,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4542,7 +4542,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"eframe",
"egui",
@@ -6190,7 +6190,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.17.1"
version = "0.17.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.17.1"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+7 -1
View File
@@ -166,7 +166,13 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
// Control characters are written escaped: a message quoting a session file (a plugin id, a
// path) must not start a line of its own in the log or steer the terminal.
let mut escaped = String::with_capacity(message.len());
for c in message.chars() {
if c.is_control() { escaped.extend(c.escape_default()) } else { escaped.push(c) }
}
format!("{ts} {level:<5} [{thread}] {target}: {escaped}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
+9 -2
View File
@@ -158,8 +158,15 @@ fn a_line_carries_time_level_thread_target_and_message() {
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "soundcraft_mix", "eq_7band: the plugin did not accept its stored state"),
"2026-10-08T07:59:17.728Z WARN [main] soundcraft_mix: eq_7band: the plugin did not accept its stored state\n"
);
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
// A message with breaks of its own stays one record; the record still ends in exactly one newline.
assert!(
format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\\nb\n"),
"a break inside the message is escaped, the final one dropped"
);
assert!(
format_line("t", log::Level::Error, "w", "x", "plugin \u{1b}[2J\r\n2026 INFO forged")
.ends_with("x: plugin \\u{1b}[2J\\r\\n2026 INFO forged\n")
);
}
#[test]
+1 -3
View File
@@ -150,9 +150,7 @@ mod tests {
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("soundcraft-clap-scan-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
// Created exclusively: a folder still there after the cleanup (someone else's) would
// let a link inside it redirect the test's writes.
std::fs::create_dir(&d).unwrap();
std::fs::create_dir_all(&d).unwrap();
d
}
+1 -27
View File
@@ -212,9 +212,6 @@ impl Autosave {
let audio_dir = self.dir.join("Audio Files");
let mut s = session.clone();
let mut copies = BTreeMap::new();
// Copies made in this write, by the audio they hold: sources sharing one decoded buffer
// (a session may name one file under many ids) share one file.
let mut written: BTreeMap<*const SourceAudio, String> = BTreeMap::new();
for src in &mut s.sources {
let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) };
let audio = s.pool.get(src.id);
@@ -227,7 +224,6 @@ impl Autosave {
let Some(audio) = audio else { continue };
let name = match self.copies.remove(&src.id) {
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name,
_ if written.contains_key(&Arc::as_ptr(audio)) => written.get(&Arc::as_ptr(audio)).cloned().unwrap_or_default(),
_ => {
let name = format!("{} {}.wav", sanitize_name(&src.name), self.next);
self.next += 1;
@@ -239,16 +235,13 @@ impl Autosave {
};
src.path = audio_dir.join(&name).to_string_lossy().into_owned();
src.unsaved = true;
written.insert(Arc::as_ptr(audio), name.clone());
copies.insert(src.id, (Arc::downgrade(audio), name));
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?;
write_atomically(&self.dir.join(SESSION), text.as_bytes())?;
for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() {
if !self.copies.values().any(|(_, kept)| *kept == name) {
let _ = std::fs::remove_file(audio_dir.join(name));
}
let _ = std::fs::remove_file(audio_dir.join(name));
}
if let Some(r) = recording {
let plan = serde_json::to_string(r).map_err(|err| EngineError::Io(err.to_string()))?;
@@ -514,25 +507,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sources_sharing_one_buffer_share_one_copy() {
let dir = folder("autosave-aliases");
let mut e = Engine::default();
record(&mut e, 0.5);
// Nine more source ids over the same decoded audio, as a session file may declare.
let s = e.session_mut();
let (src, audio) = (s.sources[0].clone(), s.pool.get(s.sources[0].id).cloned().unwrap());
for _ in 0..9 {
let id = SourceId(s.alloc());
s.sources.push(soundcraft_model::Source { id, ..src.clone() });
s.pool.insert(id, audio.clone());
}
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
a.write(e.session(), None, None).unwrap();
assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1, "one file for the ten ids");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_take_recorded_after_undo_gets_its_own_copy() {
let dir = folder("autosave-undo");
+1 -4
View File
@@ -347,10 +347,7 @@ pub fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec<ClipId> {
if let Some(arr) = p.get("clips").and_then(Value::as_array) {
// Each clip once, in the order given: a repeated id would otherwise be processed (and,
// for a render, re-rendered into another source) once per repeat.
let mut seen = std::collections::HashSet::new();
return arr.iter().filter_map(Value::as_u64).map(ClipId).filter(|id| seen.insert(*id)).collect();
return arr.iter().filter_map(Value::as_u64).map(ClipId).collect();
}
if let Some(n) = p.get("clip").and_then(Value::as_u64) {
return vec![ClipId(n)];
+1 -11
View File
@@ -418,9 +418,7 @@ fn svg_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result<Str
const STAFF_GAP: f64 = 70.0;
const SYSTEM_GAP: f64 = 40.0;
let n_measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0);
// The layout range the setup offers (1 to 16): a caller's own value can't multiply the
// barlines drawn per system beyond it.
let per = setup.bars_per_system.clamp(1, 16) as usize;
let per = setup.bars_per_system.max(1) as usize;
let systems = n_measures.div_ceil(per).max(1);
let label_w = if setup.show_track_names { 70.0 } else { 0.0 };
let staff_x0 = MARGIN + label_w + 34.0;
@@ -784,14 +782,6 @@ mod tests {
assert!(m.items.iter().any(|i| i.pitches.is_empty()), "the discarded suffix became rests");
}
#[test]
fn bars_per_system_is_held_to_the_supported_range() {
let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: u32::MAX, show_track_names: false };
let part = Part { name: "p".into(), bass: false, measures: vec![Measure { numerator: 4, denominator: 4, items: Vec::new() }] };
let out = svg_budgeted(std::slice::from_ref(&part), &setup, 1 << 20).unwrap();
assert_eq!(out.matches("stroke-width=\"1\"").count(), 16, "sixteen barlines, not billions");
}
#[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:
+3 -67
View File
@@ -213,52 +213,18 @@ impl TempoMap {
}
}
/// Remove the meters after `tick`, returning each with the bar it starts: the bar
/// [`Self::bar_beat_at_tick`] gives its tick, counted in one pass over the map rather than one
/// walk per meter, so a session with many meter changes edits in linear time.
/// Remove the meters after `tick`, returning each with the bar it starts.
fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> {
let mut later = Vec::new();
let (mut bar, mut seg_tick) = (1i64, 0i64);
for (i, m) in self.meters.iter().enumerate() {
let tpb = m.ticks_per_bar();
if m.tick > tick {
later.push((bar.saturating_add(m.tick.saturating_sub(seg_tick) / tpb), *m));
}
if let Some(n) = self.meters.get(i + 1) {
bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb));
seg_tick = n.tick;
}
}
let later: Vec<_> = self.meters.iter().filter(|e| e.tick > tick).map(|e| (self.bar_beat_at_tick(e.tick).bar, *e)).collect();
self.meters.retain(|e| e.tick <= tick);
later
}
/// The bar and tick of the last meter's segment, where [`Self::bar_start_tick`]'s walk ends.
fn last_segment(&self) -> (i64, i64) {
let (mut cur_bar, mut cur_tick) = (1i64, 0i64);
for (i, m) in self.meters.iter().enumerate() {
if let Some(n) = self.meters.get(i + 1) {
cur_bar = cur_bar.saturating_add(ceil_div(n.tick.saturating_sub(cur_tick), m.ticks_per_bar()));
cur_tick = n.tick;
}
}
(cur_bar, cur_tick)
}
/// Put meters back at the start of their bars under the current earlier meters, in bar order.
/// Each lands at or past the last meter, so the walk to its bar resumes from there; a bar
/// before it (meters off their bar lines) takes the full walk.
fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) {
let (mut cur_bar, mut cur_tick) = self.last_segment();
for (bar, ev) in later {
let tick = if bar >= cur_bar {
let tpb = self.meters.last().map_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 }, |m| *m).ticks_per_bar();
cur_tick.saturating_add(bar.saturating_sub(cur_bar).saturating_mul(tpb))
} else {
self.bar_start_tick(bar)
};
let tick = self.bar_start_tick(bar);
self.put_meter(MeterEvent { tick, ..ev });
(cur_bar, cur_tick) = if bar >= cur_bar { (bar, tick) } else { self.last_segment() };
}
}
@@ -475,36 +441,6 @@ mod tests {
assert_eq!(m.bar_beat_at_tick(m.tick_at_bar_beat(bb)), bb);
}
#[test]
fn editing_an_early_meter_keeps_later_bars_and_scales_linearly() {
// 200 000 meter changes, one per bar, built directly: a walk per meter would take
// minutes to edit here, and even to build through `set_meter`.
let mut m = TempoMap::new(120.0, 4, 4).unwrap();
let mut tick = 0;
for bar in 1..200_000i64 {
let numerator = 3 + (bar % 2) as u32;
tick += m.meters.last().map_or(0, MeterEvent::ticks_per_bar);
m.meters.push(MeterEvent { tick, numerator, denominator: 4 });
}
let sample = [1usize, 1_000, 199_998];
let bars = |m: &TempoMap| sample.map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar);
assert_eq!(bars(&m), [2, 1_001, 199_999]);
m.set_meter(0, 7, 8).unwrap();
assert_eq!(bars(&m), [2, 1_001, 199_999], "later meters keep their bar numbers");
assert_eq!(m.meters.len(), 200_000);
assert!(m.meters.windows(2).all(|w| w[0].tick < w[1].tick));
assert!(m.remove_meter(m.meters[1].tick));
assert_eq!([1usize, 999, 199_997].map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar), [3, 1_001, 199_999]);
// Meters off their bar lines (counted as bars 2 and 4) land where they always did: on
// bar 2 of the new meter, then two bars of their own later.
let mut off = TempoMap::new(120.0, 4, 4).unwrap();
off.meters.push(MeterEvent { tick: 100, numerator: 3, denominator: 4 });
off.meters.push(MeterEvent { tick: 5_000, numerator: 5, denominator: 4 });
off.set_meter(0, 2, 4).unwrap();
let placed: Vec<(i64, u32)> = off.meters.iter().map(|e| (e.tick, e.numerator)).collect();
assert_eq!(placed, vec![(0, 2), (2 * TICKS_PER_QUARTER, 3), (8 * TICKS_PER_QUARTER, 5)]);
}
#[test]
fn eighth_note_meters() {
let m = TempoMap::new(120.0, 6, 8).unwrap();
+14 -135
View File
@@ -72,22 +72,6 @@ impl Dpb {
self.entries.retain(|e| e.needed_for_output || e.mark != RefMark::Unused);
}
/// Make room for a picture: output one (C.4.5.3), or, when none waits for output and
/// references alone fill the DPB (marking that keeps more than it holds), drop the reference
/// decoded longest ago. False when the DPB is empty.
fn make_room(&mut self, out: &mut Vec<Output>) -> bool {
if self.bump(out) {
return true;
}
let Some(i) = self.entries.iter().enumerate().filter(|(_, e)| e.mark != RefMark::Unused).min_by_key(|(_, e)| e.frame.id).map(|(i, _)| i)
else {
return false;
};
self.entries[i].mark = RefMark::Unused;
self.prune();
true
}
/// Output the picture with the smallest POC (C.4.5.3 "bumping"). Returns false if none waits.
fn bump(&mut self, out: &mut Vec<Output>) -> bool {
let Some(i) = self.entries.iter().enumerate().filter(|(_, e)| e.needed_for_output).min_by_key(|(_, e)| e.poc).map(|(i, _)| i) else {
@@ -150,29 +134,21 @@ impl Dpb {
poc_state: &mut dyn FnMut(u32),
) {
let mut unused = (prev_ref_frame_num + 1) % max_frame_num;
// Only the last `max_num_ref_frames` missing frames can still be references when the gap
// ends: the sliding window drops the earlier ones first, so building their pictures would
// be work (up to 65 535 frame copies from one slice header) thrown straight away. They
// advance the POC state only.
let gap = (frame_num + max_frame_num - unused) % max_frame_num;
let skip = gap.saturating_sub(u32::try_from(max_num_ref_frames.max(1)).unwrap_or(u32::MAX));
let mut guard = 0;
while unused != frame_num && guard < max_frame_num {
self.sliding_window(unused, max_frame_num, max_num_ref_frames);
let (frame, meta) = make_frame(unused);
poc_state(unused);
if guard >= skip {
self.sliding_window(unused, max_frame_num, max_num_ref_frames);
let (frame, meta) = make_frame(unused);
self.entries.push(DpbEntry {
poc: frame.poc,
frame,
frame_num: unused,
long_term_frame_idx: 0,
mark: RefMark::Short,
needed_for_output: false,
non_existing: true,
meta,
});
}
self.entries.push(DpbEntry {
poc: frame.poc,
frame,
frame_num: unused,
long_term_frame_idx: 0,
mark: RefMark::Short,
needed_for_output: false,
non_existing: true,
meta,
});
unused = (unused + 1) % max_frame_num;
guard += 1;
}
@@ -301,11 +277,6 @@ impl Dpb {
}
}
}
3 | 6 if self.max_long_term_frame_idx.is_none_or(|max| m.long_term_frame_idx > max) => {
// A long-term index outside what MMCO 4 (or the IDR) allowed (8.2.5.4.3): a
// conforming stream never sends one, and honouring it would let distinct
// indices retain references past the DPB's capacity.
}
3 => {
let pn = i64::from(curr_pic_num) - (i64::from(m.difference_of_pic_nums_minus1) + 1);
for e in &mut self.entries {
@@ -413,13 +384,13 @@ impl Dpb {
out.push(Output { frame, poc, meta });
return;
}
if !self.make_room(out) {
if !self.bump(out) {
break;
}
}
} else {
while self.entries.len() >= self.capacity {
if !self.make_room(out) {
if !self.bump(out) {
break;
}
}
@@ -458,98 +429,6 @@ mod tests {
Mmco { op, difference_of_pic_nums_minus1: diff, long_term_pic_num: 0, long_term_frame_idx: 0, max_long_term_frame_idx_plus1: 0 }
}
fn frame(id: u32) -> (FrameRef, Arc<OutputMeta>) {
let meta = OutputMeta {
pts: 0,
key: false,
crop: (0, 0, 0, 0),
full_range: false,
colour_primaries: 0,
transfer_characteristics: 0,
matrix_coefficients: 0,
sar: (1, 1),
draft: false,
};
(Arc::new(crate::h264::picture::Frame::new(id, 0, 1, 1)), Arc::new(meta))
}
/// A P slice header of a reference picture, with nothing else set.
fn reference_slice() -> SliceHeader {
SliceHeader {
nal_ref_idc: 1,
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: 0,
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: [0; 2],
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,
}
}
#[test]
fn a_frame_num_gap_builds_only_the_frames_that_stay_references() {
let mut dpb = Dpb::new();
let (mut built, mut poc_updates) = (Vec::new(), 0u32);
// frame_num 0 was the last reference; the next slice says 60_000: 59 999 missing frames.
dpb.fill_frame_num_gap(0, 60_000, 1 << 16, 4, &mut |n| (built.push(n), frame(n)).1, &mut |_| poc_updates += 1);
assert_eq!(poc_updates, 59_999, "every missing frame advances the POC state");
assert_eq!(built, vec![59_996, 59_997, 59_998, 59_999], "only the ones the sliding window would keep are built");
assert_eq!(dpb.entries.iter().map(|e| e.frame_num).collect::<Vec<_>>(), built);
// A gap shorter than the window builds every frame, as before.
let mut dpb = Dpb::new();
let mut built = Vec::new();
dpb.fill_frame_num_gap(7, 10, 16, 4, &mut |n| (built.push(n), frame(n)).1, &mut |_| {});
assert_eq!(built, vec![8, 9]);
}
#[test]
fn long_term_marking_cannot_grow_the_dpb_past_its_capacity() {
let mut dpb = Dpb::new();
dpb.capacity = 4;
let mut out = Vec::new();
let mut sh = SliceHeader { adaptive_ref_pic_marking: true, ..reference_slice() };
// Each picture takes a distinct long-term index, after MMCO 4 allowed 1000 of them.
for n in 0..40u32 {
sh.frame_num = n;
sh.mmcos = vec![Mmco { op: 4, max_long_term_frame_idx_plus1: 1000, ..mmco(0, 0) }, Mmco { op: 6, long_term_frame_idx: n, ..mmco(0, 0) }];
let (f, meta) = frame(n);
dpb.store_picture(&sh, f, n as i32, 1 << 16, 16, meta, &mut out);
assert!(dpb.entries.len() <= 4, "{} entries after picture {n}", dpb.entries.len());
}
// An index past what MMCO 4 allowed is ignored: the picture is a short-term reference.
sh.frame_num = 40;
sh.mmcos = vec![Mmco { op: 6, long_term_frame_idx: 5000, ..mmco(0, 0) }];
let (f, meta) = frame(40);
dpb.store_picture(&sh, f, 40, 1 << 16, 16, meta, &mut out);
assert_eq!(dpb.entries.iter().find(|e| e.frame_num == 40).map(|e| e.mark), Some(RefMark::Short));
}
#[test]
fn mmco_huge_difference_does_not_panic() {
for op in [1, 3] {
-35
View File
@@ -397,10 +397,6 @@ impl Sps {
let pic_height_in_map_units = plus(r.read_ue()?, 1, "picture too large")?;
ensure!(pic_width_in_mbs <= 1024 && pic_height_in_map_units <= 1024, "picture too large");
let frame_mbs_only = r.read_flag()?;
// Picture state is allocated from these before any macroblock is read: the movie's pixel
// budget applies here too, so a 16 384-square SPS can't claim its planes.
let coded_mbs = u64::from(pic_width_in_mbs) * u64::from(pic_height_in_map_units) * if frame_mbs_only { 1 } else { 2 };
ensure!(coded_mbs * 256 <= crate::movie::MAX_PIXELS, "picture too large");
let mb_adaptive_frame_field = if !frame_mbs_only { r.read_flag()? } else { false };
let direct_8x8_inference = r.read_flag()?;
let frame_crop = if r.read_flag()? { Some((r.read_ue()?, r.read_ue()?, r.read_ue()?, r.read_ue()?)) } else { None };
@@ -703,37 +699,6 @@ mod tests {
assert_eq!(l, [10; 16]);
}
#[test]
fn an_sps_past_the_pixel_budget_is_refused_before_allocation() {
// As `parse_minimal_baseline_sps`, with `mbs` × `mbs` macroblocks.
let sps = |mbs: u32, frame_mbs_only: bool| {
let mut w = BitWriter::new();
w.write_bits(66, 8);
w.write_bits(0xC0, 8);
w.write_bits(30, 8);
w.write_ue(0);
w.write_ue(0);
w.write_ue(2);
w.write_ue(1);
w.write_bit(false);
w.write_ue(mbs - 1);
w.write_ue(mbs - 1);
w.write_bit(frame_mbs_only);
if !frame_mbs_only {
w.write_bit(false); // mb_adaptive_frame_field
}
w.write_bit(true);
w.write_bit(false);
w.write_bit(false);
w.rbsp_trailing();
Sps::parse(&w.finish())
};
assert!(sps(512, true).is_ok(), "8192 × 8192 is the budget");
assert!(sps(513, true).is_err(), "one macroblock column more is past it");
assert!(sps(512, false).is_err(), "field pictures double the map units");
assert!(sps(362, false).is_ok());
}
#[test]
fn parse_minimal_baseline_sps() {
// profile 66, level 30, sps_id 0, log2_max_frame_num_minus4 0, poc type 2, 1 ref, 11x9 MBs.
+2 -5
View File
@@ -197,11 +197,8 @@ mod tests {
#[test]
fn a_failed_update_restores_the_manifest() {
// Created exclusively: a folder already there (planted, or left by a failed run) would be
// someone else's, and a link inside it could redirect the writes below.
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos());
let root = std::env::temp_dir().join(format!("xtask-version-restore-{}-{nanos}", std::process::id()));
std::fs::create_dir(&root).unwrap();
let root = std::env::temp_dir().join(format!("xtask-version-restore-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let path = root.join("Cargo.toml");
std::fs::write(&path, MANIFEST).unwrap();
let err = set(&root, &path, MANIFEST, "2.0.0", || Err("cargo update failed".into())).unwrap_err();