From 7f2ff83c4433c41ab130723926756b9318a6545e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:11:28 +0200 Subject: [PATCH 01/14] WAV: an empty data chunk followed by metadata has no frames (#466) A data chunk of size 0 was always read as "everything that follows", so a finished empty WAV with a LIST or bext chunk after its data decoded the metadata as samples and reported a longer file. The streaming-writer case is kept: a zero size is read as empty only when the RIFF size is the file's and only whole chunks follow; a recording whose sizes were never finalised still gives the audio that was written. --- crates/audio-io/src/wav.rs | 31 ++++++++- crates/audio-io/tests/wav_empty_data.rs | 87 +++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 crates/audio-io/tests/wav_empty_data.rs diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index 1aa847d4..6c2b86af 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -140,6 +140,30 @@ pub(crate) fn is_wav(b: &[u8]) -> bool { matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE") } +/// True when everything from `from` to the end of the file is whole RIFF chunks (four printable +/// ASCII id bytes, a size that fits, the pad byte after an odd size; the last chunk's pad may be +/// missing): the metadata of a finished file, not samples. +fn only_chunks_follow(b: &[u8], from: usize) -> bool { + let mut pos = from; + while pos < b.len() { + let (Some(id), Some(size)) = (tag(b, pos), le_u32(b, pos + 4)) else { return false }; + if !id.iter().all(|c| (0x20..0x7f).contains(c)) { + return false; + } + let size = size as usize; + let Some(end) = pos.checked_add(8).and_then(|body| body.checked_add(size)) else { return false }; + if end > b.len() { + return false; + } + let next = end + (size & 1); + if end == b.len() || next == b.len() { + return true; + } + pos = next; + } + false +} + pub(crate) fn parse(b: &[u8]) -> Result { if !is_wav(b) { return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into())); @@ -168,7 +192,12 @@ pub(crate) fn parse(b: &[u8]) -> Result { if &id == b"data" { // Lenient: a truncated recording (or a streaming writer's 0 / 0xFFFFFFFF size) still // yields whatever audio is actually present. - let len = if size32 == 0 && !is_rf64 { remaining } else { declared.min(remaining) }; + // A size of 0 in a file whose RIFF size is right and that has only chunks after it is an + // empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording + // that was never finalised, and what follows is audio. + let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64; + let empty = size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body); + let len = if size32 == 0 && !is_rf64 && !empty { remaining } else { declared.min(remaining) }; if data.is_none() { data = Some((body, len)); } diff --git a/crates/audio-io/tests/wav_empty_data.rs b/crates/audio-io/tests/wav_empty_data.rs new file mode 100644 index 00000000..2299cb36 --- /dev/null +++ b/crates/audio-io/tests/wav_empty_data.rs @@ -0,0 +1,87 @@ +//! A WAV whose data chunk is empty and followed by metadata decodes to no audio; a recording +//! that was never finalised (RIFF and data sizes left at 0) still yields what was written (#466). + +use soundcraft_audio_io::{decode, probe}; + +fn fmt16(channels: u16, sr: u32) -> Vec { + let align = channels * 2; + let mut f = Vec::new(); + f.extend_from_slice(&1u16.to_le_bytes()); + f.extend_from_slice(&channels.to_le_bytes()); + f.extend_from_slice(&sr.to_le_bytes()); + f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes()); + f.extend_from_slice(&align.to_le_bytes()); + f.extend_from_slice(&16u16.to_le_bytes()); + f +} + +fn chunk(id: &[u8; 4], data: &[u8]) -> Vec { + let mut c = id.to_vec(); + c.extend_from_slice(&(data.len() as u32).to_le_bytes()); + c.extend_from_slice(data); + if data.len() % 2 == 1 { + c.push(0); + } + c +} + +/// A finished file: correct RIFF size, chunks as given. +fn riff(chunks: &[Vec]) -> Vec { + let mut body = b"WAVE".to_vec(); + for c in chunks { + body.extend_from_slice(c); + } + let mut out = b"RIFF".to_vec(); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend_from_slice(&body); + out +} + +fn list_info() -> Vec { + let mut l = b"INFO".to_vec(); + l.extend_from_slice(&chunk(b"INAM", b"Silence")); + chunk(b"LIST", &l) +} + +#[test] +fn an_empty_data_chunk_followed_by_metadata_has_no_frames() { + let file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[]), list_info(), chunk(b"bext", &[0u8; 602])]); + let info = probe(&file, Some("wav")).unwrap(); + assert_eq!(info.frames, 0, "the LIST and bext chunks are not samples"); + let (info, buf) = decode(&file, Some("wav")).unwrap(); + assert_eq!((info.frames, buf.frames()), (0, 0)); + // The same with the metadata first. + let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), list_info(), chunk(b"data", &[])]); + assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0); + // And with a single trailing chunk of odd size and no pad byte at the very end. + let mut file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &[]), chunk(b"note", b"abc")]); + file.pop(); + let riff_size = (file.len() - 8) as u32; + file[4..8].copy_from_slice(&riff_size.to_le_bytes()); + assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0); +} + +#[test] +fn a_recording_that_was_never_finalised_still_gives_its_audio() { + let samples: Vec = (0..400i16).flat_map(|i| (i * 40).to_le_bytes()).collect(); + // RIFF size and data size both left at 0, as a recorder writes them until it finishes. + let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]); + file.extend_from_slice(&samples); + file[4..8].copy_from_slice(&0u32.to_le_bytes()); + let (info, buf) = decode(&file, Some("wav")).unwrap(); + assert_eq!(info.frames, 200); + assert_eq!(buf.frames(), 200); + // Even if the RIFF size is the file's, audio that is not a run of chunks is still audio. + let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]); + file.extend_from_slice(&samples); + let riff_size = (file.len() - 8) as u32; + file[4..8].copy_from_slice(&riff_size.to_le_bytes()); + assert_eq!(probe(&file, Some("wav")).unwrap().frames, 200); +} + +#[test] +fn a_data_chunk_with_audio_is_unchanged() { + let samples: Vec = (0..100i16).flat_map(|i| i.to_le_bytes()).collect(); + let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &samples), list_info()]); + assert_eq!(probe(&file, Some("wav")).unwrap().frames, 100); +} From 686f8c96920f4ccdd665b25d68cfa8ff5beee5c3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:11:28 +0200 Subject: [PATCH 02/14] Docs: update the progress docs for this change --- ROADMAP.md | 2 ++ docs/file-format-parity.md | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 73197650..ad946f3e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -155,6 +155,8 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: empty WAV data chunk followed by metadata (#466) | +| 2026-10-13 | WAV: an empty data chunk followed by metadata (LIST, bext) reads as no audio; a recording that was never finalised (sizes left at 0) still gives what was written (#466) | | 2026-10-13 | trivial | Progress log: mix.instrument params, unknown fields refused (#265) | | 2026-10-13 | trivial | Progress log: bus bounce compensates aux returns (#246) | | 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0b13427d..385596db 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (continuous fade curvature in SoundCraft sessions) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (WAV: empty data chunk with trailing metadata (#466); previously minor: continuous fade curvature in SoundCraft sessions) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its @@ -33,7 +33,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | Format | Pro Tools | Read | Write | Tests / notes | |---|---|---|---|---| -| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; also 8-bit and float 64 read | +| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; also 8-bit and float 64 read An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). | | RF64 / BW64 (> 4 GiB) | read/write | ✅ | ✅ | `encode_wav_rf64` | | WAV `WAVE_FORMAT_EXTENSIBLE` multichannel with channel mask | read/write | ✅ | ✅ | `encode_with_channel_mask`; default 5.1 import creates a stereo track (#89, PR #128) | | AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native | @@ -80,5 +80,6 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | WAV: empty data chunk with trailing metadata (#466) | | 2026-10-13 | minor | Fade curvature round-trips in sessions with a zero default for older files. Format version and readiness estimate unchanged | | 2026-10-10 | major | Created: format inventory against Pro Tools 2026.4.1's declared document types and import/bounce options | From d8ab50b5fcc2c22e7519af546a3b740f73cb7d10 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:25:22 +0200 Subject: [PATCH 03/14] Sampler: a ping-pong loop keeps its phase for steps wider than the loop (#463) The ping-pong loop reflected a voice at the loop's end once and clamped it to the edge, so a step wider than the loop (a narrow loop with high transposition or sample-rate conversion) dropped the rest of the travel and made the pitch and loop phase wrong. The distance past the edge is now folded into the loop: an even number of crossings keeps the direction, an odd number turns it round. Steps narrower than the loop move exactly as before. --- crates/dsp/src/plugins/sampler.rs | 71 ++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/dsp/src/plugins/sampler.rs b/crates/dsp/src/plugins/sampler.rs index ee851789..721c2b40 100644 --- a/crates/dsp/src/plugins/sampler.rs +++ b/crates/dsp/src/plugins/sampler.rs @@ -331,15 +331,26 @@ impl Sampler { /// Moves a voice's read position one output frame on, looping or ending it. #[inline] fn advance(v: &mut Voice, len: f64, region: Option<(f64, f64)>, mode: LoopMode) { - v.pos = if v.forward { v.pos + v.step } else { v.pos - v.step }; + let was_forward = v.forward; + v.pos = if was_forward { v.pos + v.step } else { v.pos - v.step }; match region { Some((ls, le)) if mode == LoopMode::PingPong => { - if v.forward && v.pos >= le { - v.pos = (le - (v.pos - le)).max(ls); - v.forward = false; - } else if !v.forward && v.pos <= ls { - v.pos = (ls + (ls - v.pos)).min(le); - v.forward = true; + // How far past the edge it was heading for, measured from the edge it left. A step of + // more than the loop's width crosses it more than once: fold the distance into the + // loop (even crossings keep the direction, odd ones turn it round) instead of + // reflecting once and dropping the rest of the travel. + let width = le - ls; + let dist = if was_forward { v.pos - ls } else { le - v.pos }; + if width > 0.0 && dist >= width && dist.is_finite() { + let crossings = (dist / width).floor(); + let into = dist - crossings * width; + let odd = crossings % 2.0 == 1.0; + (v.pos, v.forward) = match (was_forward, odd) { + (true, false) => (ls + into, true), + (true, true) => (le - into, false), + (false, false) => (le - into, false), + (false, true) => (ls + into, true), + }; } } Some((ls, le)) => { @@ -703,6 +714,52 @@ mod tests { assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0)); } + /// Where a ping-pong voice is after `n` steps, by walking a triangle wave over the loop. + fn triangle(start: f64, forward: bool, step: f64, n: u32, ls: f64, le: f64) -> (f64, bool) { + let width = le - ls; + // Unfold the walk onto one line, then fold it back: phase 0..width rising, width..2 width falling. + let signed = if forward { start - ls } else { (le - start) + width }; + let t = (signed + f64::from(n) * step).rem_euclid(2.0 * width); + if t < width { (ls + t, true) } else { (le - (t - width), false) } + } + + #[test] + fn a_ping_pong_step_wider_than_the_loop_keeps_the_phase() { + let (ls, le) = (100.0, 110.0); + // Steps below, equal to and above the loop's width, with fractions, both ways round. + for step in [0.37, 3.0, 9.99, 10.0, 10.5, 23.7, 25.0, 31.3, 99.9] { + for forward in [true, false] { + let mut v = Voice::new(); + v.pos = if forward { 102.5 } else { 107.25 }; + v.forward = forward; + v.step = step; + let start = v.pos; + for n in 1..=40u32 { + advance(&mut v, 1000.0, Some((ls, le)), LoopMode::PingPong); + let (want, want_forward) = triangle(start, forward, step, n, ls, le); + assert!(v.pos >= ls - 1e-9 && v.pos <= le + 1e-9, "step {step} n {n}: {} left the loop", v.pos); + // At an exact end the direction is where the position is heading next. + let at_end = (want - ls).abs() < 1e-6 || (want - le).abs() < 1e-6; + assert!((v.pos - want).abs() < 1e-6, "step {step} {forward} n {n}: at {} want {want}", v.pos); + assert!(at_end || v.forward == want_forward, "step {step} {forward} n {n}: direction"); + } + } + } + } + + #[test] + fn a_ping_pong_step_below_the_loop_width_moves_as_before() { + let mut v = Voice::new(); + v.pos = 108.0; + v.step = 5.0; + advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong); + assert_eq!((v.pos, v.forward), (107.0, false), "one reflection off the end"); + advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong); + assert_eq!((v.pos, v.forward), (102.0, false)); + advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong); + assert_eq!((v.pos, v.forward), (103.0, true), "and one off the start"); + } + #[test] fn a_loop_too_short_to_loop_plays_once() { let mut s = sampler(mono(SR, vec![0.5; 1000])); From 7b227533309a76a44a3a4f900084958f11d97f2d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:25:22 +0200 Subject: [PATCH 04/14] Docs: update the progress docs for this change --- ROADMAP.md | 2 ++ docs/plugin-parity.md | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 73197650..036d9142 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -155,6 +155,8 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: sampler ping-pong phase for wide steps (#463) | +| 2026-10-13 | Sampler: a ping-pong loop keeps its phase and direction when a step is wider than the loop (a narrow loop with high transposition), instead of reflecting once and dropping the rest of the travel (#463) | | 2026-10-13 | trivial | Progress log: mix.instrument params, unknown fields refused (#265) | | 2026-10-13 | trivial | Progress log: bus bounce compensates aux returns (#246) | | 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) | diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index 9280d921..e4c19db9 100644 --- a/docs/plugin-parity.md +++ b/docs/plugin-parity.md @@ -1,6 +1,6 @@ # Plugin parity: built-in processors and plugin hosting -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins cover the rest. This doc tracks both. Our plugins have our own names and are written from @@ -21,7 +21,7 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories. | Harmonic | lo-fi, rectifier, amp simulation, saturation, tape | `lofi`, `rectifier`, `saturator` | Amp and cabinet models, tape | | Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift` | Formant control, varispeed effect, pitch correction | | Utility | trim, gain, invert, DC offset, signal generator, dither, time adjuster | `trim`, `gain`, `invert`, `dc_offset_removal`, `signal_generator`, `dither` | Time adjuster, meter/analyzer plugins, noise shaping options | -| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth`; `mix.instrument` takes the instrument's parameters in `params` and refuses unknown fields (#265) | Sampler (PR #108 open), piano, organ, wavetable, content library | +| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth`; `mix.instrument` takes the instrument's parameters in `params` and refuses unknown fields (#265) The sampler's ping-pong loop keeps its phase when a step is wider than the loop (#463). | Sampler (PR #108 open), piano, organ, wavetable, content library | | **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **26 processors + 2 instruments** | | AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in @@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) | | 2026-10-13 | trivial | mix.instrument sets built-in instrument parameters in params (#265) | | 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows | | 2026-10-10 | major | Created: built-in processor inventory by category and third-party hosting checklist | From df0e8928d76a035d0eab105f2356b912537201f6 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:50:58 +0200 Subject: [PATCH 05/14] Keep overlapping clips of a clip group when importing it (#452) --- crates/engine/src/clip_group_file.rs | 32 +++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index e20caf0c..4708a6ec 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -149,6 +149,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - s.add_track(kind, fmt, Some("Clip Group")) } }; + let mut prepared = Vec::new(); for mut c in lane { if let ClipContent::Audio { source, offset } = &mut c.content { let Some(n) = map.get(&source.0) else { continue }; @@ -163,7 +164,16 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - c.group = Some(group_id); c.sanitize(); placed.push(c.id); - crate::edit::place_clip(s, target, c); + prepared.push(c); + } + // Clear what was already on the track first, then add the whole lane at once, so clips of + // the group that overlap each other (crossfades) keep their overlap. + for c in &prepared { + crate::edit::clear_range(s, target, c.range(), false); + } + if let Some(pl) = s.track_mut(target).and_then(|t| t.playlist_mut()) { + pl.clips.extend(prepared); + pl.sort(); } } Ok(placed) @@ -229,6 +239,26 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn overlapping_clips_of_a_group_keep_their_overlap_on_import() { + let dir = std::env::temp_dir().join(format!("sc-grp-overlap-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let p = dir.join("overlap.scgrp"); + let note = json!({"pitch": 60, "velocity": 100, "release_velocity": 64, "channel": 0, "start": 0, "length": 100}); + let content = json!({"Midi": {"sequence": {"notes": [note], "ctrls": []}}}); + let a = json!({"id": 1, "name": "a", "start": 0, "length": 1000, "content": content}); + let b = json!({"id": 2, "name": "b", "start": 600, "length": 1000, "content": content}); + let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 1600, "lanes": [[a, b]], "audio": {}}); + std::fs::write(&p, file.to_string()).unwrap(); + let mut e = crate::demo::demo_engine(); + let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap(); + let ids: Vec<_> = r["clips"].as_array().unwrap().iter().filter_map(serde_json::Value::as_u64).collect(); + assert_eq!(ids.len(), 2); + let spans: Vec<_> = ids.iter().map(|id| e.session().find_clip(soundcraft_model::ClipId(*id)).map(|(_, c)| (c.start, c.length))).collect(); + assert_eq!(spans, vec![Some((0, 1000)), Some((600, 1000))]); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_file_named_by_many_ids_becomes_one_source() { let dir = std::env::temp_dir().join(format!("sc-grp-shared-{}", std::process::id())); From 8840fbc87a3ccf8ef69a9731baeff81cfbcd2853 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:46:27 +0200 Subject: [PATCH 06/14] Decode and probe zero-frame FLAC files written by our encoder Found while testing crates/audio-io/src/symph.rs:41 (open/run, used by probe and decode for FLAC) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/audio-io/src/flac.rs | 44 +++++++++++++++++++++++++++++- crates/audio-io/src/symph.rs | 30 ++++++++++++++++---- crates/audio-io/tests/roundtrip.rs | 25 +++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/crates/audio-io/src/flac.rs b/crates/audio-io/src/flac.rs index 8a8c0ce1..657b9119 100644 --- a/crates/audio-io/src/flac.rs +++ b/crates/audio-io/src/flac.rs @@ -3,7 +3,7 @@ //! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`]. use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer}; -use crate::{AudioBuffer, AudioError, BitDepth, Result}; +use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat}; use std::io::{Cursor, Seek, SeekFrom, Write}; const BLOCK_SIZE: usize = 4096; @@ -454,6 +454,48 @@ pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> { Ok(()) } +/// The info of a FLAC stream with no audio frames at all: STREAMINFO declares 0 samples and +/// nothing follows the metadata blocks. Symphonia's reader fails on these, as it looks for a first +/// frame, so the caller answers them itself. +pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option { + let mut pos = at.checked_add(4)?; + let mut first = true; + loop { + let head = be_u32(b, pos)?; + let (body, len) = (pos.checked_add(4)?, (head & 0x00ff_ffff) as usize); + let end = body.checked_add(len)?; + if end > b.len() { + return None; + } + if first && ((head >> 24) & 0x7f != 0 || len != 34) { + return None; + } + first = false; + if head >> 31 == 1 { + if end != b.len() { + return None; + } + break; + } + pos = end; + } + // STREAMINFO, from the sample rate on: 20 bits rate, 3 channels - 1, 5 bits per sample - 1, 36 bits total. + let packed = u64::from_be_bytes(b.get(at + 4 + 4 + 10..at + 4 + 4 + 18)?.try_into().ok()?); + let sample_rate = (packed >> 44) as u32; + let channels = ((packed >> 41) & 0x7) as u16 + 1; + let bps = ((packed >> 36) & 0x1f) as u32 + 1; + if packed & 0xf_ffff_ffff != 0 || sample_rate == 0 { + return None; + } + let sample_format = match bps { + 1..=8 => SampleFormat::Int8, + 9..=16 => SampleFormat::Int16, + 17..=24 => SampleFormat::Int24, + _ => SampleFormat::Int32, + }; + Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None }) +} + /// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds. /// The vendor string is skipped unread. fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool { diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index 61691114..5bfe91da 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -36,9 +36,15 @@ fn guarded(f: impl FnOnce() -> Result) -> Result { catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into()))) } +/// What [`open`] found: a reader, or a stream that holds no audio frames and has no reader. +enum Opened { + Reader(Box), + Empty(AudioInfo), +} + /// Symphonia's probe loop (`Probe::format`, whose hint 0.5 ignores), checking the container it /// finds before its reader allocates whatever the file claims. -fn open(bytes: &[u8]) -> Result> { +fn open(bytes: &[u8]) -> Result { let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() }; loop { @@ -50,11 +56,16 @@ fn open(bytes: &[u8]) -> Result> { match head.get(..4) { Some(b"caff") => crate::caf::check(bytes, at)?, Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?, - Some(b"fLaC") => crate::flac::check_metadata(bytes, at)?, + Some(b"fLaC") => { + crate::flac::check_metadata(bytes, at)?; + if let Some(info) = crate::flac::empty_stream_info(bytes, at) { + return Ok(Opened::Empty(info)); + } + } Some(b"RIFF") => crate::wav::check_info(bytes, at)?, _ => {} } - return reader(mss, &fmt_opts).map_err(map_err); + return reader(mss, &fmt_opts).map(Opened::Reader).map_err(map_err); } // Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it. (Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => { @@ -141,7 +152,13 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi /// Decode the whole first audio track. When `count_only`, samples are counted but not stored. fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> { - let mut reader = open(bytes)?; + let mut reader = match open(bytes)? { + Opened::Reader(reader) => reader, + Opened::Empty(info) => { + let buf = AudioBuffer::new(info.sample_rate, usize::from(info.channels), 0); + return Ok((info, buf)); + } + }; let (track_id, mut info) = info_of(reader.as_ref(), detected)?; if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) { let total = n.saturating_mul(u64::from(info.channels.max(1))); @@ -237,7 +254,10 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result { guarded(|| { - let reader = open(bytes)?; + let reader = match open(bytes)? { + Opened::Reader(reader) => reader, + Opened::Empty(info) => return Ok(info), + }; let (_, info) = info_of(reader.as_ref(), detected)?; if info.frames > 0 && info.channels > 0 { return Ok(info); diff --git a/crates/audio-io/tests/roundtrip.rs b/crates/audio-io/tests/roundtrip.rs index a53c272a..374e3aa7 100644 --- a/crates/audio-io/tests/roundtrip.rs +++ b/crates/audio-io/tests/roundtrip.rs @@ -284,3 +284,28 @@ fn float_wav_header_is_rewritten_in_place_as_the_file_grows() { assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len)); assert!(wav_float_header(0, 48_000, 0).is_err()); } + +#[test] +fn zero_frame_flac_round_trips_like_wav_and_aiff() { + for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] { + for depth in [BitDepth::Int16, BitDepth::Int24] { + for channels in [1, 2] { + let opts = EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() }; + let bytes = encode(&AudioBuffer::new(48_000, channels, 0), &opts).unwrap(); + let info = probe(&bytes, None).unwrap(); + assert_eq!((info.format, info.sample_format, info.sample_rate, info.frames), (format, expected_sf(depth), 48_000, 0)); + assert_eq!(usize::from(info.channels), channels); + let (info, buf) = decode(&bytes, None).unwrap(); + assert_eq!((info.frames, buf.frames(), buf.num_channels()), (0, 0, channels)); + } + } + } +} + +#[test] +fn flac_claiming_frames_but_holding_none_still_errors() { + let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, ..EncodeOptions::default() }; + let mut bytes = encode(&AudioBuffer::new(48_000, 2, 0), &opts).unwrap(); + bytes[25] = 1; // STREAMINFO total samples (low byte of the 36-bit field) = 1 + assert!(decode(&bytes, None).is_err()); +} From da404551b19b2ec94971e43c64b1b845f89cc684 Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:59:16 +0800 Subject: [PATCH 07/14] fix(mix): keep master automation within render blocks --- ROADMAP.md | 4 +++- crates/mix/src/lib.rs | 52 +++++++++++++++++++++++++++++++++++++++++-- docs/gaps.md | 8 ++++++- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 73197650..70578709 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (readiness by audience with hours to ~95 %) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** trivial (master fader automation block boundaries) · **Target:** Avid Pro Tools Ultimate 2026.4.1 SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-16:** Master fader volume automation uses each render block's actual last sample, matching track faders. Breakpoints in the next block no longer pull the preceding block's level toward them, and ramps reach their last-sample value. Regression tests cover nonzero starts, one-sample callbacks and several block sizes. No readiness percentage change. - **2026-10-13:** `mix.instrument` takes the built-in instrument's parameters in `params` (validated, clamped) and refuses any other field, so a synth setting written beside `plugin` is an error instead of being accepted and ignored (#265) - **2026-10-13:** A bounce of a bus that an aux returns onto lines up with the timeline: the aux insert's lookahead is compensated like the main mix's (it left the bus late and cut the end off), and tracks routed to that bus wait for it too (#246) - **2026-10-13:** Delay compensation covers the inserts of the master faders: a bounce of the main mix with a Maximizer on the master lines up with the same mix without it (it came out 71 samples late) (#351) @@ -155,6 +156,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged | | 2026-10-13 | trivial | Progress log: mix.instrument params, unknown fields refused (#265) | | 2026-10-13 | trivial | Progress log: bus bounce compensates aux returns (#246) | | 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) | diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index e8c4fbad..79ed62ef 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -765,12 +765,14 @@ impl MixEngine { } } } + // Match track faders: the next block's first sample must not ramp this one. + let last = pos.saturating_add(frames.saturating_sub(1) as i64); let v0 = volume_db_at(t, pos); - let v1 = volume_db_at(t, pos + frames as i64); + let v1 = volume_db_at(t, last); for (ch, src) in strip.buf.iter().enumerate().take(self.main.len()) { if let Some(m) = self.main.get_mut(ch) { for i in 0..frames { - let g = lerp_gain(v0, v1, i, frames); + let g = lerp_gain(v0, v1, i, frames.saturating_sub(1)); if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) { *d = x * g; } @@ -2473,6 +2475,52 @@ mod tests { assert!(out[0][30_000].abs() < 1e-4); } + #[test] + fn master_volume_breakpoint_does_not_ramp_the_previous_block() { + for block in [1, 2, 37, 512, 1024] { + let start = 37; + let boundary = start + block as i64; + let end = boundary + block as i64; + let (mut s, t) = session_with_dc(0.5, end as usize); + s.track_mut(t).unwrap().mixer.pan = vec![-1.0]; + let master = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None); + let lane = s.track_mut(master).unwrap().lane_mut(&AutoParam::Volume); + lane.set_point(start, 0.0); + lane.set_point(boundary - 1, 0.0); + lane.set_point(boundary, -60.0); + lane.set_point(end - 1, -60.0); + + let mut mixer = MixEngine::new(48_000.0, block); + let mut out = vec![vec![0.0; block]; 2]; + for (pos, expected) in [(start, 0.5), (boundary, 0.0005)] { + mixer.render(&s, pos, block, &mut out); + for (i, &sample) in out[0].iter().enumerate() { + assert!((sample - expected).abs() < 1e-6, "block {block}, position {pos}, sample {i}: {sample} vs {expected}"); + } + } + } + } + + #[test] + fn master_volume_ramp_reaches_the_last_sample_of_the_block() { + for block in [2, 37, 512, 1024] { + let start = 37; + let end = start + block as i64; + let (mut s, t) = session_with_dc(0.5, end as usize); + s.track_mut(t).unwrap().mixer.pan = vec![-1.0]; + let master = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None); + let lane = s.track_mut(master).unwrap().lane_mut(&AutoParam::Volume); + lane.set_point(start, 0.0); + lane.set_point(end - 1, -12.0); + + let out = render_range(&s, Range::new(start, end), block); + assert_eq!(out[0].len(), block); + assert!((out[0][0] - 0.5).abs() < 1e-6); + let expected = 0.5 * db_to_gain(-12.0); + assert!((out[0][block - 1] - expected).abs() < 1e-6, "block {block}: {} vs {expected}", out[0][block - 1]); + } + } + #[test] fn inserts_process_and_survive_unknown_plugins() { let (mut s, t) = session_with_dc(0.5, 4800); diff --git a/docs/gaps.md b/docs/gaps.md index f8c39b8a..498c3c50 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G-01: audio device settings and opt-in ASIO) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** trivial (G-04: master fader automation block boundaries) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker** @@ -80,6 +80,11 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t ### G-04. Correctness bugs in "done" features +**Fixed (2026-10-16):** Master fader volume automation uses the first and last samples of each +render block, matching track faders. A breakpoint at the next block no longer ramps the preceding +block, and ramps reach their last-sample value. Regression tests cover nonzero render starts, +one-sample callbacks and several block sizes. Interior automation breakpoints remain block-dependent. + Open on 2026-10-10 (several have PRs open): editing #3, #5, #140, #144; clip/session #86 (PR #123), #88 (PR #130), #91 (PR #124), #92 (PR #132); automation #104 (PR #134); mix/bounce #59 (PR #131), #103 (PR #133), #152; playback #101 (PR #129); @@ -199,6 +204,7 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged | | 2026-10-12 | minor | G-01 shrunk: audio device, rate and buffer choice; opt-in ASIO | | 2026-10-10 | minor | Marked the alpha blockers (G-01, G-02, G-03; parts of G-05 and G-06) from the core-workflow gate | | 2026-10-10 | major | Created from the full re-measure against Pro Tools Ultimate 2026.4.1: 22 ranked gaps with evidence and estimates | From 4b1c025427485ab737403441d116747cbac28d3e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:08:32 +0200 Subject: [PATCH 08/14] Honour start or end given alone in markers.add Found while testing crates/engine/src/cmd/event.rs:300-305 (add_marker) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/event.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index 205f038c..7231ab0e 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -300,6 +300,7 @@ fn add_marker(e: &mut Engine, p: &Value) -> Result { let (a, b) = match (at, st, en) { (Some(a), _, _) => (a, a), (None, Some(a), Some(b)) => (a, b), + (None, Some(p), None) | (None, None, Some(p)) => (p, p), _ if kind == MarkerKind::Selection => (sel.start, sel.end), _ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start), }; @@ -443,6 +444,20 @@ mod tests { assert_eq!((bridge.name.as_str(), bridge.comments.as_str()), ("Bridge", "last")); } + /// `markers.add` with only `start` or only `end` places the marker there, not at the edit selection. + #[test] + fn markers_add_start_or_end_alone_is_used() { + let mut e = demo_engine(); + assert_ne!(e.session().edit.selection.start, 96_000); + let a = e.execute("markers.add", &json!({"name": "A", "start": 96_000})).unwrap(); + let b = e.execute("markers.add", &json!({"name": "B", "end": 96_000})).unwrap(); + let c = e.execute("markers.add", &json!({"name": "C", "start": 1000, "end": 2000})).unwrap(); + let find = |r: &serde_json::Value| e.session().markers.iter().find(|m| Some(m.id.0) == r["id"].as_u64()).map(|m| (m.start, m.end)); + assert_eq!(find(&a), Some((96_000, 96_000))); + assert_eq!(find(&b), Some((96_000, 96_000))); + assert_eq!(find(&c), Some((1000, 2000))); + } + /// Constant replaces the tempo over [start, end) only; the tempo at the old end tick carries on after it. #[test] fn tempo_constant_is_bounded_by_its_range() { From 2a232036c253ef629c52ccc5f66f22c02154bac1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:11:34 +0000 Subject: [PATCH 09/14] Apply mix.volume once to each member of a targeted mix group Found while testing crates/engine/src/cmd/mix.rs:206-222 (volume, with set_grouped at 185) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/mix.rs | 54 ++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index c0569429..312a54a2 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -181,19 +181,24 @@ pub fn specs() -> Vec { ] } -/// Apply a change to `t` and its active mix-group siblings. -fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) { - let mut targets = vec![t]; +/// `t` plus the members of its active mix groups that share `attr`. +fn grouped_ids(s: &Session, t: TrackId, attr: &str) -> Vec { + let mut ids = vec![t]; for g in &s.groups { if g.active && g.mix && g.members.contains(&t) && g.attributes.iter().any(|a| a == attr) { for m in &g.members { - if !targets.contains(m) { - targets.push(*m); + if !ids.contains(m) { + ids.push(*m); } } } } - for id in targets { + ids +} + +/// Apply a change to `t` and its active mix-group siblings. +fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) { + for id in grouped_ids(s, t, attr) { if let Some(tr) = s.track_mut(id) { f(tr); } @@ -218,7 +223,13 @@ fn volume(e: &mut Engine, p: &Value) -> Result { return Err(bad("mix.volume", "`db` or `delta_db` required")); } let s = e.session_mut(); + // Tracks already moved as a group sibling of an earlier target must not move again. + let mut done: Vec = Vec::new(); for t in &tracks { + if done.contains(t) { + continue; + } + done.extend(grouped_ids(s, *t, "volume")); let cur = s.track(*t).map_or(0.0, |tr| tr.mixer.volume_db); let new = match (abs, delta) { (Some(a), _) => a as f32, @@ -886,4 +897,35 @@ mod tests { assert_eq!(sl.target, soundcraft_model::Route::Bus(delay)); assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true)); } + + fn grouped_demo() -> Engine { + let mut e = crate::demo::demo_engine(); + e.execute("mix.volume", &json!({"tracks": [3], "db": 0.0})).unwrap(); + e.execute("mix.volume", &json!({"tracks": [6], "db": 0.0})).unwrap(); + e.execute("track.group", &json!({"tracks": [3, 6]})).unwrap(); + e + } + + fn volumes(e: &Engine) -> Vec { + [3, 6].iter().map(|id| e.session().track(TrackId(*id)).unwrap().mixer.volume_db).collect() + } + + #[test] + fn volume_delta_on_a_whole_group_applies_once() { + let mut e = grouped_demo(); + e.execute("mix.volume", &json!({"tracks": [3, 6], "delta_db": -3.0})).unwrap(); + assert_eq!(volumes(&e), [-3.0, -3.0]); + + let mut e = grouped_demo(); + e.execute("mix.volume", &json!({"tracks": [3], "delta_db": -3.0})).unwrap(); + assert_eq!(volumes(&e), [-3.0, -3.0]); + } + + #[test] + fn volume_absolute_on_a_whole_group_keeps_offsets() { + let mut e = grouped_demo(); + e.session_mut().track_mut(TrackId(6)).unwrap().mixer.volume_db = -2.0; + e.execute("mix.volume", &json!({"tracks": [3, 6], "db": -10.0})).unwrap(); + assert_eq!(volumes(&e), [-10.0, -12.0]); + } } From 89d113a52a77c23f0fae85d3d64d8aa23dc23df7 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:29:15 +0200 Subject: [PATCH 10/14] Reject unknown param in automation.clear instead of clearing all lanes Found while testing crates/engine/src/cmd/mix.rs:516 (clear_lane) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/mix.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index c0569429..d93cbe8b 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -513,7 +513,7 @@ pub(crate) fn current_value(tr: &soundcraft_model::Track, param: &AutoParam) -> fn clear_lane(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "automation.clear", p)?; - let param = str_param(p, "param").and_then(AutoParam::parse); + let param = if str_param(p, "param").is_some() { Some(lane_param(p, "automation.clear")?) } else { None }; let r = range_param(e, "automation.clear", p)?; let (a, b) = if r.is_empty() { (i64::MIN, i64::MAX) } else { (r.start, r.end) }; let s = e.session_mut(); @@ -886,4 +886,19 @@ mod tests { assert_eq!(sl.target, soundcraft_model::Route::Bus(delay)); assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true)); } + + #[test] + fn clear_with_unknown_param_keeps_lanes() { + let (mut e, t) = tone_session(&[(0, -6.0), (100, -3.0)]); + e.session_mut().track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0)).set_point(0, 0.5); + e.session_mut().edit.selection = Range::new(0, 0); + let count = |e: &Engine| e.session().track(t).unwrap().automation.iter().map(|l| l.points.len()).sum::(); + let r = e.execute("automation.clear", &json!({"track": t.0, "param": "volum"})); + assert!(r.is_err(), "unknown param succeeded: {r:?}"); + assert_eq!(count(&e), 3, "unknown param changed the lanes"); + e.execute("automation.clear", &json!({"track": t.0, "param": "volume"})).unwrap(); + assert_eq!(count(&e), 1, "named param cleared the wrong lanes"); + e.execute("automation.clear", &json!({"track": t.0})).unwrap(); + assert_eq!(count(&e), 0, "absent param must still clear every lane"); + } } From c6037f3f7dad1cf8814d23be771698d2c182e6d8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:04:09 +0200 Subject: [PATCH 11/14] Saturate note tick addition in event.extract_chords Found while testing crates/engine/src/cmd/event_more.rs:579-580 (extract_chords) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/event_more.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/event_more.rs b/crates/engine/src/cmd/event_more.rs index c0780a55..b2da63d4 100644 --- a/crates/engine/src/cmd/event_more.rs +++ b/crates/engine/src/cmd/event_more.rs @@ -576,8 +576,8 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result { let base = s.tempo.samples_to_ticks(c.start, s.sample_rate); let end = s.tempo.samples_to_ticks(c.end(), s.sample_rate); for n in &sequence.notes { - let a = base + n.start; - let b = (base + n.end()).min(end); + let a = base.saturating_add(n.start); + let b = base.saturating_add(n.end()).min(end); if b > a { notes.push((a, b, n.pitch)); } @@ -943,6 +943,17 @@ mod tests { assert!(r["bpm"].as_f64().is_some()); } + #[test] + fn extract_chords_survives_huge_note_start() { + let (mut e, t) = midi_engine(); + let r = e.execute("midi.new_clip", &json!({"track": t.0, "start": 48_000, "end": 96_000})).unwrap(); + let clip = r["clip"].as_u64().unwrap(); + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": i64::MAX, "length_ticks": 480})).unwrap(); + e.execute("edit.select", &json!({"tracks": [t.0], "start": 0, "end": 96_000})).unwrap(); + let r = e.execute("event.extract_chords", &json!({})); + assert!(r.is_ok(), "{r:?}"); + } + #[test] fn move_song_start_moves_tick_material() { let (mut e, t) = midi_engine(); From d624f7ddb8cf46991c6782b5c6c887efcaffd79c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 02:42:50 -0400 Subject: [PATCH 12/14] flac: checked offsets when reading the empty file's STREAMINFO Co-Authored-By: Claude Opus 5.5 --- crates/audio-io/src/flac.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/audio-io/src/flac.rs b/crates/audio-io/src/flac.rs index 657b9119..b79e6e72 100644 --- a/crates/audio-io/src/flac.rs +++ b/crates/audio-io/src/flac.rs @@ -480,7 +480,8 @@ pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option { pos = end; } // STREAMINFO, from the sample rate on: 20 bits rate, 3 channels - 1, 5 bits per sample - 1, 36 bits total. - let packed = u64::from_be_bytes(b.get(at + 4 + 4 + 10..at + 4 + 4 + 18)?.try_into().ok()?); + let si = at.checked_add(4 + 4 + 10)?; + let packed = u64::from_be_bytes(b.get(si..si.checked_add(8)?)?.try_into().ok()?); let sample_rate = (packed >> 44) as u32; let channels = ((packed >> 41) & 0x7) as u16 + 1; let bps = ((packed >> 36) & 0x1f) as u32 + 1; From 5de30f96a1da430351eb775966341b10cde791b0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 02:42:56 -0400 Subject: [PATCH 13/14] docs: fix the #466 table rows Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- docs/file-format-parity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ad946f3e..06664ca8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-13:** WAV: an empty data chunk followed by metadata (LIST, bext) reads as no audio; a recording that was never finalised (sizes left at 0) still gives what was written (#466) - **2026-10-13:** `mix.instrument` takes the built-in instrument's parameters in `params` (validated, clamped) and refuses any other field, so a synth setting written beside `plugin` is an error instead of being accepted and ignored (#265) - **2026-10-13:** A bounce of a bus that an aux returns onto lines up with the timeline: the aux insert's lookahead is compensated like the main mix's (it left the bus late and cut the end off), and tracks routed to that bus wait for it too (#246) - **2026-10-13:** Delay compensation covers the inserts of the master faders: a bounce of the main mix with a Maximizer on the master lines up with the same mix without it (it came out 71 samples late) (#351) @@ -156,7 +157,6 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| | 2026-10-13 | trivial | Progress log: empty WAV data chunk followed by metadata (#466) | -| 2026-10-13 | WAV: an empty data chunk followed by metadata (LIST, bext) reads as no audio; a recording that was never finalised (sizes left at 0) still gives what was written (#466) | | 2026-10-13 | trivial | Progress log: mix.instrument params, unknown fields refused (#265) | | 2026-10-13 | trivial | Progress log: bus bounce compensates aux returns (#246) | | 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 385596db..2c322c83 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -33,7 +33,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | Format | Pro Tools | Read | Write | Tests / notes | |---|---|---|---|---| -| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; also 8-bit and float 64 read An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). | +| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; also 8-bit and float 64 read. An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). | | RF64 / BW64 (> 4 GiB) | read/write | ✅ | ✅ | `encode_wav_rf64` | | WAV `WAVE_FORMAT_EXTENSIBLE` multichannel with channel mask | read/write | ✅ | ✅ | `encode_with_channel_mask`; default 5.1 import creates a stereo track (#89, PR #128) | | AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native | From 9d3d02dbe79198dd7fa81adea43afee6b0c6a749 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 03:08:37 -0400 Subject: [PATCH 14/14] clip group import test: check positions relative to the insertion point Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/clip_group_file.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index 5e42c978..9063400e 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -258,8 +258,10 @@ mod tests { let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap(); let ids: Vec<_> = r["clips"].as_array().unwrap().iter().filter_map(serde_json::Value::as_u64).collect(); assert_eq!(ids.len(), 2); - let spans: Vec<_> = ids.iter().map(|id| e.session().find_clip(soundcraft_model::ClipId(*id)).map(|(_, c)| (c.start, c.length))).collect(); - assert_eq!(spans, vec![Some((0, 1000)), Some((600, 1000))]); + let spans: Vec<_> = ids.iter().filter_map(|id| e.session().find_clip(soundcraft_model::ClipId(*id)).map(|(_, c)| (c.start, c.length))).collect(); + // The group lands at the insertion point; its clips keep their relative positions and overlap. + let at = spans.first().map_or(0, |s| s.0); + assert_eq!(spans, vec![(at, 1000), (at + 600, 1000)]); let _ = std::fs::remove_dir_all(&dir); }