From 245015fd89135eaa0f9b014091e4c8ed5fc0d86c Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Tue, 13 Oct 2026 14:55:34 +0200 Subject: [PATCH 01/90] VST3 host: IMessage and IAttributeList for plugins (Helix loads its database) `IHostApplication::createInstance` returned nothing, so plugins whose controller and processor exchange `IMessage`s through `IConnectionPoint::notify` could not create one. Line 6 Helix Stadium Native reported "Failed to get content database recovery info (Out of memory condition. [code -2])", loaded no models and exposed no parameters. The host now creates `IMessage` (an id plus its attribute list) and `IAttributeList` (ints, floats, UTF-16 strings and binary blobs, by NUL-terminated attribute id). Strings and blobs are kept in their own allocation so pointers from `getBinary` stay valid while the list holds the value; strings are capped at 1M UTF-16 units and blobs at 256 MiB. Null arguments are refused rather than read. Other classes are still not provided. Verified with Helix Stadium Native 1.03.3a on Windows: the error is gone, its model database and presets load, and its parameters appear in the generic plugin window. A unit test drives the objects through the COM interfaces the way a plugin does. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/vst3-host/src/ffi.rs | 267 +++++++++++++++++++++++++++++++++++- docs/plugin-parity.md | 3 +- 3 files changed, 264 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 73197650..094a9a7d 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 (VST3 host messages):** The VST3 host creates `IMessage` and `IAttributeList` objects for plugins (`IHostApplication::createInstance`), so plugins whose controller and processor talk through `IConnectionPoint::notify` work: Line 6 Helix Stadium Native now loads its content database (it reported "Failed to get content database recovery info (Out of memory condition)") and exposes its parameters. 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) diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index d1ddea23..df42934a 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1,4 +1,4 @@ -//! The FFI boundary: the only module of this crate allowed to use `unsafe`. +//! The FFI boundary: the only module of this rate allowed to use `unsafe`. //! //! It loads VST3 binaries (`libloading`), runs the platform module entry, calls the COM //! interfaces of the factory, component, audio processor and edit controller (`vst3` raw @@ -646,13 +646,32 @@ impl IHostApplicationTrait for HostContext { } } - unsafe fn createInstance(&self, _cid: *mut sb::TUID, _iid: *mut sb::TUID, obj: *mut *mut c_void) -> sb::tresult { - // IMessage / IAttributeList are not provided yet; plugins must cope with that. + /// Creates the objects plugins may ask the host for: `IMessage` (what a controller and its + /// processor send each other through `IConnectionPoint::notify`) and `IAttributeList`. + /// Plugins that talk between their halves this way fail without them (Line 6 Helix + /// reported "Failed to get content database recovery info (Out of memory condition)"). + unsafe fn createInstance(&self, cid: *mut sb::TUID, iid: *mut sb::TUID, obj: *mut *mut c_void) -> sb::tresult { // SAFETY: `obj` is the plugin's out-pointer (or null, handled). - if let Some(o) = unsafe { obj.as_mut() } { - *o = std::ptr::null_mut(); + let Some(out) = (unsafe { obj.as_mut() }) else { return sb::kInvalidArgument }; + *out = std::ptr::null_mut(); + // SAFETY: `cid` and `iid` point to 16-byte TUIDs (or are null, handled). + let (Some(cid), Some(iid)) = (unsafe { cid.as_ref() }, unsafe { iid.as_ref() }) else { return sb::kInvalidArgument }; + let (cid, iid) = (tuid_bytes(cid), tuid_bytes(iid)); + let wants = |class: [u8; 16]| cid == class && (iid == class || iid == FUnknown::IID); + let made = if wants(sv::IMessage::IID) { + ComWrapper::new(HostMessage::default()).to_com_ptr::().map(|p| p.into_raw().cast::()) + } else if wants(sv::IAttributeList::IID) { + ComWrapper::new(HostAttributes::default()).to_com_ptr::().map(|p| p.into_raw().cast::()) + } else { + None + }; + match made { + Some(p) => { + *out = p; + sb::kResultOk + } + None => sb::kResultFalse, } - sb::kResultFalse } } @@ -684,6 +703,191 @@ impl IComponentHandlerTrait for HostContext { } } +/// Longest string attribute kept, in UTF-16 units (longer ones are cut). +const ATTR_STRING_MAX: usize = 1 << 20; +/// Largest binary attribute kept. +const ATTR_BINARY_MAX: usize = 256 << 20; + +/// One `IAttributeList` value. Strings and blobs live in their own allocation, so a pointer +/// handed out by `getBinary` stays valid while the list holds the value. +enum Attr { + Int(i64), + Float(f64), + Str(Box<[u16]>), + Bin(Box<[u8]>), +} + +/// The host's `IAttributeList`: what a plugin puts in an `IMessage`. +#[derive(Default)] +pub(crate) struct HostAttributes { + values: Mutex, Attr>>, +} + +impl Class for HostAttributes { + type Interfaces = (sv::IAttributeList,); +} + +/// An attribute id's bytes (`None` for a null id). +fn attr_key(id: sv::IAttributeList_::AttrID) -> Option> { + // SAFETY: a non-null AttrID is a NUL-terminated C string owned by the caller. + (!id.is_null()).then(|| unsafe { std::ffi::CStr::from_ptr(id) }.to_bytes().to_vec()) +} + +impl HostAttributes { + fn values(&self) -> std::sync::MutexGuard<'_, std::collections::HashMap, Attr>> { + self.values.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn set(&self, id: sv::IAttributeList_::AttrID, v: Attr) -> sb::tresult { + let Some(k) = attr_key(id) else { return sb::kInvalidArgument }; + self.values().insert(k, v); + sb::kResultOk + } +} + +impl sv::IAttributeListTrait for HostAttributes { + unsafe fn setInt(&self, id: sv::IAttributeList_::AttrID, value: sb::int64) -> sb::tresult { + self.set(id, Attr::Int(value)) + } + + unsafe fn getInt(&self, id: sv::IAttributeList_::AttrID, value: *mut sb::int64) -> sb::tresult { + // SAFETY: `value` is the caller's out-pointer (or null, handled). + match ( + attr_key(id).and_then(|k| match self.values().get(&k) { + Some(Attr::Int(v)) => Some(*v), + _ => None, + }), + unsafe { value.as_mut() }, + ) { + (Some(v), Some(out)) => { + *out = v; + sb::kResultOk + } + _ => sb::kResultFalse, + } + } + + unsafe fn setFloat(&self, id: sv::IAttributeList_::AttrID, value: f64) -> sb::tresult { + self.set(id, Attr::Float(value)) + } + + unsafe fn getFloat(&self, id: sv::IAttributeList_::AttrID, value: *mut f64) -> sb::tresult { + // SAFETY: `value` is the caller's out-pointer (or null, handled). + match ( + attr_key(id).and_then(|k| match self.values().get(&k) { + Some(Attr::Float(v)) => Some(*v), + _ => None, + }), + unsafe { value.as_mut() }, + ) { + (Some(v), Some(out)) => { + *out = v; + sb::kResultOk + } + _ => sb::kResultFalse, + } + } + + unsafe fn setString(&self, id: sv::IAttributeList_::AttrID, string: *const sv::TChar) -> sb::tresult { + if string.is_null() { + return sb::kInvalidArgument; + } + let mut s = Vec::new(); + // SAFETY: a non-null string is NUL-terminated UTF-16 owned by the caller; reading stops at + // the NUL or at ATTR_STRING_MAX units. + while s.len() < ATTR_STRING_MAX { + let c = unsafe { *string.add(s.len()) }; + if c == 0 { + break; + } + s.push(c); + } + self.set(id, Attr::Str(s.into_boxed_slice())) + } + + unsafe fn getString(&self, id: sv::IAttributeList_::AttrID, string: *mut sv::TChar, size_in_bytes: sb::uint32) -> sb::tresult { + let units = size_in_bytes as usize / 2; + if string.is_null() || units == 0 { + return sb::kInvalidArgument; + } + let Some(k) = attr_key(id) else { return sb::kInvalidArgument }; + let values = self.values(); + let Some(Attr::Str(s)) = values.get(&k) else { return sb::kResultFalse }; + let n = s.len().min(units - 1); + // SAFETY: the caller's buffer holds `units` UTF-16 units; `n + 1 <= units` are written. + unsafe { + std::ptr::copy_nonoverlapping(s.as_ptr(), string, n); + *string.add(n) = 0; + } + sb::kResultOk + } + + unsafe fn setBinary(&self, id: sv::IAttributeList_::AttrID, data: *const c_void, size_in_bytes: sb::uint32) -> sb::tresult { + let n = size_in_bytes as usize; + if n > ATTR_BINARY_MAX { + return sb::kOutOfMemory; + } + let bytes: Box<[u8]> = if n == 0 || data.is_null() { + Box::new([]) + } else { + // SAFETY: the caller's `data` holds `n` readable bytes. + unsafe { std::slice::from_raw_parts(data.cast::(), n) }.into() + }; + self.set(id, Attr::Bin(bytes)) + } + + unsafe fn getBinary(&self, id: sv::IAttributeList_::AttrID, data: *mut *const c_void, size_in_bytes: *mut sb::uint32) -> sb::tresult { + let Some(k) = attr_key(id) else { return sb::kInvalidArgument }; + let values = self.values(); + let Some(Attr::Bin(b)) = values.get(&k) else { return sb::kResultFalse }; + // SAFETY: both are the caller's out-pointers (or null, handled). The pointer handed out + // stays valid until this attribute is replaced or the list is released (VST3 contract). + match (unsafe { data.as_mut() }, unsafe { size_in_bytes.as_mut() }) { + (Some(d), Some(sz)) => { + *d = b.as_ptr().cast::(); + *sz = u32::try_from(b.len()).unwrap_or(u32::MAX); + sb::kResultOk + } + _ => sb::kInvalidArgument, + } + } +} + +/// The host's `IMessage`: an id and an attribute list, created for a plugin through +/// `IHostApplication::createInstance`. +pub(crate) struct HostMessage { + id: Mutex, + attributes: ComWrapper, +} + +impl Default for HostMessage { + fn default() -> Self { + HostMessage { id: Mutex::new(std::ffi::CString::default()), attributes: ComWrapper::new(HostAttributes::default()) } + } +} + +impl Class for HostMessage { + type Interfaces = (sv::IMessage,); +} + +impl sv::IMessageTrait for HostMessage { + unsafe fn getMessageID(&self) -> sb::FIDString { + // The string's buffer lives until the next `setMessageID`, as the interface expects. + self.id.lock().unwrap_or_else(PoisonError::into_inner).as_ptr() + } + + unsafe fn setMessageID(&self, id: sb::FIDString) { + // SAFETY: a non-null id is a NUL-terminated C string owned by the caller. + let s = if id.is_null() { std::ffi::CString::default() } else { unsafe { std::ffi::CStr::from_ptr(id) }.to_owned() }; + *self.id.lock().unwrap_or_else(PoisonError::into_inner) = s; + } + + unsafe fn getAttributes(&self) -> *mut sv::IAttributeList { + // Not add-ref'ed: the list belongs to the message (VST3 contract). + self.attributes.as_com_ref::().map_or(std::ptr::null_mut(), |r| r.as_ptr()) + } +} + /// One parameter's points for one block. SoundCraft sends at most one point per block; a /// plugin writing output changes may add more, of which only the last is kept. #[derive(Default)] @@ -2137,6 +2341,57 @@ mod window { #[cfg(test)] mod tests { use super::*; + use sv::{IAttributeListTrait as _, IMessageTrait as _}; + + fn tuid(iid: [u8; 16]) -> sb::TUID { + iid.map(|b| b as c_char) + } + + /// Asks the host for an object the way a plugin does. + fn create(host: &ComWrapper, cid: [u8; 16]) -> Option> { + let app = host.to_com_ptr::()?; + let (mut c, mut i) = (tuid(cid), tuid(I::IID)); + let mut obj: *mut c_void = std::ptr::null_mut(); + // SAFETY: valid TUIDs and out-pointer, as a plugin passes them. + let r = unsafe { app.createInstance(&mut c, &mut i, &mut obj) }; + // SAFETY: on success `obj` is an owned (add-ref'ed) pointer to `I`. + (r == sb::kResultOk).then(|| unsafe { ComPtr::from_raw(obj.cast::()) }).flatten() + } + + #[test] + fn plugins_get_messages_and_attribute_lists_from_the_host() { + let host = ComWrapper::new(HostContext::default()); + let msg = create::(&host, sv::IMessage::IID).expect("IMessage"); + // SAFETY: calls through the interface with valid C strings and buffers. + unsafe { + msg.setMessageID(c"DatabaseInfo".as_ptr()); + assert_eq!(std::ffi::CStr::from_ptr(msg.getMessageID()).to_str(), Ok("DatabaseInfo")); + let attrs = vst3::ComRef::from_raw(msg.getAttributes()).expect("attributes"); + assert_eq!(attrs.setInt(c"count".as_ptr(), 42), sb::kResultOk); + assert_eq!(attrs.setFloat(c"gain".as_ptr(), 0.5), sb::kResultOk); + let text: Vec = "Helix".encode_utf16().chain([0]).collect(); + assert_eq!(attrs.setString(c"name".as_ptr(), text.as_ptr()), sb::kResultOk); + assert_eq!(attrs.setBinary(c"blob".as_ptr(), [1u8, 2, 3].as_ptr().cast(), 3), sb::kResultOk); + + let (mut i, mut f) = (0i64, 0f64); + assert_eq!(attrs.getInt(c"count".as_ptr(), &mut i), sb::kResultOk); + assert_eq!(attrs.getFloat(c"gain".as_ptr(), &mut f), sb::kResultOk); + assert_eq!((i, f), (42, 0.5)); + assert_eq!(attrs.getInt(c"gain".as_ptr(), &mut i), sb::kResultFalse, "a float is not an int"); + assert_eq!(attrs.getInt(c"missing".as_ptr(), &mut i), sb::kResultFalse); + // A buffer of 4 units takes 3 characters and the NUL. + let mut buf = [0xFFFFu16; 4]; + assert_eq!(attrs.getString(c"name".as_ptr(), buf.as_mut_ptr(), 8), sb::kResultOk); + assert_eq!(buf, ['H' as u16, 'e' as u16, 'l' as u16, 0]); + let (mut p, mut n): (*const c_void, u32) = (std::ptr::null(), 0); + assert_eq!(attrs.getBinary(c"blob".as_ptr(), &mut p, &mut n), sb::kResultOk); + assert_eq!(std::slice::from_raw_parts(p.cast::(), n as usize), [1, 2, 3]); + assert_eq!(attrs.setInt(std::ptr::null(), 1), sb::kInvalidArgument, "a null id is refused"); + } + assert!(create::(&host, sv::IAttributeList::IID).is_some()); + // Anything else is still not provided. + assert!(create::(&host, sv::IComponentHandler::IID).is_none()); + } #[test] fn the_transport_fills_the_process_context() { diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index 9280d921..97532447 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:** minor (VST3 host messages: IMessage and IAttributeList; 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 @@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | VST3 host: IMessage and IAttributeList for plugins whose halves talk through messages (Line 6 Helix Stadium Native verified) | | 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 f9d04ffc986cc1367e519fb0f088fd791ce89934 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:48:38 +0200 Subject: [PATCH 02/90] Validate the full WAVE_FORMAT_EXTENSIBLE fmt chunk (#465) --- crates/audio-io/src/wav.rs | 24 +++++++++++++++------ crates/audio-io/tests/malformed.rs | 34 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index 1aa847d4..22c89ae4 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -50,13 +50,25 @@ fn parse_fmt(c: &[u8]) -> Result { fmt.valid_bits = fmt.bits; if fmt.tag == WAVE_FORMAT_EXTENSIBLE { // cbSize(2) validBits(2) channelMask(4) subFormat GUID(16) - if let (Some(valid), Some(sub)) = (le_u16(c, 18), le_u16(c, 24)) { - if valid != 0 { - fmt.valid_bits = valid; - } - fmt.tag = sub; - } else { + let (Some(cb_size), Some(valid), Some(sub)) = (le_u16(c, 16), le_u16(c, 18), le_u16(c, 24)) else { return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into())); + }; + let Some(tail) = c.get(26..40) else { + return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into())); + }; + if cb_size < 22 { + return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE cbSize is below 22".into())); + } + if valid > fmt.bits { + return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE valid bits exceed the container size".into())); + } + if valid != 0 { + fmt.valid_bits = valid; + } + // A subtype outside the KSDATAFORMAT_SUBTYPE_* family is not PCM/float, whatever its first two bytes say: + // keep the extensible tag so the file goes to the foreign decoder. + if tail == GUID_TAIL { + fmt.tag = sub; } } Ok(fmt) diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs index ed0d6e31..d9ac2459 100644 --- a/crates/audio-io/tests/malformed.rs +++ b/crates/audio-io/tests/malformed.rs @@ -485,3 +485,37 @@ proptest! { prop_assert_eq!(flac, wav); } } + +/// An extensible `fmt ` chunk: cbSize, valid bits, mask, then the 16-byte subtype GUID. +fn extensible_fmt(cb_size: u16, valid_bits: u16, guid: &[u8]) -> Vec { + let mut f = fmt(0xFFFE, 1, 44_100, 16); + f.extend_from_slice(&cb_size.to_le_bytes()); + f.extend_from_slice(&valid_bits.to_le_bytes()); + f.extend_from_slice(&4u32.to_le_bytes()); + f.extend_from_slice(guid); + f +} + +const PCM_GUID: [u8; 16] = [1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xAA, 0, 0x38, 0x9B, 0x71]; + +#[test] +fn extensible_fmt_is_checked_in_full() { + let data = vec![0u8; 8]; + let good = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID)), (b"data", data.clone())]); + assert_eq!(probe(&good, None).unwrap().sample_format, SampleFormat::Int16); + // Subtype GUID cut short after the format code. + let cut = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID[..4])), (b"data", data.clone())]); + assert!(matches!(probe(&cut, None), Err(AudioError::Malformed(_)))); + assert!(matches!(decode(&cut, None), Err(AudioError::Malformed(_)))); + // cbSize too small for the extension. + let small_cb = riff(&[(b"fmt ", extensible_fmt(0, 16, &PCM_GUID)), (b"data", data.clone())]); + assert!(matches!(probe(&small_cb, None), Err(AudioError::Malformed(_)))); + // More valid bits than the 16-bit container holds. + let wide = riff(&[(b"fmt ", extensible_fmt(22, 24, &PCM_GUID)), (b"data", data.clone())]); + assert!(matches!(probe(&wide, None), Err(AudioError::Malformed(_)))); + // PCM-like prefix but a foreign GUID tail is not read as native PCM. + let mut foreign = PCM_GUID; + foreign[15] ^= 0xFF; + let foreign = riff(&[(b"fmt ", extensible_fmt(22, 16, &foreign)), (b"data", data)]); + assert!(!matches!(probe(&foreign, None), Ok(i) if i.sample_format == SampleFormat::Int16)); +} From 9a398d79ff58e55b9e58cc0e9cf288d601ea641f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:50:51 +0200 Subject: [PATCH 03/90] Load older sessions whose track height is the bare Fit string (#447) --- crates/model/src/session.rs | 13 +++++++++ crates/model/src/track.rs | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index 449d82da..b0f74214 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -681,6 +681,19 @@ mod tests { assert_eq!(back, s); } + #[test] + fn session_with_legacy_fit_height_loads() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None); + if let Some(track) = s.track_mut(t) { + track.height = crate::TrackHeight::Large; + } + let text = s.to_json().unwrap().replace("\"height\": \"Large\"", "\"height\": \"Fit\""); + assert!(text.contains("\"height\": \"Fit\"")); + let back = Session::from_json(&text).unwrap(); + assert_eq!(back.track(t).map(|tr| tr.height), Some(crate::TrackHeight::Medium)); + } + #[test] fn video_clip_round_trips_and_old_sessions_load() { let mut s = Session::default(); diff --git a/crates/model/src/track.rs b/crates/model/src/track.rs index 9df78c35..663a6a4b 100644 --- a/crates/model/src/track.rs +++ b/crates/model/src/track.rs @@ -84,6 +84,7 @@ impl TrackKind { /// Track heights (View › Track Height presets). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +#[serde(from = "TrackHeightRepr")] pub enum TrackHeight { /// Any height from Micro to Extreme, in points. Custom(u16), @@ -99,6 +100,51 @@ pub enum TrackHeight { Fit(u16), } +/// What a saved track height may look like: the current form, or the bare `"Fit"` string older sessions used. +#[derive(serde::Deserialize)] +#[serde(untagged)] +enum TrackHeightRepr { + Current(TrackHeightCurrent), + LegacyFit(LegacyFit), +} + +#[derive(serde::Deserialize)] +enum LegacyFit { + Fit, +} + +/// Mirror of `TrackHeight` that derives the current serialized form. +#[derive(serde::Deserialize)] +enum TrackHeightCurrent { + Custom(u16), + Micro, + Mini, + Small, + Medium, + Large, + Jumbo, + Extreme, + Fit(u16), +} + +impl From for TrackHeight { + fn from(repr: TrackHeightRepr) -> Self { + match repr { + TrackHeightRepr::Current(TrackHeightCurrent::Custom(p)) => TrackHeight::Custom(p), + TrackHeightRepr::Current(TrackHeightCurrent::Micro) => TrackHeight::Micro, + TrackHeightRepr::Current(TrackHeightCurrent::Mini) => TrackHeight::Mini, + TrackHeightRepr::Current(TrackHeightCurrent::Small) => TrackHeight::Small, + TrackHeightRepr::Current(TrackHeightCurrent::Medium) => TrackHeight::Medium, + TrackHeightRepr::Current(TrackHeightCurrent::Large) => TrackHeight::Large, + TrackHeightRepr::Current(TrackHeightCurrent::Jumbo) => TrackHeight::Jumbo, + TrackHeightRepr::Current(TrackHeightCurrent::Extreme) => TrackHeight::Extreme, + TrackHeightRepr::Current(TrackHeightCurrent::Fit(p)) => TrackHeight::Fit(p), + // Older sessions stored Fit with no size, drawn 88 points tall: the default Medium height. + TrackHeightRepr::LegacyFit(LegacyFit::Fit) => TrackHeight::Medium, + } + } +} + impl TrackHeight { pub const ALL: [TrackHeight; 7] = [ TrackHeight::Micro, @@ -328,6 +374,15 @@ impl Track { mod tests { use super::*; + #[test] + fn legacy_fit_string_loads_as_default_height() { + assert_eq!(serde_json::from_str::("\"Fit\"").unwrap(), TrackHeight::Medium); + assert_eq!(serde_json::from_str::("{\"Fit\":120}").unwrap(), TrackHeight::Fit(120)); + assert_eq!(serde_json::to_string(&TrackHeight::Fit(120)).unwrap(), "{\"Fit\":120}"); + assert_eq!(serde_json::from_str::("\"Large\"").unwrap(), TrackHeight::Large); + assert_eq!(serde_json::from_str::("{\"Custom\":50}").unwrap(), TrackHeight::Custom(50)); + } + #[test] fn new_track_defaults() { let t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Stereo, TRACK_COLORS[0]); From b7329ee9047d47d80f3b3c8fd41f09f6c5236ab7 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:50:42 +0200 Subject: [PATCH 04/90] Keep unrelated overlapping clips untouched when deleting fades (#449) --- crates/engine/src/edit.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 5531953d..b26e14d8 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -812,8 +812,10 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize { let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; let mut n = 0; + let mut touched: HashSet = HashSet::new(); for c in &mut pl.clips { if c.range().overlaps(&range) || (range.is_empty() && c.range().contains(range.start)) { + touched.insert(c.id); if c.fade_in.len > 0 || c.fade_out.len > 0 { n += 1; } @@ -821,11 +823,12 @@ pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize { c.fade_out = Fade::default(); } } - // Overlapping (crossfaded) neighbours are trimmed back to the midpoint. + // Overlapping (crossfaded) neighbours in the range are trimmed back to the midpoint; other overlaps stay as they are. pl.sort(); for i in 0..pl.clips.len().saturating_sub(1) { let (Some(a_end), Some(b_start)) = (pl.clips.get(i).map(Clip::end), pl.clips.get(i + 1).map(|c| c.start)) else { continue }; - if a_end > b_start { + let in_range = pl.clips.get(i).is_some_and(|a| touched.contains(&a.id)) || pl.clips.get(i + 1).is_some_and(|b| touched.contains(&b.id)); + if in_range && a_end > b_start { let mid = b_start + (a_end - b_start) / 2; if let Some(a) = pl.clips.get_mut(i) { a.trim_end_to(mid); @@ -1041,4 +1044,23 @@ mod tests { s.find_clip_mut(id).unwrap().time_locked = true; assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0); } + + #[test] + fn delete_fades_leaves_unrelated_overlaps_alone() { + let (mut s, t) = session_with_clip(); + let ids: Vec = (0..4).map(|_| s.new_clip_id()).collect(); + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips.clear(); + for (id, start, len) in [(ids[0], 0, 1100), (ids[1], 1000, 1000), (ids[2], 5000, 1100), (ids[3], 6000, 1000)] { + let mut c = Clip::audio(id, "c", SourceId(500), start, start, len); + c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 }; + c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 }; + pl.clips.push(c); + } + } + assert_eq!(delete_fades(&mut s, t, Range::new(900, 1200)), 2); + assert_eq!(clips(&s, t), vec![(0, 1050, 0), (1050, 950, 1050), (5000, 1100, 5000), (6000, 1000, 6000)]); + let c = s.track(t).unwrap().clips(); + assert_eq!((c[1].fade_in.len, c[2].fade_out.len, c[3].fade_in.len), (0, 100, 100)); + } } From bddc113f46d4326154a5bd01cb43311c17fdba19 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:50:43 +0200 Subject: [PATCH 05/90] Heal keeps each clip's gain envelope and skips clips whose mute or stretch differ (#450) --- crates/engine/src/edit.rs | 58 ++++++++++++++++++++++++++++++++++++++- crates/model/src/clip.rs | 5 ++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 5531953d..9634cd3b 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -418,7 +418,11 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize { (ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => a == b && oa + prev.length == *ob, _ => false, }; - if contiguous && same && (prev.gain_db - c.gain_db).abs() < 1e-6 { + let same_playback = prev.muted == c.muted && (prev.stretch - c.stretch).abs() < 1e-9 && (prev.gain_db - c.gain_db).abs() < 1e-6; + if contiguous && same && same_playback { + if !prev.gain_env.is_empty() || !c.gain_env.is_empty() { + join_gain_env(prev, &c); + } prev.length += c.length; prev.fade_out = c.fade_out; healed += 1; @@ -431,6 +435,21 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize { healed } +/// Give `l` the gain envelope of `l` followed by `r`, which starts where `l` ends, so every sample keeps its gain. +fn join_gain_env(l: &mut Clip, r: &Clip) { + let cut = l.length; + let (end_db, start_db) = (l.env_db_at(cut - 1), r.env_db_at(0)); + l.gain_env.retain(|&(at, _)| at < cut); + l.gain_env.extend(r.gain_env.iter().filter(|&&(at, _)| at >= 0).map(|&(at, db)| (at.saturating_add(cut), db))); + // Pin both sides of the seam where the joined points would interpolate across it. + for (at, db) in [(cut, start_db), (cut - 1, end_db)] { + if (l.env_db_at(at) - db).abs() > 1e-4 { + let i = l.gain_env.partition_point(|&(t, _)| t < at); + l.gain_env.insert(i, (at, db)); + } + } +} + /// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them /// elsewhere (see [`destinations`]). pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize { @@ -878,6 +897,43 @@ mod tests { assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]); } + #[test] + fn heal_skips_clips_with_different_playback() { + for tweak in [(|c: &mut Clip| c.muted = true) as fn(&mut Clip), |c: &mut Clip| c.stretch = 1.5] { + let (mut s, t) = session_with_clip(); + separate_at(&mut s, t, 3000); + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + tweak(&mut pl.clips[1]); + } + assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0); + assert_eq!(s.track(t).unwrap().clips().len(), 2); + } + } + + #[test] + fn heal_keeps_each_side_gain_envelope() { + let cases: [(Vec<(Samples, f32)>, Vec<(Samples, f32)>); 4] = [ + (vec![(500, -3.0), (2500, -12.0)], vec![]), + (vec![], vec![(0, -6.0), (1000, 0.0)]), + (vec![(0, 0.0), (1999, -20.0)], vec![(-100, 4.0), (300, -9.0)]), + (vec![(500, -3.0), (2500, -12.0)], vec![(0, -9.75), (500, -12.0)]), + ]; + for (left_env, right_env) in cases { + let (mut s, t) = session_with_clip(); + separate_at(&mut s, t, 3000); + let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap(); + pl.clips[0].gain_env = left_env; + pl.clips[1].gain_env = right_env; + let (l, r) = (pl.clips[0].clone(), pl.clips[1].clone()); + assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1); + let healed = &s.track(t).unwrap().clips()[0]; + for rel in 0..4000 { + let want = if rel < 2000 { l.gain_at(rel) } else { r.gain_at(rel - 2000) }; + assert!((healed.gain_at(rel) - want).abs() < 1e-4, "sample {rel}: {} != {want}", healed.gain_at(rel)); + } + } + } + #[test] fn clear_slip_and_shuffle() { let (mut s, t) = session_with_clip(); diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index 850cf044..7543a3ad 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -242,6 +242,11 @@ impl Clip { g } + /// Gain envelope value in dB at `rel` samples from the clip start (0 without an envelope). + pub fn env_db_at(&self, rel: Samples) -> f32 { + env_value(&self.gain_env, rel) + } + /// Move the start edge to `new_start` keeping material anchored (trim start). pub fn trim_start_to(&mut self, new_start: Samples) { let new_start = new_start.min(self.end() - 1); From d13f6657f8843c03dd1bb952ff2933f202093e4b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:50:33 +0200 Subject: [PATCH 06/90] Keep MIDI note selection valid across clip, session and note-order changes (#443) --- crates/engine/src/cmd/midi.rs | 27 +++++++++++++++++- crates/ui-egui/src/lib.rs | 15 ++++++++++ crates/ui-egui/src/midi_editor.rs | 36 ++++++++++++++++++++++-- crates/ui-egui/tests/pro_tools_keys.rs | 38 ++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index 10bcd767..05f6b6ac 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -130,8 +130,19 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result { n.pitch = (i64::from(n.pitch) + ds).clamp(0, 127) as u8; } } + let moved: Vec = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| *n).collect(); seq.sort(); - Ok(json!({"moved": idx.len()})) + // Where the moved notes ended up after sorting, so the editor can keep them selected. + let mut taken = vec![false; seq.notes.len()]; + let mut indices = Vec::with_capacity(moved.len()); + for m in &moved { + if let Some(j) = seq.notes.iter().enumerate().position(|(j, n)| !taken[j] && n == m) { + taken[j] = true; + indices.push(j); + } + } + indices.sort_unstable(); + Ok(json!({"moved": idx.len(), "indices": indices})) } #[cfg(test)] @@ -157,6 +168,20 @@ mod tests { assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err()); } + #[test] + fn notes_move_reports_where_the_moved_notes_went() { + let mut e = Engine::default(); + let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); + let clip = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap()["clip"].as_u64().unwrap(); + for start in [0, 480, 960] { + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": start})).unwrap(); + } + let r = e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "ticks": 720})).unwrap(); + assert_eq!(r["indices"], json!([1])); + let notes = e.execute("midi.notes", &json!({"clip": clip})).unwrap(); + assert_eq!(notes["notes"][1]["start"], 720); + } + #[test] fn rejects_audio_tracks() { let mut e = Engine::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ddd54275..87938518 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -425,6 +425,10 @@ impl SoundApp { let replacing = matches!(id, "session.new" | "session.open" | "session.close" | "session.revert"); match self.engine.execute(id, ¶ms) { Ok(v) => { + if replacing { + // Note indices and gestures belong to the old session's clips. + self.midi = midi_editor::MidiEditorState::default(); + } if replacing || (id.starts_with("session.save") && !self.engine.is_dirty()) { self.release_autosave(false); } @@ -1344,6 +1348,17 @@ mod tests { assert_eq!(super::failed_plugin_status(&s, t, soundcraft_mix::INSTRUMENT_SLOT), None); } + #[test] + fn replacing_the_session_drops_the_midi_note_selection() { + for command in ["session.new", "session.close"] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.midi.selected = vec![0, 1]; + app.midi.clip = Some(soundcraft_model::ClipId(1)); + app.run(command, json!({})).unwrap(); + assert!(app.midi.selected.is_empty() && app.midi.clip.is_none(), "{command}"); + } + } + #[test] fn new_session_cancels_play_queued_before_the_next_frame() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index 8ea18699..dbd6f6d2 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -21,6 +21,19 @@ pub struct MidiEditorState { pub band: Option, } +impl MidiEditorState { + /// Start editing `id`. Note indices only mean something inside one clip, so the selection and + /// any gesture in progress are dropped when the clip changes. + fn switch_clip(&mut self, id: ClipId) { + if self.clip != Some(id) { + self.selected.clear(); + self.drag = None; + self.band = None; + } + self.clip = Some(id); + } +} + #[derive(Debug, Clone)] pub struct NoteDrag { pub index: usize, @@ -148,7 +161,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let hi = sequence.notes.iter().map(|n| i32::from(n.pitch)).max().unwrap_or(72); let lo = sequence.notes.iter().map(|n| i32::from(n.pitch)).min().unwrap_or(48); app.midi.scroll = (127 - (hi + lo) / 2 - rows / 2) as f32 * ROW_H; - app.midi.clip = Some(cid); + app.midi.switch_clip(cid); } if ui.rect_contains_pointer(roll) { app.midi.scroll -= ui.input(|i| i.smooth_scroll_delta.y); @@ -346,7 +359,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { 0 }; if (semi != 0 || ticks != 0) && !app.midi.selected.is_empty() { - let _ = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": ticks, "semitones": semi})); + let moved = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": ticks, "semitones": semi})); + // The engine re-sorts the notes: follow the moved ones to their new indices, so the + // selection never points at other notes and the arrows can keep nudging it. + app.midi.selected = moved.ok().and_then(|v| serde_json::from_value(v["indices"].clone()).ok()).unwrap_or_default(); } } if !app.midi.selected.is_empty() @@ -376,3 +392,19 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let _ = app.engine.execute_merged("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v}), &format!("vel:{}", cid.0)); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn switching_clip_drops_selection_and_gestures() { + let mut st = MidiEditorState { selected: vec![0, 2], clip: Some(ClipId(1)), band: Some(pos2(1.0, 2.0)), ..Default::default() }; + st.switch_clip(ClipId(1)); + assert_eq!(st.selected, vec![0, 2]); + st.switch_clip(ClipId(2)); + assert!(st.selected.is_empty()); + assert!(st.drag.is_none() && st.band.is_none()); + assert_eq!(st.clip, Some(ClipId(2))); + } +} diff --git a/crates/ui-egui/tests/pro_tools_keys.rs b/crates/ui-egui/tests/pro_tools_keys.rs index c87ede65..97952c1e 100644 --- a/crates/ui-egui/tests/pro_tools_keys.rs +++ b/crates/ui-egui/tests/pro_tools_keys.rs @@ -233,6 +233,44 @@ fn the_piano_roll_leaves_modified_arrows_to_the_shortcuts() { assert_eq!(pitch(&app), before + 1); } +#[test] +fn the_piano_roll_selection_follows_notes_the_arrows_move_past_others() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.show_midi_editor = true; + app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut SoundApp, events: Vec| { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); + }; + for _ in 0..6 { + frame(&mut app, vec![]); + } + let body = ctx.data(|d| d.get_temp::(egui::Id::new("soundcraft-midi-body"))).unwrap(); + frame(&mut app, vec![Event::PointerMoved(body.center())]); + let notes = |app: &SoundApp| { + let clip = soundcraft_ui_egui::midi_editor::target_clip(app).unwrap(); + match &app.engine.session().find_clip(clip).unwrap().1.content { + ClipContent::Midi { sequence } => sequence.notes.clone(), + _ => panic!("not a MIDI clip"), + } + }; + // The first chord's lowest note, an octave up, sorts after the other chord notes. + let first = notes(&app)[0]; + app.midi.selected = vec![0]; + for octaves in 1..=2u8 { + frame(&mut app, keys(Key::ArrowUp, Modifiers::SHIFT)); + let &[i] = app.midi.selected.as_slice() else { panic!("selection {:?}", app.midi.selected) }; + assert_ne!(i, 0); + assert_eq!((notes(&app)[i].start, notes(&app)[i].pitch), (first.start, first.pitch + 12 * octaves)); + } +} + #[test] fn shift_tab_extends_the_selection_and_tab_to_transient_stops_on_hits() { for (os, cmd) in OSES { From 2dd8f7da55c8ed919117b24dc309b4381b362e5e Mon Sep 17 00:00:00 2001 From: Billy Coleman III Date: Tue, 13 Oct 2026 13:39:46 -0400 Subject: [PATCH 07/90] Preserve exact channel formats when committing tracks Retain the source ChannelFormat for committed audio tracks and reject unsupported widths, overflowing ranges and incomplete renders before replacing selected tracks. Preserve current routing and rollback behavior and document the existing renderer limits with multichannel coverage. --- contributors/people.toml | 3 + crates/engine/src/io.rs | 23 ++- crates/engine/tests/commit_multichannel.rs | 160 +++++++++++++++++++++ docs/commit-multichannel.md | 41 ++++++ 4 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 crates/engine/tests/commit_multichannel.rs create mode 100644 docs/commit-multichannel.md diff --git a/contributors/people.toml b/contributors/people.toml index 1c344199..f67fdd57 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -50,3 +50,6 @@ sync_github_name = false [people.Momentum96] sync_github_name = true + +[people.ProdByBuddha] +sync_github_name = true diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 7b26e0b1..49d325c7 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -801,12 +801,27 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result> { if end <= 0 { return Ok(None); } - let range = Range::new(0, end + s.sample_rate.samples(2.0)); - // The track renders through a main mix of at least two channels. - refuse_longer(s, "track.commit", &tr.name, "commit", range.len(), render_limit(tr.channels().max(2)))?; + let fmt = tr.format; + if fmt.channels() > soundcraft_mix::MAX_CHANNELS { + return Err(EngineError::BadParams( + "track.commit".into(), + format!("track commit supports at most {} channels; cannot render {}", soundcraft_mix::MAX_CHANNELS, fmt.label()), + )); + } + let too_long = || EngineError::BadParams("track.commit".into(), format!("`{}` is too long to commit within the offline render limit", tr.name)); + let end = end.checked_add(s.sample_rate.samples(2.0)).ok_or_else(too_long)?; + let frames = usize::try_from(end).map_err(|_| too_long())?; + // The shared renderer uses a stereo main mix for mono, then folds it back to mono. + // Reject requests it would truncate before rendering or replacing the source track. + if frames > soundcraft_mix::MAX_RENDER_SAMPLES / fmt.channels().max(2) { + return Err(too_long()); + } + let range = Range::new(0, end); let audio = soundcraft_mix::render_track_pre_fader(s, t, range); + if audio.len() != fmt.channels() || audio.iter().any(|channel| channel.len() != frames) { + return Err(EngineError::Io("track commit did not render the complete channel format and range".into())); + } let name = format!("{}.cm", tr.name); - let fmt = ChannelFormat::for_channels(audio.len().clamp(1, 2)); let mixer = tr.mixer.clone(); let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio }; let s = e.session_mut(); diff --git a/crates/engine/tests/commit_multichannel.rs b/crates/engine/tests/commit_multichannel.rs new file mode 100644 index 00000000..e2900a48 --- /dev/null +++ b/crates/engine/tests/commit_multichannel.rs @@ -0,0 +1,160 @@ +use serde_json::json; +use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat}; +use soundcraft_engine::{Engine, io}; +use soundcraft_model::{ChannelFormat, Clip, ClipContent, Insert, Route, SendSlot, Session, TrackId, TrackKind}; +use soundcraft_time::{Range, SampleRate}; +use std::sync::Arc; + +const SIGNAL_FRAMES: usize = 128; + +fn fixture(format: ChannelFormat) -> (Engine, TrackId, Vec>) { + let mut session = Session::new("Commit layout", SampleRate::new(8_000).unwrap()); + session.outputs[0].format = format; + let track = session.add_track(TrackKind::Audio, format, Some("Distinct channels")); + // Each channel has a different bipolar waveform: swapping, summing or dropping channels + // cannot pass a width-only assertion or an identical-signal comparison. + let channels: Vec> = (0..format.channels()) + .map(|ch| (0..SIGNAL_FRAMES).map(|frame| (((frame * (ch + 3)) % 31) as f32 - 15.0) * (ch + 1) as f32 / 3_200.0).collect()) + .collect(); + let source = + io::add_source(&mut session, "Distinct channels", AudioBuffer { sample_rate: 8_000, channels: channels.clone() }, None, FileFormat::Wav); + let clip = session.new_clip_id(); + let tr = session.track_mut(track).unwrap(); + tr.playlist_mut().unwrap().clips.push(Clip::audio(clip, "Signal", source, 0, 0, SIGNAL_FRAMES as i64)); + tr.mixer.inserts[0] = Some(Insert::new("invert")); + session.edit.selected_tracks = vec![track]; + (Engine::new(session), track, channels) +} + +fn commit(e: &mut Engine, track: TrackId) -> TrackId { + TrackId(e.execute("track.commit", &json!({"tracks": [track.0]})).unwrap()["tracks"][0].as_u64().unwrap()) +} + +fn committed_audio(e: &Engine, track: TrackId) -> &AudioBuffer { + let ClipContent::Audio { source, .. } = e.session().track(track).unwrap().clips()[0].content else { panic!("commit must create audio") }; + &e.session().pool.get(source).unwrap().buffer +} + +fn assert_audio_eq(actual: &[Vec], expected: &[Vec]) { + assert_eq!(actual.len(), expected.len()); + for (ch, (actual, expected)) in actual.iter().zip(expected).enumerate() { + assert_eq!(actual.len(), expected.len(), "channel {ch} length"); + for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() { + assert!((actual - expected).abs() < 1e-6, "channel {ch}, frame {frame}: {actual} != {expected}"); + } + } +} + +#[test] +fn commit_keeps_every_renderable_exact_format_and_processed_channel_order() { + for format in ChannelFormat::ALL.into_iter().filter(|format| format.channels() <= soundcraft_mix::MAX_CHANNELS) { + let (mut e, track, signal) = fixture(format); + let committed = commit(&mut e, track); + assert_eq!(e.session().track(committed).unwrap().format, format, "{}", format.label()); + let audio = committed_audio(&e, committed); + assert_eq!(audio.num_channels(), format.channels(), "{}", format.label()); + assert_eq!(audio.frames(), SIGNAL_FRAMES + 16_000); + // Existing mono rendering uses a -3 dB center pan followed by SQRT_2 compensation. + // Model that small baseline gain explicitly, retaining the same strict signal tolerance. + let gain = if format == ChannelFormat::Mono { 10.0_f32.powf(-3.0 / 20.0) * std::f32::consts::SQRT_2 } else { 1.0 }; + let expected: Vec> = signal.iter().map(|ch| ch.iter().map(|sample| -sample * gain).collect()).collect(); + let head: Vec> = audio.channels.iter().map(|ch| ch[..SIGNAL_FRAMES].to_vec()).collect(); + assert_audio_eq(&head, &expected); + assert!(audio.channels.iter().all(|ch| ch[SIGNAL_FRAMES..].iter().all(|sample| sample.abs() < 1e-6))); + assert!(e.session().track(track).unwrap().inactive && e.session().track(track).unwrap().hidden); + assert_eq!(e.session().track_index(committed), e.session().track_index(track).map(|index| index + 1)); + } +} + +#[test] +fn commit_preserves_bus_and_send_audio_after_undo_reopen_and_wav_export() { + for format in [ChannelFormat::Surround60, ChannelFormat::Atmos504, ChannelFormat::Ambisonics(1)] { + let (mut e, track, _) = fixture(format); + let s = e.session_mut(); + let bus = s.add_bus("Same-layout bus", format); + let aux = s.add_track(TrackKind::Aux, format, Some("Bus return")); + s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus); + let tr = s.track_mut(track).unwrap(); + tr.mixer.output = Route::Bus(bus); + tr.mixer.volume_db = -4.5; + let mut send = SendSlot::new(Route::Main); + send.level_db = -9.0; + tr.mixer.sends[0] = Some(send); + let mixer = tr.mixer.clone(); + let range = Range::new(0, SIGNAL_FRAMES as i64); + let before = soundcraft_mix::render_range(e.session(), range, 64); + let committed = commit(&mut e, track); + let tr = e.session().track(committed).unwrap(); + assert_eq!(tr.mixer.output, mixer.output); + assert_eq!(tr.mixer.sends, mixer.sends); + assert_eq!(tr.mixer.volume_db, mixer.volume_db); + assert!(tr.mixer.inserts.iter().all(Option::is_none), "processing is baked once"); + assert_audio_eq(&soundcraft_mix::render_range(e.session(), range, 64), &before); + assert!(e.undo()); + assert!(e.session().track(committed).is_none()); + assert!(!e.session().track(track).unwrap().inactive); + assert_audio_eq(&soundcraft_mix::render_range(e.session(), range, 64), &before); + assert!(e.redo()); + + let dir = std::env::temp_dir().join(format!("soundcraft-commit-layout-{}-{}", std::process::id(), format.channels())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Commit.scraft").to_string_lossy().into_owned(); + io::save_session(&mut e, &path).unwrap(); + let mut reopened = Engine::default(); + assert!(io::open_session(&mut reopened, &path).unwrap().is_empty()); + assert_eq!(reopened.session().track(committed).unwrap().format, format); + assert_audio_eq(&committed_audio(&reopened, committed).channels, &committed_audio(&e, committed).channels); + assert_audio_eq(&soundcraft_mix::render_range(reopened.session(), range, 64), &before); + + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None }; + let wav_path = dir.join("mix.wav"); + io::Bounce::new(reopened.session_arc(), &wav_path, range, &opts, None, false, false).unwrap().run().unwrap(); + let wav = std::fs::read(&wav_path).unwrap(); + let (_, decoded) = soundcraft_audio_io::decode(&wav, Some("wav")).unwrap(); + assert_audio_eq(&decoded.channels, &before); + // The WAV extensible mask must describe this layout, not another layout of equal width. + let fmt_at = wav.windows(4).position(|tag| tag == b"fmt ").unwrap(); + let mask = u32::from_le_bytes(wav[fmt_at + 28..fmt_at + 32].try_into().unwrap()); + assert_eq!(mask, format.channel_mask()); + std::fs::remove_dir_all(&dir).unwrap(); + } +} + +#[test] +fn oversized_or_overflowing_commit_rolls_back_all_selected_tracks() { + for (format, length) in [ + (ChannelFormat::Mono, (soundcraft_mix::MAX_RENDER_SAMPLES / 2 + 1) as i64), + (ChannelFormat::Surround51, (soundcraft_mix::MAX_RENDER_SAMPLES / 6 + 1) as i64), + (ChannelFormat::Ambisonics(3), (soundcraft_mix::MAX_RENDER_SAMPLES / 16 + 1) as i64), + (ChannelFormat::Surround51, i64::MAX), + ] { + let (mut e, first, _) = fixture(format); + let s = e.session_mut(); + let second = s.add_track(TrackKind::Audio, format, Some("Too long")); + let mut clip = s.track(first).unwrap().clips()[0].clone(); + clip.id = s.new_clip_id(); + clip.length = length; + s.track_mut(second).unwrap().playlist_mut().unwrap().clips.push(clip); + let before = e.session_arc(); + let dirty = e.is_dirty(); + let result = e.execute("track.commit", &json!({"tracks": [first.0, second.0]})); + assert!(result.unwrap_err().to_string().contains("offline render limit")); + assert!(Arc::ptr_eq(&before, &e.session_arc()), "no hidden track, truncated source or partially committed first track"); + assert_eq!(e.is_dirty(), dirty); + assert!(!e.can_undo(), "failed commit must not add an undo step"); + } +} + +#[test] +fn commit_rejects_formats_wider_than_the_renderer_without_replacing_tracks() { + for format in ChannelFormat::ALL.into_iter().filter(|format| format.channels() > soundcraft_mix::MAX_CHANNELS) { + let (mut e, track, _) = fixture(format); + let before = e.session_arc(); + let result = e.execute("track.commit", &json!({"tracks": [track.0]})); + assert!(result.unwrap_err().to_string().contains("at most 16 channels")); + assert!(Arc::ptr_eq(&before, &e.session_arc())); + assert!(!e.session().track(track).unwrap().inactive); + assert!(!e.session().track(track).unwrap().hidden); + assert!(!e.can_undo()); + } +} diff --git a/docs/commit-multichannel.md b/docs/commit-multichannel.md new file mode 100644 index 00000000..57874a03 --- /dev/null +++ b/docs/commit-multichannel.md @@ -0,0 +1,41 @@ +# Track Commit channel-format integrity + +**Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 + +Track Commit retains the source track's exact `ChannelFormat`, including surround, height and +ambisonic layouts. Channel count alone cannot identify a layout: 5.1 and 6.0 both have six +channels, while Quad, LCRS and first-order ambisonics all have four. + +The shared pre-fader renderer already returns multichannel audio in the track's own layout. +Commit copies that format to the new audio track and checks the returned channel count and +frame count before adding the source or hiding the original track. Formats wider than the +renderer’s 16-channel limit are rejected before rendering. The fixed two-second tail uses +checked arithmetic; overflowing ranges or requests beyond the renderer’s sample limit return +an error. A command committing several tracks rolls back all of them if any selected track +fails. Current routing and mixer inheritance remain unchanged. + +The regression fixtures use distinct bipolar signals on each channel across all 30 declared +formats within the 16-channel limit, with an insert applied before committing. The four higher +ambisonic orders are rejected without replacing the source track. Additional cases check +bus/send routing, undo/redo, session save/reopen and float WAV export, including its output +speaker mask. + +Mono uses a two-channel frame limit because the existing renderer temporarily targets stereo +before folding back to mono. This frame limit does not bound total process memory. The shared +mono renderer’s -3 dB center-pan gain followed by `SQRT_2` compensation produces approximately ++0.0103 dB gain; the signal fixture accounts for that existing processing behavior. + +Session track metadata retains the exact layout. Generic source WAV saving still infers its +channel mask from channel count. This change preserves the shared renderer, the fixed tail +and the existing commit options. + +Implementation: `crates/engine/src/io.rs::commit_track` and +`crates/mix/src/lib.rs::render_track_pre_fader`. Regression fixtures: +`crates/engine/tests/commit_multichannel.rs`. + +## Revision history + +| Date | Change | +|---|---| +| 2026-10-13 | Refresh against current main; document source-supported format checks, existing renderer limits and regression fixtures. | +| 2026-10-12 | Preserve exact committed formats and reject incomplete or unsupported-width renders; add channel-signal, routing, persistence/export and rollback coverage. | From 28c936a256f43e354a334bd9e90d2d8259f6e4bb Mon Sep 17 00:00:00 2001 From: Billy Coleman III Date: Tue, 13 Oct 2026 20:42:34 -0400 Subject: [PATCH 08/90] Prevent track-name collisions after truncation Validate the trimmed 64-scalar stored track name before mutating the document, return that exact name, and preserve no-op document state. Retain routing-folder bus naming and repair with unique bus names, stable routes and undo/redo coverage. --- contributors/people.toml | 3 + crates/engine/src/cmd/track.rs | 15 +- crates/engine/tests/track_name_collisions.rs | 173 +++++++++++++++++++ 3 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/track_name_collisions.rs diff --git a/contributors/people.toml b/contributors/people.toml index 1c344199..f67fdd57 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -50,3 +50,6 @@ sync_github_name = false [people.Momentum96] sync_github_name = true + +[people.ProdByBuddha] +sync_github_name = true diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 87912389..6f585dcf 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -613,15 +613,26 @@ fn delete(e: &mut Engine, p: &Value) -> Result { fn rename(e: &mut Engine, p: &Value) -> Result { let t = track_param(e, "track.rename", p, "track")?.ok_or_else(|| bad("track.rename", "`track` required"))?; let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("track.rename", "`name` required"))?; - let s = e.session_mut(); + // Validate the stored name, including its existing 64-character limit. + let name: String = name.chars().take(64).collect(); + let s = e.session(); if s.tracks.iter().any(|x| x.name == name && x.id != t) { return Err(bad("track.rename", format!("a track named `{name}` already exists"))); } - let name: String = name.chars().take(64).collect(); let bus = s.track(t).and_then(|track| match (track.kind, &track.mixer.input) { (TrackKind::Folder, Route::Bus(bus)) => Some(*bus), _ => None, }); + let track = s.track(t).ok_or_else(|| bad("track.rename", "unknown track"))?; + if track.name == name + && bus.is_none_or(|id| { + let expected = unique_folder_bus_name(s, &format!("{name} Bus"), Some(id)); + s.bus(id).is_none_or(|bus| bus.name == expected) + }) + { + return Ok(json!({"name": name})); + } + let s = e.session_mut(); if let Some(track) = s.track_mut(t) { track.name.clone_from(&name); } diff --git a/crates/engine/tests/track_name_collisions.rs b/crates/engine/tests/track_name_collisions.rs new file mode 100644 index 00000000..cff5b277 --- /dev/null +++ b/crates/engine/tests/track_name_collisions.rs @@ -0,0 +1,173 @@ +use serde_json::json; +use soundcraft_engine::Engine; +use soundcraft_model::{ChannelFormat, Session, TrackKind}; +use std::sync::Arc; + +#[test] +fn truncated_ascii_and_unicode_names_cannot_collide_with_another_track() { + for character in ['A', '界'] { + let existing: String = std::iter::repeat_n(character, 64).collect(); + let mut session = Session::default(); + let occupied = session.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some(&existing)); + let target = session.add_track(TrackKind::Aux, ChannelFormat::Mono, Some("Target")); + let mut engine = Engine::new(session); + let before = engine.session_arc(); + let revision = engine.revision; + + let result = engine.execute("track.rename", &json!({"track": target.0, "name": format!(" {existing} extra ")})); + assert!(result.is_err()); + assert!(Arc::ptr_eq(&before, &engine.session_arc())); + assert_eq!(engine.revision, revision); + assert_eq!(engine.session().track(occupied).unwrap().name, existing); + assert_eq!(engine.session().track(target).unwrap().name, "Target"); + assert!(!engine.is_dirty()); + assert!(!engine.can_undo()); + } +} + +#[test] +fn rename_returns_the_stored_unicode_name_and_undo_restores_the_document() { + let mut session = Session::default(); + let track = session.add_track(TrackKind::Audio, ChannelFormat::Surround51, Some("Original")); + session.track_mut(track).unwrap().comments = "Retain this comment".into(); + let mut engine = Engine::new(session); + let before = engine.session().to_json().unwrap(); + let stored = "é".repeat(64); + + let result = engine.execute("track.rename", &json!({"track": track.0, "name": format!(" {stored}extra ")})).unwrap(); + assert_eq!(result["name"], stored); + let renamed = engine.session().track(track).unwrap(); + assert_eq!(renamed.name, stored); + assert_eq!(renamed.name.chars().count(), 64); + assert_eq!(renamed.id, track); + assert_eq!(renamed.format, ChannelFormat::Surround51); + assert_eq!(renamed.comments, "Retain this comment"); + assert_eq!(engine.undo_history(), ["Rename Track"]); + assert!(engine.undo()); + assert_eq!(engine.session().to_json().unwrap(), before); + assert!(engine.redo()); + assert_eq!(engine.session().track(track).unwrap().name, stored); +} + +#[test] +fn unchanged_name_after_truncation_does_not_make_an_undo_step() { + let name = "A".repeat(64); + let mut session = Session::default(); + let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, Some(&name)); + let mut engine = Engine::new(session); + let before = engine.session_arc(); + let revision = engine.revision; + + let result = engine.execute("track.rename", &json!({"track": track.0, "name": format!("{name} suffix")})).unwrap(); + assert_eq!(result["name"], name); + assert!(Arc::ptr_eq(&before, &engine.session_arc())); + assert_eq!(engine.revision, revision); + assert!(!engine.is_dirty()); + assert!(!engine.can_undo()); +} + +#[test] +fn routing_folder_rename_preserves_unique_owned_bus_routes_and_history() { + use soundcraft_model::{BusId, Route, TrackId}; + + let stored = "界".repeat(64); + let first_name = format!("{stored} Bus"); + let second_name = format!("{stored} Bus 2"); + let mut session = Session::default(); + let member = session.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Member")); + let user_bus = session.add_bus(&first_name, ChannelFormat::Stereo); + let second_user_bus = session.add_bus(&second_name, ChannelFormat::Stereo); + let mut engine = Engine::new(session); + let unrelated = engine.execute("track.new", &json!({"kind": "folder", "name": "Unrelated"})).unwrap(); + let unrelated = TrackId(unrelated["tracks"][0].as_u64().unwrap()); + let unrelated_route = engine.session().track(unrelated).unwrap().mixer.input.clone(); + let created = engine.execute("track.move_to_new_folder", &json!({"tracks": [member.0], "name": "Initial"})).unwrap(); + let folder = TrackId(created["folder"].as_u64().unwrap()); + let owned_bus = BusId(created["bus"].as_u64().unwrap()); + let before = engine.session().to_json().unwrap(); + let history = engine.undo_history(); + let bus_count = engine.session().busses.len(); + + let result = engine.execute("track.rename", &json!({"track": folder.0, "name": format!(" {stored} suffix ")})).unwrap(); + assert_eq!(result["name"], stored); + assert_eq!(engine.session().track(folder).unwrap().name, stored); + assert_eq!(engine.session().bus(owned_bus).unwrap().name, format!("{stored} Bus 3")); + assert_eq!(engine.session().bus(user_bus).unwrap().name, first_name); + assert_eq!(engine.session().bus(second_user_bus).unwrap().name, second_name); + assert_eq!(engine.session().track(unrelated).unwrap().mixer.input, unrelated_route); + assert_eq!(engine.session().track(folder).unwrap().mixer.input, Route::Bus(owned_bus)); + assert_eq!(engine.session().track(member).unwrap().mixer.output, Route::Bus(owned_bus)); + assert_eq!(engine.session().track(member).unwrap().folder, Some(folder)); + assert_eq!(engine.session().busses.len(), bus_count); + assert_eq!(engine.undo_history().len(), history.len() + 1); + assert_eq!(engine.undo_label(), Some("Rename Track")); + let renamed = engine.session().to_json().unwrap(); + + let document = engine.session_arc(); + let revision = engine.revision; + let history = engine.undo_history(); + engine.execute("track.rename", &json!({"track": folder.0, "name": format!("{stored} more")})).unwrap(); + assert!(Arc::ptr_eq(&document, &engine.session_arc())); + assert_eq!(engine.revision, revision); + assert_eq!(engine.undo_history(), history); + assert!(engine.undo()); + assert_eq!(engine.session().to_json().unwrap(), before); + assert!(engine.redo()); + assert_eq!(engine.session().to_json().unwrap(), renamed); + + // Current-main same-name folder rename also repairs the folder's own bus, without taking + // either colliding user bus or changing its members' bus identity. + engine.session_mut().busses.iter_mut().find(|bus| bus.id == owned_bus).unwrap().name = "Manually named owned bus".into(); + let manual = engine.session().to_json().unwrap(); + engine.execute("track.rename", &json!({"track": folder.0, "name": stored})).unwrap(); + assert_eq!(engine.session().bus(owned_bus).unwrap().name, format!("{stored} Bus 3")); + assert_eq!(engine.session().bus(user_bus).unwrap().name, first_name); + assert_eq!(engine.session().bus(second_user_bus).unwrap().name, second_name); + assert_eq!(engine.session().track(member).unwrap().mixer.output, Route::Bus(owned_bus)); + assert!(engine.undo()); + assert_eq!(engine.session().to_json().unwrap(), manual); + assert!(engine.redo()); + assert_eq!(engine.session().to_json().unwrap(), renamed); +} + +#[test] +fn truncated_folder_collision_refuses_bus_and_document_changes_with_redo_pending() { + use soundcraft_model::{Route, TrackId}; + + for character in ['B', '界'] { + let occupied_name: String = std::iter::repeat_n(character, 64).collect(); + let mut session = Session::default(); + let occupied = session.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some(&occupied_name)); + let mut engine = Engine::new(session); + let created = engine.execute("track.new", &json!({"kind": "folder", "name": "Target"})).unwrap(); + let folder = TrackId(created["tracks"][0].as_u64().unwrap()); + let owned_bus = match engine.session().track(folder).unwrap().mixer.input { + Route::Bus(bus) => bus, + _ => panic!("the routing folder must own a bus"), + }; + engine.execute("mix.volume", &json!({"track": folder.0, "db": -3})).unwrap(); + assert!(engine.undo()); + engine.mark_clean(); + let document = engine.session_arc(); + let before = engine.session().to_json().unwrap(); + let revision = engine.revision; + let journal = engine.journal.clone(); + let undo = engine.undo_history(); + let redo = engine.redo_label().map(str::to_owned); + assert!(redo.is_some()); + + let result = engine.execute("track.rename", &json!({"track": folder.0, "name": format!(" {occupied_name} suffix ")})); + assert!(result.unwrap_err().to_string().contains(&occupied_name)); + assert!(Arc::ptr_eq(&document, &engine.session_arc())); + assert_eq!(engine.session().to_json().unwrap(), before); + assert_eq!(engine.revision, revision); + assert_eq!(engine.journal, journal); + assert_eq!(engine.undo_history(), undo); + assert_eq!(engine.redo_label(), redo.as_deref()); + assert!(!engine.is_dirty()); + assert_eq!(engine.session().track(occupied).unwrap().name, occupied_name); + assert_eq!(engine.session().track(folder).unwrap().name, "Target"); + assert_eq!(engine.session().bus(owned_bus).unwrap().name, "Target Bus"); + assert_eq!(engine.session().track(folder).unwrap().mixer.input, Route::Bus(owned_bus)); + } +} From d4096db1f08883c10dde0f10dfc834bb57c940e0 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 14 Oct 2026 13:28:56 +0200 Subject: [PATCH 09/90] Bounce: a Dither option in the dialog and for stems (#522) The Bounce dialog never sent `dither`, so every 16- and 24-bit bounce was dithered, and file.bounce_stems hard-coded it. The dialog gets a Dither (TPDF) checkbox, off by default and disabled for 32-bit float, and file.bounce_stems takes the same `dither?: true` parameter as file.bounce_mix. --- README.md | 2 +- crates/engine/src/cmd/file.rs | 4 +-- crates/engine/src/io.rs | 43 +++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 19 +++++++++--- crates/ui-egui/tests/bounce.rs | 54 ++++++++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d88fee8a..6fef9815 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ where things are. pitch-preserving Elastic warping. - **Audio files:** WAV/BWF/RF64, AIFF/AIFC and FLAC in and out; MP3, Ogg Vorbis, AAC, ALAC, CAF and the audio of MP4/MOV movies in. Any sample rate and bit depth. -- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with dither and +- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with optional dither and normalisation, reporting peak, true peak and integrated loudness (LUFS). - **Fast:** a parallel mix engine, copy-on-write undo, and a UI that holds 120 frames per second with seventy-odd tracks playing. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 87bba997..db70c200 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec { } ), cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true, fold_down?: stereo}", always, bounce), - cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, fold_down?: stereo}", has_tracks, |e, p| { + cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, dither?: true, fold_down?: stereo}", has_tracks, |e, p| { let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string(); let r = range_param(e, "file.bounce_stems", p)?; let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r }; @@ -100,7 +100,7 @@ pub fn specs() -> Vec { _ => soundcraft_audio_io::BitDepth::Int24, }; let fold = fold_down_param(p, "file.bounce_stems")?; - let files = crate::io::bounce_stems_with(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None }, fold)?; + let files = crate::io::bounce_stems_with(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None }, fold)?; Ok(json!({"files": files})) }), cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 7b26e0b1..ac202ecf 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1823,6 +1823,49 @@ mod stem_tests { assert_eq!(n, 7, "{r}"); let _ = std::fs::remove_dir_all(&dir); } + + /// The largest gap between the 16-bit stem in `dir16` and the 32-bit float one in `dir32`, in LSBs. + fn max_error_lsb(dir16: &std::path::Path, dir32: &std::path::Path) -> f64 { + let mut worst = 0.0f64; + let mut names: Vec<_> = std::fs::read_dir(dir32).unwrap().map(|f| f.unwrap().file_name()).collect(); + names.sort(); + assert!(!names.is_empty()); + for name in names { + let (_, float) = soundcraft_audio_io::decode(&std::fs::read(dir32.join(&name)).unwrap(), None).unwrap(); + let (_, int) = soundcraft_audio_io::decode(&std::fs::read(dir16.join(&name)).unwrap(), None).unwrap(); + for (f, i) in float.channels.iter().zip(&int.channels) { + for (a, b) in f.iter().zip(i) { + worst = worst.max((f64::from(a.clamp(-1.0, 1.0)) - f64::from(*b)).abs() * 32768.0); + } + } + } + worst + } + + #[test] + fn stems_dither_only_when_asked() { + let mut e = crate::demo::demo_engine(); + let root = std::env::temp_dir().join(format!("soundcraft-stems-dither-{}", std::process::id())); + let mut bounce = |name: &str, extra: serde_json::Value| { + let dir = root.join(name); + let mut p = serde_json::json!({"dir": dir.to_string_lossy(), "start": {"seconds": 10.0}, "end": {"seconds": 10.5}}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + e.execute("file.bounce_stems", &p).unwrap(); + dir + }; + let float = bounce("float", serde_json::json!({"bit_depth": "32f"})); + let plain = bounce("plain", serde_json::json!({"bit_depth": "16", "dither": false})); + let dithered = bounce("dithered", serde_json::json!({"bit_depth": "16"})); + // Without dither every sample is the float one rounded to the nearest step; TPDF dither of + // ±1 LSB moves some of them further than half a step. + let plain_error = max_error_lsb(&plain, &float); + let dithered_error = max_error_lsb(&dithered, &float); + assert!(plain_error <= 0.5 + 1e-3, "{plain_error}"); + assert!(dithered_error > 0.6, "{dithered_error}"); + let _ = std::fs::remove_dir_all(&root); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 713e94b7..6effa628 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -12,7 +12,7 @@ use soundcraft_time::Samples; pub enum Dialog { NewTracks { count: u32, format: String, kind: String, timebase: String, name: String }, NewSession { name: String, sample_rate: u32, demo: bool }, - Bounce { path: String, format: String, bit_depth: String, normalize: bool }, + Bounce { path: String, format: String, bit_depth: String, normalize: bool, dither: bool }, RenameTrack { id: TrackId, name: String }, RenameClip { id: ClipId, name: String }, RenameMarker { id: soundcraft_model::MarkerId, name: String }, @@ -99,7 +99,13 @@ impl Dialogs { .and_then(|p| std::path::Path::new(p).parent()) .filter(|d| !d.as_os_str().is_empty()) .map_or(home.clone(), |d| d.display().to_string()); - Dialog::Bounce { path: format!("{dir}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false } + Dialog::Bounce { + path: format!("{dir}/{name} Bounce.wav"), + format: "wav".into(), + bit_depth: "24".into(), + normalize: false, + dither: false, + } } "file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() }, "file.import_midi" => Dialog::PathPrompt { cmd: id.into(), title: "Import MIDI".into(), path: home, key: "path".into() }, @@ -353,7 +359,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { )); } } - Dialog::Bounce { path, format, bit_depth, normalize } => { + Dialog::Bounce { path, format, bit_depth, normalize, dither } => { ui.horizontal(|ui| { ui.label("File"); ui.add(egui::TextEdit::singleline(path).desired_width(420.0)); @@ -383,6 +389,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { }); }); crate::widgets::checkbox(ui, normalize, "Normalize to -0.1 dBFS"); + // 32-bit float keeps every sample as it is: there is nothing to dither. + ui.add_enabled_ui(bit_depth.as_str() != "32f", |ui| { + crate::widgets::checkbox(ui, dither, "Dither (TPDF)") + .on_hover_text("Adds ±1 LSB triangular dither when writing 16- or 24-bit files. Leave it off when the master chain already dithers."); + }); ui.label( egui::RichText::new("Bounces the edit selection, or the whole session when nothing is selected.") .small() @@ -392,7 +403,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { let p = std::path::Path::new(path.as_str()).with_extension(if format == "aiff" { "aif" } else { format.as_str() }); crate::bounce::start( app, - &json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}), + &json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}), ); keep = false; } diff --git a/crates/ui-egui/tests/bounce.rs b/crates/ui-egui/tests/bounce.rs index 9745ae4e..77e73fff 100644 --- a/crates/ui-egui/tests/bounce.rs +++ b/crates/ui-egui/tests/bounce.rs @@ -114,3 +114,57 @@ fn cancel_or_escape_stops_a_bounce_and_leaves_no_file() { assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); let _ = std::fs::remove_dir_all(&dir); } + +/// The largest gap between `int` and `float` in 16-bit LSBs. +fn max_error_lsb(int: &Path, float: &Path) -> f64 { + let (_, a) = soundcraft_audio_io::decode(&std::fs::read(int).unwrap(), None).unwrap(); + let (_, b) = soundcraft_audio_io::decode(&std::fs::read(float).unwrap(), None).unwrap(); + let mut worst = 0.0f64; + for (i, f) in a.channels.iter().zip(&b.channels) { + for (x, y) in i.iter().zip(f) { + worst = worst.max((f64::from(*x) - f64::from(y.clamp(-1.0, 1.0))).abs() * 32768.0); + } + } + worst +} + +/// Bounces from the dialog at `bit_depth`, ticking Dither when `dither`, and waits for the file. +fn bounce_with(h: &mut Harness<'_, SoundApp>, file: &Path, bit_depth: &str, dither: bool) { + let app = h.state_mut(); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + if let Some(Dialog::Bounce { path, bit_depth: depth, .. }) = &mut app.dialogs.open { + *path = file.to_string_lossy().into_owned(); + *depth = bit_depth.into(); + } + h.run_steps(2); + if dither { + h.get_by_label("Dither (TPDF)").click(); + h.run_steps(2); + } + h.get_by_label("Bounce").click(); + h.run_steps(3); + h.run(); + assert!(h.state().bounce.is_none()); + assert!(file.exists(), "{}", h.state().ui.status); +} + +#[test] +fn the_bounce_dialog_dithers_only_when_dither_is_ticked() { + let dir = scratch("bounce-dither"); + let mut h = harness(); + let app = h.state_mut(); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + match &app.dialogs.open { + Some(Dialog::Bounce { dither, .. }) => assert!(!dither, "Dither starts off"), + other => panic!("{other:?}"), + } + app.dialogs.open = None; + bounce_with(&mut h, &dir.join("Float.wav"), "32f", false); + bounce_with(&mut h, &dir.join("Plain.wav"), "16", false); + bounce_with(&mut h, &dir.join("Dithered.wav"), "16", true); + let plain = max_error_lsb(&dir.join("Plain.wav"), &dir.join("Float.wav")); + let dithered = max_error_lsb(&dir.join("Dithered.wav"), &dir.join("Float.wav")); + assert!(plain <= 0.5 + 1e-3, "{plain}"); + assert!(dithered > 0.6, "{dithered}"); + let _ = std::fs::remove_dir_all(&dir); +} From f8b2f8ce2e0b611ae4b6ec3165c6af77777eb6de Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 14 Oct 2026 16:59:15 +0500 Subject: [PATCH 10/90] cli: reject unknown options and missing option values `convert`, `run` and `script` read options with `arg_value`, which matches a flag exactly and takes the argument after it. A misspelling, an `--option=value` form or an option with no value therefore read as absent: the command ran without what was asked for and still exited 0. convert IN OUT --rate=44100 wrote IN's 48000 Hz unchanged convert IN OUT --bitdepth 16 wrote the default 24-bit convert IN OUT --bogus wrote the file, said nothing run --cmd session.new --bounce=OUT bounced nothing Check what each subcommand accepts and report an unknown option or a missing value with a non-zero exit, and read `--option=value` as the value it looks like. The documented space-separated form is unchanged. --- apps/soundcraft-cli/src/main.rs | 62 +++++++++++++- apps/soundcraft-cli/tests/options.rs | 122 +++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 apps/soundcraft-cli/tests/options.rs diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 9a51cc54..4655c43b 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -38,9 +38,57 @@ macro_rules! outln { } fn arg_value(args: &[String], flag: &str) -> Option { - args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned() + args.iter().enumerate().find_map(|(i, a)| { + if a == flag { args.get(i + 1).cloned() } else { a.strip_prefix(flag).and_then(|rest| rest.strip_prefix('=')).map(str::to_owned) } + }) } +/// Every option a subcommand accepts, with whether it carries a value. +type Options = [(&'static str, bool)]; + +/// A misspelled option, or one whose value is missing, reads as absent through `arg_value`, so the +/// subcommand would run without what was asked for and still report success. Reject both instead. +fn check_options(args: &[String], allowed: &Options) -> Result<(), String> { + let mut i = 0; + while i < args.len() { + let Some(name) = args[i].strip_prefix("--") else { + i += 1; + continue; + }; + let (name, inline) = name.split_once('=').map_or((name, None), |(n, v)| (n, Some(v))); + let flag = format!("--{name}"); + let Some(&(_, takes_value)) = allowed.iter().find(|(f, _)| f == &flag) else { + return Err(format!("unknown option {flag}")); + }; + match (takes_value, inline) { + (false, Some(_)) => return Err(format!("{flag} takes no value")), + (true, None) => { + // A following option is the next flag, not this one's value. + if args.get(i + 1).is_none_or(|v| v.starts_with("--")) { + return Err(format!("{flag} needs a value")); + } + i += 1; + } + _ => {} + } + i += 1; + } + Ok(()) +} + +/// `--in`, `--demo`/`--sample`, `--bounce`, `--start`, `--end`, `--save` and `--inspect`, read by +/// `load_engine` and `finish` and so shared by `run` and `script`. +const ENGINE_OPTIONS: &Options = &[ + ("--in", true), + ("--demo", false), + ("--sample", false), + ("--bounce", true), + ("--start", true), + ("--end", true), + ("--save", true), + ("--inspect", false), +]; + fn fail(msg: impl std::fmt::Display) -> ExitCode { eprintln!("soundcraft-cli: {msg}"); ExitCode::FAILURE @@ -122,6 +170,10 @@ fn info(args: &[String]) -> ExitCode { fn convert(args: &[String]) -> ExitCode { let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") }; + const OPTIONS: &Options = &[("--rate", true), ("--bit-depth", true), ("--normalize", false)]; + if let Err(e) = check_options(&args[2..], OPTIONS) { + return fail(e); + } let rate = match parse_rate(arg_value(args, "--rate").as_deref()) { Ok(r) => r, Err(e) => return fail(e), @@ -243,6 +295,10 @@ fn parse_cmd_spec(spec: &str) -> Result<(String, Value), String> { } fn run(args: &[String]) -> ExitCode { + let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--cmd", true)]).collect(); + if let Err(e) = check_options(args, &options) { + return fail(e); + } let mut e = match load_engine(args) { Ok(e) => e, Err(err) => return fail(err), @@ -269,6 +325,10 @@ fn run(args: &[String]) -> ExitCode { fn script(args: &[String]) -> ExitCode { let Some(path) = args.first() else { return fail("script FILE") }; + let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--keep-going", false)]).collect(); + if let Err(e) = check_options(&args[1..], &options) { + return fail(e); + } let text = match std::fs::read_to_string(path) { Ok(t) => t, Err(e) => return fail(format!("{path}: {e}")), diff --git a/apps/soundcraft-cli/tests/options.rs b/apps/soundcraft-cli/tests/options.rs new file mode 100644 index 00000000..e709a3d8 --- /dev/null +++ b/apps/soundcraft-cli/tests/options.rs @@ -0,0 +1,122 @@ +//! Unknown options and missing option values must be reported, not silently skipped (#549). +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn bin() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")); + cmd.env("SOUNDCRAFT_NO_PREFS", "1"); + cmd +} + +fn run(args: &[&str]) -> Output { + bin().args(args).output().unwrap() +} + +fn tmpdir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("sc-cli-options-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A 0.1 s mono 48 kHz 16-bit WAV, written by hand so the test needs no fixture. +fn tone(dir: &Path) -> PathBuf { + let frames: u32 = 4800; + let data: Vec = (0..frames).flat_map(|_| [0x00, 0x10]).collect(); + let (bytes, rate, ch, bits) = (data.len() as u32, 48_000u32, 1u16, 16u16); + let mut wav = Vec::new(); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&(36 + bytes).to_le_bytes()); + wav.extend_from_slice(b"WAVEfmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&ch.to_le_bytes()); + wav.extend_from_slice(&rate.to_le_bytes()); + wav.extend_from_slice(&(rate * u32::from(ch) * u32::from(bits) / 8).to_le_bytes()); + wav.extend_from_slice(&(ch * bits / 8).to_le_bytes()); + wav.extend_from_slice(&bits.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&bytes.to_le_bytes()); + wav.extend_from_slice(&data); + let path = dir.join("tone.wav"); + std::fs::write(&path, wav).unwrap(); + path +} + +fn sample_format(cli_info: &str) -> String { + serde_json::from_str::(cli_info).unwrap()["sample_format"].as_str().unwrap().to_owned() +} + +#[test] +fn convert_rejects_an_unknown_option_instead_of_writing_the_file() { + let dir = tmpdir("convert-unknown"); + let input = tone(&dir); + let out = dir.join("out.wav"); + for bad in ["--bitdepth", "--normalise", "--bogus"] { + let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]); + assert!(!o.status.success(), "{bad} should fail"); + assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("unknown option {bad}")), "{bad}: {}", String::from_utf8_lossy(&o.stderr)); + assert!(!out.exists(), "{bad} should not write the output"); + } +} + +#[test] +fn convert_rejects_an_option_whose_value_is_missing() { + let dir = tmpdir("convert-novalue"); + let input = tone(&dir); + let out = dir.join("out.wav"); + for bad in ["--rate", "--bit-depth"] { + let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]); + assert!(!o.status.success(), "{bad} should fail"); + assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("{bad} needs a value")), "{bad}: {}", String::from_utf8_lossy(&o.stderr)); + assert!(!out.exists(), "{bad} should not write the output"); + } +} + +#[test] +fn convert_accepts_the_inline_value_form() { + let dir = tmpdir("convert-inline"); + let input = tone(&dir); + let out = dir.join("out.wav"); + let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth=32f"]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let info = run(&["info", out.to_str().unwrap()]); + assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Float32"); +} + +#[test] +fn convert_still_accepts_the_documented_space_separated_form() { + let dir = tmpdir("convert-space"); + let input = tone(&dir); + let out = dir.join("out.wav"); + let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth", "16"]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let info = run(&["info", out.to_str().unwrap()]); + assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Int16"); +} + +#[test] +fn run_rejects_a_misspelled_or_value_less_bounce() { + let dir = tmpdir("run-bounce"); + let out = dir.join("x.wav"); + let o = run(&["run", "--cmd", "session.new", "--bounce"]); + assert!(!o.status.success()); + assert!(String::from_utf8_lossy(&o.stderr).contains("--bounce needs a value"), "{}", String::from_utf8_lossy(&o.stderr)); + + let o = run(&["run", "--cmd", "session.new", "--bounces", out.to_str().unwrap()]); + assert!(!o.status.success()); + assert!(String::from_utf8_lossy(&o.stderr).contains("unknown option --bounces"), "{}", String::from_utf8_lossy(&o.stderr)); + assert!(!out.exists(), "nothing should be bounced"); +} + +#[test] +fn run_bounces_with_the_inline_value_form() { + let dir = tmpdir("run-inline"); + let out = dir.join("y.wav"); + let o = run(&["run", "--cmd", "session.new", &format!("--bounce={}", out.display())]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + assert!(out.exists(), "--bounce=PATH should write the file"); +} From 76696826cd95430e68e1d2fb9574b9effd7df2f7 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:01:42 +0300 Subject: [PATCH 11/90] mix: clamp negative fractional source position in stretched-clip rendering --- crates/mix/src/lib.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index e8c4fbad..a354b000 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1442,7 +1442,7 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut let x0 = f.floor(); let fr = (f - x0) as f32; let a = if x0 >= 0.0 { src.get(x0 as usize).copied().unwrap_or(0.0) } else { 0.0 }; - let b = if x0 + 1.0 >= 0.0 { src.get((x0 as usize).saturating_add(1)).copied().unwrap_or(0.0) } else { 0.0 }; + let b = if x0 + 1.0 >= 0.0 { src.get((x0 + 1.0) as usize).copied().unwrap_or(0.0) } else { 0.0 }; a + (b - a) * fr }; let t = (k - i) as f32 / n; @@ -2369,6 +2369,30 @@ mod tests { assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4); } + #[test] + fn a_stretched_clip_before_its_source_start_reads_from_the_first_sample() { + // A clip that extends before its source (a negative offset) plays silence there. When it + // is stretched, the read position passes through [-1, 0), which must interpolate from + // that silence towards sample 0 of the file -- not towards sample 1. + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![1.0, 0.5, 0.25, 0.125]] }; + s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(buf))); + let id = s.new_clip_id(); + let mut c = Clip::audio(id, "late", SourceId(901), -1, 0, 4); + c.stretch = 2.0; + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips.push(c); + } + let out = render_clips(&s, t, Range::new(0, 4)); + // rel 0 -> source -1.0: silence; rel 1 -> source -0.5: halfway from silence to sample 0; + // rel 2 -> source 0.0: sample 0; rel 3 -> source 0.5: halfway to sample 1. + let want = [0.0, 0.5, 1.0, 0.75]; + for (i, w) in want.iter().enumerate() { + assert!((out[0][i] - w).abs() < 1e-6, "frame {i}: {} vs {w}", out[0][i]); + } + } + #[test] fn fade_curvature_reaches_the_rendered_audio() { for fade_in in [true, false] { From 1e1458caf1e4d9d0ec06956539fe3efade781118 Mon Sep 17 00:00:00 2001 From: Billy Coleman III Date: Wed, 14 Oct 2026 13:14:38 -0400 Subject: [PATCH 12/90] Compile exact dependency order for deep Aux routing Prepare longest-path depths for acyclic Aux and Folder input routes so reverse-created chains beyond eight returns preserve their audio. Count output and send destinations once per producer, retain depth ties in session order, and preserve the whole-graph bounded fallback for cycles. Add public PCM regressions for deep chains, routing edits, callback sizes and cycle compatibility, plus focused private ordering cases. --- contributors/people.toml | 3 + crates/mix/src/lib.rs | 157 +++++++++++-- crates/mix/tests/aux_routing_order.rs | 307 ++++++++++++++++++++++++++ docs/aux-routing-order.md | 17 ++ 4 files changed, 468 insertions(+), 16 deletions(-) create mode 100644 crates/mix/tests/aux_routing_order.rs create mode 100644 docs/aux-routing-order.md diff --git a/contributors/people.toml b/contributors/people.toml index 1c344199..f67fdd57 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -50,3 +50,6 @@ sync_github_name = false [people.Momentum96] sync_github_name = true + +[people.ProdByBuddha] +sync_github_name = true diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index e8c4fbad..2c482fbd 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1306,26 +1306,97 @@ fn processing_order(s: &Session) -> Vec { (0..s.tracks.len()).filter(|&i| s.tracks.get(i).is_some_and(|t| !matches!(t.kind, TrackKind::Aux | TrackKind::Folder))).collect(); let mut auxes: Vec = (0..s.tracks.len()).filter(|&i| s.tracks.get(i).is_some_and(|t| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))).collect(); - // Depth = how many aux hops feed this aux's input bus (bounded; cycles cap out). - let depth = |i: usize| -> usize { - let mut d = 0; - let mut cur = vec![i]; - for _ in 0..8 { - let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect(); - let feeders: Vec = auxes_feeding(s, &inputs); - if feeders.is_empty() { - break; - } - d += 1; - cur = feeders; - } - d - }; - auxes.sort_by_key(|&i| depth(i)); + if let Some(depths) = acyclic_aux_depths(s, &auxes) { + // Stable sorting preserves session order among auxes at the same longest-path depth. + auxes.sort_by_key(|&i| depths.get(i).copied().unwrap_or(0)); + } else { + // A cycle keeps the existing bounded ordering for the entire graph, including any + // independent acyclic branches. Feedback does not have a finite longest-path depth. + auxes.sort_by_key(|&i| bounded_aux_depth(s, i)); + } order.extend(auxes); order } +/// Exact aux-hop depths when the whole Aux/Folder graph is acyclic. Bus joins retain only +/// producer counts and consumers, avoiding a producer-by-consumer adjacency matrix. +fn acyclic_aux_depths(s: &Session, auxes: &[usize]) -> Option> { + #[derive(Default)] + struct BusDependencies { + remaining: usize, + depth: usize, + last_producer: Option, + last_processed: Option, + consumers: Vec, + } + let mut busses = HashMap::::new(); + for &i in auxes { + for bus in aux_output_busses(s.tracks.get(i)?) { + let dependency = busses.entry(bus).or_default(); + // Output and multiple sends from this producer may name the same bus. + if dependency.last_producer != Some(i) { + dependency.last_producer = Some(i); + dependency.remaining = dependency.remaining.checked_add(1)?; + } + } + } + let mut depths = vec![0usize; s.tracks.len()]; + let mut ready = Vec::with_capacity(auxes.len()); + for &i in auxes { + if let Route::Bus(bus) = &s.tracks.get(i)?.mixer.input { + let dependency = busses.entry(*bus).or_default(); + dependency.consumers.push(i); + if dependency.remaining > 0 { + continue; + } + } + ready.push(i); + } + let mut processed = 0usize; + while let Some(&i) = ready.get(processed) { + processed = processed.checked_add(1)?; + let next_depth = depths.get(i)?.checked_add(1)?; + for bus in aux_output_busses(s.tracks.get(i)?) { + let dependency = busses.get_mut(&bus)?; + if dependency.last_processed == Some(i) { + continue; + } + dependency.last_processed = Some(i); + dependency.depth = dependency.depth.max(next_depth); + dependency.remaining = dependency.remaining.checked_sub(1)?; + if dependency.remaining == 0 { + for &consumer in &dependency.consumers { + *depths.get_mut(consumer)? = dependency.depth; + ready.push(consumer); + } + } + } + } + (processed == auxes.len()).then_some(depths) +} + +fn aux_output_busses(t: &Track) -> impl Iterator + '_ { + std::iter::once(&t.mixer.output) + .chain(t.mixer.sends.iter().flatten().map(|send| &send.target)) + .filter_map(|route| if let Route::Bus(bus) = route { Some(*bus) } else { None }) +} + +/// The previous bounded depth calculation, retained unchanged for any cyclic graph. +fn bounded_aux_depth(s: &Session, i: usize) -> usize { + let mut d = 0; + let mut cur = vec![i]; + for _ in 0..8 { + let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect(); + let feeders: Vec = auxes_feeding(s, &inputs); + if feeders.is_empty() { + break; + } + d += 1; + cur = feeders; + } + d +} + fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec { s.tracks .iter() @@ -1338,6 +1409,60 @@ fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec { .collect() } +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod processing_order_tests { + use super::*; + use soundcraft_model::SendSlot; + + #[test] + fn acyclic_bus_joins_use_longest_depth_and_keep_session_order_for_ties() { + let mut s = Session::default(); + s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None); + let consumer = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None); + let first_root = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None); + let second_root = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None); + let middle = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None); + let first_bus = s.add_bus("First", ChannelFormat::Stereo); + let join = s.add_bus("Join", ChannelFormat::Stereo); + s.track_mut(consumer).unwrap().mixer.input = Route::Bus(join); + let root = s.track_mut(first_root).unwrap(); + root.mixer.output = Route::Main; + root.mixer.sends[0] = Some(SendSlot::new(Route::Bus(first_bus))); + let root = s.track_mut(second_root).unwrap(); + root.mixer.output = Route::Bus(join); + root.mixer.sends[0] = Some(SendSlot::new(Route::Bus(join))); + root.mixer.sends[1] = Some(SendSlot::new(Route::Bus(join))); + let folder = s.track_mut(middle).unwrap(); + folder.mixer.input = Route::Bus(first_bus); + folder.mixer.output = Route::Bus(join); + folder.mixer.sends[0] = Some(SendSlot::new(Route::Bus(join))); + + assert_eq!(processing_order(&s), vec![0, 2, 3, 4, 1]); + } + + #[test] + fn a_cycle_preserves_bounded_order_for_the_whole_graph() { + let mut s = Session::default(); + let busses: Vec<_> = (0..10).map(|i| s.add_bus(&format!("Chain {i}"), ChannelFormat::Stereo)).collect(); + let tracks: Vec<_> = (0..10).map(|_| s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None)).collect(); + for (i, id) in tracks.iter().enumerate() { + let track = s.track_mut(*id).unwrap(); + track.mixer.input = Route::Bus(busses[i]); + track.mixer.output = if i == 0 { Route::Main } else { Route::Bus(busses[i - 1]) }; + } + assert_eq!(processing_order(&s), vec![9, 8, 7, 6, 5, 4, 3, 2, 1, 0]); + let cycle_bus = s.add_bus("Feedback", ChannelFormat::Stereo); + let cycle = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None); + let track = s.track_mut(cycle).unwrap(); + track.mixer.input = Route::Bus(cycle_bus); + track.mixer.output = Route::Bus(cycle_bus); + + // Both deepest independent returns tie with the cycle at the legacy eight-hop cap. + assert_eq!(processing_order(&s), vec![9, 8, 7, 6, 5, 4, 3, 2, 0, 1, 10]); + } +} + /// Implicit solo: an aux is not muted when a soloed track feeds its input bus. fn receives_solo(s: &Session, t: &Track) -> bool { let Route::Bus(b) = &t.mixer.input else { return false }; diff --git a/crates/mix/tests/aux_routing_order.rs b/crates/mix/tests/aux_routing_order.rs new file mode 100644 index 00000000..4c557fa7 --- /dev/null +++ b/crates/mix/tests/aux_routing_order.rs @@ -0,0 +1,307 @@ +//! Aux and routing-folder dependencies preserve PCM beyond eight hops. Cyclic graphs keep +//! the established whole-graph fallback, including its session-order-dependent audio. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use soundcraft_audio_io::AudioBuffer; +use soundcraft_mix::{MixEngine, render_range}; +use soundcraft_model::{BusId, ChannelFormat, Clip, Route, SendSlot, Session, SourceAudio, SourceId, TrackId, TrackKind}; +use soundcraft_time::Range; +use std::sync::Arc; + +const FRAMES: usize = 4097; +const DEPTH: usize = 12; + +fn signal() -> Vec> { + // Distinct, exactly representable channels expose lost, duplicated, swapped and delayed PCM. + vec![ + (0..FRAMES).map(|frame| ((frame % 31) as i32 - 15) as f32 / 256.0).collect(), + (0..FRAMES).map(|frame| ((frame % 17) as i32 - 8) as f32 / 512.0).collect(), + ] +} + +fn scaled_signal(gain: f32) -> Vec> { + signal().into_iter().map(|channel| channel.into_iter().map(|value| value * gain).collect()).collect() +} + +fn session(pdc: bool) -> Session { + let mut session = Session::default(); + session.edit.click = false; + session.edit.delay_compensation = pdc; + session +} + +fn add_source(session: &mut Session, output: Route) -> TrackId { + let source = SourceId(session.alloc()); + session.pool.insert(source, Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: signal() }))); + let clip = session.new_clip_id(); + let track = session.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("PCM source")); + let track_state = session.track_mut(track).unwrap(); + track_state.mixer.output = output; + track_state.playlist_mut().unwrap().clips.push(Clip::audio(clip, "PCM", source, 0, 0, FRAMES as i64)); + track +} + +fn add_aux(session: &mut Session, kind: TrackKind, input: BusId, output: Route) -> TrackId { + let track = session.add_track(kind, ChannelFormat::Stereo, None); + let mixer = &mut session.track_mut(track).unwrap().mixer; + mixer.input = Route::Bus(input); + mixer.output = output; + track +} + +fn send(bus: BusId) -> SendSlot { + let mut send = SendSlot::new(Route::Bus(bus)); + send.level_db = 0.0; + send +} + +struct Chain { + session: Session, + buses: Vec, + /// Logical routing order, deliberately different from the session's creation order. + tracks: Vec, +} + +fn reverse_chain(pdc: bool, send_links: bool, folders: bool) -> Chain { + let mut session = session(pdc); + let buses: Vec<_> = (0..DEPTH).map(|stage| session.add_bus(&format!("Stage {stage}"), ChannelFormat::Stereo)).collect(); + let mut tracks = Vec::new(); + for stage in (0..DEPTH).rev() { + let kind = if folders && stage % 3 == 1 { TrackKind::Folder } else { TrackKind::Aux }; + let output = if stage + 1 == DEPTH { + Route::Main + } else if send_links { + Route::None + } else { + Route::Bus(buses[stage + 1]) + }; + let track = add_aux(&mut session, kind, buses[stage], output); + if send_links && stage + 1 < DEPTH { + session.track_mut(track).unwrap().mixer.sends[0] = Some(send(buses[stage + 1])); + } + tracks.push(track); + } + tracks.reverse(); + add_source(&mut session, Route::Bus(buses[0])); + Chain { session, buses, tracks } +} + +fn assert_pcm(actual: &[Vec], expected: &[Vec], context: &str) { + assert_eq!(actual.len(), expected.len(), "{context}: channel count"); + for (channel, (actual, expected)) in actual.iter().zip(expected).enumerate() { + assert_eq!(actual.len(), expected.len(), "{context}: channel {channel} length"); + for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() { + assert_eq!(actual, expected, "{context}: channel {channel}, frame {frame}"); + } + } +} + +fn render(session: &Session, block: usize) -> Vec> { + render_range(session, Range::new(0, FRAMES as i64), block) +} + +fn engine_block(engine: &mut MixEngine, session: &Session) -> Vec> { + let mut out = vec![vec![0.0; 128]; 2]; + engine.render(session, 0, 128, &mut out); + out +} + +fn first_block(pcm: Vec>) -> Vec> { + pcm.into_iter().map(|channel| channel.into_iter().take(128).collect()).collect() +} + +#[test] +fn reverse_created_twelve_aux_chain_preserves_every_sample() { + for pdc in [false, true] { + let chain = reverse_chain(pdc, false, false); + assert_pcm(&render(&chain.session, 1024), &signal(), "deep output chain"); + } +} + +#[test] +fn deep_dependencies_through_sends_without_outputs_preserve_every_sample() { + for pdc in [false, true] { + let chain = reverse_chain(pdc, true, false); + assert_pcm(&render(&chain.session, 97), &signal(), "deep send-only chain"); + } +} + +#[test] +fn output_and_send_to_the_same_bus_sum_audio_without_duplicate_dependency() { + for pdc in [false, true] { + let mut chain = reverse_chain(pdc, false, false); + chain.session.track_mut(chain.tracks[4]).unwrap().mixer.sends[0] = Some(send(chain.buses[5])); + assert_pcm(&render(&chain.session, 256), &scaled_signal(2.0), "duplicate destination"); + } +} + +#[test] +fn short_and_deep_branches_both_reach_the_join() { + for pdc in [false, true] { + let mut chain = reverse_chain(pdc, false, false); + add_aux(&mut chain.session, TrackKind::Aux, chain.buses[0], Route::Bus(chain.buses[DEPTH - 1])); + assert_pcm(&render(&chain.session, 512), &scaled_signal(2.0), "branch join"); + } +} + +#[test] +fn routing_folders_participate_in_deep_dependencies() { + for pdc in [false, true] { + let chain = reverse_chain(pdc, true, true); + assert_pcm(&render(&chain.session, 63), &signal(), "aux and folder send chain"); + } +} + +#[test] +fn output_edit_reorders_the_same_engine_before_the_next_block() { + let mut session = session(true); + let root = session.add_bus("Root", ChannelFormat::Stereo); + let join = session.add_bus("Join", ChannelFormat::Stereo); + add_aux(&mut session, TrackKind::Aux, join, Route::Main); + let producer = add_aux(&mut session, TrackKind::Aux, root, Route::Main); + add_source(&mut session, Route::Bus(root)); + let mut engine = MixEngine::new(48_000.0, 128); + let expected = first_block(signal()); + assert_pcm(&engine_block(&mut engine, &session), &expected, "before output edit"); + session.track_mut(producer).unwrap().mixer.output = Route::Bus(join); + assert_pcm(&engine_block(&mut engine, &session), &expected, "after output edit"); +} + +#[test] +fn input_edit_reorders_the_same_engine_before_the_next_block() { + let mut session = session(true); + let root = session.add_bus("Root", ChannelFormat::Stereo); + let join = session.add_bus("Join", ChannelFormat::Stereo); + let consumer = add_aux(&mut session, TrackKind::Folder, root, Route::Main); + add_aux(&mut session, TrackKind::Aux, root, Route::Bus(join)); + add_source(&mut session, Route::Bus(root)); + let mut engine = MixEngine::new(48_000.0, 128); + let expected = first_block(signal()); + assert_pcm(&engine_block(&mut engine, &session), &expected, "before input edit"); + session.track_mut(consumer).unwrap().mixer.input = Route::Bus(join); + assert_pcm(&engine_block(&mut engine, &session), &expected, "after input edit"); +} + +#[test] +fn send_add_retarget_remove_and_readd_update_the_same_engine() { + let mut session = session(true); + let root = session.add_bus("Root", ChannelFormat::Stereo); + let join = session.add_bus("Join", ChannelFormat::Stereo); + let unused = session.add_bus("Unused", ChannelFormat::Stereo); + add_aux(&mut session, TrackKind::Aux, join, Route::Main); + let producer = add_aux(&mut session, TrackKind::Folder, root, Route::None); + add_source(&mut session, Route::Bus(root)); + let mut engine = MixEngine::new(48_000.0, 128); + let expected = first_block(signal()); + let silence = vec![vec![0.0; 128]; 2]; + assert_pcm(&engine_block(&mut engine, &session), &silence, "before send"); + session.track_mut(producer).unwrap().mixer.sends[0] = Some(send(join)); + assert_pcm(&engine_block(&mut engine, &session), &expected, "send added"); + session.track_mut(producer).unwrap().mixer.sends[0].as_mut().unwrap().target = Route::Bus(unused); + assert_pcm(&engine_block(&mut engine, &session), &silence, "send retargeted"); + session.track_mut(producer).unwrap().mixer.sends[0].as_mut().unwrap().target = Route::Bus(join); + assert_pcm(&engine_block(&mut engine, &session), &expected, "send retargeted back"); + session.track_mut(producer).unwrap().mixer.sends[0] = None; + assert_pcm(&engine_block(&mut engine, &session), &silence, "send removed"); + session.track_mut(producer).unwrap().mixer.sends[0] = Some(send(join)); + assert_pcm(&engine_block(&mut engine, &session), &expected, "send readded"); +} + +#[test] +fn track_reorder_removal_and_new_identity_rebuild_the_same_engine() { + let mut chain = reverse_chain(true, false, false); + let mut engine = MixEngine::new(48_000.0, 128); + let expected = first_block(signal()); + assert_pcm(&engine_block(&mut engine, &chain.session), &expected, "initial track order"); + chain.session.tracks.reverse(); + assert_pcm(&engine_block(&mut engine, &chain.session), &expected, "tracks reordered"); + chain.session.tracks.retain(|track| track.id != chain.tracks[6]); + let silence = vec![vec![0.0; 128]; 2]; + assert_pcm(&engine_block(&mut engine, &chain.session), &silence, "track removed"); + let replacement = add_aux(&mut chain.session, TrackKind::Aux, chain.buses[6], Route::Bus(chain.buses[7])); + assert_ne!(replacement, chain.tracks[6]); + assert_pcm(&engine_block(&mut engine, &chain.session), &expected, "track readded with new identity"); +} + +#[test] +fn strip_and_bus_format_edits_refresh_the_same_engine_workspace() { + let mut chain = reverse_chain(true, false, false); + let mut engine = MixEngine::new(48_000.0, 128); + let stereo = first_block(signal()); + assert_pcm(&engine_block(&mut engine, &chain.session), &stereo, "initial stereo formats"); + chain.session.track_mut(chain.tracks[5]).unwrap().format = ChannelFormat::Mono; + // A stereo bus folds to mono at -6 dB; the retained hard-left pan sends that mono to L. + let mono: Vec<_> = stereo[0].iter().zip(&stereo[1]).map(|(left, right)| 0.5 * (left + right)).collect(); + assert_pcm(&engine_block(&mut engine, &chain.session), &[mono.clone(), vec![0.0; 128]], "mono strip"); + chain.session.track_mut(chain.tracks[5]).unwrap().format = ChannelFormat::Stereo; + assert_pcm(&engine_block(&mut engine, &chain.session), &stereo, "stereo strip restored"); + chain.session.busses.iter_mut().find(|bus| bus.id == chain.buses[5]).unwrap().format = ChannelFormat::Mono; + // A mono bus feeds both channels of the following stereo aux at unity. + assert_pcm(&engine_block(&mut engine, &chain.session), &[mono.clone(), mono], "mono bus"); +} + +#[test] +fn deep_routing_is_invariant_to_offline_and_callback_block_sizes() { + let chain = reverse_chain(true, true, true); + let expected = signal(); + for block in [16, 63, 97, 256, 512, 1024] { + assert_pcm(&render(&chain.session, block), &expected, "offline block size"); + } + let mut engine = MixEngine::new(48_000.0, 1024); + let callbacks = [1, 63, 257, 17, 511, 1024]; + let mut out = vec![Vec::new(), Vec::new()]; + let mut position = 0; + let mut callback = 0; + while position < FRAMES { + let frames = callbacks[callback % callbacks.len()].min(FRAMES - position); + let mut block = vec![vec![0.0; frames]; 2]; + engine.render(&chain.session, position as i64, frames, &mut block); + for (destination, channel) in out.iter_mut().zip(block) { + destination.extend(channel); + } + position += frames; + callback += 1; + } + assert_pcm(&out, &expected, "varying callback sizes"); +} + +#[test] +fn self_loop_preserves_audio_and_selects_fallback_for_the_whole_graph() { + for pdc in [false, true] { + let mut chain = reverse_chain(pdc, false, false); + let loop_bus = chain.session.add_bus("Self loop", ChannelFormat::Stereo); + let aux = add_aux(&mut chain.session, TrackKind::Aux, loop_bus, Route::Main); + chain.session.track_mut(aux).unwrap().mixer.sends[0] = Some(send(loop_bus)); + add_source(&mut chain.session, Route::Bus(loop_bus)); + // The self-loop reads its source once, then writes back to the already-read bus. + // The disconnected deep chain remains silent under the established eight-hop fallback. + assert_pcm(&render(&chain.session, 97), &signal(), "self-loop whole-graph fallback"); + } +} + +#[test] +fn two_aux_cycle_preserves_session_order_audio_and_whole_graph_fallback() { + for pdc in [false, true] { + for reverse in [false, true] { + let mut chain = reverse_chain(pdc, false, false); + let a_bus = chain.session.add_bus("Cycle A", ChannelFormat::Stereo); + let b_bus = chain.session.add_bus("Cycle B", ChannelFormat::Stereo); + let (a, b) = if reverse { + let b = add_aux(&mut chain.session, TrackKind::Aux, b_bus, Route::Main); + let a = add_aux(&mut chain.session, TrackKind::Folder, a_bus, Route::Main); + (a, b) + } else { + let a = add_aux(&mut chain.session, TrackKind::Folder, a_bus, Route::Main); + let b = add_aux(&mut chain.session, TrackKind::Aux, b_bus, Route::Main); + (a, b) + }; + chain.session.track_mut(a).unwrap().mixer.sends[0] = Some(send(b_bus)); + chain.session.track_mut(b).unwrap().mixer.sends[0] = Some(send(a_bus)); + add_source(&mut chain.session, Route::Bus(a_bus)); + // A then B: both hear the source. B then A: B reads its still-empty bus. + // Buses clear each block, so neither order feeds a previous block back into this one. + let expected = scaled_signal(if reverse { 1.0 } else { 2.0 }); + assert_pcm(&render(&chain.session, 256), &expected, "two-aux whole-graph fallback"); + } + } +} diff --git a/docs/aux-routing-order.md b/docs/aux-routing-order.md new file mode 100644 index 00000000..3c93eddd --- /dev/null +++ b/docs/aux-routing-order.md @@ -0,0 +1,17 @@ +# Aux and Folder routing order + +Updated: 2026-10-14 + +Aux and Folder strips read audio from busses populated by earlier strips. The mixer prepares their execution order when the existing session-structure fingerprint changes, then reuses that order for subsequent blocks. + +For an acyclic routing graph, preparation computes the longest producer path to every Aux or Folder input. Producers reach a bus before its consumers read it, even when tracks were created in reverse routing order or the chain contains more than eight returns. Output and send routes both create dependencies. Multiple routes from the same producer to the same bus contribute audio normally but count as one producer dependency. Strips at the same depth retain session order, preserving the previous ordering for ordinary short acyclic graphs. + +The earlier ordering calculation capped every depth at eight. In a sufficiently deep reverse-created chain, that made a consumer tie with its producer and execute first. Because bus buffers are cleared each block, the consumer could receive silence. Exact acyclic depths remove that tie. + +If any cycle exists, the whole graph retains the previous bounded depth calculation and deterministic session-order ties. Preparation does not reject a cyclic session or change its feedback policy. This increment does not alter PDC accounting, plugin processing or failure containment, render-helper APIs, pan coefficients, Master insert order, playback ownership, or device selection. + +This is the routing-order increment of the remaining runtime work discussed in #394. Further runtime changes require independent treatment of prepared routing coefficients, workspace and instance lifetime, hosted automation delivery, graph latency, and explicit render failures. This increment does not claim to complete that rewrite or move all preparation off the audio callback. + +Regression tests use synthetic source samples and public mixer APIs. They cover deep reverse-created chains, output/send dependencies, duplicate routes, branching, Folder consumers, callback-size invariance, edits on an existing mixer and preserved cyclic behavior with PDC enabled or disabled. The same public fixture can run against the previous main to demonstrate the depth-cap failure independently of the new preparation helper. + +Revision history: 2026-10-14 — exact acyclic routing order with the existing whole-graph cyclic fallback. From b50373d6db9a89e32406bdb659fab6d112b39f1a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 19:25:20 +0200 Subject: [PATCH 13/90] A Pencil click on a MIDI clip selects it, so the MIDI Editor follows (fixes #562) --- crates/ui-egui/src/edit_window.rs | 9 +++++++-- crates/ui-egui/tests/new_midi_clip.rs | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f77560a5..78fc4ff2 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -2177,8 +2177,13 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, "automation.set_point", json!({"track": track.id.0, "param": automation_id(param), "at": at, "value": lo + (hi - lo) * k}), ); - } else if track.kind.is_midi() && hit.is_none() { - new_midi_clip(app, &s, track.id, at, at); + } else if track.kind.is_midi() { + match &hit { + Some(c) => { + let _ = app.run("edit.select", json!({"clips": [c.id.0]})); + } + None => new_midi_clip(app, &s, track.id, at, at), + } } } _ if !selector_press => place_insertion(app, ui, &s, track.id, at, mods.shift), diff --git a/crates/ui-egui/tests/new_midi_clip.rs b/crates/ui-egui/tests/new_midi_clip.rs index ec3fb07b..46b54616 100644 --- a/crates/ui-egui/tests/new_midi_clip.rs +++ b/crates/ui-egui/tests/new_midi_clip.rs @@ -135,3 +135,18 @@ fn a_click_in_the_empty_midi_editor_adds_a_clip_at_the_insertion_point() { assert_eq!(r.clips(), vec![(r.bar(3), r.bar(4))]); assert_eq!(midi_editor::target_clip(&r.app), Some(r.app.engine.session().tracks[0].clips()[0].id)); } + +#[test] +fn a_pencil_click_on_a_clip_selects_it_for_the_midi_editor() { + let mut r = Rig::new("instrument"); + r.app.ui.show_midi_editor = true; + r.drag((r.bar(2) + r.bar(3)) / 2, (r.bar(3) + r.bar(4)) / 2); + r.drag((r.bar(6) + r.bar(7)) / 2, (r.bar(7) + r.bar(8)) / 2); + let ids: Vec<_> = r.app.engine.session().tracks[0].clips().iter().map(|c| c.id).collect(); + assert_eq!(r.clips(), vec![(r.bar(2), r.bar(4)), (r.bar(6), r.bar(8))]); + assert_eq!(midi_editor::target_clip(&r.app), Some(ids[1])); + r.click((r.bar(2) + r.bar(3)) / 2); + assert_eq!(r.clips().len(), 2); + assert_eq!(r.app.engine.session().edit.selected_clips, vec![ids[0]]); + assert_eq!(midi_editor::target_clip(&r.app), Some(ids[0])); +} From 800b27013df9f82a5c427e3247a18834c9122a7b Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:22:31 +0300 Subject: [PATCH 14/90] session: reject duplicate ids and an exhausted id space on load (#448) --- crates/model/src/session.rs | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index 449d82da..1c5c229c 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -541,6 +541,7 @@ impl Session { } let mut s: Session = serde_json::from_value(v).map_err(|e| SessionError::Format(e.to_string()))?; s.sanitize(); + s.validate()?; Ok(s) } @@ -633,6 +634,25 @@ impl Session { } self.tempo.sanitize(); } + + /// Reject a document whose ids are ambiguous or exhausted, run after [`Session::sanitize`] on + /// untrusted data. Duplicate ids in a namespace make commands, selections and source + /// associations act on an arbitrary one of the objects; a maximal `next_id` -- or a maximal + /// existing id, which `sanitize` folds into `next_id` -- makes every later allocation return + /// `u64::MAX` and repeat itself. + fn validate(&self) -> Result<(), SessionError> { + if self.next_id == u64::MAX { + return Err(SessionError::Invalid("the session id space is exhausted".into())); + } + distinct("track", self.tracks.iter().map(|t| t.id.0))?; + distinct("clip", self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))))?; + distinct("source", self.sources.iter().map(|s| s.id.0))?; + distinct("video", self.videos.iter().map(|v| v.id.0))?; + distinct("bus", self.busses.iter().map(|b| b.id.0))?; + distinct("marker", self.markers.iter().map(|m| m.id.0))?; + distinct("group", self.groups.iter().map(|g| g.id.0))?; + Ok(()) + } } /// The most characters kept of a name read from a file: longer ones can't be shown, and drawing @@ -648,6 +668,17 @@ pub fn truncate_chars(s: &mut String, max: usize) { } } +/// Every id in `ids` distinct, or an error naming the namespace and the repeated id. +fn distinct(kind: &str, ids: impl Iterator) -> Result<(), SessionError> { + let mut seen = std::collections::HashSet::new(); + for id in ids { + if !seen.insert(id) { + return Err(SessionError::Invalid(format!("duplicate {kind} id {id}"))); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -758,6 +789,33 @@ mod tests { assert!(matches!(Session::from_json(&v.to_string()), Err(SessionError::Version(999)))); } + #[test] + fn duplicate_ids_are_rejected_on_load() { + let mut s = Session::default(); + s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut v = serde_json::to_value(&s).unwrap(); + v["tracks"][1]["id"] = v["tracks"][0]["id"].clone(); + let err = Session::from_json(&v.to_string()).unwrap_err(); + assert!(matches!(&err, SessionError::Invalid(m) if m.contains("duplicate track id")), "{err}"); + } + + #[test] + fn an_exhausted_id_space_is_rejected_on_load() { + let mut s = Session::default(); + s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut v = serde_json::to_value(&s).unwrap(); + v["next_id"] = serde_json::json!(u64::MAX); + let err = Session::from_json(&v.to_string()).unwrap_err(); + assert!(matches!(&err, SessionError::Invalid(m) if m.contains("exhausted")), "{err}"); + // A maximal existing id is exhausted too, however `next_id` was written: every later + // allocation would return `u64::MAX` again. + let mut v = serde_json::to_value(&s).unwrap(); + v["tracks"][0]["id"] = serde_json::json!(u64::MAX); + let err = Session::from_json(&v.to_string()).unwrap_err(); + assert!(matches!(&err, SessionError::Invalid(m) if m.contains("exhausted")), "{err}"); + } + #[test] fn sanitize_repairs() { let mut s = Session::default(); From 952ca5e4ed66a09da6ac51606d7659a072b921e8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:16:37 +0200 Subject: [PATCH 15/90] Round negative timecode and feet+frames positions symmetrically Found while testing crates/time/src/timecode.rs:111 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/time/src/format.rs | 13 +++++++++++++ crates/time/src/timecode.rs | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index bdd2fcb1..995b3265 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -185,6 +185,19 @@ mod tests { } } + #[test] + fn parse_round_trips_timecode_negative() { + let (sr, map, _) = ctx(); + let rate = FrameRate::Fps25; + for fmt in [TimeFormat::Timecode, TimeFormat::FeetFrames] { + for s in [-48_000i64, -96_000 * 3, -1_440_000] { + let text = format_position(s, fmt, sr, &map, rate, 0, 0); + let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap(); + assert_eq!(back, s, "{fmt:?} {s} → {text} → {back}"); + } + } + } + #[test] fn parse_rejects_garbage() { let (sr, map, rate) = ctx(); diff --git a/crates/time/src/timecode.rs b/crates/time/src/timecode.rs index f8f6041b..b16f392c 100644 --- a/crates/time/src/timecode.rs +++ b/crates/time/src/timecode.rs @@ -108,7 +108,9 @@ impl FrameRate { if n == 0 { return 0; } - let v = (i128::from(f) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n); + // Round half away from zero so negative frames mirror positive ones. + let mag = (i128::from(f.unsigned_abs()) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n); + let v = if f < 0 { -mag } else { mag }; i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX }) } @@ -221,6 +223,18 @@ mod tests { assert_eq!(tc.to_samples(FrameRate::Fps25, sr), 48_000 * 3661); } + #[test] + fn negative_timecode_rounds_symmetrically() { + let sr = SampleRate::HZ_48000; + let tc = Timecode::parse("-00:00:01:00").unwrap(); + assert_eq!(tc.to_samples(FrameRate::Fps25, sr), -48_000); + for r in [FrameRate::Fps25, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps23_976] { + for f in [1i64, 7, 25, 1799, 107_892] { + assert_eq!(r.samples_at_frame(-f, sr), -r.samples_at_frame(f, sr), "{r:?} frame {f}"); + } + } + } + #[test] fn drop_frame_labels() { let r = FrameRate::Fps29_97Drop; From 315745b02207b29b3d8bdec5586f3091a6e0f7ea Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:36:03 +0300 Subject: [PATCH 16/90] ui: MIDI Real-Time Properties submits only the property that changed (#444) --- crates/ui-egui/src/ops_windows.rs | 92 +++++++++++++++++++++++++++--- crates/ui-egui/tests/rtp_window.rs | 54 ++++++++++++++++++ 2 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/tests/rtp_window.rs diff --git a/crates/ui-egui/src/ops_windows.rs b/crates/ui-egui/src/ops_windows.rs index beba5237..45a2f3a9 100644 --- a/crates/ui-egui/src/ops_windows.rs +++ b/crates/ui-egui/src/ops_windows.rs @@ -30,6 +30,9 @@ pub struct OpsState { pub rtp_transpose: i64, pub rtp_duration: f64, pub rtp_delay: i64, + /// The MIDI tracks the Real-Time Properties form was last filled from: the form re-reads the + /// tracks when the target selection changes (#444). + pub rtp_targets: Vec, pub identify_bars: u32, } @@ -57,6 +60,7 @@ impl Default for OpsState { rtp_transpose: 0, rtp_duration: 100.0, rtp_delay: 0, + rtp_targets: Vec::new(), identify_bars: 1, } } @@ -261,15 +265,25 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) { if !open { return; } + // Fill the form from the tracks it edits whenever the target selection changes, so it never + // shows (and a control change never writes back) stale defaults (#444). + let targets = app.engine.target_tracks(); + if app.ops.rtp_targets != targets { + app.ops.rtp_targets = targets.clone(); + let (velocity, transpose, duration, delay) = rtp_form_values(app.engine.session(), &targets); + app.ops.rtp_velocity = velocity; + app.ops.rtp_transpose = transpose; + app.ops.rtp_duration = duration; + app.ops.rtp_delay = delay; + } egui::Window::new("MIDI Real-Time Properties").open(&mut open).default_size(vec2(340.0, 200.0)).show(ctx, |ui| { ui.label("Non-destructive, applied at playback to the selected MIDI tracks."); - let mut changed = false; - changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_velocity, -127..=127).text("Velocity +")).changed(); - changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_transpose, -48..=48).text("Transpose")).changed(); - changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_duration, 1.0..=400.0).text("Duration %")).changed(); - changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_delay, -960..=960).text("Delay (ticks)")).changed(); - if changed { - let p = json!({"velocity": app.ops.rtp_velocity, "transpose": app.ops.rtp_transpose, "duration": app.ops.rtp_duration, "delay": app.ops.rtp_delay}); + let mut changed = RtpChanged::default(); + changed.velocity = ui.add(egui::Slider::new(&mut app.ops.rtp_velocity, -127..=127).text("Velocity +")).changed(); + changed.transpose = ui.add(egui::Slider::new(&mut app.ops.rtp_transpose, -48..=48).text("Transpose")).changed(); + changed.duration = ui.add(egui::Slider::new(&mut app.ops.rtp_duration, 1.0..=400.0).text("Duration %")).changed(); + changed.delay = ui.add(egui::Slider::new(&mut app.ops.rtp_delay, -960..=960).text("Delay (ticks)")).changed(); + if let Some(p) = rtp_params(changed, &app.ops) { let _ = app.engine.execute_merged("event.midi_rtp", &p, "rtp"); } ui.horizontal(|ui| { @@ -288,6 +302,47 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_rtp = open; } +/// Which of the form's controls the user changed this frame. +#[derive(Default, Clone, Copy)] +struct RtpChanged { + velocity: bool, + transpose: bool, + duration: bool, + delay: bool, +} + +/// A form change submits only the properties the user touched: the command stores each key it is +/// sent, so sending untouched ones would overwrite the target's values with the form's (#444). +fn rtp_params(changed: RtpChanged, ops: &OpsState) -> Option { + let mut p = serde_json::Map::new(); + if changed.velocity { + p.insert("velocity".into(), json!(ops.rtp_velocity)); + } + if changed.transpose { + p.insert("transpose".into(), json!(ops.rtp_transpose)); + } + if changed.duration { + p.insert("duration".into(), json!(ops.rtp_duration)); + } + if changed.delay { + p.insert("delay".into(), json!(ops.rtp_delay)); + } + (!p.is_empty()).then_some(Value::Object(p)) +} + +/// The values the form shows for `targets`: the first target's own Real-Time Properties, or the +/// neutral defaults when nothing is selected. +fn rtp_form_values(s: &soundcraft_model::Session, targets: &[soundcraft_model::TrackId]) -> (i64, i64, f64, i64) { + let Some(&t) = targets.first() else { return (0, 0, 100.0, 0) }; + let value = |key: &str, d: f64| s.edit.value(&format!("rtp.{}.{key}", t.0), d); + ( + value("velocity", 0.0).clamp(-127.0, 127.0) as i64, + value("transpose", 0.0).clamp(-127.0, 127.0) as i64, + value("duration", 100.0).clamp(1.0, 1000.0), + value("delay", 0.0).clamp(-1e7, 1e7) as i64, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -308,4 +363,27 @@ mod tests { assert_eq!(id, "event.tempo_linear"); assert_eq!(p["end_bpm"], 140.0); } + + /// One control moved: only its property is submitted, so the command cannot overwrite the + /// target's other Real-Time Properties with the form's values (#444). + #[test] + fn rtp_submits_only_the_property_that_changed() { + let ops = OpsState { rtp_velocity: 12, rtp_transpose: 7, rtp_duration: 50.0, rtp_delay: 480, ..OpsState::default() }; + let only_velocity = RtpChanged { velocity: true, ..RtpChanged::default() }; + assert_eq!(rtp_params(only_velocity, &ops), Some(json!({"velocity": 12}))); + let only_duration = RtpChanged { duration: true, ..RtpChanged::default() }; + assert_eq!(rtp_params(only_duration, &ops), Some(json!({"duration": 50.0}))); + assert_eq!(rtp_params(RtpChanged::default(), &ops), None, "no control moved, no command"); + } + + /// The form reads the target's own values when the window opens or the selection changes. + #[test] + fn the_rtp_form_shows_the_target_tracks_values() { + let mut e = soundcraft_engine::Engine::default(); + e.execute("track.new", &json!({"kind": "instrument", "name": "Synth"})).unwrap(); + let t = e.session().tracks.last().unwrap().id; + e.execute("event.midi_rtp", &json!({"track": t.0, "velocity": -12, "transpose": 7, "duration": 50.0, "delay": 480})).unwrap(); + assert_eq!(rtp_form_values(e.session(), &[]), (0, 0, 100.0, 0), "no target: the neutral defaults"); + assert_eq!(rtp_form_values(e.session(), &[t]), (-12, 7, 50.0, 480)); + } } diff --git a/crates/ui-egui/tests/rtp_window.rs b/crates/ui-egui/tests/rtp_window.rs new file mode 100644 index 00000000..cd62eb53 --- /dev/null +++ b/crates/ui-egui/tests/rtp_window.rs @@ -0,0 +1,54 @@ +use egui::accesskit::Role; +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool) { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// #444: the MIDI Real-Time Properties window submitted all four properties from its form on any +/// change, so editing Velocity reset the target's Transpose, Duration and Delay to the form's +/// (stale) defaults — and the form never showed the target's own values. +#[test] +fn editing_rtp_velocity_leaves_the_other_properties_alone() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.engine.execute("track.new", &json!({"kind": "instrument", "name": "Synth"})).unwrap(); + let t = app.engine.session().tracks.last().unwrap().id; + app.engine.execute("event.midi_rtp", &json!({"track": t.0, "transpose": 7, "duration": 50.0})).unwrap(); + app.run("edit.select", json!({"tracks": [t.0], "exact": true})).unwrap(); + menus::invoke_menu(&mut app, "window.rtp", "Event > MIDI Real-Time Properties"); + + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + assert!(h.state().ui.show_rtp, "the window did not open"); + let rtp = |h: &Harness<'_, SoundApp>, key: &str, d: f64| h.state().engine.session().edit.value(&format!("rtp.{}.{key}", t.0), d); + + // Drag the Velocity slider right: the velocity changes, the rest must stay as the track had it. + let rect = h.get_by_role_and_label(Role::Slider, "Velocity +").rect(); + let from = rect.center(); + let to = from + vec2(30.0, 0.0); + h.event(Event::PointerMoved(from)); + h.run_steps(1); + press(&mut h, from, true); + h.event(Event::PointerMoved(to)); + h.run_steps(2); + press(&mut h, to, false); + assert!(rtp(&h, "velocity", 0.0) > 0.0, "the velocity drag did not reach the engine"); + assert_eq!(rtp(&h, "transpose", 0.0), 7.0, "editing Velocity reset Transpose"); + assert_eq!(rtp(&h, "duration", 100.0), 50.0, "editing Velocity reset Duration"); + assert_eq!(rtp(&h, "delay", 0.0), 0.0, "editing Velocity reset Delay"); + + // The form shows the target track's values, not its defaults. + assert_eq!(h.state().ops.rtp_transpose, 7, "the form did not show the track's Transpose"); + assert_eq!(h.state().ops.rtp_duration, 50.0, "the form did not show the track's Duration"); +} From 2cba9ed4cd748f4c6c233ad9ff5f0bc1d667a2f7 Mon Sep 17 00:00:00 2001 From: vxMacDev <77440838+vxMacDev@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:54:56 -0500 Subject: [PATCH 17/90] Save a session bundle only if it opens again (#433) The bundle reader refuses more than 10,000 files or 1 GiB unpacked, but the writer had no limits, so a browser save of a long session could succeed, mark the session clean and produce a file SoundCraft won't open. The writer now counts files and bytes the way the reader does and refuses the save before anything is delivered or marked clean. Both use one BundleLimits. --- crates/engine/src/io.rs | 56 +++++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 7b26e0b1..c937d315 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1154,9 +1154,15 @@ pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std: /// saves and opens sessions as bundles. pub const BUNDLE_EXTENSION: &str = "scraftz"; -/// Most entries and bytes a bundle may unpack to (bundles are hostile input). -const MAX_BUNDLE_ENTRIES: usize = 10_000; -const MAX_BUNDLE_BYTES: u64 = 1 << 30; +/// Most entries and bytes a bundle may unpack to (bundles are hostile input). The writer keeps to +/// them too, so a bundle it saves always opens again (#433). +#[derive(Clone, Copy, Debug)] +struct BundleLimits { + entries: usize, + bytes: u64, +} + +const BUNDLE_LIMITS: BundleLimits = BundleLimits { entries: 10_000, bytes: 1 << 30 }; /// Hands a finished file (name, bytes) to the person on a host without a filesystem: the web app /// installs one that starts a download. Native builds write files and never install it. @@ -1185,6 +1191,11 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { /// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the /// session with its source paths pointing into the bundle. pub fn session_bundle(session: &Session) -> Result<(Vec, Session)> { + session_bundle_within(session, BUNDLE_LIMITS) +} + +/// [`session_bundle`], refused when it would hold more than `limits` allows. +fn session_bundle_within(session: &Session, limits: BundleLimits) -> Result<(Vec, Session)> { use std::io::Write as _; let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}")); let ioerr = |err: std::io::Error| EngineError::Io(format!("bundle: {err}")); @@ -1192,6 +1203,20 @@ pub fn session_bundle(session: &Session) -> Result<(Vec, Session)> { let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true); let mut taken = std::collections::HashSet::new(); + let (mut entries, mut total) = (0usize, 0u64); + // Counted as the reader counts them: files, and the bytes they unpack to. + let mut fits = |len: usize| { + entries += 1; + total = total.saturating_add(len as u64); + if entries > limits.entries || total > limits.bytes { + return Err(EngineError::Io(format!( + "bundle: too large to save (a bundle holds at most {} files and {} MiB, or it can't be opened again)", + limits.entries, + limits.bytes >> 20 + ))); + } + Ok(()) + }; for src in &mut s.sources { let Some(audio) = s.pool.get(src.id) else { continue }; let base = sanitize_name(&src.name); @@ -1203,6 +1228,7 @@ pub fn session_bundle(session: &Session) -> Result<(Vec, Session)> { } let opts_wav = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None }; let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts_wav).map_err(|err| EngineError::Io(err.to_string()))?; + fits(bytes.len())?; w.start_file(target.as_str(), opts).map_err(zerr)?; w.write_all(&bytes).map_err(ioerr)?; taken.insert(target.clone()); @@ -1211,6 +1237,7 @@ pub fn session_bundle(session: &Session) -> Result<(Vec, Session)> { src.format = FileFormat::Wav; } let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; + fits(text.len())?; w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?; w.write_all(text.as_bytes()).map_err(ioerr)?; let bytes = w.finish().map_err(zerr)?.into_inner(); @@ -1239,7 +1266,7 @@ fn save_session_bundle(e: &mut Engine, path: &str) -> Result { /// [`open_session`]. pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result> { let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") { - read_bundle(bytes)? + read_bundle(bytes, BUNDLE_LIMITS)? } else { ( String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?, @@ -1269,11 +1296,11 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result Result<(String, std::collections::HashMap>)> { +fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, std::collections::HashMap>)> { use std::io::Read as _; let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}")); let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(zerr)?; - if z.len() > MAX_BUNDLE_ENTRIES { + if z.len() > limits.entries { return Err(EngineError::Io("bundle: too many files".into())); } let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64); @@ -1285,7 +1312,7 @@ fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap MAX_BUNDLE_BYTES { + if total > limits.bytes { return Err(EngineError::Io("bundle: too large".into())); } let mut data = Vec::new(); @@ -1886,6 +1913,21 @@ mod bundle_tests { assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz")); } + #[test] + fn a_bundle_is_saved_only_if_it_opens_again() { + // #433: the writer keeps to the reader's limits. + let e = crate::demo::demo_engine(); + let (bytes, _) = session_bundle(e.session()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let entries = z.len(); + let size: u64 = (0..entries).map(|i| z.by_index(i).unwrap().size()).sum(); + let exact = BundleLimits { entries, bytes: size }; + let (at_limit, _) = session_bundle_within(e.session(), exact).unwrap(); + assert!(read_bundle(&at_limit, exact).is_ok(), "what the writer takes, the reader opens"); + assert!(session_bundle_within(e.session(), BundleLimits { entries: entries - 1, ..exact }).is_err()); + assert!(session_bundle_within(e.session(), BundleLimits { bytes: size - 1, ..exact }).is_err()); + } + #[test] fn same_named_sources_get_distinct_files() { let mut e = crate::demo::demo_engine(); From ea0e4719fcb20db268f2d784b752855e6d713634 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:00:26 +0200 Subject: [PATCH 18/90] Release held VST3 notes on reset instead of discarding them Fixes #441 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/vst3-host/src/plugin.rs | 8 +++++++- crates/vst3-host/tests/fixture_e2e.rs | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index 2975465f..20d5fa6f 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -213,8 +213,14 @@ impl Plugin for Vst3Plugin { } fn reset(&mut self) { - self.notes.clear(); + // Keep pending releases and release whatever is still held, so the processor's voices stop + // on the next block instead of sounding on while the host forgets them. + self.notes.retain(|e| matches!(e, NoteEvent::Off { .. })); + for e in &mut self.notes { + e.set_time(0); + } self.controls.clear(); + self.all_notes_off(); self.held = [false; 128]; } diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index 5efd33cc..9c87b8bb 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -144,6 +144,29 @@ fn synth_plays_notes_at_their_offsets() { assert!(mono[0].iter().all(|&x| (x - 1.0).abs() < 1e-6), "the note is still held"); } +#[test] +fn reset_after_all_notes_off_still_releases_held_voices() { + let mut p = soundcraft_vst3_host::create(&id_of("Fixture Synth")).unwrap(); + p.prepare(48_000.0, 64, 2); + p.note_on(0, 60, 127); + let mut io = vec![vec![0.0f32; 64], vec![0.0f32; 64]]; + p.process(&mut io, 64); + assert!((io[0][63] - 1.0).abs() < 1e-6, "the note sounds"); + // Mixer::reset calls these two in a row on an instrument (stop / seek). + p.all_notes_off(); + p.reset(); + let mut io = vec![vec![9.0f32; 64], vec![9.0f32; 64]]; + p.process(&mut io, 64); + assert!(io[0].iter().all(|x| x.abs() < 1e-6), "voice still sounding after reset: {}", io[0][63]); + // A reset alone also releases what the host still holds. + p.note_on(0, 61, 127); + p.process(&mut io, 64); + assert!((io[0][63] - 1.0).abs() < 1e-6); + p.reset(); + p.process(&mut io, 64); + assert!(io[0].iter().all(|x| x.abs() < 1e-6), "voice still sounding after reset: {}", io[0][63]); +} + #[test] fn many_instances_create_and_drop_cleanly() { let (g, s) = (id_of("Fixture Gain"), id_of("Fixture Synth")); From 075104f44800c2aa7cc7538e96619b7ea22f81a0 Mon Sep 17 00:00:00 2001 From: Billy Coleman III Date: Thu, 15 Oct 2026 04:35:33 -0400 Subject: [PATCH 19/90] Apply strip mute automation within each processing block Gate ordinary strip output and pre-fader copies at each represented sample using the existing mute lane, mode, held values and threshold. Preserve the fader and pan schedules, static suppression and hosted processing blocks. --- contributors/people.toml | 3 + crates/mix/src/lib.rs | 20 +- crates/mix/tests/strip_mute_automation.rs | 243 ++++++++++++++++++++++ docs/strip-mute-automation.md | 9 + 4 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 crates/mix/tests/strip_mute_automation.rs create mode 100644 docs/strip-mute-automation.md diff --git a/contributors/people.toml b/contributors/people.toml index 1c344199..f67fdd57 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -50,3 +50,6 @@ sync_github_name = false [people.Momentum96] sync_github_name = true + +[people.ProdByBuddha] +sync_github_name = true diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index e8c4fbad..cf3b21e6 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1040,9 +1040,8 @@ fn process_strip( } strip.gr = gr; strip.latency = latency.min(MAX_LATENCY); - let auto_mute = t.mixer.automation_mode.reads() && t.lane(&AutoParam::Mute).is_some_and(|l| l.value_at(pos, 0.0) >= 0.5); let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t); - strip.muted = t.mixer.mute || auto_mute || soloed_out; + strip.muted = t.mixer.mute || soloed_out; if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) { strip.pre.resize_with(strip.buf.len(), Vec::new); for (d, src) in strip.pre.iter_mut().zip(strip.buf.iter()) { @@ -1083,6 +1082,23 @@ fn process_strip( } } } + // Track mute gates the audio and its pre-fader tap without splitting processor blocks. + // Keep the existing curve interpolation, first-lane and mode policy, and render-position + // clock. Processor/bus age and Master/VCA automation policy are separate concerns. + if t.mixer.automation_mode.reads() + && let Some(lane) = t.lane(&AutoParam::Mute).filter(|lane| !lane.points.is_empty()) + { + for i in 0..frames { + let at = pos.saturating_add(i64::try_from(i).unwrap_or(i64::MAX)); + if lane.value_at(at, 0.0) >= 0.5 { + for channel in strip.buf.iter_mut().chain(strip.pre.iter_mut()) { + if let Some(sample) = channel.get_mut(i) { + *sample = 0.0; + } + } + } + } + } } /// Silences a block the plugin filled with NaN or infinity and takes the plugin out until the diff --git a/crates/mix/tests/strip_mute_automation.rs b/crates/mix/tests/strip_mute_automation.rs new file mode 100644 index 00000000..fba6ab23 --- /dev/null +++ b/crates/mix/tests/strip_mute_automation.rs @@ -0,0 +1,243 @@ +//! Ordinary strip mute changes sample gates without splitting processor blocks. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +use soundcraft_audio_io::AudioBuffer; +use soundcraft_dsp::Plugin; +use soundcraft_mix::{MixEngine, PreparedInstance, render_range}; +use soundcraft_model::{ + AutoParam, AutomationLane, AutomationMode, ChannelFormat, Clip, Insert, Route, SendSlot, Session, SourceAudio, SourceId, TrackId, TrackKind, +}; +use soundcraft_time::Range; +use std::sync::{Arc, Mutex}; + +const LEN: usize = 768; +fn fixture() -> (Session, TrackId) { + let mut s = Session::default(); + s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; LEN]; 2] }))); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Source")); + let clip = s.new_clip_id(); + let tr = s.track_mut(t).unwrap(); + tr.playlist_mut().unwrap().clips.push(Clip::audio(clip, "tone", SourceId(901), 0, 0, LEN as i64)); + tr.mixer.pan = vec![-1.0, 1.0]; + tr.mixer.output = Route::Main; + (s, t) +} +fn mute(s: &mut Session, t: TrackId, start: i64, end: i64) { + let lane = s.track_mut(t).unwrap().lane_mut(&AutoParam::Mute); + for (at, value) in [(0, 0.0), (start - 1, 0.0), (start, 1.0), (end - 1, 1.0), (end, 0.0)] { + lane.set_point(at, value); + } +} +fn rendered(s: &Session, block: usize) -> Vec> { + render_range(s, Range::new(0, LEN as i64), block) +} +fn gated(actual: &[Vec], reference: &[Vec], start: usize, end: usize) { + assert_eq!(actual.len(), reference.len()); + assert!(reference.iter().flatten().any(|x| x.abs() > 0.01)); + for (got, want) in actual.iter().zip(reference) { + assert_eq!(got.len(), LEN); + for i in 0..LEN { + assert_eq!(got[i], if (start..end).contains(&i) { 0.0 } else { want[i] }, "frame {i}"); + } + } +} +#[test] +fn internal_and_exact_block_edges_are_partition_independent() { + for (start, end) in [(101, 200), (128, 256), (1, 767)] { + let (mut s, t) = fixture(); + let reference = rendered(&s, 512); + mute(&mut s, t, start, end); + for block in [16, 63, 128, 512, 1024] { + gated(&rendered(&s, block), &reference, start as usize, end as usize); + } + } +} +#[test] +fn existing_held_mute_curve_and_threshold_are_preserved() { + let (mut s, t) = fixture(); + let reference = rendered(&s, 512); + let lane = s.track_mut(t).unwrap().lane_mut(&AutoParam::Mute); + lane.set_point(0, 0.49); + lane.set_point(200, 0.5); + gated(&rendered(&s, 512), &reference, 200, LEN); +} +#[test] +fn pre_and_post_fader_send_audio_share_the_track_mute_gate() { + for pre in [false, true] { + let (mut s, t) = fixture(); + let bus = s.add_bus("Send", ChannelFormat::Stereo); + let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Return")); + s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus); + s.track_mut(aux).unwrap().mixer.output = Route::Main; + let tr = s.track_mut(t).unwrap(); + tr.mixer.output = Route::None; + tr.mixer.volume_db = -9.0; + let mut send = SendSlot::new(Route::Bus(bus)); + send.pre_fader = pre; + send.level_db = -3.0; + tr.mixer.sends[0] = Some(send); + let reference = rendered(&s, 512); + mute(&mut s, t, 101, 200); + for block in [63, 512] { + gated(&rendered(&s, block), &reference, 101, 200); + } + } +} +#[test] +fn off_empty_and_first_empty_lane_keep_exact_unautomated_pcm() { + let (mut s, t) = fixture(); + let reference = rendered(&s, 512); + s.track_mut(t).unwrap().lane_mut(&AutoParam::Mute); + assert_eq!(rendered(&s, 512), reference); + let mut second = AutomationLane::new(AutoParam::Mute); + second.set_point(0, 1.0); + s.track_mut(t).unwrap().automation.push(second); + assert_eq!(rendered(&s, 512), reference, "first empty lane remains authoritative"); + s.track_mut(t).unwrap().automation.clear(); + mute(&mut s, t, 101, 200); + s.track_mut(t).unwrap().mixer.automation_mode = AutomationMode::Off; + assert_eq!(rendered(&s, 512), reference); +} +#[test] +fn reading_modes_retain_existing_policy() { + for mode in AutomationMode::ALL.into_iter().filter(|mode| mode.reads()) { + let (mut s, t) = fixture(); + let reference = rendered(&s, 512); + mute(&mut s, t, 101, 200); + s.track_mut(t).unwrap().mixer.automation_mode = mode; + gated(&rendered(&s, 512), &reference, 101, 200); + } +} +#[test] +fn static_mute_and_solo_suppression_cannot_be_reopened_by_automation() { + for solo in [false, true] { + let (mut s, t) = fixture(); + if solo { + let other = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None); + s.track_mut(other).unwrap().mixer.solo = true; + } else { + s.track_mut(t).unwrap().mixer.mute = true; + } + let reference = rendered(&s, 512); + assert!(reference.iter().flatten().all(|x| x.abs() < 1e-6)); + mute(&mut s, t, 101, 200); + gated_silent(&rendered(&s, 512)); + } +} +fn gated_silent(output: &[Vec]) { + assert!(output.iter().flatten().all(|x| x.abs() < 1e-6)); +} +#[test] +fn aux_and_folder_inputs_gate_their_audio_inside_the_block() { + for kind in [TrackKind::Aux, TrackKind::Folder] { + let (mut s, source) = fixture(); + let bus = s.add_bus("Input", ChannelFormat::Stereo); + s.track_mut(source).unwrap().mixer.output = Route::Bus(bus); + let target = s.add_track(kind, ChannelFormat::Stereo, Some("Return")); + s.track_mut(target).unwrap().mixer.input = Route::Bus(bus); + s.track_mut(target).unwrap().mixer.output = Route::Main; + let reference = rendered(&s, 512); + mute(&mut s, target, 101, 200); + gated(&rendered(&s, 512), &reference, 101, 200); + } +} +#[test] +fn no_active_mute_lane_preserves_fader_and_pan_schedules_exactly() { + let (mut s, t) = fixture(); + for (param, a, b) in [(AutoParam::Volume, -12.0, -3.0), (AutoParam::Pan(0), -1.0, 0.5)] { + let lane = s.track_mut(t).unwrap().lane_mut(¶m); + lane.set_point(0, a); + lane.set_point(LEN as i64, b); + } + for block in [63, 256, 512] { + let reference = rendered(&s, block); + let mut with_lane = s.clone(); + with_lane.track_mut(t).unwrap().lane_mut(&AutoParam::Mute).set_point(0, 0.0); + assert_eq!(rendered(&with_lane, block), reference); + with_lane.track_mut(t).unwrap().mixer.automation_mode = AutomationMode::Off; + let mut no_lane = with_lane.clone(); + no_lane.track_mut(t).unwrap().automation.retain(|lane| lane.param != AutoParam::Mute); + assert_eq!(rendered(&with_lane, block), rendered(&no_lane, block)); + } +} +#[test] +fn static_vca_mute_remains_dominant_over_member_mute_automation() { + let (mut s, t) = fixture(); + let vca = s.add_track(TrackKind::Vca, ChannelFormat::Stereo, Some("VCA")); + s.track_mut(vca).unwrap().mixer.mute = true; + s.track_mut(t).unwrap().mixer.vca = Some(vca.0); + let reference = rendered(&s, 512); + gated_silent(&reference); + mute(&mut s, t, 101, 200); + gated_silent(&rendered(&s, 512)); +} + +struct Counted { + inner: Box, + calls: Arc>>, + instrument: bool, +} +impl Plugin for Counted { + fn info(&self) -> &'static soundcraft_dsp::PluginInfo { + self.inner.info() + } + fn prepare(&mut self, sr: f32, block: usize, ch: usize) { + self.inner.prepare(sr, block, ch); + } + fn reset(&mut self) { + self.inner.reset(); + } + fn set_param(&mut self, id: &str, value: f32) -> bool { + self.inner.set_param(id, value) + } + fn param(&self, id: &str) -> Option { + self.inner.param(id) + } + fn process(&mut self, io: &mut [Vec], frames: usize) { + self.calls.lock().unwrap().push(frames); + if self.instrument { + for channel in io { + channel.iter_mut().take(frames).for_each(|sample| *sample = 0.25); + } + } else { + self.inner.process(io, frames); + } + } +} +#[test] +fn hosted_processing_keeps_the_callers_blocks_and_unmuted_pcm() { + for instrument in [false, true] { + let (mut s, t) = fixture(); + let reference = rendered(&s, 256); + mute(&mut s, t, 101, 200); + let slot = if instrument { + s.track_mut(t).unwrap().kind = TrackKind::Instrument; + s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.mute-cadence")); + soundcraft_mix::INSTRUMENT_SLOT + } else { + s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.mute-cadence")); + 0 + }; + let calls = Arc::new(Mutex::new(Vec::with_capacity(4))); + let mut plugin = soundcraft_dsp::create("gain").unwrap(); + plugin.prepare(48_000.0, 256, 2); + let mut mix = MixEngine::new(48_000.0, 256); + mix.set_external_instances(true); + mix.adopt(vec![PreparedInstance { + track: t, + slot, + id: "clap:test.mute-cadence".into(), + ch: 2, + plugin: Box::new(Counted { inner: plugin, calls: Arc::clone(&calls), instrument }), + }]); + let mut output = vec![Vec::new(); 2]; + for (pos, frames) in [(0, 256), (256, 128), (384, 256), (640, 128)] { + let mut block = vec![vec![0.0; frames]; 2]; + mix.render(&s, pos, frames, &mut block); + for (out, channel) in output.iter_mut().zip(block) { + out.extend(channel); + } + } + assert_eq!(*calls.lock().unwrap(), vec![256, 128, 256, 128]); + gated(&output, &reference, 101, 200); + } +} diff --git a/docs/strip-mute-automation.md b/docs/strip-mute-automation.md new file mode 100644 index 00000000..59907b9e --- /dev/null +++ b/docs/strip-mute-automation.md @@ -0,0 +1,9 @@ +# Ordinary strip mute automation + +Audio, instrument, Aux and Folder strips evaluate their existing mute lane at each sample represented by a render block. Values at or above 0.5 silence both the processed output and its pre-fader send copy. A mute and unmute inside one block therefore take effect without splitting that block into smaller processor calls. + +The first mute lane, existing interpolation, and existing automation-mode policy remain authoritative. An empty lane or Off mode leaves ordinary audio unchanged. Static mute, solo suppression and static VCA mute remain dominant. Fader ramps, pan scheduling and hosted processor call sizes remain unchanged. + +Mute reads use the current render-position clock. Signal alignment, Master/VCA automation and send-control policies retain their existing behavior. + +Synthetic regressions cover interior and boundary transitions, different block partitions, pre/post-fader sends, Aux/Folder input paths, mode/empty-lane fallbacks, static/solo/VCA suppression, unchanged fader/pan scheduling, and an explicitly adopted mock hosted processor's call cadence. No installed hosted plugin is required. From fb087c879b31b4ecd44e5a3f5a2e3a9c7e9d923c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:14:38 +0200 Subject: [PATCH 20/90] Give the New Tracks name field its own row so it stays wide (#626) --- crates/ui-egui/src/dialogs.rs | 4 +++- crates/ui-egui/tests/new_tracks.rs | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 713e94b7..87a29f59 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -327,8 +327,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ui.selectable_value(timebase, "ticks".to_string(), "Ticks"); }, ); + }); + ui.horizontal(|ui| { ui.label("Name:"); - ui.add(egui::TextEdit::singleline(name).desired_width(140.0)); + ui.add(egui::TextEdit::singleline(name).desired_width(240.0)); }); if buttons(ui, "Create", enter) { action = diff --git a/crates/ui-egui/tests/new_tracks.rs b/crates/ui-egui/tests/new_tracks.rs index 86f9132f..3a6d37a6 100644 --- a/crates/ui-egui/tests/new_tracks.rs +++ b/crates/ui-egui/tests/new_tracks.rs @@ -65,3 +65,13 @@ fn double_click_right_of_the_last_strip_opens_new_tracks() { click(&mut h, at); assert_eq!(h.state().dialogs.open_name(), Some("new_tracks")); } + +#[test] +fn the_new_tracks_name_field_is_wide_enough_to_read_a_name() { + let mut h = harness(0); + h.get_by_label_contains("New Tracks").click(); + h.run_steps(4); + assert_eq!(h.state().dialogs.open_name(), Some("new_tracks")); + let width = h.get_by_value("Audio").rect().width(); + assert!(width >= 200.0, "the Name field is only {width}px wide"); +} From 05fe56ec1a3346fbac5819a220f9f2e936d9ee1b Mon Sep 17 00:00:00 2001 From: Paul G <4waymedia@gmail.com> Date: Thu, 15 Oct 2026 11:21:28 -0700 Subject: [PATCH 21/90] Import audio off the UI thread (#597) Decode, resample and build peaks on a worker; file.import_audio then only places the clip. CLI/MCP/control unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/soundcraft/src/main.rs | 4 +- crates/engine/src/cmd/file.rs | 52 +++++++- crates/engine/src/io.rs | 74 ++++++++++-- crates/engine/src/lib.rs | 29 +++++ crates/ui-egui/src/dialogs.rs | 16 ++- crates/ui-egui/src/import.rs | 217 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/windows.rs | 2 +- 8 files changed, 380 insertions(+), 19 deletions(-) create mode 100644 crates/ui-egui/src/import.rs diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 808cdef8..818f77d9 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -112,8 +112,10 @@ fn open_path(app: &mut SoundApp, p: &str) { } else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") { app.run("file.import_midi", serde_json::json!({"path": p})) } else { + // Decoded in the background so a long file doesn't freeze the window (#597). let at = app.engine.session().edit.selection.start; - app.run("file.import_audio", serde_json::json!({"path": p, "at": at})) + soundcraft_ui_egui::import::start(app, serde_json::json!({"path": p, "at": at})); + Ok(serde_json::Value::Null) }; if let Err(e) = r { log::warn!("{p}: {e}"); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 87bba997..527e6ec1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -237,9 +237,17 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result { let target = import_audio_target(e, p)?; let mut out = Vec::new(); for path in &paths { - let bytes = crate::io::read_file(path)?; let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio").to_string(); - let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?; + // Audio the app decoded on a worker thread (`Engine::offer_import`) is only placed here; + // the path is checked the same way either way. + crate::file_access::check_read(std::path::Path::new(path)).map_err(crate::EngineError::Io)?; + let r = match e.take_import(std::path::Path::new(path)) { + Some(d) => crate::io::place_decoded(e, &name, d, Some(path.as_str()), target, at)?, + None => { + let bytes = crate::io::read_file(path)?; + crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)? + } + }; out.push(r); } Ok(json!({"imported": out})) @@ -350,6 +358,46 @@ mod tests { path.to_string_lossy().into_owned() } + /// Issue #597: the app decodes on a worker thread and hands the audio over; the command then + /// only places it. Audio decoded for another rate is ignored and the file is read again. + #[test] + fn import_audio_places_audio_offered_by_a_worker() { + let dir = std::env::temp_dir().join(format!("sc-import-offered-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let wav = tone_wav(&dir, "tone.wav"); + + let mut e = Engine::default(); + let rate = e.session().sample_rate.hz(); + let decoded = std::thread::spawn({ + let wav = wav.clone(); + move || crate::io::decode_import(&wav, rate) + }) + .join() + .unwrap() + .unwrap(); + assert_eq!(decoded.frames(), 4_800); + e.offer_import(decoded); + // The file changes after decoding: the offered audio is placed, not the new bytes. + std::fs::write(&wav, b"not audio").unwrap(); + let r = e.execute("file.import_audio", &json!({"path": wav, "at": 960})).unwrap(); + assert_eq!(r["imported"][0]["frames"], 4_800); + assert_eq!(e.session().tracks.len(), 1); + assert_eq!(e.session().tracks[0].clips()[0].start, 960); + assert_eq!(e.session().sources[0].path, wav); + // Taken once: the next import reads the (now broken) file. + assert!(e.execute("file.import_audio", &json!({"path": wav})).is_err()); + + // Decoded for another rate: ignored, so the broken file is read and refused. + let good = tone_wav(&dir, "good.wav"); + let other = crate::io::decode_import(&good, rate / 2).unwrap(); + std::fs::write(&good, b"not audio").unwrap(); + e.offer_import(other); + assert!(e.execute("file.import_audio", &json!({"path": good})).is_err()); + e.drop_offered_imports(); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn import_audio_honours_new_tracks() { let dir = std::env::temp_dir().join(format!("sc-import-new-tracks-{}", std::process::id())); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 7b26e0b1..4429a061 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -102,6 +102,11 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr); buf.sample_rate = sr; } + add_source_audio(s, name, Arc::new(SourceAudio::new(buf)), path, format) +} + +/// Register audio already at the session rate, with its peaks built, as a new source. +fn add_source_audio(s: &mut Session, name: &str, audio: Arc, path: Option<&str>, format: FileFormat) -> SourceId { let id = SourceId(s.alloc()); let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string(); let stored = match path { @@ -112,14 +117,14 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio id, name: stem.clone(), path: stored, - channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX), - frames: buf.frames() as u64, - sample_rate: sr, + channels: u16::try_from(audio.buffer.num_channels()).unwrap_or(u16::MAX), + frames: audio.frames() as u64, + sample_rate: s.sample_rate.hz(), format, time_reference: 0, unsaved: true, }); - s.pool.insert(id, Arc::new(SourceAudio::new(buf))); + s.pool.insert(id, audio); id } @@ -132,20 +137,69 @@ fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 { u64::try_from((u128::from(stamp) * to + from / 2) / from).unwrap_or(u64::MAX) } -/// Decode and place an audio file. With no target track, a new track of matching width is made. -pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option, at: Samples) -> Result { +/// An audio file decoded, resampled to a session rate and given its waveform peaks: everything an +/// import costs except placing the clip. Making one needs no engine, so the app makes it on a +/// worker thread ([`decode_import`]) and hands it over with [`Engine::offer_import`] before it +/// runs `file.import_audio`, which then only places the clip (issue #597). +#[derive(Debug, Clone)] +pub struct DecodedImport { + pub(crate) key: PathBuf, + pub(crate) rate: u32, + info: soundcraft_audio_io::AudioInfo, + audio: Arc, +} + +impl DecodedImport { + /// Frames at the session rate it was decoded for. + pub fn frames(&self) -> usize { + self.audio.frames() + } +} + +/// Reads and decodes the audio file at `path` for a session at `rate`. Thread-safe and slow (the +/// whole file is read, decoded, resampled and given peaks): run it off the UI thread. +pub fn decode_import(path: &str, rate: u32) -> Result { + let bytes = read_file(path)?; + let name = Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio"); + let (info, audio) = decode_import_bytes(name, &bytes, rate)?; + Ok(DecodedImport { key: file_key(Path::new(path)), rate, info, audio }) +} + +fn decode_import_bytes(name: &str, bytes: &[u8], rate: u32) -> Result<(soundcraft_audio_io::AudioInfo, Arc)> { let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; - let buf = at_rate(buf, e.session().sample_rate.hz(), MAX_IMPORT_SAMPLES) - .ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?; + let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?; if buf.frames() == 0 { return Err(EngineError::Io(format!("{name}: no audio"))); } - let channels = buf.num_channels(); + Ok((info, Arc::new(SourceAudio::new(buf)))) +} + +/// Decode and place an audio file. With no target track, a new track of matching width is made. +pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option, at: Samples) -> Result { + let (info, audio) = decode_import_bytes(name, bytes, e.session().sample_rate.hz())?; + place_import(e, name, &info, audio, path, target, at) +} + +/// Place audio from [`decode_import`] (made for this session's rate) as `import_audio_bytes` does. +pub(crate) fn place_decoded(e: &mut Engine, name: &str, d: DecodedImport, path: Option<&str>, target: Option, at: Samples) -> Result { + place_import(e, name, &d.info, d.audio, path, target, at) +} + +fn place_import( + e: &mut Engine, + name: &str, + info: &soundcraft_audio_io::AudioInfo, + audio: Arc, + path: Option<&str>, + target: Option, + at: Samples, +) -> Result { + let channels = audio.buffer.num_channels(); let file_rate = info.sample_rate; let s = e.session_mut(); let session_rate = s.sample_rate.hz(); - let src = add_source(s, name, buf, path, info.format); + let src = add_source_audio(s, name, audio, path, info.format); if let (Some(bwf), Some(sx)) = (info.bwf.as_ref(), s.sources.last_mut()) { sx.time_reference = stamp_at_rate(bwf.time_reference, file_rate, session_rate); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6cc1c90a..9321539d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -110,6 +110,9 @@ struct HistoryEntry { doc: Arc, } +/// Most decoded imports [`Engine::offer_import`] holds at once (each can be gigabytes). +const MAX_OFFERED_IMPORTS: usize = 4; + /// Engine state: the document, history, clipboard and transport plumbing. pub struct Engine { doc: Arc, @@ -139,6 +142,8 @@ pub struct Engine { /// The edit selection (range, tracks) before the last change, for Restore Last Selection. /// Kept out of the document so selecting never marks the session modified. pub last_selection: Option<(soundcraft_time::Range, Vec)>, + /// Audio decoded off the UI thread, waiting for its `file.import_audio` (see `offer_import`). + imports: Vec, } impl Default for Engine { @@ -169,9 +174,33 @@ impl Engine { merge_hold: None, messages: Vec::new(), last_selection: None, + imports: Vec::new(), } } + /// Hands over audio [`io::decode_import`] made off the UI thread: the next `file.import_audio` + /// of the same file at the session's rate places it instead of reading the file again. The + /// command still checks the path may be read. Only the few newest are kept. + pub fn offer_import(&mut self, d: io::DecodedImport) { + self.imports.retain(|x| x.key != d.key); + self.imports.push(d); + let extra = self.imports.len().saturating_sub(MAX_OFFERED_IMPORTS); + self.imports.drain(..extra); + } + + /// Drops audio offered for imports that never ran (the command failed or was not sent). + pub fn drop_offered_imports(&mut self) { + self.imports.clear(); + } + + /// The offered audio of the file at `path`, if it was decoded for the session's current rate. + pub(crate) fn take_import(&mut self, path: &std::path::Path) -> Option { + let key = io::file_key(path); + let rate = self.session().sample_rate.hz(); + let i = self.imports.iter().position(|d| d.key == key && d.rate == rate)?; + Some(self.imports.remove(i)) + } + pub fn session(&self) -> &Session { &self.doc } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 713e94b7..bc745393 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -265,6 +265,7 @@ fn default_dir() -> String { pub fn show(app: &mut SoundApp, ctx: &egui::Context) { audiosuite_window(app, ctx); crate::bounce::show(app, ctx); + crate::import::show(app, ctx); let Some(mut d) = app.dialogs.open.take() else { return }; let mut keep = true; let mut action: Option<(String, Value)> = None; @@ -613,12 +614,17 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { let saving = action.as_ref().is_some_and(|(cmd, _)| cmd.starts_with("session.save")); if let Some((cmd, params)) = action { keep = false; - match app.run(&cmd, params) { - Ok(v) if cmd == "file.export_clips" => { - app.ui.status = format!("Exported {} clip(s) to {}", v["written"], v["dir"].as_str().unwrap_or_default()); + if cmd == "file.import_audio" { + // Decoded in the background so a long file doesn't freeze the app (#597). + crate::import::start(app, params); + } else { + match app.run(&cmd, params) { + Ok(v) if cmd == "file.export_clips" => { + app.ui.status = format!("Exported {} clip(s) to {}", v["written"], v["dir"].as_str().unwrap_or_default()); + } + Ok(_) => {} + Err(e) => app.ui.status = e, } - Ok(_) => {} - Err(e) => app.ui.status = e, } } if saving diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs new file mode 100644 index 00000000..e9c2d83f --- /dev/null +++ b/crates/ui-egui/src/import.rs @@ -0,0 +1,217 @@ +//! Import Audio reads and decodes on a worker thread, so the app keeps drawing and stays usable +//! while a long file loads (issue #597). When the audio is ready it is handed to the engine +//! (`Engine::offer_import`) and `file.import_audio` runs as before, now only placing the clip: +//! undo, the journal, the CLI, MCP and the control channel see the same command, and calls from +//! the control channel still run synchronously. + +use crate::SoundApp; +use serde_json::Value; +use std::collections::VecDeque; + +/// Imports waiting and the one decoding now. One file decodes at a time: each can take gigabytes. +#[derive(Default)] +pub struct Imports { + queue: VecDeque, + #[cfg(not(target_arch = "wasm32"))] + running: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +struct Running { + params: Value, + name: String, + /// The session rate the audio is decoded for. + rate: u32, + rx: std::sync::mpsc::Receiver>, +} + +impl Imports { + /// Whether a file is decoding or waiting. + pub fn busy(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + if self.running.is_some() { + return true; + } + !self.queue.is_empty() + } + + /// The file decoding now and how many wait after it. + fn label(&self) -> Option<(String, usize)> { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.running { + return Some((r.name.clone(), self.queue.len())); + } + None + } +} + +/// Imports the audio file `params["path"]` names (the `file.import_audio` params) in the +/// background. Anything else (several `paths`, no path, the browser) runs the command now. +pub fn start(app: &mut SoundApp, params: Value) { + if cfg!(target_arch = "wasm32") || params.get("path").and_then(Value::as_str).is_none() { + run_now(app, params); + return; + } + app.imports.queue.push_back(params); +} + +/// Starts the next import, and places a finished one. Called every frame from `logic`. +pub fn step(app: &mut SoundApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + finish(app); + if app.imports.running.is_none() + && let Some(params) = app.imports.queue.pop_front() + { + spawn(app, params, ctx); + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = ctx; + while let Some(params) = app.imports.queue.pop_front() { + run_now(app, params); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn spawn(app: &mut SoundApp, params: Value, ctx: &egui::Context) { + let path = params.get("path").and_then(Value::as_str).unwrap_or_default().to_string(); + let name = std::path::Path::new(&path).file_name().map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned()); + let rate = app.engine.session().sample_rate.hz(); + let (tx, rx) = std::sync::mpsc::channel(); + let ctx = ctx.clone(); + let work = move || { + let r = soundcraft_engine::io::decode_import(&path, rate).map_err(|e| e.to_string()); + let _ = tx.send(r); + ctx.request_repaint(); + }; + match std::thread::Builder::new().name("audio-import".into()).spawn(work) { + Ok(_) => { + app.ui.status = format!("Importing {name}…"); + app.imports.running = Some(Running { params, name, rate, rx }); + } + // No thread to spare: import the old way rather than not at all. + Err(_) => { + run_now(app, params); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn finish(app: &mut SoundApp) { + let Some(r) = &app.imports.running else { return }; + let done = match r.rx.try_recv() { + Ok(done) => done, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => Err(format!("{}: the import stopped", r.name)), + }; + let Some(Running { params, name, rate, .. }) = app.imports.running.take() else { return }; + // The session changed rate meanwhile (another session was opened): decode again for it. + if rate != app.engine.session().sample_rate.hz() { + app.imports.queue.push_front(params); + return; + } + match done { + Ok(decoded) => { + app.engine.offer_import(decoded); + if run_now(app, params) { + app.ui.status = format!("Imported {name}"); + } + app.engine.drop_offered_imports(); + } + Err(e) => app.ui.status = e, + } +} + +/// Runs `file.import_audio` on this thread; the status line shows a failure. True on success. +fn run_now(app: &mut SoundApp, params: Value) -> bool { + match app.run("file.import_audio", params) { + Ok(_) => true, + Err(e) => { + app.ui.status = e; + false + } + } +} + +/// A small, non-blocking note while files import: the rest of the app stays usable. +pub fn show(app: &SoundApp, ctx: &egui::Context) { + let Some((name, waiting)) = app.imports.label() else { return }; + egui::Area::new(egui::Id::new("audio_import_progress")).anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -40.0)).interactable(false).show( + ctx, + |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new()); + let more = if waiting > 0 { format!(" ({waiting} more)") } else { String::new() }; + ui.label(format!("Importing {name}{more}")); + }); + }); + }, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Services, SoundApp}; + use serde_json::json; + + fn tone_wav(name: &str) -> String { + let dir = std::env::temp_dir().join(format!("sc-ui-import-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let tone: Vec = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.2).collect(); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone.clone(), tone] }; + let bytes = soundcraft_audio_io::encode(&buf, &soundcraft_audio_io::EncodeOptions::default()).unwrap(); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().into_owned() + } + + fn settle(app: &mut SoundApp, ctx: &egui::Context) { + for _ in 0..2_000 { + step(app, ctx); + if !app.imports.busy() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("import never finished"); + } + + /// Issue #597: starting an import returns at once; the clip lands, as one undo step, later. + #[test] + fn import_runs_in_the_background_and_places_one_undoable_clip() { + let ctx = egui::Context::default(); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let wav = tone_wav("bg.wav"); + start(&mut app, json!({"path": wav, "at": 480})); + assert!(app.imports.busy()); + assert!(app.engine.session().tracks.is_empty(), "nothing is placed before the audio is ready"); + settle(&mut app, &ctx); + let s = app.engine.session(); + assert_eq!(s.tracks.len(), 1); + assert_eq!(s.tracks[0].clips()[0].start, 480); + assert_eq!(s.sources[0].frames, 9_600); + assert!(app.ui.status.starts_with("Imported bg.wav"), "{}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + assert!(app.engine.session().tracks.is_empty()); + } + + #[test] + fn imports_queue_and_a_bad_file_reports_without_stopping_the_rest() { + let ctx = egui::Context::default(); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let junk = tone_wav("junk.wav"); + std::fs::write(&junk, b"not audio").unwrap(); + let good = tone_wav("good.wav"); + start(&mut app, json!({"path": junk})); + start(&mut app, json!({"path": good})); + step(&mut app, &ctx); + assert_eq!(app.imports.label().map(|l| l.1), Some(1), "the second waits for the first"); + settle(&mut app, &ctx); + assert_eq!(app.engine.session().tracks.len(), 1, "the good file still imports"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ddd54275..3e791a61 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod edit_window; pub mod extra_windows; pub mod fonts; pub mod icons; +pub mod import; pub mod menus; pub mod midi_editor; pub mod mix_window; @@ -326,6 +327,8 @@ pub struct SoundApp { pub video: video_track::VideoPool, /// The Bounce Mix in progress, rendered a slice per frame. pub bounce: Option, + /// Audio files importing in the background (issue #597). + pub imports: import::Imports, /// Test-only: owns the process-wide palette lock while this app exists, so frames painted /// and colour assertions in one test can never straddle another test's palette change. #[cfg(test)] @@ -400,6 +403,7 @@ impl SoundApp { last_meter_poll: f64::NEG_INFINITY, video: video_track::VideoPool::default(), bounce: None, + imports: import::Imports::default(), #[cfg(test)] _palette_lock: theme::lock_test_palette(), } @@ -1134,6 +1138,7 @@ impl SoundApp { self.note_recent_session(); self.apply_editor_edits(); bounce::step(self, ctx); + import::step(self, ctx); if !ctx.input(|i| i.pointer.any_down()) { if matches!(self.gesture, Some(Gesture::Fader { .. })) { self.gesture = None; diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 78a91081..0ac0253a 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -363,7 +363,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) { let _ = app.run("file.import_midi", json!({"path": path})); } else { let at = app.engine.session().edit.selection.start; - let _ = app.run("file.import_audio", json!({"path": path, "at": at})); + crate::import::start(app, json!({"path": path, "at": at})); } } } From adab577f9a472b92bf781cbc2a0c70d4bbb59e84 Mon Sep 17 00:00:00 2001 From: Paul G <4waymedia@gmail.com> Date: Thu, 15 Oct 2026 15:50:13 -0700 Subject: [PATCH 22/90] Resample through a polyphase bank: 5x faster rate-converting imports Precompute the windowed-sinc weights once per phase of the reduced from/to ratio; each output is one dot product. Same weights, order and edges as the per-tap kernel; large ratios fall back to it. Co-Authored-By: Claude Opus 5.5 --- crates/dsp/src/offline.rs | 111 +++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index 8bbc80a6..6884c4ec 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -216,16 +216,95 @@ fn resample_ratio(ch: &[Vec], ratio: f64, out_len: usize) -> Vec> ch.iter().map(|c| resample_channel(c, ratio, out_len, &table)).collect() } +/// Most kernel weights a [`Polyphase`] bank may hold (16 MiB of f32). +const MAX_POLYPHASE_WEIGHTS: usize = 1 << 22; + +/// The windowed-sinc kernel of [`resample_channel`] precomputed for every phase of a rational +/// rate change `from` → `to` (reduced to `step` / `phases`). Output sample `n` reads input time +/// `n * step / phases`: whole part `i`, phase `p`. Its taps are input samples `i + first ..`, one +/// weight each, so every output is one dot product over contiguous memory instead of a table +/// lookup and interpolation per tap. The weights are the ones `resample_channel` computes, taps +/// beyond the kernel's half width weigh 0, and taps are summed in the same order. +struct Polyphase { + phases: u64, + step: u64, + /// Offset of the first tap from `i`. + first: i64, + taps: usize, + /// `phases * taps` weights, phase-major. + weights: Vec, +} + +impl Polyphase { + /// `None` when the bank would be too large (rates with no small common ratio); the caller + /// then falls back to [`resample_channel`]. + fn new(from: u32, to: u32, table: &SincTable) -> Option { + let g = gcd(from, to); + let (step, phases) = (u64::from(from / g), u64::from(to / g)); + let hw = table.half_width; + let first = (-hw).floor() as i64; + let last = hw.ceil() as i64 + 1; + let taps = usize::try_from(last - first + 1).ok()?; + if usize::try_from(phases).ok()?.checked_mul(taps)? > MAX_POLYPHASE_WEIGHTS { + return None; + } + let mut weights = Vec::with_capacity(taps * phases as usize); + for p in 0..phases { + let frac = p as f64 / phases as f64; + for k in first..first + taps as i64 { + let d = frac - k as f64; + weights.push(if d.abs() <= hw { table.at(d) } else { 0.0 }); + } + } + Some(Polyphase { phases, step, first, taps, weights }) + } + + fn run(&self, x: &[f32], out_len: usize) -> Vec { + let len = x.len() as i64; + let mut out = Vec::with_capacity(out_len); + for n in 0..out_len as u64 { + let Some(pos) = n.checked_mul(self.step) else { break }; + let (i, p) = ((pos / self.phases) as i64, (pos % self.phases) as usize); + let w = self.weights.get(p * self.taps..(p + 1) * self.taps).unwrap_or_default(); + let start = i + self.first; + let acc = match usize::try_from(start).ok().and_then(|s| x.get(s..s + self.taps)) { + // Inside the signal: a plain dot product. + Some(xs) => xs.iter().zip(w).fold(0.0f32, |a, (v, k)| a + v * k), + // At the edges, taps outside the signal are skipped, as `resample_channel` does. + None => (0..self.taps as i64) + .filter(|k| (0..len).contains(&(start + k))) + .fold(0.0f32, |a, k| a + x.get((start + k) as usize).copied().unwrap_or(0.0) * w.get(k as usize).copied().unwrap_or(0.0)), + }; + out.push(acc); + } + out.resize(out_len, 0.0); + out + } +} + +fn gcd(mut a: u32, mut b: u32) -> u32 { + while b != 0 { + (a, b) = (b, a % b); + } + a.max(1) +} + /// High-quality windowed-sinc sample-rate conversion from `from` Hz to `to` Hz. pub fn resample(ch: &[Vec], from: u32, to: u32) -> Vec> { if from == 0 || to == 0 || from == to { return ch.to_vec(); } let ratio = f64::from(to) / f64::from(from); + let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 }; + let table = SincTable::new(cutoff); + let bank = Polyphase::new(from, to, &table); ch.iter() .map(|c| { let out_len = ((c.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES); - resample_ratio(std::slice::from_ref(c), ratio, out_len).into_iter().next().unwrap_or_default() + match &bank { + Some(b) => b.run(c, out_len), + None => resample_channel(c, ratio, out_len, &table), + } }) .collect() } @@ -652,6 +731,36 @@ pub fn peak_db(ch: &[Vec]) -> f32 { mod tests { use super::*; + /// The polyphase bank gives what the per-tap kernel gives (it only moves where the weights + /// are computed), at common rate pairs, both directions, and at the signal's edges. + #[test] + fn polyphase_resample_matches_the_per_tap_kernel() { + let x: Vec = (0..3_000).map(|i| (i as f32 * 0.071).sin() * 0.6 + (i as f32 * 0.53).sin() * 0.3).collect(); + let pairs = [(44_100, 48_000), (48_000, 44_100), (48_000, 96_000), (96_000, 48_000), (22_050, 48_000), (8_000, 44_100), (48_000, 22_050)]; + for (from, to) in pairs { + let ratio = f64::from(to) / f64::from(from); + let out_len = ((x.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES); + let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 }; + let table = SincTable::new(cutoff); + let reference = resample_channel(&x, ratio, out_len, &table); + let bank = Polyphase::new(from, to, &table).expect("small common ratio"); + let fast = bank.run(&x, out_len); + assert_eq!(fast.len(), reference.len(), "{from}->{to}"); + let worst = fast.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(worst < 1e-5, "{from}->{to}: worst difference {worst}"); + assert_eq!(resample(std::slice::from_ref(&x), from, to)[0], fast, "resample uses the bank"); + } + // Signals shorter than the kernel are all edge. + let short = [0.5f32, -0.25, 0.75]; + let table = SincTable::new(0.97); + let reference = resample_channel(&short, 48_000.0 / 44_100.0, 3, &table); + let fast = Polyphase::new(44_100, 48_000, &table).expect("bank").run(&short, 3); + assert!(fast.iter().zip(&reference).all(|(a, b)| (a - b).abs() < 1e-6), "{fast:?} {reference:?}"); + // Rates with no small common ratio fall back to the per-tap kernel. + assert!(Polyphase::new(44_100, 96_001, &table).is_none()); + assert_eq!(resample(&[short.to_vec()], 44_100, 96_001)[0].len(), 7); + } + #[test] fn fade_shapes_endpoints() { for s in FadeShape::ALL { From 7a086baa7b11602d7d2099f1f6a7aae12f8d37a4 Mon Sep 17 00:00:00 2001 From: euisuh <55032583+euisuh@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:36:19 +0900 Subject: [PATCH 23/90] fix(ui): capture live plugin state before saving track presets Track presets serialized the insert model's earlier capture instead of the hosted plugin's current state. Reuse the existing save/bounce capture path before preset serialization and exercise it with the real CLAP fixture. Closes #661 Related #639 Co-Authored-By: OpenAI Codex --- crates/clap-host/tests/common/mod.rs | 2 +- crates/clap-host/tests/fixture/Cargo.toml | 2 +- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/tests/track_preset_state.rs | 36 ++++++++++++++++++++++ docs/gaps.md | 9 ++++-- 5 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/track_preset_state.rs diff --git a/crates/clap-host/tests/common/mod.rs b/crates/clap-host/tests/common/mod.rs index 2da23955..6ae4ef74 100644 --- a/crates/clap-host/tests/common/mod.rs +++ b/crates/clap-host/tests/common/mod.rs @@ -9,7 +9,7 @@ use std::sync::OnceLock; pub fn fixture_dir() -> &'static Path { static DIR: OnceLock = OnceLock::new(); DIR.get_or_init(|| { - let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/Cargo.toml"); + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../clap-host/tests/fixture/Cargo.toml"); let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("clap-fixture-target"); let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); let st = Command::new(cargo) diff --git a/crates/clap-host/tests/fixture/Cargo.toml b/crates/clap-host/tests/fixture/Cargo.toml index dac4af39..18efb527 100644 --- a/crates/clap-host/tests/fixture/Cargo.toml +++ b/crates/clap-host/tests/fixture/Cargo.toml @@ -1,4 +1,4 @@ -# A tiny CLAP plugin used only by soundcraft-clap-host's integration tests. Not part of the +# A tiny CLAP plugin used only by SoundCraft's integration tests. Not part of the # SoundCraft workspace (the empty [workspace] table makes it its own root); the test builds it # with `cargo build` and loads the resulting library. Never shipped. [package] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ddd54275..cd756716 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -418,7 +418,7 @@ impl SoundApp { if let Some(r) = menus::run_ui_command(self, id, ¶ms) { return r; } - if id.starts_with("session.save") || id.starts_with("file.bounce") { + if id.starts_with("session.save") || id.starts_with("file.bounce") || id == "track.save_preset" { // Third-party plugins keep state the session can't see: store it first. self.capture_plugin_states(); } diff --git a/crates/ui-egui/tests/track_preset_state.rs b/crates/ui-egui/tests/track_preset_state.rs new file mode 100644 index 00000000..155a7997 --- /dev/null +++ b/crates/ui-egui/tests/track_preset_state.rs @@ -0,0 +1,36 @@ +//! Track presets capture the hosted plugin's current state before writing the model. + +#[path = "../../clap-host/tests/common/mod.rs"] +mod common; + +use serde_json::{Value, json}; +use soundcraft_engine::Engine; +use soundcraft_model::{ChannelFormat, Insert, TrackKind, b64}; +use soundcraft_playback::Player; +use soundcraft_ui_egui::{Services, SoundApp}; + +#[test] +fn track_preset_captures_live_plugin_state_before_serializing() { + common::fixture_dir(); + let mut engine = Engine::default(); + let track = engine.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Vox")); + engine.session_mut().track_mut(track).unwrap().mixer.inserts[0] = Some(Insert::new("clap:org.soundcraft.test.gain")); + let player = Player::new(engine.session_arc()); + let states = player.capture_states(); + assert_eq!(states.len(), 1, "the synthetic CLAP plugin must load even without an audio device"); + assert_eq!((states[0].0, states[0].1), (track, 0)); + let live = states[0].2.clone(); + + // The model still holds the earlier capture while the live plugin has its current settings. + let stale: Vec = [0.25_f64, 0.0].into_iter().flat_map(f64::to_le_bytes).collect(); + assert_ne!(live, stale); + engine.session_mut().track_mut(track).unwrap().mixer.inserts[0].as_mut().unwrap().set_state_bytes(&stale); + let mut app = SoundApp::new(engine, Some(player), Services::default()); + assert_eq!(app.engine.session().track(track).unwrap().mixer.inserts[0].as_ref().unwrap().state_bytes(), Some(stale)); + + let path = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("track-preset-state-{}.json", std::process::id())); + app.run("track.save_preset", json!({"track": track.0, "path": path.to_string_lossy()})).unwrap(); + let preset: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + std::fs::remove_file(path).unwrap(); + assert_eq!(preset["mixer"]["inserts"][0]["state"], b64::encode(&live), "the preset must contain the current hosted state"); +} diff --git a/docs/gaps.md b/docs/gaps.md index f8c39b8a..6703b6f9 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-10 · **Last updated:** 2026-10-16 · **Change:** minor (G-16: live plugin state in track-preset saves) · **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** @@ -163,8 +163,10 @@ varispeed, high-quality offline) plus Elastic Pitch and warp-marker editing dept ### G-16. Session management -No session templates, track presets, workspace/sound browser with previews, batch rename (new in -2026.4), disk-usage and relink depth. ≈ 25–40 h. +Session templates, track-preset recall UI and browser, workspace/sound browser with previews, +batch rename (new in 2026.4), disk-usage and relink depth remain. Track-preset saves capture the +current hosted-plugin state before writing (#661); recall UI, partial recall and preset browsing remain +tracked in #639. ≈ 25–40 h. ### G-17. Video and post @@ -199,6 +201,7 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | G-16: capture current hosted-plugin state before saving a track preset (#661); broader preset work remains (#639) | | 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 3ea890496e108aa0432d0f7a8f9d628b6e5867af Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:33:09 +0800 Subject: [PATCH 24/90] fix(engine): preserve channels when splitting tracks into mono --- ROADMAP.md | 4 +- crates/engine/src/cmd/track.rs | 42 ++++++- crates/engine/tests/split_into_mono.rs | 159 +++++++++++++++++++++++++ docs/gaps.md | 7 +- 4 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 crates/engine/tests/split_into_mono.rs diff --git a/ROADMAP.md b/ROADMAP.md index 73197650..7af8c561 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 (Split into Mono channel preservation) · **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:** Split into Mono extracts the correct source channel for each mono track instead of playing channel zero on every track. Clip offsets, gain, fades and stretch, source timestamps, Undo/Redo and saved audio are preserved; repeated clips share each extracted mono source. Unavailable media returns an error without partial edits. 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 | Split into Mono preserves each source channel, clip edits and saved audio; 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/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 87912389..c0ebcbee 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -3,7 +3,7 @@ use super::*; use crate::cmd; use serde_json::json; -use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackKind}; +use soundcraft_model::{ChannelFormat, ClipContent, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackKind}; pub fn specs() -> Vec { vec![ @@ -362,6 +362,8 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.split_into_mono", p)?; let s = e.session_mut(); let mut out = Vec::new(); + let mut mono_sources = std::collections::BTreeMap::new(); + let mut copied_samples = 0usize; for t in tracks { let Some(src) = s.track(t).cloned() else { continue }; if src.channels() < 2 || src.kind != TrackKind::Audio { @@ -377,12 +379,46 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result { let mut clips = src.clips().to_vec(); for c in &mut clips { c.id = s.new_clip_id(); - // Mono tracks read channel 0 of the source; record the channel in the name for now. + if let ClipContent::Audio { source, .. } = &mut c.content { + let audio = + s.pool.get(*source).ok_or_else(|| bad("track.split_into_mono", format!("audio unavailable for clip `{}`", c.name)))?; + let channels = audio.buffer.channels.len(); + if channels == 0 { + return Err(bad("track.split_into_mono", format!("audio has no channels for clip `{}`", c.name))); + } + // Match the renderer's channel wrapping when a track is wider than its source. + // An already-mono source can be shared without copying it. + if channels > 1 { + let key = (*source, ch % channels); + let mono = if let Some(&id) = mono_sources.get(&key) { + id + } else { + let samples = + audio.buffer.channels.get(key.1).ok_or_else(|| bad("track.split_into_mono", "source channel unavailable"))?; + copied_samples = copied_samples + .checked_add(samples.len()) + .filter(|&n| n <= crate::io::MAX_IMPORT_SAMPLES) + .ok_or_else(|| bad("track.split_into_mono", "split would copy too much audio"))?; + // Keep the full channel: the cloned clips retain their offsets, stretch, + // gain and fades, and every reader sees genuine mono media (including saves). + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![samples.clone()] }; + let original = s.source(*source); + let name = format!("{}.{suffix}.wav", original.map_or(c.name.as_str(), |source| source.name.as_str())); + let time_reference = original.map_or(0, |source| source.time_reference); + let id = crate::io::add_source(s, &name, buf, None, soundcraft_audio_io::FileFormat::Wav); + if let Some(source) = s.sources.iter_mut().find(|source| source.id == id) { + source.time_reference = time_reference; + } + mono_sources.insert(key, id); + id + }; + *source = mono; + } + } c.name = format!("{}.{suffix}", c.name); } if let Some(tr) = s.track_mut(id) { tr.mixer.pan = vec![if ch == 0 { -1.0 } else { 1.0 }]; - tr.comments = format!("source_channel={ch}"); if let Some(pl) = tr.playlist_mut() { pl.clips = clips; } diff --git a/crates/engine/tests/split_into_mono.rs b/crates/engine/tests/split_into_mono.rs new file mode 100644 index 00000000..7925af58 --- /dev/null +++ b/crates/engine/tests/split_into_mono.rs @@ -0,0 +1,159 @@ +//! Split tracks into mono without replacing the right/surround channels with channel zero. + +use serde_json::json; +use soundcraft_audio_io::{AudioBuffer, FileFormat}; +use soundcraft_engine::{Engine, io}; +use soundcraft_model::{ChannelFormat, Clip, Session, SourceAudio, SourceId, TrackId, TrackKind}; +use soundcraft_time::Range; +use std::sync::Arc; + +fn fixture(format: ChannelFormat, channels: usize) -> (Engine, TrackId, SourceId) { + let mut s = Session::default(); + let track = s.add_track(TrackKind::Audio, format, Some("Original")); + let channels = (0..channels).map(|ch| (0..256).map(|i| (ch + 1) as f32 * 0.1 + i as f32 * 0.0001).collect()).collect(); + let source = io::add_source(&mut s, "Original.wav", AudioBuffer { sample_rate: 48_000, channels }, None, FileFormat::Wav); + s.sources[0].time_reference = 123_456; + let mut clip = Clip::audio(s.new_clip_id(), "Edited", source, 7, 13, 47); + clip.gain_db = -6.0; + clip.gain_env = vec![(0, -3.0), (20, 0.0), (46, -9.0)]; + clip.fade_in.len = 5; + clip.fade_out.len = 9; + clip.stretch = 1.25; + let mut second = clip.clone(); + second.id = s.new_clip_id(); + second.start = 100; + second.content = soundcraft_model::ClipContent::Audio { source, offset: 90 }; + s.track_mut(track).unwrap().playlist_mut().unwrap().clips = vec![clip, second]; + (Engine::new(s), track, source) +} + +fn split(e: &mut Engine, track: TrackId) -> Vec { + let result = e.execute("track.split_into_mono", &json!({"track": track.0})).unwrap(); + result["tracks"].as_array().unwrap().iter().map(|id| TrackId(id.as_u64().unwrap())).collect() +} + +#[test] +fn split_into_mono_preserves_every_channel_and_clip_edit() { + for (format, channels) in [(ChannelFormat::Stereo, 2), (ChannelFormat::Surround51, 6)] { + let (mut e, track, source) = fixture(format, channels); + let original = e.session().track(track).unwrap().clone(); + let audio = Arc::clone(e.session().pool.get(source).unwrap()); + let expected = soundcraft_mix::render_clips(e.session(), track, Range::new(0, 192)); + let tracks = split(&mut e, track); + assert_eq!(tracks.len(), channels); + assert_eq!(e.session().track(track).unwrap(), &original); + assert!(Arc::ptr_eq(e.session().pool.get(source).unwrap(), &audio)); + // Two clips referencing the same source share one extracted source per channel. + assert_eq!(e.session().sources.len(), 1 + channels); + for (ch, &id) in tracks.iter().enumerate() { + let mono = e.session().track(id).unwrap(); + assert_eq!(mono.format, ChannelFormat::Mono); + let actual = soundcraft_mix::render_clips(e.session(), id, Range::new(0, 192)); + assert_eq!(actual[0], expected[ch], "{format:?} channel {ch}"); + let mono_source = mono.clips()[0].source().unwrap(); + assert_ne!(mono_source, source); + assert!(mono.clips().iter().all(|clip| clip.source() == Some(mono_source))); + let data = e.session().pool.get(mono_source).unwrap(); + assert_eq!(data.buffer.channels, vec![audio.buffer.channels[ch].clone()]); + assert_eq!(data.peaks.len(), 1); + let metadata = e.session().source(mono_source).unwrap(); + assert_eq!((metadata.channels, metadata.time_reference), (1, 123_456)); + assert!(metadata.unsaved); + for (clip, original) in mono.clips().iter().zip(original.clips()) { + let mut expected_clip = original.clone(); + expected_clip.id = clip.id; + expected_clip.name = clip.name.clone(); + expected_clip.content = soundcraft_model::ClipContent::Audio { source: mono_source, offset: original.source_offset() }; + assert_eq!(clip, &expected_clip); + } + } + } +} + +#[test] +fn split_stereo_matches_the_original_mix_in_playback_and_bounce_blocks() { + let (mut e, track, _) = fixture(ChannelFormat::Stereo, 2); + let original = e.session().clone(); + let tracks = split(&mut e, track); + assert_eq!(e.session().track(tracks[0]).unwrap().name, "Original.L"); + assert_eq!(e.session().track(tracks[1]).unwrap().name, "Original.R"); + e.session_mut().track_mut(track).unwrap().inactive = true; + for block in [37, 512, 1024] { + let expected = soundcraft_mix::render_range(&original, Range::new(0, 192), block); + let actual = soundcraft_mix::render_range(e.session(), Range::new(0, 192), block); + assert_eq!(actual, expected, "block {block}"); + } +} + +#[test] +fn split_stereo_preserves_distinct_left_and_right_levels() { + let (mut e, track, source) = fixture(ChannelFormat::Stereo, 2); + let s = e.session_mut(); + s.pool.insert(source, Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.2; 256], vec![0.7; 256]] }))); + let clip = Clip::audio(s.new_clip_id(), "Distinct channels", source, 0, 0, 256); + s.track_mut(track).unwrap().playlist_mut().unwrap().clips = vec![clip]; + split(&mut e, track); + e.session_mut().track_mut(track).unwrap().inactive = true; + let audio = soundcraft_mix::render_range(e.session(), Range::new(0, 128), 64); + eprintln!("Split into Mono output: L={}, R={}", audio[0][64], audio[1][64]); + assert_eq!(audio[0][64], 0.2); + assert_eq!(audio[1][64], 0.7); +} + +#[test] +fn split_sources_survive_save_and_reopen_with_distinct_audio_files() { + let (mut e, track, _) = fixture(ChannelFormat::Stereo, 2); + let tracks = split(&mut e, track); + let (bytes, saved) = io::session_bundle(e.session()).unwrap(); + let paths: std::collections::BTreeSet<_> = saved.sources.iter().map(|source| &source.path).collect(); + assert_eq!(paths.len(), 3); + let mut reopened = Engine::default(); + assert!(io::open_session_bytes(&mut reopened, "split.scraftz", &bytes).unwrap().is_empty()); + for id in tracks { + let expected = soundcraft_mix::render_clips(e.session(), id, Range::new(0, 192)); + let actual = soundcraft_mix::render_clips(reopened.session(), id, Range::new(0, 192)); + assert_eq!(actual, expected); + } +} + +#[test] +fn split_tracks_and_sources_undo_and_redo_together() { + let (mut e, track, _) = fixture(ChannelFormat::Stereo, 2); + let before = e.session().clone(); + split(&mut e, track); + let after = e.session().clone(); + assert_eq!(e.undo_history().len(), 1); + assert!(e.undo()); + assert_eq!(e.session(), &before); + assert!(e.redo()); + assert_eq!(e.session(), &after); +} + +#[test] +fn split_reuses_a_mono_source_on_a_stereo_track() { + let (mut e, track, source) = fixture(ChannelFormat::Stereo, 1); + let expected = soundcraft_mix::render_clips(e.session(), track, Range::new(0, 192)); + for id in split(&mut e, track) { + assert!(e.session().track(id).unwrap().clips().iter().all(|clip| clip.source() == Some(source))); + assert_eq!(soundcraft_mix::render_clips(e.session(), id, Range::new(0, 192))[0], expected[0]); + } + assert_eq!(e.session().sources.len(), 1); +} + +#[test] +fn missing_or_empty_media_refuses_the_split_without_partial_changes() { + for empty in [false, true] { + let (mut e, track, _) = fixture(ChannelFormat::Stereo, 2); + let missing = SourceId(9999); + if empty { + e.session_mut().pool.insert(missing, Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![] }))); + } + let clip = Clip::audio(e.session_mut().new_clip_id(), "Unavailable", missing, 0, 160, 16); + e.session_mut().track_mut(track).unwrap().playlist_mut().unwrap().clips.push(clip); + let before = e.session().clone(); + let error = e.execute("track.split_into_mono", &json!({"track": track.0})).unwrap_err(); + assert!(matches!(error, soundcraft_engine::EngineError::BadParams(..)), "{error}"); + assert_eq!(e.session(), &before); + assert!(e.undo_history().is_empty()); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index f8c39b8a..21420705 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: Split into Mono channel fix) · **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,10 @@ 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):** Split into Mono now gives each output track its own source channel, +including surround channels, instead of repeating channel zero. Clip edits, source timestamps, +Undo/Redo and save/reopen are covered by regression tests; missing or empty media fails atomically. + 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 +203,7 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | G-04: Split into Mono channel preservation 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 a4b6bd01794302b0815ce8b3ec69db7431a115e9 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:09:42 +0000 Subject: [PATCH 25/90] Fix source offset when trimming the start of a stretched audio clip Found while testing crates/model/src/clip.rs:246 (Clip::trim_start_to, used by crates/engine/src/edit.rs:36 split_clip) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/model/src/clip.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index 850cf044..3153259f 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -246,8 +246,16 @@ impl Clip { pub fn trim_start_to(&mut self, new_start: Samples) { let new_start = new_start.min(self.end() - 1); let delta = new_start - self.start; - if let ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } = &mut self.content { - *offset = offset.saturating_add(delta); + // The mixer plays a stretched audio clip from `offset + rel / stretch`. + let stretch = if self.stretch.is_finite() && self.stretch > 0.0 { self.stretch } else { 1.0 }; + match &mut self.content { + ClipContent::Audio { offset, .. } if (stretch - 1.0).abs() >= 1e-9 => { + *offset = offset.saturating_add((delta as f64 / stretch).round() as Samples); + } + ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } => { + *offset = offset.saturating_add(delta); + } + ClipContent::Midi { .. } => {} } // MIDI note ticks are re-anchored by the engine, which knows the tempo map. self.length -= delta; @@ -479,4 +487,21 @@ mod tests { c.trim_start_to(900); assert!(c.fade_out.len <= c.length); } + + #[test] + fn trim_start_of_stretched_audio_advances_offset_by_source_time() { + // The mixer plays source `offset + rel / stretch`, so a split at timeline 1000 of a 2x clip + // must start the right half at source sample 500. + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 0, 2000); + c.stretch = 2.0; + c.trim_start_to(1000); + assert_eq!(c.source_offset(), 500); + let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 0, 0, 2000); + c.stretch = 0.5; + c.trim_start_to(1000); + assert_eq!(c.source_offset(), 2000); + let mut c = Clip::audio(ClipId(3), "c", SourceId(1), 0, 0, 2000); + c.trim_start_to(1000); + assert_eq!(c.source_offset(), 1000); + } } From 6f3591ae113283a2a2cb20d0fda9b2daca38873b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:43:37 +0000 Subject: [PATCH 26/90] Keep the existing volume lane in automation.clip_gain_to_volume Found while testing crates/engine/src/cmd/mix.rs:676-681 (clip_gain_to_vol) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/mix.rs | 82 ++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index c0569429..01e96372 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -669,23 +669,34 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result { for t in &tracks { let Some(tr) = s.track_mut(*t) else { continue }; let base = tr.mixer.volume_db; + // An existing lane overrides the fader, so the clip gain rides on top of the lane value. + let old = tr.lane(&AutoParam::Volume).cloned(); + let level = |at: i64| old.as_ref().map_or(base, |l| l.value_at(at, base)); + let mut pins = Vec::new(); let mut pts = Vec::new(); if let Some(pl) = tr.playlist_mut() { for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) { - if c.gain_env.is_empty() { - pts.push((c.start, base + c.gain_db)); - pts.push((c.end() - 1, base + c.gain_db)); - } else { - for (o, db) in &c.gain_env { - pts.push((c.start + o, base + c.gain_db + db)); - } + // Pin the unchanged level just outside the clip so the neighbouring segments keep their shape. + if c.start > 0 { + pins.push((c.start - 1, level(c.start - 1))); + } + pins.push((c.end(), level(c.end()))); + let mut ats: Vec = + if c.gain_env.is_empty() { vec![c.start, c.end() - 1] } else { c.gain_env.iter().map(|&(o, _)| c.start + o).collect() }; + // The lane's own points inside the clip carry the clip gain too. + if let Some(l) = &old { + ats.extend(l.points.iter().map(|pt| pt.at).filter(|&at| at > c.start && at < c.end() - 1)); + } + for at in ats { + pts.push((at, level(at) + c.gain_db + env_db(&c.gain_env, at - c.start))); } c.gain_db = 0.0; c.gain_env.clear(); } } let lane = tr.lane_mut(&AutoParam::Volume); - for (at, v) in pts { + // Pins first, so a neighbouring clip's converted points win where they meet. + for (at, v) in pins.into_iter().chain(pts) { lane.set_point(at, v); } } @@ -838,6 +849,61 @@ mod tests { } } + #[test] + fn clip_gain_to_volume_keeps_the_existing_lane() { + let clip_gain_to_volume = |points: &[(i64, f32)], env: Vec<(i64, f32)>| { + let (mut e, t) = tone_session(points); + let clip = e.session().track(t).unwrap().clips()[0].id; + let c = e.session_mut().find_clip_mut(clip).unwrap(); + c.gain_db = 6.0; + c.gain_env = env; + let before = rendered(e.session()); + e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap(); + let after = rendered(e.session()); + assert!(e.session().find_clip(clip).unwrap().1.gain_db == 0.0); + (before, after) + }; + let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], Vec::new()); + let delta = rms_db(&before, &after); + assert!(delta.abs() < 0.02, "level changed by {delta} dB"); + + // A moving lane keeps its shape, and its points inside the clip carry the gain. + let (before, after) = clip_gain_to_volume(&[(0, -12.0), (4096, -6.0), (8192, -12.0)], Vec::new()); + for (start, end) in [(0, 2048), (2048, 4096), (4096, 6144), (6144, 8192)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB"); + } + + // The clip's own gain envelope rides on the lane value as well. + let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], vec![(2000, 0.0), (6000, -10.0)]); + for (start, end) in [(3900, 4100), (5000, 5200)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB"); + } + } + + #[test] + fn clip_gain_to_volume_leaves_audio_outside_the_clip_alone() { + // Three clips in a row; only the middle one is converted, and the lane extends past it on both sides. + let (mut e, t) = tone_session(&[(0, -6.0), (16384, -6.0)]); + let (middle, last) = (e.session_mut().new_clip_id(), e.session_mut().new_clip_id()); + let pl = e.session_mut().track_mut(t).unwrap().playlist_mut().unwrap(); + if let Some(c) = pl.clips.first_mut() { + c.length = 2048; + } + pl.clips.push(Clip::audio(middle, "mid", SourceId(910), 2048, 2048, 4096)); + pl.clips.push(Clip::audio(last, "end", SourceId(910), 6144, 6144, 2048)); + e.session_mut().find_clip_mut(middle).unwrap().gain_db = 6.0; + let before = rendered(e.session()); + e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 2048, "end": 6144})).unwrap(); + let after = rendered(e.session()); + assert!(e.session().find_clip(middle).unwrap().1.gain_db == 0.0); + for (start, end) in [(0, 2040), (2056, 6136), (6152, 8192)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB"); + } + } + #[test] fn insert_params_rejects_unknown_and_non_numeric_keys() { let mut e = Engine::default(); From a72e46e7b68e13930b437d990d53b8897f6a0b3e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:04:41 +0200 Subject: [PATCH 27/90] Bounce the whole session when CLI --bounce has no range Fixes #745 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- apps/soundcraft-cli/src/main.rs | 8 +++++++- apps/soundcraft-cli/tests/cli.rs | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 9a51cc54..0207a1f4 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -4,7 +4,8 @@ //! soundcraft-cli info FILE audio file or .scraft summary //! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize] //! soundcraft-cli run [--in S.scraft | --demo] [--cmd ID[=JSON]]… [--bounce OUT] [--start T --end T] [--save S.scraft] [--inspect] -//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT] (one `ID JSON` per line) +//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT [--start T --end T]] (one `ID JSON` per line; +//! without --start/--end, --bounce covers the whole session) //! soundcraft-cli commands [FILTER] list commands //! soundcraft-cli describe ID one command's details //! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel @@ -212,6 +213,11 @@ fn finish(e: &mut Engine, args: &[String]) -> ExitCode { if let Some(s) = arg_value(args, "--end") { p["end"] = time_arg(&s); } + // No range given: bounce the whole session, not whatever edit selection the commands left. + if p.get("start").is_none() && p.get("end").is_none() { + p["start"] = json!(0); + p["end"] = json!(e.session().content_end().max(1)); + } match e.execute("file.bounce_mix", &p) { Ok(v) => outln!("{v}"), Err(err) => return fail(err), diff --git a/apps/soundcraft-cli/tests/cli.rs b/apps/soundcraft-cli/tests/cli.rs index ef2fb881..6074999d 100644 --- a/apps/soundcraft-cli/tests/cli.rs +++ b/apps/soundcraft-cli/tests/cli.rs @@ -138,3 +138,28 @@ fn usage_lists_the_root_flags() { let help = String::from_utf8_lossy(&o.stdout).into_owned(); assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"), "{help}"); } + +/// Without `--start`/`--end`, `--bounce` covers the whole session, not a stray edit selection +/// left by the script (a bare `19.2` in command params is 19 samples). +#[test] +fn script_bounce_without_range_covers_the_session() { + let base = tmpdir("bounce-range"); + let script = base.join("cmds.txt"); + std::fs::write( + &script, + concat!( + "track.new {\"count\":1,\"kind\":\"instrument\",\"name\":\"Keys\"}\n", + "midi.new_clip {\"track\":\"Keys\",\"start\":0,\"end\":{\"seconds\":19.2}}\n", + "edit.select {\"start\":0,\"end\":19.2}\n", + ), + ) + .unwrap(); + let out = base.join("out.wav"); + let o = run(&["script", script.to_str().unwrap(), "--bounce", out.to_str().unwrap()]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let stdout = String::from_utf8_lossy(&o.stdout).into_owned(); + let last: Value = serde_json::from_str(stdout.lines().last().unwrap()).unwrap(); + let secs = last["seconds"].as_f64().unwrap(); + assert!((secs - 19.2).abs() < 0.01, "bounced {secs} s: {stdout}"); + let _ = std::fs::remove_dir_all(base); +} From 4110f5ff10b6ddf35e9588ad251e958745d0920d Mon Sep 17 00:00:00 2001 From: Derek Wright Date: Sun, 18 Oct 2026 13:07:27 -0400 Subject: [PATCH 28/90] Mix tests: never load real plug-ins stored_state_comes_first_then_session_params ends with an offline render_range over a vst3: insert. That reached create_plugin and the real VST3 host, which scans the machine's plug-in folders and loads every installed plug-in in-process; some (Native Instruments) hang on unload, so the test binary never exited on a developer machine. CI has no plug-ins installed, so it stayed green. Under cfg(test), create_plugin now returns None for third-party ids, so a test reaching the real hosts sees an absent plug-in instead of hanging. The rule, stated in the tests module doc: tests adopt fakes under clap:test.* ids and never call the real hosts. --- crates/mix/src/lib.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index e8c4fbad..c7c2a592 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -2220,6 +2220,13 @@ pub fn render_track_pre_fader(s: &Session, track: TrackId, range: Range) -> Vec< /// A built-in plugin, else a hosted CLAP plugin (`clap:`). fn create_plugin(id: &str) -> Option> { + // Tests never load real plug-ins; adopt a fake instead. Scanning the machine's plug-in folders + // loads every installed plug-in in-process, and some never unload, so a test that reached the + // real hosts would hang on a developer machine (CI has none installed). + #[cfg(test)] + if is_third_party(id) { + return None; + } soundcraft_dsp::create(id) .or_else(|| soundcraft_clap_host::create(id)) .or_else(|| soundcraft_vst3_host::create(id)) @@ -2272,6 +2279,10 @@ mod audio_thread_tests { #[cfg(test)] mod tests { + //! Rule: tests never call the real plug-in hosts. Third-party ids are not created here + //! (create_plugin returns None under cfg(test)); a test that needs a hosted plug-in + //! adopts a fake under a clap:test.* id via MixEngine::adopt. + use super::*; use soundcraft_audio_io::AudioBuffer; use soundcraft_model::{Insert, SourceAudio, SourceId}; @@ -2832,7 +2843,7 @@ mod tests { s.track_mut(t).unwrap().mixer.inserts[0].as_mut().unwrap().params.insert("gain".into(), -12.0); render_block(&mut eng, &s, 1024); assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 2, "later session edits apply"); - // Offline (not external): unknown third-party plugins are simply absent. + // Offline (not external): third-party plugins are simply absent (tests never load real ones). let out = render_range(&s, Range::new(0, 1024), 512); assert!(out[0][100] > 0.3); } From c09dab9fef0f5c604897554d2ce9de832e2ca3d3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:20:18 +0000 Subject: [PATCH 29/90] fix: heal split halves of stretched audio clips Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/edit.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 5531953d..b7e49e97 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -405,6 +405,11 @@ pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize { n } +/// Source samples covered by `length` timeline samples of a clip with the given `stretch`. +fn source_len(length: Samples, stretch: f64) -> Samples { + if stretch.is_finite() && stretch > 0.0 && (stretch - 1.0).abs() >= 1e-9 { (length as f64 / stretch).round() as Samples } else { length } +} + /// Heal separations inside `range`: merge neighbouring audio clips that continue the same file. pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize { let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; @@ -415,7 +420,9 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize { if let Some(prev) = out.last_mut() { let contiguous = prev.end() == c.start && c.start >= range.start && c.start <= range.end; let same = match (&prev.content, &c.content) { - (ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => a == b && oa + prev.length == *ob, + (ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => { + a == b && oa.saturating_add(source_len(prev.length, prev.stretch)) == *ob + } _ => false, }; if contiguous && same && (prev.gain_db - c.gain_db).abs() < 1e-6 { @@ -878,6 +885,18 @@ mod tests { assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]); } + #[test] + fn split_then_heal_rejoins_stretched_clip() { + let (mut s, t) = session_with_clip(); + if let Some(c) = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()) { + c.stretch = 2.0; + } + assert_eq!(separate_at(&mut s, t, 3000), 1); + assert_eq!(clips(&s, t), vec![(1000, 2000, 1000), (3000, 2000, 2000)]); + assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1); + assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]); + } + #[test] fn clear_slip_and_shuffle() { let (mut s, t) = session_with_clip(); From 3c04a3c36fe841c3e0d4e07f72a6999bcf33289f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:04:51 +0000 Subject: [PATCH 30/90] Fix empty SMF export re-importing as one phantom empty track Found while testing crates/midi/src/smf.rs:207 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/midi/src/smf.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/midi/src/smf.rs b/crates/midi/src/smf.rs index 7747a061..3fe84678 100644 --- a/crates/midi/src/smf.rs +++ b/crates/midi/src/smf.rs @@ -204,7 +204,12 @@ pub fn read_smf(bytes: &[u8]) -> Result { tracks.push(track); } - if format == 1 && tracks.len() > 1 && tracks.first().is_some_and(|t| t.sequence.notes.is_empty() && t.sequence.ctrls.is_empty()) { + // A lone track is only a conductor track if it carried global data and has no name; otherwise it is kept. + let globals_present = !(globals.tempos.is_empty() && globals.meters.is_empty() && globals.markers.is_empty() && globals.key_sigs.is_empty()); + if format == 1 + && (tracks.len() > 1 || globals_present) + && tracks.first().is_some_and(|t| t.sequence.notes.is_empty() && t.sequence.ctrls.is_empty() && (tracks.len() > 1 || t.name.is_empty())) + { tracks.remove(0); } @@ -444,7 +449,12 @@ fn write_track(out: &mut Vec, mut events: Vec) -> Result<(), MidiErr /// Errors: invalid tempo (non-finite or <= 0 bpm), meter denominator that is not a power of two /// (1..=128), more than 65535 tracks, or a gap between events beyond the 28-bit delta limit. pub fn write_smf(smf: &Smf) -> Result, MidiError> { - let ntrks = u16::try_from(smf.tracks.len().saturating_add(1)).map_err(|_| MidiError::Write("more than 65535 tracks".into()))?; + // The conductor track is omitted when there is nothing to put in it and no content tracks, so an + // empty `Smf` is written as a file with zero tracks and reads back as zero tracks. + let has_conductor = + !(smf.tempos.is_empty() && smf.meters.is_empty() && smf.key_sigs.is_empty() && smf.markers.is_empty() && smf.tracks.is_empty()); + let ntrks = + u16::try_from(smf.tracks.len().saturating_add(usize::from(has_conductor))).map_err(|_| MidiError::Write("more than 65535 tracks".into()))?; let mut out = Vec::new(); out.extend_from_slice(b"MThd"); out.extend_from_slice(&6u32.to_be_bytes()); @@ -476,7 +486,9 @@ pub fn write_smf(smf: &Smf) -> Result, MidiError> { for (t, text) in &smf.markers { ev.push(WEvent { tick: clamp_tick(*t), order: 3, bytes: meta(0x06, text.as_bytes())? }); } - write_track(&mut out, ev)?; + if has_conductor { + write_track(&mut out, ev)?; + } for track in &smf.tracks { let mut ev = Vec::new(); @@ -527,6 +539,19 @@ mod tests { assert!(push_vlq(&mut Vec::new(), MAX_VLQ + 1).is_err()); } + #[test] + fn empty_smf_round_trips_with_no_tracks() { + let r = read_smf(&write_smf(&Smf::default()).unwrap()).unwrap(); + assert_eq!(r.tracks.len(), 0); + let one = Smf { tracks: vec![SmfTrack::default()], ..Smf::default() }; + let r = read_smf(&write_smf(&one).unwrap()).unwrap(); + assert_eq!(r.tracks.len(), 1); + let cond = Smf { tempos: vec![(0, 90.0)], ..Smf::default() }; + let r = read_smf(&write_smf(&cond).unwrap()).unwrap(); + assert_eq!(r.tracks.len(), 0); + assert_eq!(r.tempos.len(), 1); + } + #[test] fn spec_vlq_examples() { let mut b = Vec::new(); From c8f8fc45eb88d694018b67242d146d6be9a32a8b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 14 Oct 2026 14:55:21 +0200 Subject: [PATCH 31/90] convert: dither only when the output loses resolution (#548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit soundcraft-cli convert always passed dither: true, so a same-depth or wider conversion still added ±1 LSB TPDF noise and digital silence came out as noise. It now dithers only when writing integer samples that cannot hold the source exactly: fewer bits than the source, a float or lossy source, or samples changed by --rate/--normalize. 32-bit float output never dithers. --dither and --no-dither override the choice, like the Bounce option in #551. --- ROADMAP.md | 1 + apps/soundcraft-cli/src/main.rs | 79 +++++++++++++++++++++-- apps/soundcraft-cli/tests/convert.rs | 95 ++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 apps/soundcraft-cli/tests/convert.rs diff --git a/ROADMAP.md b/ROADMAP.md index f214e1e0..3d673d9c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,6 +126,7 @@ mutated and truncated movies. Field reports from the first public week are track - **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-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change. - **2026-10-14:** MCP `batch` rejects more than 1000 calls before executing any command, instead of silently running only the first 1000 (#460). No readiness percentage change. +- **2026-10-14:** `soundcraft-cli convert` dithers only when the output loses resolution (fewer bits than the source, a float or lossy source, or resampled/normalized samples); a same-depth, wider or 32-bit float conversion copies the samples exactly and digital silence stays silent. `--dither` and `--no-dither` override the choice (#548) - **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:** 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:** `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) diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 9a51cc54..5a6645ca 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -2,7 +2,8 @@ //! //! ```text //! soundcraft-cli info FILE audio file or .scraft summary -//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize] +//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize] [--dither | --no-dither] +//! dithers only when the output loses resolution, unless told //! soundcraft-cli run [--in S.scraft | --demo] [--cmd ID[=JSON]]… [--bounce OUT] [--start T --end T] [--save S.scraft] [--inspect] //! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT] (one `ID JSON` per line) //! soundcraft-cli commands [FILTER] list commands @@ -130,12 +131,16 @@ fn convert(args: &[String]) -> ExitCode { Ok(b) => b, Err(e) => return fail(e), }; + let dither_flag = match parse_dither(args) { + Ok(d) => d, + Err(e) => return fail(e), + }; let bytes = match std::fs::read(input) { Ok(b) => b, Err(e) => return fail(format!("{input}: {e}")), }; let ext = std::path::Path::new(input).extension().and_then(|x| x.to_str()); - let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) { + let (info, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) { Ok(x) => x, Err(e) => return fail(format!("{input}: {e}")), }; @@ -143,14 +148,17 @@ fn convert(args: &[String]) -> ExitCode { buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate); buf.sample_rate = rate; } - if args.iter().any(|a| a == "--normalize") { + let normalize = args.iter().any(|a| a == "--normalize"); + if normalize { soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false); } + let processed = rate.is_some_and(|r| r != info.sample_rate) || normalize; + let dither = dither_flag.unwrap_or_else(|| auto_dither(info.sample_format, bit_depth, processed)); let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase(); let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else { return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS)); }; - let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None }; + let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None }; match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) { Ok(()) => { outln!("wrote {output} ({} ch, {} Hz, {:.2} s)", buf.num_channels(), buf.sample_rate, buf.duration_secs()); @@ -179,6 +187,41 @@ fn parse_bit_depth(v: Option<&str>) -> Result Result, String> { + match (args.iter().any(|a| a == "--dither"), args.iter().any(|a| a == "--no-dither")) { + (true, true) => Err("--dither and --no-dither: choose one".into()), + (true, false) => Ok(Some(true)), + (false, true) => Ok(Some(false)), + (false, false) => Ok(None), + } +} + +/// Whether `convert` dithers when not told: only when writing integer samples that cannot hold the +/// source exactly, i.e. the output has fewer bits than the source, the source is float or lossy, or +/// resampling/normalizing produced new sample values. A 16-bit file written as 16 or 24-bit, or +/// anything written as 32-bit float, is copied sample for sample (digital silence stays silent). +fn auto_dither(source: soundcraft_audio_io::SampleFormat, target: soundcraft_audio_io::BitDepth, processed: bool) -> bool { + use soundcraft_audio_io::{BitDepth, SampleFormat}; + let target_bits = match target { + BitDepth::Float32 => return false, + BitDepth::Int16 => 16, + BitDepth::Int24 => 24, + BitDepth::Int32 => 32, + }; + if processed { + return true; + } + match source { + SampleFormat::Int8 => target_bits < 8, + SampleFormat::Int16 => target_bits < 16, + SampleFormat::Int24 => target_bits < 24, + // Decoded through f32, which holds 24 bits: a 32-bit integer source has already lost + // resolution, so treat it like float. + SampleFormat::Int32 | SampleFormat::Float32 | SampleFormat::Float64 | SampleFormat::Compressed => true, + } +} + /// One stderr line per media file a session refers to but could not load (the exit code is unchanged). fn missing_warnings(missing: &[String]) -> Vec { missing.iter().map(|m| format!("warning: missing media: {m}")).collect() @@ -511,6 +554,34 @@ mod tests { assert!(parse_bit_depth(Some("8")).is_err()); } + #[test] + fn convert_dither_flags() { + let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(parse_dither(&args(&["in.wav", "out.wav"])), Ok(None)); + assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--dither"])), Ok(Some(true))); + assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--no-dither"])), Ok(Some(false))); + assert!(parse_dither(&args(&["in.wav", "out.wav", "--dither", "--no-dither"])).is_err()); + } + + #[test] + fn convert_dithers_only_when_resolution_is_lost() { + use soundcraft_audio_io::{BitDepth, SampleFormat}; + // Same depth or wider: the samples fit exactly. + assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int16, false)); + assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int24, false)); + assert!(!auto_dither(SampleFormat::Int24, BitDepth::Int24, false)); + assert!(!auto_dither(SampleFormat::Int8, BitDepth::Int16, false)); + // Float output never dithers. + assert!(!auto_dither(SampleFormat::Float32, BitDepth::Float32, true)); + assert!(!auto_dither(SampleFormat::Int24, BitDepth::Float32, false)); + // Fewer bits, a float or lossy source, or processed samples: dither. + assert!(auto_dither(SampleFormat::Int24, BitDepth::Int16, false)); + assert!(auto_dither(SampleFormat::Float32, BitDepth::Int24, false)); + assert!(auto_dither(SampleFormat::Compressed, BitDepth::Int16, false)); + assert!(auto_dither(SampleFormat::Int32, BitDepth::Int24, false)); + assert!(auto_dither(SampleFormat::Int16, BitDepth::Int16, true)); + } + #[test] fn missing_media_gets_one_warning_each() { assert!(missing_warnings(&[]).is_empty()); diff --git a/apps/soundcraft-cli/tests/convert.rs b/apps/soundcraft-cli/tests/convert.rs new file mode 100644 index 00000000..eb62ac77 --- /dev/null +++ b/apps/soundcraft-cli/tests/convert.rs @@ -0,0 +1,95 @@ +//! `soundcraft-cli convert` dithers only when the output loses resolution (#548), run as the real binary. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat}; + +fn tmpdir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A 16-bit mono WAV of `samples`, written without dither so the file holds exactly these values. +fn write_wav16(path: &Path, samples: &[i16]) { + let mut buf = AudioBuffer::new(48_000, 1, samples.len()); + for (d, s) in buf.channels[0].iter_mut().zip(samples) { + *d = f32::from(*s) / 32768.0; + } + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None }; + std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap(); +} + +fn convert(input: &Path, output: &Path, extra: &[&str]) { + let o = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")) + .env("SOUNDCRAFT_NO_PREFS", "1") + .arg("convert") + .arg(input) + .arg(output) + .args(extra) + .output() + .unwrap(); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); +} + +/// The decoded samples of `path`, scaled to 16-bit steps. +fn steps16(path: &Path) -> Vec { + let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap(); + buf.channels[0].iter().map(|s| f64::from(*s) * 32768.0).collect() +} + +#[test] +fn convert_copies_samples_when_nothing_is_lost() { + let dir = tmpdir("exact"); + let (silence, tone) = (dir.join("silence.wav"), dir.join("tone.wav")); + let tone_samples: Vec = (0..4800).map(|i| (8000.0 * (f64::from(i) * 0.05).sin()) as i16).collect(); + write_wav16(&silence, &[0; 4800]); + write_wav16(&tone, &tone_samples); + let want: Vec = tone_samples.iter().map(|s| f64::from(*s)).collect(); + + // Same depth, wider and float: every sample comes back unchanged, silence stays silent. + for (depth, name) in [("16", "s16.wav"), ("24", "s24.wav"), ("32f", "s32.wav")] { + let out = dir.join(name); + convert(&silence, &out, &["--bit-depth", depth]); + assert!(steps16(&out).iter().all(|s| *s == 0.0), "silence at {depth} is not silent"); + let out = dir.join(format!("t{name}")); + convert(&tone, &out, &["--bit-depth", depth]); + assert_eq!(steps16(&out), want, "tone at {depth} changed"); + } + // The default output depth (24-bit) too. + let out = dir.join("default.wav"); + convert(&tone, &out, &[]); + assert_eq!(steps16(&out), want); + + // Asked for, dither is still applied. + let out = dir.join("forced.wav"); + convert(&silence, &out, &["--bit-depth", "16", "--dither"]); + assert!(steps16(&out).iter().any(|s| *s != 0.0), "--dither did not dither"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn convert_dithers_when_reducing_depth_unless_told_not_to() { + let dir = tmpdir("reduce"); + let src = dir.join("src24.wav"); + // A 24-bit file with detail below one 16-bit step. + let mut buf = AudioBuffer::new(48_000, 1, 4800); + for (i, s) in buf.channels[0].iter_mut().enumerate() { + *s = 0.3 / 32768.0 * (i as f32 * 0.01).sin(); + } + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None }; + std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap(); + + let dithered = dir.join("d16.wav"); + convert(&src, &dithered, &["--bit-depth", "16"]); + assert!(steps16(&dithered).iter().any(|s| *s != 0.0), "24 to 16-bit was not dithered"); + // Plain rounding: everything is below half a step, so it all rounds to zero. + let plain = dir.join("p16.wav"); + convert(&src, &plain, &["--bit-depth", "16", "--no-dither"]); + assert!(steps16(&plain).iter().all(|s| *s == 0.0), "--no-dither still dithered"); + let _ = std::fs::remove_dir_all(&dir); +} From 82616dcfb800d2cb8f768281dfcb380bdbbb47f9 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Mon, 19 Oct 2026 18:01:38 +0200 Subject: [PATCH 32/90] vst3-host: restore "crate" in the ffi module doc Co-Authored-By: Claude Opus 5.5 --- crates/vst3-host/src/ffi.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 0e7acdc2..8b20e4ef 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1,4 +1,4 @@ -//! The FFI boundary: the only module of this rate allowed to use `unsafe`. +//! The FFI boundary: the only module of this crate allowed to use `unsafe`. //! //! It loads VST3 binaries (`libloading`), runs the platform module entry, calls the COM //! interfaces of the factory, component, audio processor and edit controller (`vst3` raw From 4eee6b17a82b95bf34eb6506cd0ab2b22c207868 Mon Sep 17 00:00:00 2001 From: Matthew Burlage Date: Mon, 19 Oct 2026 12:58:35 -0400 Subject: [PATCH 33/90] Preserve finite Float32 headroom during audio encoding --- ROADMAP.md | 4 +- contributors/people.toml | 3 + crates/audio-io/src/lib.rs | 1 + crates/audio-io/src/pcm.rs | 16 ++-- crates/audio-io/tests/float_headroom.rs | 101 ++++++++++++++++++++++++ crates/audio-io/tests/roundtrip.rs | 4 +- crates/engine/tests/bounce.rs | 52 ++++++++++++ docs/file-format-parity.md | 7 +- docs/gaps.md | 5 +- 9 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 crates/audio-io/tests/float_headroom.rs diff --git a/ROADMAP.md b/ROADMAP.md index f214e1e0..e9534ba1 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-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (Float32 file headroom) · **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-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. No readiness percentage change. - **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change. - **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-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change. @@ -161,6 +162,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged | | 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged | | 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged | | 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged | diff --git a/contributors/people.toml b/contributors/people.toml index 7799757f..8ce926b2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -55,3 +55,6 @@ sync_github_name = true real_name = "Marco Arnulfo" display_name = "Marco Arnulfo" sync_github_name = false + +[people.mattburlage] +display_name = "Matt Smith-Burlage" diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 4a871946..3fc7372c 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -115,6 +115,7 @@ pub enum BitDepth { Int16, Int24, Int32, + /// Preserve finite samples, including values above full scale; map NaN to 0 and infinities to ±1. Float32, } diff --git a/crates/audio-io/src/pcm.rs b/crates/audio-io/src/pcm.rs index 35c761aa..eaa9ca73 100644 --- a/crates/audio-io/src/pcm.rs +++ b/crates/audio-io/src/pcm.rs @@ -193,8 +193,8 @@ pub(crate) fn write_interleaved( for (c, q) in quantizers.iter_mut().enumerate() { let s = block.get(c).and_then(|ch| ch.get(f)).copied().unwrap_or(0.0); match (is_float, big_endian) { - (true, false) => scratch.extend_from_slice(&clamp_float(s).to_le_bytes()), - (true, true) => scratch.extend_from_slice(&clamp_float(s).to_be_bytes()), + (true, false) => scratch.extend_from_slice(&sanitize_float(s).to_le_bytes()), + (true, true) => scratch.extend_from_slice(&sanitize_float(s).to_be_bytes()), (false, false) => scratch.extend_from_slice(q.quantize(s).to_le_bytes().get(..size).unwrap_or_default()), (false, true) => scratch.extend_from_slice(q.quantize(s).to_be_bytes().get(4 - size..).unwrap_or_default()), } @@ -206,9 +206,15 @@ pub(crate) fn write_interleaved( Ok(()) } -/// Clamp a float sample to [-1, 1], mapping NaN to 0. -pub(crate) fn clamp_float(x: f32) -> f32 { - if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) } +/// Preserve finite float samples, mapping NaN to 0 and infinities to their sign (±1). +fn sanitize_float(x: f32) -> f32 { + if x.is_finite() { + x + } else if x.is_nan() { + 0.0 + } else { + x.signum() + } } /// Check that a buffer is encodable: at least one channel and a non-zero sample rate. diff --git a/crates/audio-io/tests/float_headroom.rs b/crates/audio-io/tests/float_headroom.rs new file mode 100644 index 00000000..d1fc5283 --- /dev/null +++ b/crates/audio-io/tests/float_headroom.rs @@ -0,0 +1,101 @@ +use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileEncoder, FileFormat, SampleFormat, decode, encode}; +use std::io::Cursor; + +fn encoded(audio: &AudioBuffer, options: &EncodeOptions, streaming: bool) -> Vec { + if !streaming { + return encode(audio, options).unwrap(); + } + let mut writer = FileEncoder::new(Cursor::new(Vec::new()), options, audio.num_channels(), audio.sample_rate, None).unwrap(); + // Cross the writer's 4096-frame scratch boundary, then write a short final block. + for start in (0..audio.frames()).step_by(4097) { + let end = (start + 4097).min(audio.frames()); + let block: Vec> = audio.channels.iter().map(|channel| channel[start..end].to_vec()).collect(); + writer.write(&block, end - start).unwrap(); + } + writer.finish().unwrap().into_inner() +} + +fn assert_float_headroom(streaming: bool) { + let samples = [ + -f32::MAX, + -2.0, + -1.25, + -1.0, + -0.5, + -f32::MIN_POSITIVE, + -f32::from_bits(1), + -0.0, + 0.0, + f32::from_bits(1), + f32::MIN_POSITIVE, + 0.5, + 1.0, + 1.25, + 2.0, + f32::MAX, + ]; + for format in [FileFormat::Wav, FileFormat::Aiff] { + for dither in [false, true] { + for channels in [1, 2] { + let audio = AudioBuffer { + sample_rate: 48_000, + channels: (0..channels).map(|c| samples.iter().copied().cycle().skip(c).take(4103).collect()).collect(), + }; + let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither, bwf: None }; + let bytes = encoded(&audio, &options, streaming); + let pcm: Vec = + audio.interleaved().iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect(); + assert!(bytes.ends_with(&pcm), "{format:?}, dither={dither}: stored sample bytes changed"); + let (info, decoded) = decode(&bytes, None).unwrap(); + assert_eq!(info.sample_format, SampleFormat::Float32); + assert_eq!((decoded.sample_rate, decoded.num_channels(), decoded.frames()), (48_000, channels, 4103)); + for (channel, (actual, expected)) in decoded.channels.iter().zip(&audio.channels).enumerate() { + for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() { + assert_eq!(actual.to_bits(), expected.to_bits(), "{format:?}, dither={dither}, channel={channel}, frame={frame}"); + } + } + } + } + } +} + +#[test] +fn whole_file_float32_preserves_finite_headroom() { + assert_float_headroom(false); +} + +#[test] +fn streaming_float32_preserves_finite_headroom() { + assert_float_headroom(true); +} + +#[test] +fn float32_keeps_non_finite_sample_handling() { + let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![f32::NAN, f32::NEG_INFINITY, f32::INFINITY]] }; + let expected = [0.0_f32, -1.0, 1.0]; + for format in [FileFormat::Wav, FileFormat::Aiff] { + for streaming in [false, true] { + let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither: false, bwf: None }; + let bytes = encoded(&audio, &options, streaming); + // Inspect stored samples too: the decoder itself replaces non-finite values. + let pcm: Vec = expected.iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect(); + assert!(bytes.ends_with(&pcm), "{format:?}, streaming={streaming}"); + assert_eq!(decode(&bytes, None).unwrap().1.channels[0], expected); + } + } +} + +#[test] +fn integer_pcm_still_clips_at_full_scale() { + let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![-2.0, -1.25, -1.0, -0.5, 0.0, 0.5, 1.0, 1.25, 2.0]] }; + for (depth, max) in [(BitDepth::Int16, 32767.0 / 32768.0), (BitDepth::Int24, 8_388_607.0 / 8_388_608.0), (BitDepth::Int32, 1.0)] { + for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] { + for streaming in [false, true] { + let options = EncodeOptions { format, bit_depth: depth, dither: false, bwf: None }; + let bytes = encoded(&audio, &options, streaming); + let (_, decoded) = decode(&bytes, None).unwrap(); + assert_eq!(decoded.channels[0], [-1.0, -1.0, -1.0, -0.5, 0.0, 0.5, max, max, max], "{format:?} {depth:?}, streaming={streaming}"); + } + } + } +} diff --git a/crates/audio-io/tests/roundtrip.rs b/crates/audio-io/tests/roundtrip.rs index 867bb84e..70042966 100644 --- a/crates/audio-io/tests/roundtrip.rs +++ b/crates/audio-io/tests/roundtrip.rs @@ -205,7 +205,7 @@ fn encode_rejects_bad_buffers_and_formats() { for f in [FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Alac, FileFormat::Caf, FileFormat::Other] { assert!(encode(&sine(48_000, 1, 10), &EncodeOptions { format: f, ..EncodeOptions::default() }).is_err()); } - // Non-finite and out-of-range samples are clamped, not errors. + // Non-finite samples are sanitized; only integer output clips finite samples. let mut b = AudioBuffer::new(48_000, 1, 4); b.channels[0] = vec![f32::NAN, f32::INFINITY, -5.0, 0.5]; for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] { @@ -217,7 +217,7 @@ fn encode_rejects_bad_buffers_and_formats() { let (_, out) = decode(&bytes, None).unwrap(); assert_eq!(out.channels[0][0], 0.0); assert!(out.channels[0][1] > 0.99 && out.channels[0][1] <= 1.0); - assert_eq!(out.channels[0][2], -1.0); + assert_eq!(out.channels[0][2], if depth == BitDepth::Float32 { -5.0 } else { -1.0 }); } } // Ragged buffers encode the common length. diff --git a/crates/engine/tests/bounce.rs b/crates/engine/tests/bounce.rs index ea4cf127..688be40d 100644 --- a/crates/engine/tests/bounce.rs +++ b/crates/engine/tests/bounce.rs @@ -5,6 +5,58 @@ use soundcraft_engine::io::{Bounce, bounce_stems}; use soundcraft_time::Range; use std::path::PathBuf; +#[test] +fn float_bounce_preserves_headroom_and_reports_the_written_levels() { + let mut e = soundcraft_engine::Engine::default(); + let sr = e.session().sample_rate.hz(); + let frames = sr as usize + 17; + let samples: Vec = (0..frames).map(|i| 1.5 * (std::f32::consts::TAU * 440.0 * i as f32 / sr as f32).sin()).collect(); + let audio = soundcraft_audio_io::AudioBuffer { sample_rate: sr, channels: vec![samples.clone(), samples] }; + let s = e.session_mut(); + // A new track has no inserts, so no limiter can mask encoder clipping. + let track = s.add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Stereo, Some("Headroom")); + let source = soundcraft_engine::io::add_source(s, "headroom", audio, None, FileFormat::Wav); + let clip = soundcraft_model::Clip::audio(s.new_clip_id(), "headroom", source, 0, 0, frames as i64); + soundcraft_engine::edit::place_clip(s, track, clip); + let mix = soundcraft_mix::render_range(e.session(), Range::new(0, frames as i64), 1024); + assert!(soundcraft_dsp::offline::peak(&mix) > 1.0); + let dir = scratch("bounce-float-headroom"); + for extension in ["wav", "aiff"] { + for normalize in [false, true] { + let path = dir.join(format!("Mix.{extension}")); + let result = e + .execute( + "file.bounce_mix", + &serde_json::json!({ + "path": path, "bit_depth": "32f", "dither": false, "normalize": normalize, + "start": 0, "end": frames, + }), + ) + .unwrap(); + let bytes = std::fs::read(&path).unwrap(); + let (_, file) = soundcraft_audio_io::decode(&bytes, None).unwrap(); + let mut expected = mix.clone(); + if normalize { + soundcraft_dsp::offline::normalize(&mut expected, -0.1, false); + assert!(file.peak() < 1.0); + } else { + assert!(file.peak() > 1.0, "{extension}: headroom was clipped"); + } + assert_eq!(file.channels, expected, "{extension}, normalize={normalize}"); + let mut meter = soundcraft_dsp::meter::LoudnessMeter::new(sr as f32, file.num_channels()); + meter.process(&file.channels, file.frames()); + for (key, value) in [ + ("peak_db", soundcraft_dsp::offline::peak_db(&file.channels)), + ("lufs", meter.integrated_lufs()), + ("true_peak_db", meter.true_peak_db()), + ] { + assert_eq!(result[key].as_f64(), Some(f64::from(value)), "{extension}, normalize={normalize}, {key}"); + } + } + } + let _ = std::fs::remove_dir_all(&dir); +} + fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2c322c83..924546d8 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:** 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 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (Float32 file headroom, #791) · **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,10 +33,10 @@ 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; Float32 retains finite headroom (#791); 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 | +| AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native; `fl32` retains finite headroom (#791) | | FLAC | import | ✅ | ✅ | Own encoder | | MP3 | import, bounce | ✅ (symphonia) | ❌ | G-21 | | AAC / M4A / MP4 audio | import, bounce | ✅ (symphonia) | ❌ | G-21 | @@ -80,6 +80,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | Date | Change | Summary | |---|---|---| +| 2026-10-19 | trivial | WAV and AIFF-C Float32 encoders retain finite headroom (#791); whole-file, streaming and bounce regression tests | | 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 | diff --git a/docs/gaps.md b/docs/gaps.md index 3601a2a4..1b7c074e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (G-04: mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (G-04: Float32 file headroom) · **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,8 @@ 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-19, #791):** Float32 WAV and AIFF-C encoding retain finite samples above full scale. Whole-file and streaming tests cover both formats, integer clipping, and the existing NaN/infinity behavior. A bounce regression checks preserved headroom and file/report agreement with normalization off, then checks normalized output. + **Fixed (2026-10-18):** Mono device playback averages the stereo channels before output clipping, **Fixed (2026-10-16):** Master fader volume automation uses the first and last samples of each **Fixed (2026-10-16):** Aux, folder and master latency calculations now skip inserts already @@ -214,6 +216,7 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained | | 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged | | 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged | | 2026-10-16 | trivial | G-04: failed aux/folder/master inserts excluded from latency compensation after containment; estimates unchanged | From b5bcf577b213fd6844bbf6f60e771a60f058adc1 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:27:44 +0200 Subject: [PATCH 34/90] Fix saved audio trust test on Windows by joining path parts separately (#789) --- crates/engine/src/io.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index eff105ec..bc60fd03 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1710,7 +1710,7 @@ mod save_tests { let mut r = Engine::default(); assert_eq!( open_session(&mut r, &path("B/Moved.scraft")).unwrap(), - vec![dir.join("A").join("Audio Files/Audio 1_02.wav").display().to_string()] + vec![dir.join("A").join("Audio Files").join("Audio 1_02.wav").display().to_string()] ); let _ = std::fs::remove_dir_all(&dir); } From 24fa4668ef968b78ae0c902fa104ee4dbdcab783 Mon Sep 17 00:00:00 2001 From: lucasegen <281051305+lucasegen@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:37:35 +0200 Subject: [PATCH 35/90] Name F12 in the Record button's tooltip The tooltip only offered Cmd+Space, which macOS gives to Spotlight unless the user frees it, so on a stock Mac the advertised shortcut opens Spotlight. F12 already records (shortcuts.rs), as on the incumbent; the tooltip now names it first. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/toolbar.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 41d83ac4..0dd217b6 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -338,7 +338,8 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) { let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click()); key(ui.painter(), r, &resp, if recording { Color32::from_rgb(110, 30, 30) } else { key_fill(&resp) }); icons::draw(ui.painter(), r.shrink(5.0), "record", t.rec); - let resp = resp.on_hover_text("Record (Cmd+Space)"); + // macOS gives Cmd+Space to Spotlight unless the user frees it, so name F12 first. + let resp = resp.on_hover_text("Record (F12 or Cmd+Space)"); if let Some(why) = app.record_hint() { // Below the whole group, so the hint covers none of its buttons. let below = Rect::from_min_max(r.min, pos2(r.max.x, g.max.y)); From 1f72dc30be7fac44b874ee1b60fca2ccf5a6c7fa Mon Sep 17 00:00:00 2001 From: lucasegen <281051305+lucasegen@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:29:51 +0200 Subject: [PATCH 36/90] Import registry in au-host's macOS api module #988 replaced `use super::*;` in the macOS `api` module with an explicit list that leaves `registry` out, so soundcraft-au-host, and with it the app, no longer builds on macOS: four E0433 errors. Linux CI compiles this module out, so it stayed green. Co-Authored-By: Claude Opus 5.5 --- crates/au-host/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/au-host/src/lib.rs b/crates/au-host/src/lib.rs index 3a664a4d..7aa3b2eb 100644 --- a/crates/au-host/src/lib.rs +++ b/crates/au-host/src/lib.rs @@ -192,7 +192,7 @@ pub enum AuError { #[cfg(target_os = "macos")] mod api { - use super::{AuDescriptor, AuError, Plugin, PluginInfo}; + use super::{AuDescriptor, AuError, Plugin, PluginInfo, registry}; pub fn scan() -> Vec { registry::scan() From c2da4cf912bb6bf13163194308164931c7d0da6f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 21:41:04 -0700 Subject: [PATCH 37/90] Add a built-in Ring Modulator plugin The built-in plugins had no ring modulator, and AudioSuite > Modulation > Sci-Fi ran the Flanger instead. Ring Modulator (`ring_mod`, Modulation category, also offline as AudioSuite) has four effects: Ring multiplies the input by a sine carrier, Frequency Mod reads the input through a delay the carrier swings by 1 ms, and Resonator + and Resonator - feed the output back after one period of the frequency with a positive or a negative sign. At full depth an LFO or sample and hold moves the frequency by up to two octaves either way, and an envelope follower raises it by up to two octaves. AudioSuite > Modulation > Sci-Fi now opens it. The Total row in docs/plugin-parity.md now counts 25 processors and 3 instruments, the entries in the built-in registry. --- README.md | 10 +- ROADMAP.md | 4 +- crates/dsp/src/plugins/mod.rs | 2 + crates/dsp/src/plugins/modulation.rs | 222 ++++++++++++++++++++++++++- crates/dsp/tests/it/plugins.rs | 92 +++++++++++ crates/ui-egui/src/menus.rs | 11 +- docs/plugin-parity.md | 7 +- 7 files changed, 336 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c31fc87a..3699d23a 100644 --- a/README.md +++ b/README.md @@ -122,11 +122,11 @@ where things are. - **Automation:** volume, pan, mute, send and every plugin parameter; Write, Touch, Latch and Trim passes recorded live from the faders; thin, glide, coalesce and convert to clip gain. - **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer, - channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi, - rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither, - a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG across the - keyboard), all original. **CLAP plugins** load too. Any processor - runs offline as AudioSuite, and user presets save per plugin. + channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, ring modulator, + saturator, lo-fi, rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, + signal generator, dither, a subtractive synth, a drum synth and a sampler (plays any WAV, + AIFF, FLAC, MP3 or OGG across the keyboard), all original. **CLAP plugins** load too. Any + processor runs offline as AudioSuite, and user presets save per plugin. - **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files, and Audio-to-MIDI from a sung or played melody. diff --git a/ROADMAP.md b/ROADMAP.md index f214e1e0..a6da72f6 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-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-23 · **Change:** trivial (Ring Modulator plugin) · **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-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change. - **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change. - **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-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change. @@ -161,6 +162,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged | | 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged | | 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged | | 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged | diff --git a/crates/dsp/src/plugins/mod.rs b/crates/dsp/src/plugins/mod.rs index aef3e39d..97d672e9 100644 --- a/crates/dsp/src/plugins/mod.rs +++ b/crates/dsp/src/plugins/mod.rs @@ -29,6 +29,7 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[ &modulation::CHORUS_INFO, &modulation::FLANGER_INFO, &modulation::PHASER_INFO, + &modulation::RING_INFO, &harmonic::SAT_INFO, &harmonic::LOFI_INFO, &harmonic::RECT_INFO, @@ -61,6 +62,7 @@ pub(crate) fn instantiate(id: &str) -> Option> { "chorus" => Box::new(modulation::ModFx::chorus()), "flanger" => Box::new(modulation::ModFx::flanger()), "phaser" => Box::new(modulation::Phaser::new()), + "ring_mod" => Box::new(modulation::RingMod::new()), "saturator" => Box::new(harmonic::Saturator::new()), "lofi" => Box::new(harmonic::LoFi::new()), "rectifier" => Box::new(harmonic::Rectifier::new()), diff --git a/crates/dsp/src/plugins/modulation.rs b/crates/dsp/src/plugins/modulation.rs index 962f57de..80d48cc1 100644 --- a/crates/dsp/src/plugins/modulation.rs +++ b/crates/dsp/src/plugins/modulation.rs @@ -1,7 +1,7 @@ -//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser. +//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser, Ring Modulator. use crate::params::{Params, choice, lin, log, param_plumbing}; -use crate::util::{DelayLine, Lfo, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr}; +use crate::util::{DelayLine, Lfo, Rng, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef}; use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit}; /// Per-channel LFO phase offset for stereo movement (channel 1 is offset by `spread` cycles). @@ -459,3 +459,221 @@ impl Plugin for Phaser { ms_to_samples(100.0, self.sr) as usize } } + +// --------------------------------------------------------------------------------------------- + +const RING_EFFECTS: &[&str] = &["Ring", "Frequency Mod", "Resonator +", "Resonator -"]; +const RING_SOURCES: &[&str] = &["LFO", "Envelope", "Sample & Hold"]; +const RING_MIN_HZ: f32 = 20.0; +/// Octaves the frequency moves at full modulation depth. +const RING_MOD_OCTAVES: f32 = 2.0; +/// Peak delay swing of Frequency Mod. +const RING_FM_SWING_MS: f32 = 1.0; +/// Sample-and-hold noise seed (the same sequence after every reset). +const RING_SEED: u32 = 0x5EED_1234; + +static RING_PARAMS: [ParamInfo; 7] = [ + choice("effect", "Effect", RING_EFFECTS, 0), + log("frequency", "Frequency", RING_MIN_HZ, 5000.0, 440.0, Unit::Hz), + lin("resonance", "Resonance", 0.0, 95.0, 50.0, Unit::Percent), + choice("mod_source", "Mod Source", RING_SOURCES, 0), + log("mod_rate", "Mod Rate", 0.05, 20.0, 1.0, Unit::Hz), + lin("mod_depth", "Mod Depth", 0.0, 100.0, 0.0, Unit::Percent), + lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent), +]; + +pub static RING_INFO: PluginInfo = PluginInfo { + id: "ring_mod", + name: "Ring Modulator", + short_name: "RingMod", + category: Category::Modulation, + params: &RING_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// Ring modulation, frequency modulation and positive or negative comb resonators, with the +/// frequency moved by an LFO, an envelope follower or a sample-and-hold source. +/// +/// - Ring multiplies the input by a sine carrier at the frequency. +/// - Frequency Mod reads the input through a delay that the carrier swings by ±1 ms. +/// - Resonator + and Resonator - feed the output back after one period of the frequency, with +/// a positive or a negative sign. The output is scaled by `1 - resonance`, so the peak gain at +/// resonance is unity. +/// +/// At 100 % depth the modulation moves the frequency by up to two octaves either way. The +/// envelope follower moves it up only. +pub struct RingMod { + p: Params, + sr: f32, + lines: Vec, + lfo: Lfo, + rng: Rng, + carrier: f32, + env: f32, + env_attack: f32, + env_release: f32, + held: f32, + hold_left: usize, + last_effect: usize, + freq: Smoother, + res: Smoother, + depth: Smoother, + mix: Smoother, +} + +impl Default for RingMod { + fn default() -> Self { + Self::new() + } +} + +impl RingMod { + pub fn new() -> Self { + let mut r = Self { + p: Params::new(&RING_PARAMS), + sr: 48_000.0, + lines: Vec::new(), + lfo: Lfo::new(), + rng: Rng::new(RING_SEED), + carrier: 0.0, + env: 0.0, + env_attack: 0.0, + env_release: 0.0, + held: 0.0, + hold_left: 0, + last_effect: 0, + freq: Smoother::new(440.0), + res: Smoother::new(0.0), + depth: Smoother::new(0.0), + mix: Smoother::new(1.0), + }; + r.update(); + r + } + + fn update(&mut self) { + self.freq.set(self.p.v(1)); + self.res.set(self.p.v(2) / 100.0); + self.depth.set(self.p.v(5) / 100.0); + self.mix.set(self.p.v(6) / 100.0); + } + + /// Samples in one period of the lowest frequency the modulation can reach. + fn longest_period(&self) -> f32 { + self.sr / (self.p.v(1) * 2f32.powf(-RING_MOD_OCTAVES * self.p.v(5) / 100.0)).max(1.0) + } +} + +impl Plugin for RingMod { + param_plumbing!(RING_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + let comb = self.sr / (RING_MIN_HZ * 2f32.powf(-RING_MOD_OCTAVES)); + let max = comb.max(ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr)) as usize + 8; + self.lines = (0..sane_channels(channels)) + .map(|_| { + let mut d = DelayLine::default(); + d.allocate(max); + d + }) + .collect(); + self.env_attack = time_coef(5.0, self.sr); + self.env_release = time_coef(100.0, self.sr); + for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] { + s.set_time(20.0, self.sr); + } + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.lines.iter_mut().for_each(DelayLine::clear); + self.lfo.reset(); + self.rng = Rng::new(RING_SEED); + self.carrier = 0.0; + self.env = 0.0; + self.held = 0.0; + self.hold_left = 0; + self.last_effect = self.p.choice(0); + for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] { + s.snap(); + } + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.lines.len().min(io.len()); + let frames = frames_in(io, frames, ch); + let effect = self.p.choice(0); + if effect != self.last_effect { + // The lines hold the previous effect's state. + self.lines.iter_mut().for_each(DelayLine::clear); + self.last_effect = effect; + } + let source = self.p.choice(3); + let rate = self.p.v(4); + let inc = rate / self.sr; + let hold = ((self.sr / rate.max(0.05)) as usize).max(1); + let nyq = self.sr * 0.45; + let swing = ms_to_samples(RING_FM_SWING_MS, self.sr); + for n in 0..frames { + let level = io.iter().take(ch).filter_map(|b| b.get(n)).filter(|x| x.is_finite()).fold(0.0f32, |m, x| m.max(x.abs())); + let k = if level > self.env { self.env_attack } else { self.env_release }; + self.env = flush(level + (self.env - level) * k); + let lfo = Lfo::sin_at(self.lfo.tick(inc)); + let m = match source { + 1 => self.env.min(1.0), + 2 => { + if self.hold_left == 0 { + self.held = self.rng.bipolar(); + self.hold_left = hold; + } + self.hold_left -= 1; + self.held + } + _ => lfo, + }; + let f = (self.freq.next() * 2f32.powf(RING_MOD_OCTAVES * self.depth.next() * m)).clamp(1.0, nyq); + let fb = self.res.next(); + let mix = self.mix.next(); + self.carrier += f / self.sr; + if self.carrier >= 1.0 { + self.carrier -= 1.0; + } + let carrier = Lfo::sin_at(self.carrier); + for c in 0..ch { + let (Some(buf), Some(line)) = (io.get_mut(c), self.lines.get_mut(c)) else { continue }; + let Some(x) = buf.get_mut(n) else { continue }; + let dry = if x.is_finite() { *x } else { 0.0 }; + let wet = match effect { + 1 => { + line.push(dry); + line.read_cubic(2.0 + swing * (1.0 + carrier)) + } + 2 | 3 => { + let sign = if effect == 2 { 1.0 } else { -1.0 }; + let y = flush(dry + sign * fb * line.read_cubic(self.sr / f)); + line.push(y); + y * (1.0 - fb) + } + _ => dry * carrier, + }; + *x = dry * (1.0 - mix) + wet * mix; + } + } + } + + fn tail_samples(&self) -> usize { + match self.p.choice(0) { + 1 => ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr) as usize + 4, + 2 | 3 => { + // Time for the feedback to fall by 60 dB at the longest period the modulation reaches. + let fb = (self.p.v(2) / 100.0).clamp(0.0, 0.99); + let repeats = if fb < 1e-3 { 1.0 } else { (-60.0 / (20.0 * fb.log10())).min(500.0) + 1.0 }; + (self.longest_period() * repeats) as usize + } + _ => 0, + } + } +} diff --git a/crates/dsp/tests/it/plugins.rs b/crates/dsp/tests/it/plugins.rs index ad55d838..ceb68c09 100644 --- a/crates/dsp/tests/it/plugins.rs +++ b/crates/dsp/tests/it/plugins.rs @@ -524,3 +524,95 @@ fn lofi_quantizes() { levels.dedup(); assert!(levels.len() <= 5, "{levels:?}"); } + +fn ring_mod(params: &[(&str, f32)]) -> Box { + let mut p = prepared("ring_mod"); + for &(id, v) in params { + assert!(p.set_param(id, v), "ring_mod.{id}"); + } + p.reset(); + p +} + +/// Loudest bin within two bins of `hz` in an 8192-point spectrum. +fn band_db(spec: &[f32], hz: f32) -> f32 { + let bin = (hz / (SR / 8192.0)).round() as usize; + spec[bin - 2..=bin + 2].iter().cloned().fold(f32::MIN, f32::max) +} + +fn zero_crossings(x: &[f32]) -> usize { + x.windows(2).filter(|w| (w[0] < 0.0) != (w[1] < 0.0)).count() +} + +#[test] +fn ring_mod_makes_sum_and_difference_tones() { + let mut p = ring_mod(&[("effect", 0.0), ("frequency", 300.0), ("mod_depth", 0.0), ("mix", 100.0)]); + let out = run(p.as_mut(), &[sine(1000.0, 0.5, 48_000)]); + let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192); + let (low, high, input) = (band_db(&spec, 700.0), band_db(&spec, 1300.0), band_db(&spec, 1000.0)); + // The two tones fall at different places between bins, so their bin peaks differ a little. + assert!((low - high).abs() < 2.0, "700 Hz {low} dB, 1300 Hz {high} dB"); + assert!(low - input > 40.0, "the 1000 Hz input is left at {input} dB against {low} dB"); +} + +#[test] +fn ring_mod_frequency_mod_adds_sidebands() { + let x = sine(1000.0, 0.5, 48_000); + let dry = soundcraft_dsp::spectrum::magnitude_db(&x[8192..], 8192); + assert!(band_db(&dry, 1000.0) - band_db(&dry, 1200.0) > 40.0); + let mut p = ring_mod(&[("effect", 1.0), ("frequency", 200.0), ("mix", 100.0)]); + let out = run(p.as_mut(), &[x]); + let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192); + for hz in [800.0, 1200.0] { + assert!(band_db(&spec, 1000.0) - band_db(&spec, hz) < 20.0, "no sideband at {hz} Hz"); + } +} + +#[test] +fn ring_mod_resonators_ring_at_the_frequency_with_their_sign() { + // 480 Hz at 48 kHz is a 100-sample period. + for (effect, sign) in [(2.0, 1.0), (3.0, -1.0)] { + let mut p = ring_mod(&[("effect", effect), ("frequency", 480.0), ("resonance", 90.0), ("mix", 100.0)]); + let mut x = vec![0.0f32; 9600]; + x[0] = 1.0; + let out = run(p.as_mut(), &[x]); + let y = &out[0]; + assert!((y[0] - 0.1).abs() < 1e-4, "effect {effect}: direct {}", y[0]); + assert!((y[100] - sign * 0.09).abs() < 1e-4, "effect {effect}: first echo {}", y[100]); + assert!((y[200] - 0.081).abs() < 1e-4, "effect {effect}: second echo {}", y[200]); + assert!(y[50].abs() < 1e-6 && y[150].abs() < 1e-6, "effect {effect}: sound between the echoes"); + let last = y.iter().rposition(|v| v.abs() > 0.1e-3).unwrap(); + assert!(last < p.tail_samples(), "effect {effect}: ringing at {last}, tail {}", p.tail_samples()); + } +} + +#[test] +fn ring_mod_modulation_sources_move_the_frequency() { + // A constant input through Ring gives the carrier itself. + let dc = vec![1.0f32; 48_000]; + + // The envelope of a full-scale input lifts 100 Hz by one octave at 50 % depth. + let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 1.0), ("mod_depth", 50.0), ("mix", 100.0)]); + let out = run(p.as_mut(), std::slice::from_ref(&dc)); + let n = zero_crossings(&out[0][24_000..]); + assert!((195..=205).contains(&n), "envelope: {n} crossings in 0.5 s, 200 expected for 200 Hz"); + + // A 1 Hz LFO raises the frequency for its first half cycle and lowers it for the second. + let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 0.0), ("mod_rate", 1.0), ("mod_depth", 100.0), ("mix", 100.0)]); + let out = run(p.as_mut(), std::slice::from_ref(&dc)); + let (up, down) = (zero_crossings(&out[0][..12_000]), zero_crossings(&out[0][24_000..36_000])); + assert!(up > 3 * down, "LFO: {up} crossings rising, {down} falling"); + + // Sample and hold at 4 Hz keeps one frequency for each 12,000-sample step. + let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 2.0), ("mod_rate", 4.0), ("mod_depth", 100.0), ("mix", 100.0)]); + let out = run(p.as_mut(), std::slice::from_ref(&dc)); + let mut steps = Vec::new(); + for s in out[0].chunks(12_000) { + let (a, b) = (zero_crossings(&s[..6000]), zero_crossings(&s[6000..])); + assert!(a.abs_diff(b) <= 2, "sample and hold changed within a step: {a} then {b}"); + steps.push(a); + } + steps.sort_unstable(); + steps.dedup_by(|a, b| a.abs_diff(*b) <= 2); + assert!(steps.len() >= 3, "sample and hold steps: {steps:?}"); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9ba795ca..66d444d3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -130,7 +130,7 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[ ("AudioSuite > Pitch Shift > Vari-Fi", "varispeed"), ("AudioSuite > Reverb > D-Verb", "room_reverb"), ("AudioSuite > Delay > Mod Delay III", "mod_delay"), - ("AudioSuite > Modulation > Sci-Fi", "flanger"), + ("AudioSuite > Modulation > Sci-Fi", "ring_mod"), ("AudioSuite > Harmonic > Eleven Lite", "saturator"), ("AudioSuite > Harmonic > Lo-Fi", "lofi"), ("AudioSuite > Harmonic > Recti-Fi", "rectifier"), @@ -757,4 +757,13 @@ mod tests { e.execute("edit.select", &json!({"tracks": [audio, inst]})).unwrap(); assert_eq!(instrument_track(e.session()).map(|t| t.0), Some(inst)); } + + #[test] + fn audiosuite_sci_fi_opens_the_ring_modulator() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + invoke_menu(&mut app, "audiosuite.process", "AudioSuite > Modulation > Sci-Fi"); + assert_eq!(app.ui.audiosuite.as_deref(), Some("ring_mod")); + let info = soundcraft_dsp::plugin_info("ring_mod").unwrap(); + assert!(info.audiosuite && info.category == soundcraft_dsp::Category::Modulation); + } } diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index 6aa796b5..c055a15a 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 (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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** trivial (Ring Modulator; previously trivial: Sampler ping-pong loop phase (#463); 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 @@ -17,12 +17,12 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories. | Dynamics | compressor/limiter, expander/gate, de-esser, maximizer, vintage compressors, multiband | `compressor`, `expander_gate`, `de_esser`, `maximizer` | Vintage/opto models, multiband, sidechain input (no key input anywhere) | | Reverb | algorithmic room/plate/hall; convolution (paid tier) | `room_reverb`, `plate_reverb` | Hall/chamber, convolution, early-reflection editor | | Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth | -| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `chorus`, `flanger`, `phaser` | Tremolo, auto-pan, ring modulation | +| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `chorus`, `flanger`, `phaser`, `ring_mod` (ring, frequency modulation, positive and negative resonators; LFO, envelope or sample-and-hold modulation) | Tremolo, auto-pan, frequency shift | | 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). 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** | | +| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **25 processors + 3 instruments** | | AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in `crates/ui-egui/src/menus.rs`), so the AudioSuite menu is 100 % present. Depth: no preview, @@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | trivial | Modulation: Ring Modulator (`ring_mod`), also used by the AudioSuite Modulation menu | | 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 | From 6c03d7e8deae8c3c30f4ed2509ccbe1ea4b206c5 Mon Sep 17 00:00:00 2001 From: SusieeTheLinuxUser <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:30:31 +0200 Subject: [PATCH 38/90] Tracks: clip track and group names to their rows in the side lists (fixes #990) Co-Authored-By: GLM-5.3-Flash --- crates/ui-egui/src/panels.rs | 10 +++- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/side_lists_text.rs | 57 ++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/it/side_lists_text.rs diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 5522e4cf..778af47b 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -77,7 +77,13 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { let _ = app.run("track.hide", json!({"tracks": [id], "hidden": shown})); } ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color)); - ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), if shown { t.text } else { t.text_dim }); + ui.painter().with_clip_rect(r).text( + pos2(r.min.x + 30.0, r.center().y), + Align2::LEFT_CENTER, + &name, + regular(11.5), + if shown { t.text } else { t.text_dim }, + ); if resp.clicked() { crate::edit_window::click_track(app, soundcraft_model::TrackId(id), ui.input(|i| i.modifiers), |_, _| true); } @@ -118,7 +124,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { bold(10.0), if active { t.text_dark } else { t.text_dim }, ); - ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), t.text); + ui.painter().with_clip_rect(r).text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), t.text); if resp.clicked() { let _ = app.run("track.group_toggle", json!({"group": id})); } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index eb09a58b..df71ff82 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -43,6 +43,7 @@ mod send_window; mod shared_controls; mod shortcuts; mod side_lists_access; +mod side_lists_text; mod toolbar_scroll; mod toolbar_tempo; mod track_height; diff --git a/crates/ui-egui/tests/it/side_lists_text.rs b/crates/ui-egui/tests/it/side_lists_text.rs new file mode 100644 index 00000000..a99929a8 --- /dev/null +++ b/crates/ui-egui/tests/it/side_lists_text.rs @@ -0,0 +1,57 @@ +//! #990: track and group names in the side lists are clipped to their row, so a long name cannot +//! paint across the scrollbar gutter and over the panel's edge. + +use egui::{Shape, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp}; + +fn harness() -> Harness<'static, SoundApp> { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let first = app.engine.session().tracks.first().map(|t| t.id.0).unwrap_or_default(); + app.run("track.rename", json!({"track": first, "name": LONG_TRACK})).unwrap(); + app.run("track.group", json!({"name": LONG_GROUP, "tracks": [first]})).unwrap(); + app.engine.session_mut().edit.selected_tracks.clear(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +const LONG_TRACK: &str = "24H2026_27_JANVIER_SESSION_AUDIO_BED_A"; +const LONG_GROUP: &str = "Rhythm section drums bass and percussion group"; + +/// The name's paint must not be clipped any further right than its row: a wider clip lets the text +/// run over the lists' scrollbar gutter and across the panel's edge. Only the paint inside the row +/// counts — the name is drawn in its own clipped column in the edit window's headers, too. +fn name_is_clipped_to_its_row(h: &Harness<'_, SoundApp>, name: &str) { + let row = h.get_by_label(name).rect(); + let painted = h.output().shapes.iter().filter_map(|s| match &s.shape { + Shape::Text(t) if t.galley.text().contains(name) && s.clip_rect.intersects(row) => Some(s.clip_rect), + _ => None, + }); + let mut count = 0; + for clip in painted { + count += 1; + assert!(clip.right() <= row.right() + 0.5, "the name's paint is clipped at x {}, past its row's right edge {}", clip.right(), row.right()); + } + assert!(count > 0, "no painted text found for {name:?}"); +} + +#[test] +fn a_long_track_name_stays_inside_its_row() { + let h = harness(); + name_is_clipped_to_its_row(&h, LONG_TRACK); +} + +#[test] +fn a_long_group_name_stays_inside_its_row() { + let h = harness(); + name_is_clipped_to_its_row(&h, LONG_GROUP); +} From 2adec7f1287dee3944810cee89b14fb4c4c2dd88 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:16:40 +0000 Subject: [PATCH 39/90] Integration: #509 accept the Ambisonic WAVEX subtype GUIDs as PCM/float --- crates/audio-io/src/wav.rs | 4 +++- crates/audio-io/tests/it/malformed.rs | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index 32f86ab4..f7979404 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -10,6 +10,8 @@ const WAVE_FORMAT_IEEE_FLOAT: u16 = 3; const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE; /// Tail of the KSDATAFORMAT_SUBTYPE_* GUIDs (bytes 2..16); bytes 0..2 hold the format tag. const GUID_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]; +/// Tail of the Ambisonic subtype GUIDs {0000000x-0721-11D3-8644-C8C1CA000000}: PCM (x = 1) and float (x = 3) as well. +const AMBISONIC_TAIL: [u8; 14] = [0x00, 0x00, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0x00, 0x00, 0x00]; const BEXT_MIN_LEN: usize = 602; /// Parsed WAV layout (no sample data decoded yet). @@ -67,7 +69,7 @@ fn parse_fmt(c: &[u8]) -> Result { } // A subtype outside the KSDATAFORMAT_SUBTYPE_* family is not PCM/float, whatever its first two bytes say: // keep the extensible tag so the file goes to the foreign decoder. - if tail == GUID_TAIL { + if tail == GUID_TAIL || tail == AMBISONIC_TAIL { fmt.tag = sub; } } diff --git a/crates/audio-io/tests/it/malformed.rs b/crates/audio-io/tests/it/malformed.rs index d09ad8ba..ec862f66 100644 --- a/crates/audio-io/tests/it/malformed.rs +++ b/crates/audio-io/tests/it/malformed.rs @@ -544,4 +544,8 @@ fn extensible_fmt_is_checked_in_full() { foreign[15] ^= 0xFF; let foreign = riff(&[(b"fmt ", extensible_fmt(22, 16, &foreign)), (b"data", data)]); assert!(!matches!(probe(&foreign, None), Ok(i) if i.sample_format == SampleFormat::Int16)); + // The Ambisonic PCM subtype is PCM. + let ambi = [1, 0, 0, 0, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0, 0, 0]; + let ambi = riff(&[(b"fmt ", extensible_fmt(22, 16, &ambi)), (b"data", vec![0u8; 8])]); + assert_eq!(probe(&ambi, None).unwrap().sample_format, SampleFormat::Int16); } From 9719f955a4ac3b56130af79f60bfda65a6176682 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:17:33 +0000 Subject: [PATCH 40/90] Integration: #552 allow --dither and --no-dither in convert's options --- apps/soundcraft-cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 276333c8..2c22b689 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -173,7 +173,7 @@ fn info(args: &[String]) -> ExitCode { fn convert(args: &[String]) -> ExitCode { let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") }; - const OPTIONS: &Options = &[("--rate", true), ("--bit-depth", true), ("--normalize", false)]; + const OPTIONS: &Options = &[("--rate", true), ("--bit-depth", true), ("--normalize", false), ("--dither", false), ("--no-dither", false)]; if let Err(e) = check_options(&args[2..], OPTIONS) { return fail(e); } From 678cb0f57d298ba1bbfec26eaa7003d506d81909 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:18:49 +0000 Subject: [PATCH 41/90] Integration: #517 join_gain_env places the right envelope at the seam when heal bridges a gap --- crates/engine/src/edit.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 4b8dac8b..4e8b2532 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -441,14 +441,17 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize { healed } -/// Give `l` the gain envelope of `l` followed by `r`, which starts where `l` ends, so every sample keeps its gain. +/// Give `l` the gain envelope of `l` followed by `r`, which starts at or after where `l` ends, so +/// every sample keeps its gain. Call it before `l.length` grows to cover `r`. fn join_gain_env(l: &mut Clip, r: &Clip) { let cut = l.length; - let (end_db, start_db) = (l.env_db_at(cut - 1), r.env_db_at(0)); + let seam = r.start.saturating_sub(l.start); + let last = cut.saturating_sub(1); + let (end_db, start_db) = (l.env_db_at(last), r.env_db_at(0)); l.gain_env.retain(|&(at, _)| at < cut); - l.gain_env.extend(r.gain_env.iter().filter(|&&(at, _)| at >= 0).map(|&(at, db)| (at.saturating_add(cut), db))); + l.gain_env.extend(r.gain_env.iter().filter(|&&(at, _)| at >= 0).map(|&(at, db)| (at.saturating_add(seam), db))); // Pin both sides of the seam where the joined points would interpolate across it. - for (at, db) in [(cut, start_db), (cut - 1, end_db)] { + for (at, db) in [(seam, start_db), (last, end_db)] { if (l.env_db_at(at) - db).abs() > 1e-4 { let i = l.gain_env.partition_point(|&(t, _)| t < at); l.gain_env.insert(i, (at, db)); @@ -990,6 +993,23 @@ mod tests { } } + #[test] + fn heal_keeps_gain_envelopes_across_a_gap() { + let (mut s, t) = session_with_clip(); + clear_range(&mut s, t, Range::new(2000, 3000), false); + let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap(); + pl.clips[0].gain_env = vec![(0, 0.0), (999, -6.0)]; + pl.clips[1].gain_env = vec![(0, -12.0), (1999, -3.0)]; + let (l, r) = (pl.clips[0].clone(), pl.clips[1].clone()); + assert_eq!(heal(&mut s, t, Range::new(1500, 3500)), 1); + let healed = &s.track(t).unwrap().clips()[0]; + assert_eq!(healed.length, 4000); + for rel in (0..1000).chain(2000..4000) { + let want = if rel < 1000 { l.gain_at(rel) } else { r.gain_at(rel - 2000) }; + assert!((healed.gain_at(rel) - want).abs() < 1e-4, "sample {rel}: {} != {want}", healed.gain_at(rel)); + } + } + #[test] fn clear_slip_and_shuffle() { let (mut s, t) = session_with_clip(); From 045938728f5597112ae9ea2218083650e0b4c9af Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:19:09 +0000 Subject: [PATCH 42/90] Integration: #674 advance the source offset by delta/stretch on start trims and compare heal offsets in source time --- crates/engine/src/edit.rs | 2 +- crates/model/src/clip.rs | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 9863ab2c..698a6624 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -425,7 +425,7 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize { let gap = if contiguous { c.start.saturating_sub(prev.end()) } else { 0 }; let same = match (&prev.content, &c.content) { (ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => { - a == b && oa.saturating_add(prev.length).saturating_add(gap) == *ob + a == b && oa.saturating_add(source_len(prev.length.saturating_add(gap), prev.stretch)) == *ob } _ => false, }; diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index cda10e74..f5be30b2 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -256,8 +256,16 @@ impl Clip { let end = self.end(); let new_start = new_start.max(end.saturating_sub(MAX_POSITION)).clamp(-MAX_POSITION, MAX_POSITION).min(end.saturating_sub(1)); let delta = new_start.saturating_sub(self.start); - if let ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } = &mut self.content { - *offset = offset.saturating_add(delta); + // The mixer plays a stretched audio clip from `offset + rel / stretch`. + let stretch = if self.stretch.is_finite() && self.stretch > 0.0 { self.stretch } else { 1.0 }; + match &mut self.content { + ClipContent::Audio { offset, .. } if (stretch - 1.0).abs() >= 1e-9 => { + *offset = offset.saturating_add((delta as f64 / stretch).round() as Samples); + } + ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } => { + *offset = offset.saturating_add(delta); + } + ClipContent::Midi { .. } => {} } // MIDI note ticks are re-anchored by the engine, which knows the tempo map. self.length = self.length.saturating_sub(delta).clamp(1, MAX_POSITION); From 3fe53ee834f29ef6b4c1468fff3e85de6465da51 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:19:20 +0000 Subject: [PATCH 43/90] Integration: #595 pass BUNDLE_LIMITS in main's sources_sharing_one_file test --- crates/engine/src/io.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index b41eabbc..0618edd6 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -2256,7 +2256,7 @@ mod bundle_tests { use std::io::Write as _; let e = crate::demo::demo_engine(); let (bytes, _) = session_bundle(e.session()).unwrap(); - let (text, files) = read_bundle(&bytes).unwrap(); + let (text, files) = read_bundle(&bytes, BUNDLE_LIMITS).unwrap(); let mut s = Session::from_json(&text).unwrap(); s.sources[1].path = s.sources[0].path.clone(); let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); From 42f22369fba1befb52998ddbea8379c65ddbab20 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:41:56 +0000 Subject: [PATCH 44/90] Integration: build the batch's new integration tests into each crate's tests/it binary --- crates/audio-io/tests/{ => it}/float_headroom.rs | 0 crates/audio-io/tests/it/main.rs | 1 + crates/engine/tests/{ => it}/commit_multichannel.rs | 0 crates/engine/tests/it/main.rs | 3 +++ crates/engine/tests/{ => it}/split_into_mono.rs | 0 crates/engine/tests/{ => it}/track_name_collisions.rs | 0 crates/mix/tests/{ => it}/aux_routing_order.rs | 1 - crates/mix/tests/it/main.rs | 4 ++++ crates/mix/tests/{ => it}/strip_mute_automation.rs | 1 - crates/ui-egui/tests/it/main.rs | 2 ++ crates/ui-egui/tests/{ => it}/rtp_window.rs | 0 crates/ui-egui/tests/{ => it}/track_preset_state.rs | 2 +- 12 files changed, 11 insertions(+), 3 deletions(-) rename crates/audio-io/tests/{ => it}/float_headroom.rs (100%) rename crates/engine/tests/{ => it}/commit_multichannel.rs (100%) rename crates/engine/tests/{ => it}/split_into_mono.rs (100%) rename crates/engine/tests/{ => it}/track_name_collisions.rs (100%) rename crates/mix/tests/{ => it}/aux_routing_order.rs (99%) rename crates/mix/tests/{ => it}/strip_mute_automation.rs (99%) rename crates/ui-egui/tests/{ => it}/rtp_window.rs (100%) rename crates/ui-egui/tests/{ => it}/track_preset_state.rs (97%) diff --git a/crates/audio-io/tests/float_headroom.rs b/crates/audio-io/tests/it/float_headroom.rs similarity index 100% rename from crates/audio-io/tests/float_headroom.rs rename to crates/audio-io/tests/it/float_headroom.rs diff --git a/crates/audio-io/tests/it/main.rs b/crates/audio-io/tests/it/main.rs index 36b5129f..84b90aa1 100644 --- a/crates/audio-io/tests/it/main.rs +++ b/crates/audio-io/tests/it/main.rs @@ -1,6 +1,7 @@ //! Integration tests, built as one test binary: each file is a module. mod encode_format; +mod float_headroom; mod malformed; mod roundtrip; mod stream; diff --git a/crates/engine/tests/commit_multichannel.rs b/crates/engine/tests/it/commit_multichannel.rs similarity index 100% rename from crates/engine/tests/commit_multichannel.rs rename to crates/engine/tests/it/commit_multichannel.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index f021c292..611c7266 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -2,6 +2,7 @@ mod bounce; mod clap_e2e; +mod commit_multichannel; mod consolidate_selection; mod current_feet_frames_position; mod edit_modes; @@ -14,4 +15,6 @@ mod mix_required_params; mod random_edits; mod recording; mod session_transport; +mod split_into_mono; +mod track_name_collisions; mod trim_to_selection; diff --git a/crates/engine/tests/split_into_mono.rs b/crates/engine/tests/it/split_into_mono.rs similarity index 100% rename from crates/engine/tests/split_into_mono.rs rename to crates/engine/tests/it/split_into_mono.rs diff --git a/crates/engine/tests/track_name_collisions.rs b/crates/engine/tests/it/track_name_collisions.rs similarity index 100% rename from crates/engine/tests/track_name_collisions.rs rename to crates/engine/tests/it/track_name_collisions.rs diff --git a/crates/mix/tests/aux_routing_order.rs b/crates/mix/tests/it/aux_routing_order.rs similarity index 99% rename from crates/mix/tests/aux_routing_order.rs rename to crates/mix/tests/it/aux_routing_order.rs index 4c557fa7..4ea9d814 100644 --- a/crates/mix/tests/aux_routing_order.rs +++ b/crates/mix/tests/it/aux_routing_order.rs @@ -1,6 +1,5 @@ //! Aux and routing-folder dependencies preserve PCM beyond eight hops. Cyclic graphs keep //! the established whole-graph fallback, including its session-order-dependent audio. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use soundcraft_audio_io::AudioBuffer; use soundcraft_mix::{MixEngine, render_range}; diff --git a/crates/mix/tests/it/main.rs b/crates/mix/tests/it/main.rs index 77997334..de468bfe 100644 --- a/crates/mix/tests/it/main.rs +++ b/crates/mix/tests/it/main.rs @@ -1,6 +1,10 @@ //! Integration tests, built as one test binary: each file is a module. +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod aux_routing_order; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod bus_aux_interval; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod master_insert_latency; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod strip_mute_automation; diff --git a/crates/mix/tests/strip_mute_automation.rs b/crates/mix/tests/it/strip_mute_automation.rs similarity index 99% rename from crates/mix/tests/strip_mute_automation.rs rename to crates/mix/tests/it/strip_mute_automation.rs index fba6ab23..7e37ba89 100644 --- a/crates/mix/tests/strip_mute_automation.rs +++ b/crates/mix/tests/it/strip_mute_automation.rs @@ -1,5 +1,4 @@ //! Ordinary strip mute changes sample gates without splitting processor blocks. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use soundcraft_audio_io::AudioBuffer; use soundcraft_dsp::Plugin; use soundcraft_mix::{MixEngine, PreparedInstance, render_range}; diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index df71ff82..6214f0a0 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -35,6 +35,7 @@ mod plugin_window; mod pro_tools_keys; mod record_hint; mod rename_dialog; +mod rtp_window; mod sampler_window; mod save_as_replace; mod save_changes; @@ -49,6 +50,7 @@ mod toolbar_tempo; mod track_height; mod track_list; mod track_name_menu; +mod track_preset_state; mod trackpad; mod tracks_scroll_bar; mod waveform; diff --git a/crates/ui-egui/tests/rtp_window.rs b/crates/ui-egui/tests/it/rtp_window.rs similarity index 100% rename from crates/ui-egui/tests/rtp_window.rs rename to crates/ui-egui/tests/it/rtp_window.rs diff --git a/crates/ui-egui/tests/track_preset_state.rs b/crates/ui-egui/tests/it/track_preset_state.rs similarity index 97% rename from crates/ui-egui/tests/track_preset_state.rs rename to crates/ui-egui/tests/it/track_preset_state.rs index 155a7997..048f6f28 100644 --- a/crates/ui-egui/tests/track_preset_state.rs +++ b/crates/ui-egui/tests/it/track_preset_state.rs @@ -1,6 +1,6 @@ //! Track presets capture the hosted plugin's current state before writing the model. -#[path = "../../clap-host/tests/common/mod.rs"] +#[path = "../../../clap-host/tests/common/mod.rs"] mod common; use serde_json::{Value, json}; From 1cc7b40e6a63d63f94a360c1a28506548f813a9a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:51:27 +0000 Subject: [PATCH 45/90] Integration: #631 find the New Tracks name field by its text-input role --- crates/ui-egui/tests/it/new_tracks.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/it/new_tracks.rs b/crates/ui-egui/tests/it/new_tracks.rs index 3a6d37a6..ff4c97ce 100644 --- a/crates/ui-egui/tests/it/new_tracks.rs +++ b/crates/ui-egui/tests/it/new_tracks.rs @@ -1,9 +1,10 @@ //! New Tracks opens from the Tracks list's + button, and from a double-click below the last //! track (Edit) or right of the last strip (Mix). +use egui::accesskit::Role; use egui::{Pos2, pos2, vec2}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{Queryable, by}; use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; fn harness(tracks: usize) -> Harness<'static, SoundApp> { @@ -72,6 +73,7 @@ fn the_new_tracks_name_field_is_wide_enough_to_read_a_name() { h.get_by_label_contains("New Tracks").click(); h.run_steps(4); assert_eq!(h.state().dialogs.open_name(), Some("new_tracks")); - let width = h.get_by_value("Audio").rect().width(); + // The field's text run carries the same value as the field itself. + let width = h.get(by().role(Role::TextInput).value("Audio")).rect().width(); assert!(width >= 200.0, "the Name field is only {width}px wide"); } From c29510af86bcce1b5f767da4e080c9e568650565 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:56:33 +0000 Subject: [PATCH 46/90] Integration: regenerate docs/parity-checklist.md --- docs/parity-checklist.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 229b7835..de89a1c5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,7 +1,5 @@ # Parity report -> **Generated:** 2026-10-06 (last run of `cargo xtask parity`; the catalog and aliases are unchanged since) · **Target:** Avid Pro Tools Ultimate 2026.4.1 menu tree - Generated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`). It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry. UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel). From a05b1e7dc15351832811d79d2dbb75e83931083c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 00:47:02 -0700 Subject: [PATCH 47/90] Add a built-in Auto Pan plugin Auto Pan (`auto_pan`) pans the mono sum of a stereo track between the left and right channels with constant-power gains. The position is Manual scaled by (1 - Width) plus the modulator scaled by Width, and Path sets the direction. The modulator is an LFO with seven waveforms, or an envelope follower that moves the signal from the start of the path at the threshold to the end at full scale. The LFO rate is set in BPM, or follows the session tempo with Link to Tempo, and Duration (a 16th note to 4 bars) sets the length of one cycle at that rate. The LFO runs free, runs once, restarts at every Duration on the transport (Beat Clock; bar Durations count from the bar line), or runs one cycle each time the level rises through the threshold or the Manual Trigger switch changes. After an envelope trigger, the next one waits until the level has stayed below the threshold for the Release time. Output sets the level from -24 dB to +12 dB. On a mono track the insert has one channel, and only Output applies. The plugin parity doc, ROADMAP and the README plugin list name it, and the parity doc's built-in count now reads 25 processors + 3 instruments. --- README.md | 8 +- ROADMAP.md | 4 +- crates/dsp/src/plugins/auto_pan.rs | 355 +++++++++++++++++++++++++++++ crates/dsp/src/plugins/mod.rs | 3 + crates/dsp/tests/it/auto_pan.rs | 296 ++++++++++++++++++++++++ crates/dsp/tests/it/main.rs | 1 + docs/plugin-parity.md | 7 +- 7 files changed, 666 insertions(+), 8 deletions(-) create mode 100644 crates/dsp/src/plugins/auto_pan.rs create mode 100644 crates/dsp/tests/it/auto_pan.rs diff --git a/README.md b/README.md index c31fc87a..96dd2284 100644 --- a/README.md +++ b/README.md @@ -122,10 +122,10 @@ where things are. - **Automation:** volume, pan, mute, send and every plugin parameter; Write, Touch, Latch and Trim passes recorded live from the faders; thin, glide, coalesce and convert to clip gain. - **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer, - channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi, - rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither, - a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG across the - keyboard), all original. **CLAP plugins** load too. Any processor + channel strip, room and plate reverbs, mod delay, auto pan, chorus, flanger, phaser, saturator, + lo-fi, rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, + dither, a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG + across the keyboard), all original. **CLAP plugins** load too. Any processor runs offline as AudioSuite, and user presets save per plugin. - **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files, diff --git a/ROADMAP.md b/ROADMAP.md index f214e1e0..a4821a01 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-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-24 · **Change:** trivial (Auto Pan plugin) · **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-24:** A built-in Auto Pan plugin pans the mono sum of a stereo track between left and right. An LFO with seven waveforms moves it at a rate in BPM or at the session tempo, running free, once, in step with the transport, or when the level crosses a threshold or a manual trigger changes; an envelope follower can move it by level instead. Width, Manual and Path set the field, and Output sets the level. No readiness percentage change. - **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change. - **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-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change. @@ -161,6 +162,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Progress log: Auto Pan plugin; readiness estimates unchanged | | 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged | | 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged | | 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged | diff --git a/crates/dsp/src/plugins/auto_pan.rs b/crates/dsp/src/plugins/auto_pan.rs new file mode 100644 index 00000000..f5a2f3ad --- /dev/null +++ b/crates/dsp/src/plugins/auto_pan.rs @@ -0,0 +1,355 @@ +//! Auto Pan: moves a signal between the left and right channels with an LFO or an envelope. + +use std::f32::consts::{FRAC_PI_4, PI, SQRT_2, TAU}; + +use crate::params::{Params, choice, lin, log, param_plumbing, toggle}; +use crate::util::{Smoother, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef}; +use crate::{Category, ParamInfo, Plugin, PluginInfo, Transport, Unit, db_to_gain, gain_to_db}; + +const SOURCE_CHOICES: &[&str] = &["LFO", "Envelope"]; +const PATH_CHOICES: &[&str] = &["Left to Right", "Right to Left"]; +const WAVEFORM_CHOICES: &[&str] = &["Sine", "Triangle", "Square", "Saw Up", "Saw Down", "Half Sine", "4 Step Triangle"]; +const TRIGGER_CHOICES: &[&str] = &["Free", "Single", "Beat Clock", "Envelope", "Manual"]; +const DURATION_CHOICES: &[&str] = &["16th Note", "8th Note", "1 Beat", "2 Beats", "1 Bar", "2 Bars", "4 Bars"]; + +const SOURCE_ENVELOPE: usize = 1; +const PATH_RIGHT_TO_LEFT: usize = 1; +const TRIGGER_FREE: usize = 0; +const TRIGGER_SINGLE: usize = 1; +const TRIGGER_BEAT_CLOCK: usize = 2; +const TRIGGER_ENVELOPE: usize = 3; +const TRIGGER_MANUAL: usize = 4; + +static AUTOPAN_PARAMS: [ParamInfo; 14] = [ + lin("output", "Output", -24.0, 12.0, 0.0, Unit::Db), + lin("width", "Width", 0.0, 100.0, 100.0, Unit::Percent), + lin("manual", "Manual", -100.0, 100.0, 0.0, Unit::Percent), + choice("source", "Source", SOURCE_CHOICES, 0), + choice("path", "Path", PATH_CHOICES, 0), + log("rate", "Rate (BPM)", 1.0, 960.0, 120.0, Unit::None), + choice("waveform", "Waveform", WAVEFORM_CHOICES, 0), + choice("trigger", "Trigger", TRIGGER_CHOICES, 0), + toggle("retrigger", "Manual Trigger", false), + toggle("link_tempo", "Link to Tempo", false), + choice("duration", "Duration", DURATION_CHOICES, 4), + lin("threshold", "Threshold", -60.0, -1.0, -30.0, Unit::Db), + log("attack", "Attack", 0.1, 100.0, 5.0, Unit::Ms), + log("release", "Release", 5.0, 2000.0, 150.0, Unit::Ms), +]; + +pub static AUTOPAN_INFO: PluginInfo = PluginInfo { + id: "auto_pan", + name: "Auto Pan", + short_name: "Auto Pan", + category: Category::Modulation, + params: &AUTOPAN_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// The LFO shape at `t` in 0..=1 of a cycle, from -1 (the start of the path) to 1 (its end). +fn wave_at(waveform: usize, t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + let tri = if t < 0.5 { 4.0 * t - 1.0 } else { 3.0 - 4.0 * t }; + match waveform { + 1 => tri, + 2 => { + if t < 0.5 { + -1.0 + } else { + 1.0 + } + } + 3 => 2.0 * t - 1.0, + 4 => 1.0 - 2.0 * t, + 5 => 2.0 * (PI * t).sin() - 1.0, + 6 => { + let step = ((tri + 1.0) * 2.0).floor().clamp(0.0, 3.0); + step * 2.0 / 3.0 - 1.0 + } + _ => -(TAU * t).cos(), + } +} + +/// Length of one Duration choice in quarter notes, for a bar of `bar` quarter notes. +fn duration_quarters(duration: usize, bar: f64) -> f64 { + match duration { + 0 => 0.25, + 1 => 0.5, + 2 => 1.0, + 3 => 2.0, + 5 => 2.0 * bar, + 6 => 4.0 * bar, + _ => bar, + } +} + +/// Number of bars in a bar-based Duration choice. +fn duration_bars(duration: usize) -> Option { + match duration { + 4 => Some(1), + 5 => Some(2), + 6 => Some(4), + _ => None, + } +} + +/// Pans the mono sum of the first two channels between them. The pan position is +/// `manual · (1 - width) + modulator · width`, where the modulator is the LFO or the envelope +/// follower in -1..=1. A signal that is the same on both channels keeps its level at the center. +pub struct AutoPan { + p: Params, + sr: f32, + ch: usize, + transport: Transport, + /// Samples processed since the last `set_transport`, for hosts that call it less than once a block. + since_transport: u64, + was_playing: bool, + /// LFO phase in cycles, in `f64`: at the slowest rates an `f32` step is below half an ULP. + phase: f64, + /// A Single, Envelope or Manual trigger has finished its cycle and holds the last position. + holding: bool, + env: f32, + armed: bool, + /// Samples the envelope has spent below the threshold since the last Envelope trigger. The + /// trigger re-arms once this reaches the Release time, so a longer Release spaces triggers out. + below: u64, + last_retrigger: bool, + last_trigger: usize, + output: Smoother, + width: Smoother, + manual: Smoother, + /// Short declicking smoother on the pan position. + position: Smoother, + /// The next sample starts the position at its target instead of gliding to it. + snap_position: bool, +} + +impl Default for AutoPan { + fn default() -> Self { + Self::new() + } +} + +impl AutoPan { + pub fn new() -> Self { + let mut a = Self { + p: Params::new(&AUTOPAN_PARAMS), + sr: 48_000.0, + ch: 0, + transport: Transport::default(), + since_transport: 0, + was_playing: false, + phase: 0.0, + holding: false, + env: 0.0, + armed: true, + below: 0, + last_retrigger: false, + last_trigger: TRIGGER_FREE, + output: Smoother::new(1.0), + width: Smoother::new(1.0), + manual: Smoother::new(0.0), + position: Smoother::new(0.0), + snap_position: true, + }; + a.update(); + a.restart(); + a + } + + fn update(&mut self) { + self.output.set(db_to_gain(self.p.v(0))); + self.width.set(self.p.v(1) / 100.0); + self.manual.set(self.p.v(2) / 100.0); + let trigger = self.p.choice(7); + if trigger != self.last_trigger { + self.last_trigger = trigger; + self.restart(); + } + let retrigger = self.p.on(8); + if retrigger != self.last_retrigger { + self.last_retrigger = retrigger; + if trigger == TRIGGER_MANUAL { + self.phase = 0.0; + self.holding = false; + } + } + } + + /// Puts the LFO back at the start of the path. Single runs one cycle from there; Envelope and + /// Manual wait at the start for their first trigger. + fn restart(&mut self) { + self.phase = 0.0; + self.holding = matches!(self.p.choice(7), TRIGGER_ENVELOPE | TRIGGER_MANUAL); + self.armed = true; + self.below = 0; + } + + /// LFO frequency in cycles per second. + fn lfo_hz(&self) -> f64 { + let tempo = if self.p.on(9) && self.transport.tempo.is_finite() && self.transport.tempo > 0.0 { + self.transport.tempo + } else { + f64::from(self.p.v(5)) + }; + tempo / 60.0 / duration_quarters(self.p.choice(10), self.bar_quarters()) + } + + fn bar_quarters(&self) -> f64 { + let (num, den) = (self.transport.numerator, self.transport.denominator); + if num == 0 || den == 0 { 4.0 } else { (f64::from(num) * 4.0 / f64::from(den)).clamp(0.25, 64.0) } + } + + /// The LFO phase at `n` samples into the block when Beat Clock follows a playing transport. + /// Beat Clock restarts the LFO at the start of every Duration and runs it at Rate in between, + /// so a Rate slower than the session tempo plays only part of each cycle before the restart. + fn beat_clock_phase(&self, n: usize, hz: f64) -> Option { + let t = &self.transport; + if !(t.playing && t.tempo.is_finite() && t.tempo > 0.0 && t.quarters.is_finite()) { + return None; + } + let duration = self.p.choice(10); + let bar = self.bar_quarters(); + let dur = duration_quarters(duration, bar); + let elapsed = self.since_transport.saturating_add(n as u64) as f64; + let quarters = t.quarters + elapsed * t.tempo / 60.0 / f64::from(self.sr); + // Bar-based cycles start on the bar line, so a change of time signature does not shift them; + // note and beat cycles follow the song position in quarter notes. + let into = match duration_bars(duration) { + Some(bars) => { + let in_bar = if t.bar_start.is_finite() { quarters - t.bar_start } else { quarters }; + ((t.bar.rem_euclid(bars) as f64 + in_bar / bar) / bars as f64).rem_euclid(1.0) + } + None => (quarters / dur).rem_euclid(1.0), + }; + // Cycles of the LFO per Duration: 1 when its rate matches the session tempo. + let ratio = hz * dur * 60.0 / t.tempo; + Some((into * ratio).rem_euclid(1.0)) + } +} + +impl Plugin for AutoPan { + param_plumbing!(AUTOPAN_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.ch = sane_channels(channels); + for s in [&mut self.output, &mut self.width, &mut self.manual] { + s.set_time(20.0, self.sr); + } + self.position.set_time(2.0, self.sr); + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.restart(); + self.env = 0.0; + for s in [&mut self.output, &mut self.width, &mut self.manual] { + s.snap(); + } + self.snap_position = true; + } + + fn set_transport(&mut self, transport: &Transport) { + self.transport = *transport; + self.since_transport = 0; + if transport.playing && !self.was_playing && self.p.choice(7) == TRIGGER_SINGLE { + self.restart(); + } + self.was_playing = transport.playing; + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.ch.min(io.len()); + let frames = frames_in(io, frames, ch); + let hz = self.lfo_hz(); + let inc = if hz.is_finite() { (hz / f64::from(self.sr)).clamp(0.0, 0.5) } else { 0.0 }; + let waveform = self.p.choice(6); + let trigger = self.p.choice(7); + let envelope_source = self.p.choice(3) == SOURCE_ENVELOPE; + let sign = if self.p.choice(4) == PATH_RIGHT_TO_LEFT { -1.0 } else { 1.0 }; + let threshold_db = self.p.v(11); + let threshold = db_to_gain(threshold_db); + let attack = time_coef(self.p.v(12), self.sr); + let release = time_coef(self.p.v(13), self.sr); + let release_samples = ms_to_samples(self.p.v(13), self.sr) as u64; + for n in 0..frames { + let sample = |io: &[Vec], c: usize| io.get(c).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0); + let mono = if ch >= 2 { 0.5 * (sample(io, 0) + sample(io, 1)) } else { sample(io, 0) }; + let level = mono.abs(); + let k = if level > self.env { attack } else { release }; + self.env = level + (self.env - level) * k; + if self.env < 1.0e-12 { + self.env = 0.0; + } + if trigger == TRIGGER_ENVELOPE { + if self.armed && self.env >= threshold { + self.armed = false; + self.phase = 0.0; + self.holding = false; + self.below = 0; + } else if !self.armed { + if self.env < threshold { + self.below = self.below.saturating_add(1); + self.armed = self.below >= release_samples; + } else { + self.below = 0; + } + } + } + let lfo_phase = match (trigger == TRIGGER_BEAT_CLOCK).then(|| self.beat_clock_phase(n, hz)).flatten() { + Some(phase) => phase, + None if self.holding => self.phase, + None => { + let current = self.phase; + self.phase += inc; + if self.phase >= 1.0 { + if matches!(trigger, TRIGGER_FREE | TRIGGER_BEAT_CLOCK) { + self.phase -= 1.0; + } else { + self.phase = 1.0; + self.holding = true; + } + } + current + } + }; + let modulator = if envelope_source { + let above = (gain_to_db(self.env) - threshold_db) / -threshold_db; + 2.0 * above.clamp(0.0, 1.0) - 1.0 + } else { + wave_at(waveform, lfo_phase as f32) + }; + let width = self.width.next(); + let manual = self.manual.next(); + self.position.set(manual * (1.0 - width) + sign * modulator * width); + if self.snap_position { + self.snap_position = false; + self.position.snap(); + } + let position = self.position.next(); + let out = self.output.next(); + if ch >= 2 { + // Constant power: sin² + cos² = 1 across the field. + let theta = (position.clamp(-1.0, 1.0) + 1.0) * FRAC_PI_4; + let (l, r) = (theta.cos().max(0.0), theta.sin().max(0.0)); + if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) { + *x = mono * l * SQRT_2 * out; + } + if let Some(x) = io.get_mut(1).and_then(|b| b.get_mut(n)) { + *x = mono * r * SQRT_2 * out; + } + for c in 2..ch { + if let Some(x) = io.get_mut(c).and_then(|b| b.get_mut(n)) { + *x = if x.is_finite() { *x * out } else { 0.0 }; + } + } + } else if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) { + *x = mono * out; + } + } + self.since_transport = self.since_transport.saturating_add(frames as u64); + } +} diff --git a/crates/dsp/src/plugins/mod.rs b/crates/dsp/src/plugins/mod.rs index aef3e39d..52d6683a 100644 --- a/crates/dsp/src/plugins/mod.rs +++ b/crates/dsp/src/plugins/mod.rs @@ -1,5 +1,6 @@ //! The built-in plugins and their registry. +pub mod auto_pan; pub mod drums; pub mod dynamics; pub mod eq; @@ -26,6 +27,7 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[ &reverb::ROOM_INFO, &reverb::PLATE_INFO, &modulation::DELAY_INFO, + &auto_pan::AUTOPAN_INFO, &modulation::CHORUS_INFO, &modulation::FLANGER_INFO, &modulation::PHASER_INFO, @@ -58,6 +60,7 @@ pub(crate) fn instantiate(id: &str) -> Option> { "room_reverb" => Box::new(reverb::Reverb::room()), "plate_reverb" => Box::new(reverb::Reverb::plate()), "mod_delay" => Box::new(modulation::ModDelay::new()), + "auto_pan" => Box::new(auto_pan::AutoPan::new()), "chorus" => Box::new(modulation::ModFx::chorus()), "flanger" => Box::new(modulation::ModFx::flanger()), "phaser" => Box::new(modulation::Phaser::new()), diff --git a/crates/dsp/tests/it/auto_pan.rs b/crates/dsp/tests/it/auto_pan.rs new file mode 100644 index 00000000..59fb3d12 --- /dev/null +++ b/crates/dsp/tests/it/auto_pan.rs @@ -0,0 +1,296 @@ +use std::f32::consts::PI; + +use soundcraft_dsp::{Plugin, Transport, create, db_to_gain}; + +const SR: f32 = 48_000.0; +const BLOCK: usize = 512; + +fn auto_pan(params: &[(&str, f32)]) -> Box { + let mut p = create("auto_pan").unwrap(); + p.prepare(SR, BLOCK, 2); + for (id, v) in params { + assert!(p.set_param(id, *v), "{id}"); + } + p.reset(); + p +} + +/// Runs `seconds` of a constant `level` on both channels in host-sized blocks. +fn run_level(p: &mut dyn Plugin, level: f32, seconds: f32) -> Vec> { + let len = (seconds * SR) as usize; + let mut out = vec![Vec::new(), Vec::new()]; + let mut pos = 0; + while pos < len { + let n = BLOCK.min(len - pos); + let mut buf = vec![vec![level; n]; 2]; + p.process(&mut buf, n); + for c in 0..2 { + out[c].extend_from_slice(&buf[c]); + } + pos += n; + } + out +} + +/// Pan position (-1 left ..= 1 right) of frame `i`, from the constant-power gains. +fn position(out: &[Vec], i: usize) -> f32 { + 4.0 / PI * out[1][i].atan2(out[0][i]) - 1.0 +} + +fn at(seconds: f32) -> usize { + (seconds * SR) as usize +} + +fn assert_near(got: f32, want: f32, tol: f32, what: &str) { + assert!((got - want).abs() <= tol, "{what}: got {got}, want {want}"); +} + +#[test] +fn auto_pan_manual_places_the_signal_when_width_is_zero() { + let mut p = auto_pan(&[("width", 0.0), ("manual", -100.0)]); + let out = run_level(p.as_mut(), 0.5, 0.1); + assert_near(out[0][at(0.05)], 0.5 * 2f32.sqrt(), 1e-4, "hard left, left channel"); + assert_near(out[1][at(0.05)], 0.0, 1e-4, "hard left, right channel"); + + let mut p = auto_pan(&[("width", 0.0), ("manual", 50.0)]); + let out = run_level(p.as_mut(), 0.5, 0.1); + assert_near(position(&out, at(0.05)), 0.5, 1e-3, "manual 50 %"); + + let mut p = auto_pan(&[("width", 0.0)]); + let out = run_level(p.as_mut(), 0.5, 0.1); + assert_near(out[0][at(0.05)], 0.5, 1e-4, "center keeps the level on the left"); + assert_near(out[1][at(0.05)], 0.5, 1e-4, "center keeps the level on the right"); +} + +#[test] +fn auto_pan_lfo_moves_from_left_to_right_and_back() { + // 60 BPM with a Duration of 1 beat is one cycle per second. + let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0)]); + let out = run_level(p.as_mut(), 0.5, 1.0); + for (t, want) in [(0.0, -1.0), (0.25, 0.0), (0.5, 1.0), (0.75, 0.0)] { + assert_near(position(&out, at(t)), want, 0.05, &format!("left to right at {t} s")); + } + + let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("path", 1.0)]); + let out = run_level(p.as_mut(), 0.5, 1.0); + assert_near(position(&out, at(0.01)), 1.0, 0.05, "right to left starts on the right"); + assert_near(position(&out, at(0.5)), -1.0, 0.05, "right to left at 0.5 s"); + + // Width 50 % sweeps half the field, and Manual moves that half to the right. + let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("width", 50.0), ("manual", 50.0)]); + let out = run_level(p.as_mut(), 0.5, 1.0); + assert_near(position(&out, at(0.0)), -0.25, 0.05, "width 50 % at the start"); + assert_near(position(&out, at(0.5)), 0.75, 0.05, "width 50 % at the far end"); +} + +#[test] +fn auto_pan_keeps_moving_at_the_slowest_rate() { + // Rate 1 BPM over 4 bars of 4/4 is one cycle in 960 s; 600 s in, a saw is 62.5 % across. + let mut p = auto_pan(&[("rate", 1.0), ("duration", 6.0), ("waveform", 3.0)]); + let mut buf = vec![vec![0.5f32; BLOCK]; 2]; + for _ in 0..(600.0 * SR) as usize / BLOCK { + buf.iter_mut().for_each(|c| c.fill(0.5)); + p.process(&mut buf, BLOCK); + } + assert_near(position(&buf, BLOCK - 1), 0.25, 0.01, "600 s into a 960 s cycle"); +} + +#[test] +fn auto_pan_waveforms_take_their_shapes() { + let shape = |waveform: f32, t: f32| { + let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("waveform", waveform)]); + let out = run_level(p.as_mut(), 0.5, 1.0); + position(&out, at(t)) + }; + assert_near(shape(1.0, 0.25), 0.0, 0.05, "triangle at a quarter cycle"); + assert_near(shape(1.0, 0.125), -0.5, 0.05, "triangle at an eighth of a cycle"); + assert_near(shape(2.0, 0.2), -1.0, 0.02, "square in its first half"); + assert_near(shape(2.0, 0.7), 1.0, 0.02, "square in its second half"); + assert_near(shape(3.0, 0.25), -0.5, 0.05, "saw up"); + assert_near(shape(4.0, 0.25), 0.5, 0.05, "saw down"); + assert_near(shape(5.0, 1.0 / 6.0), 0.0, 0.05, "half sine reaches the center at a sixth of a cycle"); + assert_near(shape(6.0, 0.1), -1.0, 0.02, "4 step triangle, first step"); + assert_near(shape(6.0, 0.2), -1.0 / 3.0, 0.02, "4 step triangle, second step"); + assert_near(shape(6.0, 0.45), 1.0, 0.02, "4 step triangle, top step"); +} + +#[test] +fn auto_pan_rate_follows_the_session_tempo_and_duration() { + // Linked to a 120 BPM session with a Duration of 1 beat: two cycles per second. + let mut p = auto_pan(&[("link_tempo", 1.0), ("rate", 30.0), ("duration", 2.0)]); + p.set_transport(&Transport { tempo: 120.0, numerator: 4, denominator: 4, ..Transport::default() }); + let out = run_level(p.as_mut(), 0.5, 0.5); + assert_near(position(&out, at(0.25)), 1.0, 0.05, "linked to 120 BPM, 1 beat"); + + // Not linked: Rate 120 BPM and the default Duration of 1 bar (4/4) is one cycle in 2 s. + let mut p = auto_pan(&[]); + p.set_transport(&Transport { tempo: 60.0, numerator: 4, denominator: 4, ..Transport::default() }); + let out = run_level(p.as_mut(), 0.5, 1.5); + assert_near(position(&out, at(1.0)), 1.0, 0.05, "Rate 120 BPM, 1 bar"); + + // A 3/4 bar at 120 BPM lasts 1.5 s. + let mut p = auto_pan(&[("link_tempo", 1.0)]); + p.set_transport(&Transport { tempo: 120.0, numerator: 3, denominator: 4, ..Transport::default() }); + let out = run_level(p.as_mut(), 0.5, 1.0); + assert_near(position(&out, at(0.75)), 1.0, 0.05, "1 bar of 3/4"); +} + +#[test] +fn auto_pan_beat_clock_follows_the_transport_position() { + let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("duration", 2.0)]); + // Half a beat into the song, the LFO is half way through its cycle. + p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 7.5, ..Transport::default() }); + let out = run_level(p.as_mut(), 0.5, 0.01); + assert_near(position(&out, 0), 1.0, 0.05, "half a beat in"); + + let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("duration", 2.0)]); + p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 3.0, ..Transport::default() }); + let out = run_level(p.as_mut(), 0.5, 0.2); + assert_near(position(&out, 0), -1.0, 0.05, "on the beat"); + assert_near(position(&out, at(0.125)), 0.0, 0.05, "a quarter of a beat later"); + + // Not linked, Rate 60 BPM in a 120 BPM session: the LFO restarts on every beat (0.5 s) and + // runs at 1 Hz in between, so it reaches the right and then starts again from the left. + let mut p = auto_pan(&[("trigger", 2.0), ("rate", 60.0), ("duration", 2.0)]); + p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 3.0, ..Transport::default() }); + let out = run_level(p.as_mut(), 0.5, 1.0); + assert_near(position(&out, at(0.25)), 0.0, 0.05, "a quarter cycle at 1 Hz"); + assert_near(position(&out, at(0.49)), 1.0, 0.05, "on the right at the end of the beat"); + assert_near(position(&out, at(0.51)), -1.0, 0.05, "restarted on the next beat"); + assert_near(position(&out, at(0.75)), 0.0, 0.05, "a quarter cycle after the restart"); + + // 2 Beats in 3/4, with the host updating the transport every block: the cycle runs on across + // the bar line at quarter 3 instead of restarting there. + let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0), ("duration", 3.0)]); + let mut buf = vec![vec![0.5f32; 100]; 2]; + let mut positions = Vec::new(); + for block in 0..480 { + let quarters = 2.0 + block as f64 * 100.0 * 2.0 / f64::from(SR); + let bar = (quarters / 3.0).floor(); + p.set_transport(&Transport { + playing: true, + tempo: 120.0, + numerator: 3, + denominator: 4, + quarters, + bar_start: bar * 3.0, + bar: bar as i64, + ..Transport::default() + }); + buf.iter_mut().for_each(|c| c.fill(0.5)); + p.process(&mut buf, 100); + positions.push((quarters, position(&buf, 0))); + } + for (q, want) in [(2.5, -0.5), (3.0, 0.0), (3.5, 0.5)] { + let got = positions.iter().find(|(quarters, _)| (quarters - q).abs() < 1e-9).map(|(_, pos)| *pos).unwrap(); + assert_near(got, want, 0.02, &format!("2 beats in 3/4 at quarter {q}")); + } + + // Bar-based cycles start on the bar line after a change from 4/4 to 3/4 at quarter 4. + let three_four = |quarters: f64| Transport { + playing: true, + tempo: 120.0, + numerator: 3, + denominator: 4, + quarters, + bar_start: 4.0, + bar: 1, + ..Transport::default() + }; + let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0)]); + p.set_transport(&three_four(4.0)); + let out = run_level(p.as_mut(), 0.5, 0.01); + assert_near(position(&out, 0), -1.0, 0.02, "1 bar starts on the 3/4 bar line"); + let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0), ("duration", 5.0)]); + p.set_transport(&three_four(5.5)); + let out = run_level(p.as_mut(), 0.5, 0.01); + assert_near(position(&out, 0), 0.5, 0.02, "half way through the second bar of 2 bars"); +} + +#[test] +fn auto_pan_single_runs_one_cycle_and_holds() { + let mut p = auto_pan(&[("trigger", 1.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0)]); + let out = run_level(p.as_mut(), 0.5, 2.0); + assert_near(position(&out, at(0.5)), 0.0, 0.05, "half way through the one cycle"); + assert_near(position(&out, at(1.5)), 1.0, 0.02, "holds at the end of the path"); + assert_near(position(&out, at(1.99)), 1.0, 0.02, "still holding"); + + // Starting the transport runs the cycle again. + p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, ..Transport::default() }); + let out = run_level(p.as_mut(), 0.5, 0.5); + assert_near(position(&out, at(0.25)), -0.5, 0.05, "restarted when playback starts"); +} + +#[test] +fn auto_pan_envelope_source_pans_by_level() { + let settled = |level: f32| { + let mut p = auto_pan(&[("source", 1.0), ("threshold", -30.0)]); + let out = run_level(p.as_mut(), level, 0.5); + position(&out, at(0.45)) + }; + assert_near(settled(db_to_gain(-40.0)), -1.0, 0.02, "below the threshold"); + assert_near(settled(db_to_gain(-15.0)), 0.0, 0.05, "half way to full scale"); + assert_near(settled(1.0), 1.0, 0.02, "full scale"); +} + +#[test] +fn auto_pan_envelope_and_manual_triggers_start_a_cycle() { + let mut p = auto_pan(&[("trigger", 3.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0), ("threshold", -30.0)]); + let quiet = run_level(p.as_mut(), db_to_gain(-40.0), 0.5); + assert_near(position(&quiet, at(0.4)), -1.0, 0.02, "waits at the start below the threshold"); + let loud = run_level(p.as_mut(), 1.0, 1.5); + assert_near(position(&loud, at(0.25)), -0.5, 0.05, "a quarter cycle after the level crosses the threshold"); + assert_near(position(&loud, at(1.4)), 1.0, 0.02, "holds after one cycle while the level stays up"); + + let mut p = auto_pan(&[("trigger", 4.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0)]); + let before = run_level(p.as_mut(), 0.5, 0.5); + assert_near(position(&before, at(0.4)), -1.0, 0.02, "waits for the manual trigger"); + assert!(p.set_param("retrigger", 1.0)); + let after = run_level(p.as_mut(), 0.5, 0.5); + assert_near(position(&after, at(0.25)), -0.5, 0.05, "a quarter cycle after switching the trigger on"); + let held = run_level(p.as_mut(), 0.5, 1.0); + assert!(p.set_param("retrigger", 0.0)); + let again = run_level(p.as_mut(), 0.5, 0.5); + assert_near(position(&held, at(0.9)), 1.0, 0.02, "holds after one cycle"); + assert_near(position(&again, at(0.25)), -0.5, 0.05, "switching the trigger off starts another cycle"); +} + +#[test] +fn auto_pan_envelope_trigger_waits_for_the_release() { + let mut p = auto_pan(&[ + ("trigger", 3.0), + ("rate", 60.0), + ("duration", 2.0), + ("waveform", 3.0), + ("threshold", -30.0), + ("attack", 0.1), + ("release", 100.0), + ]); + let quiet = db_to_gain(-40.0); + let first = run_level(p.as_mut(), 1.0, 1.5); + assert_near(position(&first, at(1.4)), 1.0, 0.02, "the first cycle ends and holds"); + // The envelope falls below the threshold about 0.38 s into the quiet part, so a 0.42 s dip + // leaves it below for less than the 100 ms Release. + run_level(p.as_mut(), quiet, 0.42); + let short = run_level(p.as_mut(), 1.0, 0.5); + assert_near(position(&short, at(0.25)), 1.0, 0.02, "no trigger before the release is over"); + run_level(p.as_mut(), quiet, 1.0); + let long = run_level(p.as_mut(), 1.0, 0.5); + assert_near(position(&long, at(0.25)), -0.5, 0.05, "a trigger once the release is over"); +} + +#[test] +fn auto_pan_output_gain_and_mono_tracks() { + let mut p = auto_pan(&[("width", 0.0), ("output", 6.0)]); + let out = run_level(p.as_mut(), 0.5, 0.1); + assert_near(out[0][at(0.05)], 0.5 * db_to_gain(6.0), 1e-4, "+6 dB at the center"); + + // A mono insert has nothing to pan to; Output still applies. + let mut p = create("auto_pan").unwrap(); + p.prepare(SR, BLOCK, 1); + assert!(p.set_param("output", -6.0)); + p.reset(); + let mut buf = vec![vec![0.5f32; BLOCK]]; + p.process(&mut buf, BLOCK); + assert_near(buf[0][BLOCK - 1], 0.5 * db_to_gain(-6.0), 1e-4, "mono output"); +} diff --git a/crates/dsp/tests/it/main.rs b/crates/dsp/tests/it/main.rs index c214cd3e..d33661a7 100644 --- a/crates/dsp/tests/it/main.rs +++ b/crates/dsp/tests/it/main.rs @@ -1,5 +1,6 @@ //! Integration tests, built as one test binary: each file is a module. +mod auto_pan; mod fuzz; mod offline_meters; mod offline_normalize; diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index 6aa796b5..96e5afea 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 (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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Auto Pan; previously trivial: Sampler ping-pong loop phase (#463); 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 @@ -17,12 +17,12 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories. | Dynamics | compressor/limiter, expander/gate, de-esser, maximizer, vintage compressors, multiband | `compressor`, `expander_gate`, `de_esser`, `maximizer` | Vintage/opto models, multiband, sidechain input (no key input anywhere) | | Reverb | algorithmic room/plate/hall; convolution (paid tier) | `room_reverb`, `plate_reverb` | Hall/chamber, convolution, early-reflection editor | | Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth | -| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `chorus`, `flanger`, `phaser` | Tremolo, auto-pan, ring modulation | +| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `auto_pan`, `chorus`, `flanger`, `phaser`. Auto Pan pans the mono sum of a stereo track with an LFO (free, single, beat clock, envelope or manual trigger; rate in BPM or linked to the session tempo) or an envelope follower. | Tremolo, ring modulation; Auto Pan on a mono track (inserts on a mono track have one channel), LCR, quad and 5.0 outputs with Angle, Place and Spread, circular surround paths, output meters, side-chain input | | 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). 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** | | +| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **25 processors + 3 instruments** | | AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in `crates/ui-egui/src/menus.rs`), so the AudioSuite menu is 100 % present. Depth: no preview, @@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Auto Pan: LFO and envelope panning of stereo tracks; built-in count corrected to 25 processors + 3 instruments | | 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 | From 33c979cd347b0fe6e2973e03a6c4e45e6f382b6e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 01:06:32 -0700 Subject: [PATCH 48/90] List the sampler in the plugin parity Instruments row The Instruments row now names `sampler` beside `subtractive_synth` and `drum_synth`, matching the 3 instruments in the Total row, and its Missing column no longer lists the Sampler, which #108 added. --- docs/plugin-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index c055a15a..1865de34 100644 --- a/docs/plugin-parity.md +++ b/docs/plugin-parity.md @@ -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). 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 | +| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth`, `sampler`; `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). | Piano, organ, wavetable, content library | | **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **25 processors + 3 instruments** | | AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in From 93800e0764ca62435ee66a4770f355df954b7fcd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:30:47 -0400 Subject: [PATCH 49/90] refactor(video): use method paths instead of closures for first_chunk (S1612) (#1012) Co-authored-by: Claude Sonnet 5.5 --- crates/video/src/h264/deblock.rs | 6 +++--- crates/video/src/h264/picture.rs | 2 +- crates/video/src/h264/slicedec.rs | 8 ++++---- crates/video/src/h264/transform.rs | 4 ++-- crates/video/src/prores/decode.rs | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/video/src/h264/deblock.rs b/crates/video/src/h264/deblock.rs index 887c93e5..12ba8da5 100644 --- a/crates/video/src/h264/deblock.rs +++ b/crates/video/src/h264/deblock.rs @@ -232,7 +232,7 @@ fn filter_vertical(pix: &mut [u8], stride: usize, x: usize, y: u let mut w = [0u64; N]; for (i, w) in w.iter_mut().enumerate() { let o = (y + i) * stride + x - 4; - let Some(&b) = pix.get(o..).and_then(|p| p.first_chunk::<8>()) else { + let Some(&b) = pix.get(o..).and_then(<[_]>::first_chunk::<8>) else { return; }; *w = u64::from_le_bytes(b); @@ -259,7 +259,7 @@ fn filter_horizontal(pix: &mut [u8], stride: usize, x: usize, y: let base = (y - 4) * stride + x; let mut s = [[0i16; N]; 8]; for (k, row) in s.iter_mut().enumerate() { - let Some(src) = pix.get(base + k * stride..).and_then(|p| p.first_chunk::()) else { + let Some(src) = pix.get(base + k * stride..).and_then(<[_]>::first_chunk::) else { return; }; for i in 0..N { @@ -269,7 +269,7 @@ fn filter_horizontal(pix: &mut [u8], stride: usize, x: usize, y: filter_lanes(&mut s, ep, chroma); let (k0, k1) = if chroma { (3, 5) } else { (1, 7) }; for (k, row) in s.iter().enumerate().take(k1).skip(k0) { - let Some(dst) = pix.get_mut(base + k * stride..).and_then(|p| p.first_chunk_mut::()) else { + let Some(dst) = pix.get_mut(base + k * stride..).and_then(<[_]>::first_chunk_mut::) else { return; }; for i in 0..N { diff --git a/crates/video/src/h264/picture.rs b/crates/video/src/h264/picture.rs index 5e52f074..d1cbe225 100644 --- a/crates/video/src/h264/picture.rs +++ b/crates/video/src/h264/picture.rs @@ -319,7 +319,7 @@ fn plane_allocator() -> fn(usize) -> Vec { fn copy_line(line: &[u8], x0: i32, out: &mut [u8], w: usize) { if w <= N && x0 >= 0 - && let Some(src) = line.get(x0 as usize..).and_then(|l| l.first_chunk::()) + && let Some(src) = line.get(x0 as usize..).and_then(<[_]>::first_chunk::) && let Some(dst) = out.first_chunk_mut::() { *dst = *src; diff --git a/crates/video/src/h264/slicedec.rs b/crates/video/src/h264/slicedec.rs index 7e5529dd..5f2b436e 100644 --- a/crates/video/src/h264/slicedec.rs +++ b/crates/video/src/h264/slicedec.rs @@ -918,7 +918,7 @@ impl<'a> SliceDecoder<'a> { let stride = self.pic.planes.width; let px = self.mb_x * 16 + (raster & 3) * 4; let py = self.mb_y * 16 + (raster >> 2) * 4; - let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(|c| c.first_chunk_mut::<16>()) else { + let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(<[_]>::first_chunk_mut::<16>) else { return; }; transform::idct4_add(blk, &mut self.pic.planes.y[py * stride + px..], stride); @@ -932,7 +932,7 @@ impl<'a> SliceDecoder<'a> { let stride = self.pic.planes.width; let px = self.mb_x * 16 + (b8 & 1) * 8; let py = self.mb_y * 16 + (b8 >> 1) * 8; - let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(|c| c.first_chunk_mut::<64>()) else { + let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(<[_]>::first_chunk_mut::<64>) else { return; }; transform::idct8_add(blk, &mut self.pic.planes.y[py * stride + px..], stride); @@ -951,7 +951,7 @@ impl<'a> SliceDecoder<'a> { } let px = self.mb_x * 8 + (b & 1) * 4; let py = self.mb_y * 8 + (b >> 1) * 4; - let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(|c| c.first_chunk_mut::<16>()) else { + let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(<[_]>::first_chunk_mut::<16>) else { continue; }; let plane = if c == 0 { &mut self.pic.planes.cb } else { &mut self.pic.planes.cr }; @@ -1252,7 +1252,7 @@ impl<'a> SliceDecoder<'a> { let pos = r.byte_pos(); let data = r.data(); ensure!(data.len() >= pos + 384, "truncated I_PCM macroblock"); - let Some(&samples) = data.get(pos..).and_then(|d| d.first_chunk::<384>()) else { + let Some(&samples) = data.get(pos..).and_then(<[_]>::first_chunk::<384>) else { return invalid("truncated I_PCM macroblock"); }; r.skip(384 * 8)?; diff --git a/crates/video/src/h264/transform.rs b/crates/video/src/h264/transform.rs index de01c09c..4f4fdf83 100644 --- a/crates/video/src/h264/transform.rs +++ b/crates/video/src/h264/transform.rs @@ -126,7 +126,7 @@ pub fn idct4_add(d: &[i32; 16], dst: &mut [u8], stride: usize) { o[3][j] = e - h; } for (i, row) in o.iter().enumerate() { - let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<4>()) else { + let Some(line) = dst.get_mut(i * stride..).and_then(<[_]>::first_chunk_mut::<4>) else { return; }; for (p, &v) in line.iter_mut().zip(row) { @@ -182,7 +182,7 @@ pub fn idct8_add(d: &[i32; 64], dst: &mut [u8], stride: usize) { } } for (i, row) in o.iter().enumerate() { - let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<8>()) else { + let Some(line) = dst.get_mut(i * stride..).and_then(<[_]>::first_chunk_mut::<8>) else { return; }; for (p, &v) in line.iter_mut().zip(row) { diff --git a/crates/video/src/prores/decode.rs b/crates/video/src/prores/decode.rs index 01b1900f..4fc5101a 100644 --- a/crates/video/src/prores/decode.rs +++ b/crates/video/src/prores/decode.rs @@ -492,7 +492,7 @@ fn put_block(f: &[f32; 64], plane: &mut [u16], stride: usize, x0: usize, y0: usi if xn == 8 && (y0 + 7) * step + parity < rows { for yy in 0..8 { let o = ((y0 + yy) * step + parity) * stride + x0; - let (Some(row), Some(src)) = (plane.get_mut(o..).and_then(|p| p.first_chunk_mut::<8>()), f.as_chunks::<8>().0.get(yy)) else { + let (Some(row), Some(src)) = (plane.get_mut(o..).and_then(<[_]>::first_chunk_mut::<8>), f.as_chunks::<8>().0.get(yy)) else { return; }; for x in 0..8 { From e2206665467f043682bbbbcc5a812a171a6adb4c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 08:32:19 +0000 Subject: [PATCH 50/90] refactor: group parameters into structs for too-many-args functions (S107) Co-Authored-By: Claude Sonnet 5.5 --- crates/audio-io/src/lib.rs | 9 +- crates/audio-io/src/wav.rs | 22 +-- crates/mix/src/lib.rs | 18 +-- crates/ui-egui/src/edit_window.rs | 228 +++++++++++++++++++++++----- crates/ui-egui/src/waveform.rs | 18 ++- crates/ui-egui/tests/it/waveform.rs | 4 +- crates/video/src/convert.rs | 30 ++-- crates/video/src/h264/picture.rs | 2 +- crates/video/src/h264/slicedec.rs | 2 +- crates/video/src/movie.rs | 19 ++- 10 files changed, 271 insertions(+), 81 deletions(-) diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 4a871946..f3a38162 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -317,7 +317,14 @@ impl FileEncoder { pub fn new(out: W, opts: &EncodeOptions, channels: usize, sample_rate: u32, mask: Option) -> Result { let (depth, dither) = (opts.bit_depth, opts.dither); Ok(FileEncoder(match opts.format { - FileFormat::Wav => Writer::Wav(wav::Writer::new(out, channels, sample_rate, depth, dither, opts.bwf.as_ref(), mask, false)?), + FileFormat::Wav => Writer::Wav(wav::Writer::new( + out, + channels, + sample_rate, + depth, + dither, + wav::HeaderExtras { bwf: opts.bwf.as_ref(), mask, force_rf64: false }, + )?), FileFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?), FileFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?), other => return Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))), diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index 1f11b0f4..98d0b76f 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -355,17 +355,17 @@ pub(crate) struct Writer { failed: bool, } +/// The optional header parts of a WAV file: BWF metadata, speaker mask, and whether to force RF64. +#[derive(Clone, Copy)] +pub(crate) struct HeaderExtras<'a> { + pub(crate) bwf: Option<&'a BwfInfo>, + pub(crate) mask: Option, + pub(crate) force_rf64: bool, +} + impl Writer { - pub(crate) fn new( - mut out: W, - channels: usize, - sample_rate: u32, - depth: BitDepth, - dither: bool, - bwf: Option<&BwfInfo>, - mask: Option, - force_rf64: bool, - ) -> Result { + pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool, extras: HeaderExtras<'_>) -> Result { + let HeaderExtras { bwf, mask, force_rf64 } = extras; validate_layout(channels, sample_rate)?; let format = sample_format(depth); let start = out.stream_position()?; @@ -449,7 +449,7 @@ pub(crate) fn encode( let mut out = Vec::new(); out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1)) .map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?; - let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, bwf, mask, force_rf64)?; + let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, HeaderExtras { bwf, mask, force_rf64 })?; w.write(&buf.channels, frames)?; Ok(w.finish()?.into_inner()) } diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index ba30c94c..8cc68afd 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -682,7 +682,7 @@ impl MixEngine { let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; run_strips(&mut work, |(i, st)| { if let Some(t) = s.tracks.get(*i) { - process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo); + process_strip(s, t, st, &busses, &live_in, Block { pos, frames, any_solo }); } }); self.busses = busses; @@ -761,7 +761,7 @@ impl MixEngine { st.scratch.resize(self.max_block, 0.0); } let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; - process_strip(s, t, &mut st, &self.busses, &live_in, pos, frames, any_solo); + process_strip(s, t, &mut st, &self.busses, &live_in, Block { pos, frames, any_solo }); let ch = st.buf.len(); st.align.set(if pdc { lmax_a.saturating_sub(st.latency) } else { 0 }, ch); st.align.process(&mut st.buf, frames); @@ -924,16 +924,16 @@ struct LiveInput<'a> { recording: bool, } -fn process_strip( - s: &Session, - t: &Track, - strip: &mut Strip, - busses: &HashMap, - live: &LiveInput<'_>, +/// The block being rendered: its timeline position, length in frames, and whether any track is soloed. +#[derive(Clone, Copy)] +struct Block { pos: Samples, frames: usize, any_solo: bool, -) { +} + +fn process_strip(s: &Session, t: &Track, strip: &mut Strip, busses: &HashMap, live: &LiveInput<'_>, block: Block) { + let Block { pos, frames, any_solo } = block; for c in strip.buf.iter_mut() { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index dbd74137..421dfae9 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -655,7 +655,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ } else { soundcraft_time::FrameRate::Fps25 }; - timebase_ruler(ui, &painter, &s, tl, row, view, TimeFormat::Timecode, alt, i + 1 == rulers.len()); + timebase_ruler(&painter, &s, tl, Ruler { row, view, f: TimeFormat::Timecode, rate: alt, bottom: i + 1 == rulers.len() }); } "markers" | "markers2" | "markers3" | "markers4" | "markers5" | "chords" => { let lane: u8 = match id.as_str() { @@ -710,7 +710,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ } _ => { if let Some(f) = fmt { - timebase_ruler(ui, &painter, &s, tl, row, view, f, s.frame_rate, i + 1 == rulers.len()); + timebase_ruler(&painter, &s, tl, Ruler { row, view, f, rate: s.frame_rate, bottom: i + 1 == rulers.len() }); // Clicking a timebase ruler locates. let resp = ui.interact(row, ui.id().with(("ruler", id.as_str())), Sense::click_and_drag()); if (resp.clicked() || resp.dragged()) @@ -732,17 +732,17 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ ui.painter().line_segment([pos2(area.min.x, area.max.y - 1.0), pos2(area.max.x, area.max.y - 1.0)], Stroke::new(1.0, t.border)); } -fn timebase_ruler( - _ui: &Ui, - painter: &egui::Painter, - s: &Session, - tl: Rect, +/// One timebase ruler row: its rect, the visible range, the format and frame rate, and whether it is the bottom row. +struct Ruler { row: Rect, view: Range, f: TimeFormat, rate: soundcraft_time::FrameRate, bottom: bool, -) { +} + +fn timebase_ruler(painter: &egui::Painter, s: &Session, tl: Rect, ruler: Ruler) { + let Ruler { row, view, f, rate, bottom } = ruler; let t = Tokens::current(); let sr = s.sample_rate; let spp = s.edit.zoom.samples_per_px; @@ -921,7 +921,7 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, if !clip.range().overlaps(&view) { continue; } - draw_clip(app, &painter, &s, track, sorted, clip, tl, lane, auto_view.is_some()); + draw_clip(app, &painter, &s, track, clip, LaneView { sorted, tl, lane, dim: auto_view.is_some() }); } draw_live_take(app, &painter, &s, track, tl, lane); crate::video_track::draw_lane(app, &painter, &s, track, tl, lane); @@ -967,7 +967,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai let sorted = track.clips().is_sorted_by_key(|c| c.start); let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); for c in pl.clips.iter().filter(|c| c.range().overlaps(&view)) { - draw_clip(app, &painter, &s, track, sorted, c, tl, lane, false); + draw_clip(app, &painter, &s, track, c, LaneView { sorted, tl, lane, dim: false }); } let resp = ui.interact(lane, ui.id().with(("pl_lane", track.id.0, i)), Sense::click()); if resp.clicked() @@ -1656,7 +1656,14 @@ fn draw_live_take(app: &SoundApp, painter: &egui::Painter, s: &Session, track: & let (body, _, wave) = clip_colors([t.rec.r(), t.rec.g(), t.rec.b()], false); painter.rect(r, CornerRadius::same(2), body, Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside); if track.view != "blocks" { - crate::waveform::draw_live(&painter.with_clip_rect(r.intersect(lane)), s, tl, origin as f64, block_len, &take, r, wave); + crate::waveform::draw_live( + crate::waveform::Canvas { painter: &painter.with_clip_rect(r.intersect(lane)), s, tl }, + origin as f64, + block_len, + &take, + r, + wave, + ); } } @@ -1671,8 +1678,17 @@ fn grid_veil(line: Color32, bg: Color32) -> Color32 { } } -/// `sorted`: whether `track.clips()` is sorted by start (worked out once per track and frame). -fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, sorted: bool, clip: &Clip, tl: Rect, lane: Rect, dim: bool) { +/// Where and how a track's clips are drawn. `sorted`: whether `track.clips()` is sorted by start +/// (worked out once per track and frame). +struct LaneView { + sorted: bool, + tl: Rect, + lane: Rect, + dim: bool, +} + +fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, clip: &Clip, view: LaneView) { + let LaneView { sorted, tl, lane, dim } = view; let x0 = x_of(s, tl, clip.start); let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0); let r = Rect::from_min_max(pos2(x0, lane.min.y + 1.0), pos2(x1, lane.max.y - 1.0)); @@ -1722,7 +1738,14 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track preview = Clip { fade_in, fade_out, ..clip.clone() }; &preview }; - crate::waveform::draw(&painter.with_clip_rect(vis), s, tl, shown, audio, track.channels(), body_r, wave); + crate::waveform::draw( + crate::waveform::Canvas { painter: &painter.with_clip_rect(vis), s, tl }, + shown, + audio, + track.channels(), + body_r, + wave, + ); let n = track.channels().min(audio.peaks.len()).max(1); for k in 1..n { let y = body_r.min.y + body_r.height() / n as f32 * k as f32; @@ -1776,7 +1799,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } else { clip_colors(prev.color.unwrap_or(track.color), false).2.gamma_multiply(0.6) }; - crate::waveform::draw(&painter.with_clip_rect(xf), s, tl, prev, audio, track.channels(), body_r, tail); + crate::waveform::draw(crate::waveform::Canvas { painter: &painter.with_clip_rect(xf), s, tl }, prev, audio, track.channels(), body_r, tail); } // Clip gain line. let show_gain = s.edit.flag("view.clip.gain_line") && (!clip.gain_env.is_empty() || clip.gain_db.abs() > 0.01 || track.view == "clip_gain"); @@ -2732,6 +2755,18 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { } } +/// One frame's inputs to [`follow_update`]. +struct FollowFrame { + playing: bool, + mode_follow: bool, + playhead_visible: bool, + cur: Samples, + last_scroll: Samples, + last_follow_to: Option, + hold: bool, + pos: Samples, +} + /// Next follow state after observing one frame: `(hold, follow_to)`. /// /// The view is ours to move only while nothing else moved it: when the @@ -2741,16 +2776,8 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { /// jump is issued. Stopping, or scrolling back to the playhead, clears the /// hold. Only `page`/`continuous`/`center` ever issue follow jumps while /// playing; `none`/`after_playback` leave the view alone. -fn follow_update( - playing: bool, - mode_follow: bool, - playhead_visible: bool, - cur: Samples, - last_scroll: Samples, - last_follow_to: Option, - hold: bool, - pos: Samples, -) -> (bool, Option) { +fn follow_update(frame: FollowFrame) -> (bool, Option) { + let FollowFrame { playing, mode_follow, playhead_visible, cur, last_scroll, last_follow_to, hold, pos } = frame; if !playing || playhead_visible { return (false, None); } @@ -2828,7 +2855,16 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { let mode_follow = matches!(s.edit.scrolling.as_str(), "page" | "continuous" | "center"); let visible = x >= tl.min.x && x <= tl.max.x; let layout = &mut app.edit_layout; - let (hold, follow_to) = follow_update(true, mode_follow, visible, cur, layout.last_scroll, layout.last_follow_to, layout.follow_hold, pos); + let (hold, follow_to) = follow_update(FollowFrame { + playing: true, + mode_follow, + playhead_visible: visible, + cur, + last_scroll: layout.last_scroll, + last_follow_to: layout.last_follow_to, + hold: layout.follow_hold, + pos, + }); layout.follow_hold = hold; layout.last_scroll = cur; layout.last_follow_to = follow_to; @@ -2955,40 +2991,160 @@ mod tests { #[test] fn follow_jumps_to_offscreen_playhead_while_playing() { // Steady view, follow mode, playhead left the screen: jump to it. - assert_eq!(follow_update(true, true, false, 1_000, 1_000, None, false, 96_000), (false, Some(96_000))); + assert_eq!( + follow_update(FollowFrame { + playing: true, + mode_follow: true, + playhead_visible: false, + cur: 1_000, + last_scroll: 1_000, + last_follow_to: None, + hold: false, + pos: 96_000 + }), + (false, Some(96_000)) + ); } #[test] fn follow_leaves_onscreen_playhead_alone() { - assert_eq!(follow_update(true, true, true, 1_000, 1_000, None, false, 96_000), (false, None)); + assert_eq!( + follow_update(FollowFrame { + playing: true, + mode_follow: true, + playhead_visible: true, + cur: 1_000, + last_scroll: 1_000, + last_follow_to: None, + hold: false, + pos: 96_000 + }), + (false, None) + ); } #[test] fn follow_never_scrolls_when_stopped_nor_in_free_modes() { - assert_eq!(follow_update(false, true, false, 1_000, 1_000, None, false, 96_000), (false, None)); + assert_eq!( + follow_update(FollowFrame { + playing: false, + mode_follow: true, + playhead_visible: false, + cur: 1_000, + last_scroll: 1_000, + last_follow_to: None, + hold: false, + pos: 96_000 + }), + (false, None) + ); // `none`/`after_playback` never follow, even with a steady view. - assert_eq!(follow_update(true, false, false, 1_000, 1_000, None, false, 96_000), (false, None)); - assert_eq!(follow_update(true, false, false, 9_000, 1_000, None, false, 96_000), (false, None)); + assert_eq!( + follow_update(FollowFrame { + playing: true, + mode_follow: false, + playhead_visible: false, + cur: 1_000, + last_scroll: 1_000, + last_follow_to: None, + hold: false, + pos: 96_000 + }), + (false, None) + ); + assert_eq!( + follow_update(FollowFrame { + playing: true, + mode_follow: false, + playhead_visible: false, + cur: 9_000, + last_scroll: 1_000, + last_follow_to: None, + hold: false, + pos: 96_000 + }), + (false, None) + ); } #[test] fn outside_view_move_pauses_the_follow() { // The view moved and it was not our own jump: pause, no jump. - assert_eq!(follow_update(true, true, false, 9_000, 1_000, None, false, 96_000), (true, None)); + assert_eq!( + follow_update(FollowFrame { + playing: true, + mode_follow: true, + playhead_visible: false, + cur: 9_000, + last_scroll: 1_000, + last_follow_to: None, + hold: false, + pos: 96_000 + }), + (true, None) + ); // A held view stays held while it sits still elsewhere. - assert_eq!(follow_update(true, true, false, 9_000, 9_000, None, true, 96_000), (true, None)); + assert_eq!( + follow_update(FollowFrame { + playing: true, + mode_follow: true, + playhead_visible: false, + cur: 9_000, + last_scroll: 9_000, + last_follow_to: None, + hold: true, + pos: 96_000 + }), + (true, None) + ); } #[test] fn own_follow_jump_is_not_mistaken_for_a_takeover() { // The scroll equals the target we issued last frame: keep following. - assert_eq!(follow_update(true, true, false, 96_000, 1_000, Some(96_000), false, 97_000), (false, Some(97_000))); + assert_eq!( + follow_update(FollowFrame { + playing: true, + mode_follow: true, + playhead_visible: false, + cur: 96_000, + last_scroll: 1_000, + last_follow_to: Some(96_000), + hold: false, + pos: 97_000 + }), + (false, Some(97_000)) + ); } #[test] fn hold_clears_when_playhead_returns_or_transport_stops() { - assert_eq!(follow_update(true, true, true, 9_000, 9_000, None, true, 96_000), (false, None)); - assert_eq!(follow_update(false, true, false, 9_000, 9_000, None, true, 96_000), (false, None)); + assert_eq!( + follow_update(FollowFrame { + playing: true, + mode_follow: true, + playhead_visible: true, + cur: 9_000, + last_scroll: 9_000, + last_follow_to: None, + hold: true, + pos: 96_000 + }), + (false, None) + ); + assert_eq!( + follow_update(FollowFrame { + playing: false, + mode_follow: true, + playhead_visible: false, + cur: 9_000, + last_scroll: 9_000, + last_follow_to: None, + hold: true, + pos: 96_000 + }), + (false, None) + ); } fn harness(n: usize) -> Harness<'static, SoundApp> { diff --git a/crates/ui-egui/src/waveform.rs b/crates/ui-egui/src/waveform.rs index 0a2ccd46..620662e2 100644 --- a/crates/ui-egui/src/waveform.rs +++ b/crates/ui-egui/src/waveform.rs @@ -4,10 +4,18 @@ use egui::{Color32, Mesh, Painter, Rect, Shape, Stroke, pos2, vec2}; use soundcraft_model::{Clip, Session, SourceAudio}; use soundcraft_time::Samples; +/// Where to draw: the painter, the session and the timeline rect that maps samples to x. +#[derive(Clone, Copy)] +pub struct Canvas<'a> { + pub painter: &'a Painter, + pub s: &'a Session, + pub tl: Rect, +} + /// Draw a clip's audio across `body`, one band per channel, `channels` of them at most. -pub fn draw(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, channels: usize, body: Rect, wave: Color32) { +pub fn draw(canvas: Canvas<'_>, clip: &Clip, audio: &SourceAudio, channels: usize, body: Rect, wave: Color32) { for (ch, band) in bands(body, channels.min(audio.peaks.len()).max(1)).enumerate() { - channel(painter, s, tl, clip, audio, ch, band, wave); + channel(canvas, clip, audio, ch, band, wave); } } @@ -16,7 +24,8 @@ fn bands(body: Rect, n: usize) -> impl Iterator { (0..n).map(move |k| Rect::from_min_size(pos2(body.min.x, body.min.y + h * k as f32), vec2(body.width(), h))) } -fn channel(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, ch: usize, band: Rect, wave: Color32) { +fn channel(canvas: Canvas<'_>, clip: &Clip, audio: &SourceAudio, ch: usize, band: Rect, wave: Color32) { + let Canvas { painter, s, tl } = canvas; let (Some(samples), Some(peaks)) = (audio.channel(ch), audio.peaks.get(ch)) else { return }; let Some(g) = Grid::new(painter, s, tl, band) else { return }; let (rectified, power, outlines) = (s.edit.flag("waveform.rectified"), s.edit.flag("waveform.power"), s.edit.flag("waveform.outlines")); @@ -59,7 +68,8 @@ fn channel(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &Source /// Draw a take while it records across `body`, one band per channel: `blocks` holds each channel's /// (min, max) per block of input frames, the blocks `block_len` timeline samples long from /// timeline position `origin`. -pub fn draw_live(painter: &Painter, s: &Session, tl: Rect, origin: f64, block_len: f64, blocks: &[Vec<(f32, f32)>], body: Rect, wave: Color32) { +pub fn draw_live(canvas: Canvas<'_>, origin: f64, block_len: f64, blocks: &[Vec<(f32, f32)>], body: Rect, wave: Color32) { + let Canvas { painter, s, tl } = canvas; let zoom = s.edit.zoom.waveform_zoom; for (blocks, band) in blocks.iter().zip(bands(body, blocks.len())) { let Some(g) = Grid::new(painter, s, tl, band) else { continue }; diff --git a/crates/ui-egui/tests/it/waveform.rs b/crates/ui-egui/tests/it/waveform.rs index 2724ec05..fcf24705 100644 --- a/crates/ui-egui/tests/it/waveform.rs +++ b/crates/ui-egui/tests/it/waveform.rs @@ -22,13 +22,13 @@ fn paint(samples_per_px: f64, f: impl Fn(&egui::Painter, &Session, Rect)) -> Vec fn draw(samples: Vec, samples_per_px: f64) -> Vec { let clip = Clip::audio(ClipId(1), "c", SourceId(1), 0, 0, samples.len() as i64); let audio = SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![samples] }); - paint(samples_per_px, |p, s, band| waveform::draw(p, s, band, &clip, &audio, 1, band, WAVE)) + paint(samples_per_px, |p, s, band| waveform::draw(waveform::Canvas { painter: p, s, tl: band }, &clip, &audio, 1, band, WAVE)) } #[test] fn a_take_draws_its_overview_while_it_records() { let blocks = vec![vec![(-0.5, 0.5); 100]]; - let shapes = paint(128.0, |p, s, band| waveform::draw_live(p, s, band, 1280.0, 128.0, &blocks, band, WAVE)); + let shapes = paint(128.0, |p, s, band| waveform::draw_live(waveform::Canvas { painter: p, s, tl: band }, 1280.0, 128.0, &blocks, band, WAVE)); let [Shape::Mesh(mesh)] = shapes.as_slice() else { panic!("{shapes:?}") }; let span = |f: fn(&Pos2) -> f32| mesh.vertices.iter().map(|v| f(&v.pos)).fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(v), b.max(v))); let ((left, right), (top, bottom)) = (span(|p| p.x), span(|p| p.y)); diff --git a/crates/video/src/convert.rs b/crates/video/src/convert.rs index e9605409..6907329f 100644 --- a/crates/video/src/convert.rs +++ b/crates/video/src/convert.rs @@ -54,20 +54,24 @@ impl Matrix { } } +/// The planes (and their geometry) of an 8-bit planar Y'CbCr picture for [`planar_to_rgba`]. +#[derive(Clone, Copy)] +pub struct Planes<'a> { + pub width: usize, + pub height: usize, + pub y: &'a [u8], + pub y_stride: usize, + pub u: &'a [u8], + pub v: &'a [u8], + pub uv_stride: usize, + pub cx: usize, + pub cy: usize, +} + /// Planar 8-bit Y'CbCr with chroma subsampled by `cx` horizontally and `cy` vertically /// (1 or 2 each) → packed RGBA. Missing samples (short planes) read as black / neutral. -pub fn planar_to_rgba( - width: usize, - height: usize, - y: &[u8], - y_stride: usize, - u: &[u8], - v: &[u8], - uv_stride: usize, - cx: usize, - cy: usize, - m: &Matrix, -) -> Vec { +pub fn planar_to_rgba(p: &Planes<'_>, m: &Matrix) -> Vec { + let Planes { width, height, y, y_stride, u, v, uv_stride, cx, cy } = *p; let (cx, cy) = (cx.max(1), cy.max(1)); let mut out = vec![0u8; width.saturating_mul(height).saturating_mul(4)]; for (row, line) in out.chunks_exact_mut(width.max(1) * 4).enumerate().take(height) { @@ -164,7 +168,7 @@ mod tests { #[test] fn planar_conversion_tolerates_short_planes() { let m = Matrix::new(true, false); - let out = planar_to_rgba(4, 4, &[235; 3], 4, &[], &[], 2, 2, 2, &m); + let out = planar_to_rgba(&Planes { width: 4, height: 4, y: &[235; 3], y_stride: 4, u: &[], v: &[], uv_stride: 2, cx: 2, cy: 2 }, &m); assert_eq!(out.len(), 64); assert_eq!(&out[..4], &[255, 255, 255, 255]); } diff --git a/crates/video/src/h264/picture.rs b/crates/video/src/h264/picture.rs index d1cbe225..040a7e97 100644 --- a/crates/video/src/h264/picture.rs +++ b/crates/video/src/h264/picture.rs @@ -268,7 +268,7 @@ impl Frame { /// Copy the w x h window of chroma component `c` at (x0, y0) (clamped) into `out`. #[inline] - pub fn chroma_window(&self, c: usize, x0: i32, y0: i32, w: usize, h: usize, out: &mut [u8], os: usize) { + pub fn chroma_window(&self, c: usize, (x0, y0): (i32, i32), w: usize, h: usize, out: &mut [u8], os: usize) { let (cw, ch) = (self.cwidth, self.cheight); for r in 0..h { let yy = (y0 + r as i32).clamp(0, ch as i32 - 1) as usize; diff --git a/crates/video/src/h264/slicedec.rs b/crates/video/src/h264/slicedec.rs index 5f2b436e..978de3f4 100644 --- a/crates/video/src/h264/slicedec.rs +++ b/crates/video/src/h264/slicedec.rs @@ -236,7 +236,7 @@ fn predict_block(f: &Frame, mv: [i16; 2], px: usize, py: usize, bw: usize, bh: u let (fx, fy) = ((mv[0] & 7) as u32, (mv[1] & 7) as u32); for (comp, out) in c.into_iter().enumerate() { let mut w = [0u8; 16 * 9]; - f.chroma_window(comp, cx, cy, cw + 1, ch + 1, &mut w, 16); + f.chroma_window(comp, (cx, cy), cw + 1, ch + 1, &mut w, 16); inter::mc_chroma_win(&w, 16, fx, fy, cw, ch, out, cs); } } diff --git a/crates/video/src/movie.rs b/crates/video/src/movie.rs index c5755ca3..752f701d 100644 --- a/crates/video/src/movie.rs +++ b/crates/video/src/movie.rs @@ -1,6 +1,6 @@ //! [`Movie`]: open an MP4/MOV, report its picture format, and decode frames at times to RGBA. -use crate::convert::{Matrix, planar_to_rgba, scale_rgba}; +use crate::convert::{Matrix, Planes, planar_to_rgba, scale_rgba}; use crate::isobmff::{self, ByteSource, CodecConfig, Mp4File, TrackKind}; use crate::{Result, VideoError}; use std::collections::VecDeque; @@ -411,7 +411,7 @@ impl Movie { let (y, u, v) = (narrow(&f.y), narrow(&f.cb), narrow(&f.cr)); let cx = if cw < w { 2 } else { 1 }; let m = Matrix::for_code(f.color.matrix, f.height, false); - let rgba = planar_to_rgba(w, h, &y, w, &u, &v, cw, cx, 1, &m); + let rgba = planar_to_rgba(&Planes { width: w, height: h, y: &y, y_stride: w, u: &u, v: &v, uv_stride: cw, cx, cy: 1 }, &m); Ok(Frame { width: f.width, height: f.height, rgba, index, time }) } Codec::Jpeg => { @@ -429,7 +429,20 @@ impl Movie { fn h264_to_frame(&self, p: &crate::h264::Picture, index: usize) -> Frame { let m = Matrix::for_code(p.color.matrix, p.height, p.color.full_range); - let rgba = planar_to_rgba(p.width as usize, p.height as usize, &p.y, p.y_stride, &p.u, &p.v, p.uv_stride, 2, 2, &m); + let rgba = planar_to_rgba( + &Planes { + width: p.width as usize, + height: p.height as usize, + y: &p.y, + y_stride: p.y_stride, + u: &p.u, + v: &p.v, + uv_stride: p.uv_stride, + cx: 2, + cy: 2, + }, + &m, + ); Frame { width: p.width, height: p.height, rgba, index, time: self.times.get(index).copied().unwrap_or(0.0) } } From 74698344207f8147df956a657561ceba1d9cb9ce Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 08:44:41 +0000 Subject: [PATCH 51/90] refactor: leave timebase_ruler, draw_clip, process_strip for their cognitive-complexity refactor Co-Authored-By: Claude Sonnet 5.5 --- crates/mix/src/lib.rs | 18 ++++++++--------- crates/ui-egui/src/edit_window.rs | 33 +++++++++++-------------------- 2 files changed, 21 insertions(+), 30 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 8cc68afd..ba30c94c 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -682,7 +682,7 @@ impl MixEngine { let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; run_strips(&mut work, |(i, st)| { if let Some(t) = s.tracks.get(*i) { - process_strip(s, t, st, &busses, &live_in, Block { pos, frames, any_solo }); + process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo); } }); self.busses = busses; @@ -761,7 +761,7 @@ impl MixEngine { st.scratch.resize(self.max_block, 0.0); } let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; - process_strip(s, t, &mut st, &self.busses, &live_in, Block { pos, frames, any_solo }); + process_strip(s, t, &mut st, &self.busses, &live_in, pos, frames, any_solo); let ch = st.buf.len(); st.align.set(if pdc { lmax_a.saturating_sub(st.latency) } else { 0 }, ch); st.align.process(&mut st.buf, frames); @@ -924,16 +924,16 @@ struct LiveInput<'a> { recording: bool, } -/// The block being rendered: its timeline position, length in frames, and whether any track is soloed. -#[derive(Clone, Copy)] -struct Block { +fn process_strip( + s: &Session, + t: &Track, + strip: &mut Strip, + busses: &HashMap, + live: &LiveInput<'_>, pos: Samples, frames: usize, any_solo: bool, -} - -fn process_strip(s: &Session, t: &Track, strip: &mut Strip, busses: &HashMap, live: &LiveInput<'_>, block: Block) { - let Block { pos, frames, any_solo } = block; +) { for c in strip.buf.iter_mut() { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 421dfae9..c83f32c1 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -655,7 +655,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ } else { soundcraft_time::FrameRate::Fps25 }; - timebase_ruler(&painter, &s, tl, Ruler { row, view, f: TimeFormat::Timecode, rate: alt, bottom: i + 1 == rulers.len() }); + timebase_ruler(ui, &painter, &s, tl, row, view, TimeFormat::Timecode, alt, i + 1 == rulers.len()); } "markers" | "markers2" | "markers3" | "markers4" | "markers5" | "chords" => { let lane: u8 = match id.as_str() { @@ -710,7 +710,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ } _ => { if let Some(f) = fmt { - timebase_ruler(&painter, &s, tl, Ruler { row, view, f, rate: s.frame_rate, bottom: i + 1 == rulers.len() }); + timebase_ruler(ui, &painter, &s, tl, row, view, f, s.frame_rate, i + 1 == rulers.len()); // Clicking a timebase ruler locates. let resp = ui.interact(row, ui.id().with(("ruler", id.as_str())), Sense::click_and_drag()); if (resp.clicked() || resp.dragged()) @@ -732,17 +732,17 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ ui.painter().line_segment([pos2(area.min.x, area.max.y - 1.0), pos2(area.max.x, area.max.y - 1.0)], Stroke::new(1.0, t.border)); } -/// One timebase ruler row: its rect, the visible range, the format and frame rate, and whether it is the bottom row. -struct Ruler { +fn timebase_ruler( + _ui: &Ui, + painter: &egui::Painter, + s: &Session, + tl: Rect, row: Rect, view: Range, f: TimeFormat, rate: soundcraft_time::FrameRate, bottom: bool, -} - -fn timebase_ruler(painter: &egui::Painter, s: &Session, tl: Rect, ruler: Ruler) { - let Ruler { row, view, f, rate, bottom } = ruler; +) { let t = Tokens::current(); let sr = s.sample_rate; let spp = s.edit.zoom.samples_per_px; @@ -921,7 +921,7 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, if !clip.range().overlaps(&view) { continue; } - draw_clip(app, &painter, &s, track, clip, LaneView { sorted, tl, lane, dim: auto_view.is_some() }); + draw_clip(app, &painter, &s, track, sorted, clip, tl, lane, auto_view.is_some()); } draw_live_take(app, &painter, &s, track, tl, lane); crate::video_track::draw_lane(app, &painter, &s, track, tl, lane); @@ -967,7 +967,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai let sorted = track.clips().is_sorted_by_key(|c| c.start); let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); for c in pl.clips.iter().filter(|c| c.range().overlaps(&view)) { - draw_clip(app, &painter, &s, track, c, LaneView { sorted, tl, lane, dim: false }); + draw_clip(app, &painter, &s, track, sorted, c, tl, lane, false); } let resp = ui.interact(lane, ui.id().with(("pl_lane", track.id.0, i)), Sense::click()); if resp.clicked() @@ -1678,17 +1678,8 @@ fn grid_veil(line: Color32, bg: Color32) -> Color32 { } } -/// Where and how a track's clips are drawn. `sorted`: whether `track.clips()` is sorted by start -/// (worked out once per track and frame). -struct LaneView { - sorted: bool, - tl: Rect, - lane: Rect, - dim: bool, -} - -fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, clip: &Clip, view: LaneView) { - let LaneView { sorted, tl, lane, dim } = view; +/// `sorted`: whether `track.clips()` is sorted by start (worked out once per track and frame). +fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, sorted: bool, clip: &Clip, tl: Rect, lane: Rect, dim: bool) { let x0 = x_of(s, tl, clip.start); let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0); let r = Rect::from_min_max(pos2(x0, lane.min.y + 1.0), pos2(x1, lane.max.y - 1.0)); From 08679d5a655eab4611729c218b19723d3d0523c3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 09:11:44 +0000 Subject: [PATCH 52/90] Integration: cli: run the inline --cmd=ID form --- apps/soundcraft-cli/src/main.rs | 32 +++++++++++++++------------- apps/soundcraft-cli/tests/options.rs | 8 +++++++ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index ccec6697..899ba71e 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -354,22 +354,24 @@ fn run(args: &[String]) -> ExitCode { Ok(e) => e, Err(err) => return fail(err), }; - let mut i = 0; - while i < args.len() { - if args.get(i).is_some_and(|a| a == "--cmd") - && let Some(spec) = args.get(i + 1) - { - let (id, params) = match parse_cmd_spec(spec) { - Ok(v) => v, - Err(err) => return fail(err), - }; - match e.execute(&id, ¶ms) { - Ok(v) => outln!("{id}: {v}"), - Err(err) => return fail(format!("{id}: {err}")), - } - i += 1; + let mut it = args.iter(); + while let Some(a) = it.next() { + let spec = match a.strip_prefix("--cmd=") { + Some(inline) => inline, + None if a == "--cmd" => match it.next() { + Some(next) => next, + None => continue, + }, + None => continue, + }; + let (id, params) = match parse_cmd_spec(spec) { + Ok(v) => v, + Err(err) => return fail(err), + }; + match e.execute(&id, ¶ms) { + Ok(v) => outln!("{id}: {v}"), + Err(err) => return fail(format!("{id}: {err}")), } - i += 1; } finish(&mut e, args) } diff --git a/apps/soundcraft-cli/tests/options.rs b/apps/soundcraft-cli/tests/options.rs index e709a3d8..5ca67031 100644 --- a/apps/soundcraft-cli/tests/options.rs +++ b/apps/soundcraft-cli/tests/options.rs @@ -120,3 +120,11 @@ fn run_bounces_with_the_inline_value_form() { assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); assert!(out.exists(), "--bounce=PATH should write the file"); } + +#[test] +fn run_executes_the_inline_cmd_form() { + let o = run(&["run", "--cmd=track.new", "--cmd", "track.new"]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let stdout = String::from_utf8_lossy(&o.stdout); + assert_eq!(stdout.lines().filter(|l| l.starts_with("track.new: ")).count(), 2, "both --cmd forms should run: {stdout}"); +} From f6a27c2ccf21cc9a357c209df7b8cb2fb677f181 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 09:13:19 +0000 Subject: [PATCH 53/90] Integration: automation: hold clip gain envelope ends to the clip edges --- crates/engine/src/cmd/mix.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index d5cfcdee..8daacedf 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -709,8 +709,9 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result { pins.push((c.start - 1, level(c.start - 1))); } pins.push((c.end(), level(c.end()))); - let mut ats: Vec = - if c.gain_env.is_empty() { vec![c.start, c.end() - 1] } else { c.gain_env.iter().map(|&(o, _)| c.start + o).collect() }; + // The clip edges always get a point, so the envelope's first and last values hold out to them. + let mut ats = vec![c.start, c.end() - 1]; + ats.extend(c.gain_env.iter().map(|&(o, _)| c.start + o).filter(|&at| at > c.start && at < c.end() - 1)); // The lane's own points inside the clip carry the clip gain too. if let Some(l) = &old { ats.extend(l.points.iter().map(|pt| pt.at).filter(|&at| at > c.start && at < c.end() - 1)); @@ -916,6 +917,22 @@ mod tests { } } + #[test] + fn clip_gain_to_volume_holds_the_envelope_ends_out_to_the_clip_edges() { + let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]); + let clip = e.session().track(t).unwrap().clips()[0].id; + let c = e.session_mut().find_clip_mut(clip).unwrap(); + c.gain_db = 6.0; + c.gain_env = vec![(2000, 0.0), (6000, -10.0)]; + let before = rendered(e.session()); + e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap(); + let after = rendered(e.session()); + for (start, end) in [(0, 1000), (1000, 2000), (6000, 7000), (7000, 8192)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB"); + } + } + #[test] fn clip_gain_to_volume_leaves_audio_outside_the_clip_alone() { // Three clips in a row; only the middle one is converted, and the lane extends past it on both sides. From 824cf5cecbeba3e6491cdf5aaa47bda99ba7cc46 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 09:14:21 +0000 Subject: [PATCH 54/90] Integration: import: keep a failed import's error on the status line --- crates/ui-egui/src/import.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index e9c2d83f..44f1c201 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -88,10 +88,8 @@ fn spawn(app: &mut SoundApp, params: Value, ctx: &egui::Context) { ctx.request_repaint(); }; match std::thread::Builder::new().name("audio-import".into()).spawn(work) { - Ok(_) => { - app.ui.status = format!("Importing {name}…"); - app.imports.running = Some(Running { params, name, rate, rx }); - } + // No status line here: it may hold the previous file's failure, and the progress note names this file. + Ok(_) => app.imports.running = Some(Running { params, name, rate, rx }), // No thread to spare: import the old way rather than not at all. Err(_) => { run_now(app, params); @@ -214,4 +212,24 @@ mod tests { settle(&mut app, &ctx); assert_eq!(app.engine.session().tracks.len(), 1, "the good file still imports"); } + + #[test] + fn a_failed_import_stays_on_the_status_line_while_the_next_one_starts() { + let ctx = egui::Context::default(); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let junk = tone_wav("junk-status.wav"); + std::fs::write(&junk, b"not audio").unwrap(); + start(&mut app, json!({"path": junk})); + start(&mut app, json!({"path": tone_wav("next-status.wav")})); + for _ in 0..2_000 { + step(&mut app, &ctx); + if app.imports.label().is_some_and(|(name, _)| name == "next-status.wav") { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(app.imports.label().is_some_and(|(name, _)| name == "next-status.wav"), "the second import never started"); + assert!(!app.ui.status.is_empty() && !app.ui.status.starts_with("Importing"), "the failure was overwritten: {}", app.ui.status); + settle(&mut app, &ctx); + } } From 4ab3bde39bee4c0a35ac1e4b6485fef693c93503 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 09:15:31 +0000 Subject: [PATCH 55/90] Integration: MIDI real-time properties: reload the form when stored values change --- crates/ui-egui/src/ops_windows.rs | 54 +++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/ops_windows.rs b/crates/ui-egui/src/ops_windows.rs index 45a2f3a9..c4a492ca 100644 --- a/crates/ui-egui/src/ops_windows.rs +++ b/crates/ui-egui/src/ops_windows.rs @@ -30,9 +30,10 @@ pub struct OpsState { pub rtp_transpose: i64, pub rtp_duration: f64, pub rtp_delay: i64, - /// The MIDI tracks the Real-Time Properties form was last filled from: the form re-reads the - /// tracks when the target selection changes (#444). + /// The MIDI tracks the Real-Time Properties form was last filled from, and the values it read: + /// the form re-reads the tracks when either changes (#444). pub rtp_targets: Vec, + pub rtp_loaded: Option, pub identify_bars: u32, } @@ -61,6 +62,7 @@ impl Default for OpsState { rtp_duration: 100.0, rtp_delay: 0, rtp_targets: Vec::new(), + rtp_loaded: None, identify_bars: 1, } } @@ -265,17 +267,8 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) { if !open { return; } - // Fill the form from the tracks it edits whenever the target selection changes, so it never - // shows (and a control change never writes back) stale defaults (#444). let targets = app.engine.target_tracks(); - if app.ops.rtp_targets != targets { - app.ops.rtp_targets = targets.clone(); - let (velocity, transpose, duration, delay) = rtp_form_values(app.engine.session(), &targets); - app.ops.rtp_velocity = velocity; - app.ops.rtp_transpose = transpose; - app.ops.rtp_duration = duration; - app.ops.rtp_delay = delay; - } + sync_rtp_form(&mut app.ops, app.engine.session(), targets, ctx.dragged_id().is_some()); egui::Window::new("MIDI Real-Time Properties").open(&mut open).default_size(vec2(340.0, 200.0)).show(ctx, |ui| { ui.label("Non-destructive, applied at playback to the selected MIDI tracks."); let mut changed = RtpChanged::default(); @@ -330,9 +323,28 @@ fn rtp_params(changed: RtpChanged, ops: &OpsState) -> Option { (!p.is_empty()).then_some(Value::Object(p)) } +/// Velocity, transpose, duration % and delay, as the form shows them. +pub type RtpValues = (i64, i64, f64, i64); + +/// Fills the form from the tracks it edits whenever the targets or their stored values change +/// (selection, undo, a command), so it never shows (and a control change never writes back) stale +/// values (#444). A slider being dragged keeps its value until it is released. +fn sync_rtp_form(ops: &mut OpsState, s: &soundcraft_model::Session, targets: Vec, dragging: bool) { + let values = rtp_form_values(s, &targets); + if ops.rtp_targets != targets || (ops.rtp_loaded != Some(values) && !dragging) { + let (velocity, transpose, duration, delay) = values; + ops.rtp_targets = targets; + ops.rtp_loaded = Some(values); + ops.rtp_velocity = velocity; + ops.rtp_transpose = transpose; + ops.rtp_duration = duration; + ops.rtp_delay = delay; + } +} + /// The values the form shows for `targets`: the first target's own Real-Time Properties, or the /// neutral defaults when nothing is selected. -fn rtp_form_values(s: &soundcraft_model::Session, targets: &[soundcraft_model::TrackId]) -> (i64, i64, f64, i64) { +fn rtp_form_values(s: &soundcraft_model::Session, targets: &[soundcraft_model::TrackId]) -> RtpValues { let Some(&t) = targets.first() else { return (0, 0, 100.0, 0) }; let value = |key: &str, d: f64| s.edit.value(&format!("rtp.{}.{key}", t.0), d); ( @@ -386,4 +398,20 @@ mod tests { assert_eq!(rtp_form_values(e.session(), &[]), (0, 0, 100.0, 0), "no target: the neutral defaults"); assert_eq!(rtp_form_values(e.session(), &[t]), (-12, 7, 50.0, 480)); } + + /// Undo keeps the same targets but changes their stored values: the form follows. + #[test] + fn the_rtp_form_follows_an_undo_on_the_same_targets() { + let mut e = soundcraft_engine::Engine::default(); + e.execute("track.new", &json!({"kind": "instrument", "name": "Synth"})).unwrap(); + let t = e.session().tracks.last().unwrap().id; + let mut ops = OpsState::default(); + sync_rtp_form(&mut ops, e.session(), vec![t], false); + e.execute("event.midi_rtp", &json!({"track": t.0, "velocity": -12})).unwrap(); + sync_rtp_form(&mut ops, e.session(), vec![t], false); + assert_eq!(ops.rtp_velocity, -12); + e.execute("edit.undo", &json!({})).unwrap(); + sync_rtp_form(&mut ops, e.session(), vec![t], false); + assert_eq!(ops.rtp_velocity, 0, "the form kept the undone value"); + } } From e884d6eb3738ff94c4c456628672e4ebacceab6a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 09:16:19 +0000 Subject: [PATCH 56/90] Integration: VST3 host: keep room for note releases after a reset --- crates/vst3-host/src/plugin.rs | 53 ++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index 0248c077..89435747 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -169,11 +169,28 @@ impl Vst3Plugin { /// Inserts keeping the queue time-sorted; drops the event when the queue is full (never /// allocates on the audio thread). fn push_note(&mut self, e: NoteEvent) { - if self.notes.len() >= self.notes.capacity() { - return; - } - let pos = self.notes.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1); - self.notes.insert(pos, e); + push_sorted(&mut self.notes, e); + } +} + +fn push_sorted(notes: &mut Vec, e: NoteEvent) { + if notes.len() >= notes.capacity() { + return; + } + let pos = notes.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1); + notes.insert(pos, e); +} + +/// Keeps only the pending releases, one per key and moved to the start of the next block, so at +/// most 128 remain and `all_notes_off` always has room in the queue. +fn keep_releases(notes: &mut Vec) { + let mut seen = [false; 128]; + notes.retain(|e| match *e { + NoteEvent::Off { key, .. } => seen.get_mut(usize::from(key)).is_some_and(|s| !std::mem::replace(s, true)), + _ => false, + }); + for e in notes.iter_mut() { + e.set_time(0); } } @@ -216,10 +233,7 @@ impl Plugin for Vst3Plugin { fn reset(&mut self) { // Keep pending releases and release whatever is still held, so the processor's voices stop // on the next block instead of sounding on while the host forgets them. - self.notes.retain(|e| matches!(e, NoteEvent::Off { .. })); - for e in &mut self.notes { - e.set_time(0); - } + keep_releases(&mut self.notes); self.controls.clear(); self.all_notes_off(); self.held = [false; 128]; @@ -523,3 +537,24 @@ impl PluginEditor for Vst3Editor { if units.is_empty() || text.trim_end().to_lowercase().ends_with(&units.to_lowercase()) { Some(text) } else { Some(format!("{text} {units}")) } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A queue full of releases still has room to release every held key after a reset. + #[test] + fn reset_keeps_room_to_release_every_held_key() { + let mut notes = Vec::with_capacity(EVENT_CAP); + for i in 0..EVENT_CAP { + push_sorted(&mut notes, NoteEvent::Off { time: i as u32, key: (i % 4) as u8 }); + } + keep_releases(&mut notes); + for key in 0..128u8 { + push_sorted(&mut notes, NoteEvent::Off { time: 0, key }); + } + for key in 0..128u8 { + assert!(notes.iter().any(|e| matches!(*e, NoteEvent::Off { key: k, .. } if k == key)), "key {key} was never released"); + } + } +} From f0f9addd165028ad4dba46b997233d2e86bc1dbc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 02:16:43 -0700 Subject: [PATCH 57/90] AudioSuite: Vari-Fi slows a clip down to a stop or speeds it up from one AudioSuite > Pitch Shift > Vari-Fi resampled the clip at a constant speed. It now ramps the speed between normal and a complete stop, so the pitch falls or rises with it. Slow Down and Speed Up set the direction. Fit To keeps the clip's length and plays two thirds of it; Extend plays all of it and makes the clip 150 % long. Fades On fades the audio out with the slowdown or in with the speedup. The ramp is offline::vari_fi, run by audiosuite.process as vari_fi with change, selection and fades params. The constant-speed varispeed process stays available to commands. --- ROADMAP.md | 4 +- crates/dsp/src/offline.rs | 70 ++++++++++++++++ crates/dsp/tests/it/fuzz.rs | 3 + crates/dsp/tests/it/main.rs | 1 + crates/dsp/tests/it/vari_fi.rs | 110 +++++++++++++++++++++++++ crates/engine/src/cmd/audiosuite.rs | 52 +++++++++++- crates/engine/tests/it/long_renders.rs | 15 ++++ crates/ui-egui/src/dialogs.rs | 42 ++++++++++ crates/ui-egui/src/menus.rs | 2 +- docs/plugin-parity.md | 5 +- 10 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 crates/dsp/tests/it/vari_fi.rs diff --git a/ROADMAP.md b/ROADMAP.md index f214e1e0..9bf5e8a8 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-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-24 · **Change:** trivial (AudioSuite Vari-Fi speed ramps) · **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-24:** AudioSuite ▸ Pitch Shift ▸ Vari-Fi slows the selected clips down to a complete stop or speeds them up from one, and the pitch follows the speed. Fit To keeps each clip's length and plays two thirds of it; Extend plays all of it and makes the clip 150 % long. Fades On fades the audio out with the slowdown or in with the speedup. Before, the menu item resampled the clip at a constant speed. No readiness percentage change. - **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change. - **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-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change. @@ -161,6 +162,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Progress log: AudioSuite Vari-Fi speed ramps; readiness estimates unchanged | | 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged | | 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged | | 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged | diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index 337f3589..5ec85c26 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -373,6 +373,76 @@ pub fn pitch_shift(ch: &[Vec], semitones: f32, sample_rate: f32) -> Vec], change: VariFiChange, selection: VariFiSelection, fades: bool) -> Vec> { + let len = max_len(ch) as f64; + let (total, first, played) = match selection { + VariFiSelection::Extend => ((len * 1.5).round(), 0.0, len), + VariFiSelection::FitTo => { + let played = len * 2.0 / 3.0; + (len, if change == VariFiChange::SlowDown { len - played } else { 0.0 }, played) + } + }; + let out_len = (total as usize).min(MAX_OFFLINE_SAMPLES); + let table = SincTable::new(0.97); + let hw = table.half_width; + // The fraction of the played input reached at `u`, and the speed there (0 to 1). + let at = |u: f64| -> (f64, f64) { + match change { + VariFiChange::SlowDown => ((3.0 * u - u * u * u) / 2.0, 1.0 - u * u), + VariFiChange::SpeedUp => { + let r = 1.0 - u; + (1.0 - (3.0 * r - r * r * r) / 2.0, 1.0 - r * r) + } + } + }; + ch.iter() + .map(|x| { + let last = x.len() as i64 - 1; + (0..out_len) + .map(|n| { + let (pos, speed) = at(n as f64 / total); + let t = first + played * pos; + let lo = (t - hw).ceil().max(0.0) as i64; + let hi = ((t + hw).floor() as i64).min(last); + let mut acc = 0.0f32; + let mut j = lo; + while j <= hi { + if let Some(&v) = x.get(j as usize) { + acc += v * table.at(t - j as f64); + } + j += 1; + } + if fades { acc * speed as f32 } else { acc } + }) + .collect() + }) + .collect() +} + // --------------------------------------------------------------------------------------------- // Generators and analysis diff --git a/crates/dsp/tests/it/fuzz.rs b/crates/dsp/tests/it/fuzz.rs index 0a1564a5..56659af4 100644 --- a/crates/dsp/tests/it/fuzz.rs +++ b/crates/dsp/tests/it/fuzz.rs @@ -46,6 +46,9 @@ proptest! { let _ = offline::detect_transients(&ch, a, a); let _ = offline::time_stretch(&ch, r, a); let _ = offline::pitch_shift(&ch, a, a); + let change = if n % 2 == 0 { offline::VariFiChange::SpeedUp } else { offline::VariFiChange::SlowDown }; + let selection = if n % 3 == 0 { offline::VariFiSelection::Extend } else { offline::VariFiSelection::FitTo }; + let _ = offline::vari_fi(&ch, change, selection, n % 5 == 0); let _ = offline::resample(&ch, n as u32, (n as u32).wrapping_mul(3) % 200_000); let mut m = ch.clone(); offline::normalize(&mut m, a, n % 2 == 0); diff --git a/crates/dsp/tests/it/main.rs b/crates/dsp/tests/it/main.rs index c214cd3e..276a1ca8 100644 --- a/crates/dsp/tests/it/main.rs +++ b/crates/dsp/tests/it/main.rs @@ -5,3 +5,4 @@ mod offline_meters; mod offline_normalize; mod plugins; mod transients; +mod vari_fi; diff --git a/crates/dsp/tests/it/vari_fi.rs b/crates/dsp/tests/it/vari_fi.rs new file mode 100644 index 00000000..6fe4075a --- /dev/null +++ b/crates/dsp/tests/it/vari_fi.rs @@ -0,0 +1,110 @@ +use soundcraft_dsp::offline::{self, VariFiChange, VariFiSelection}; + +/// A channel whose samples rise from 0 to 1, so each output sample shows where it was read. +fn ramp(len: usize) -> Vec { + (0..len).map(|i| i as f32 / len as f32).collect() +} + +/// The fraction of the played input reached at `u` (0 to 1) of the result. +fn slow_down_pos(u: f64) -> f64 { + (3.0 * u - u * u * u) / 2.0 +} + +fn speed_up_pos(u: f64) -> f64 { + 1.0 - slow_down_pos(1.0 - u) +} + +fn near(got: f32, want: f64, what: &str) { + assert!((f64::from(got) - want).abs() < 2e-3, "{what}: got {got}, want {want}"); +} + +#[test] +fn vari_fi_fit_to_keeps_the_length_and_plays_two_thirds() { + let len = 30_000; + let x = vec![ramp(len)]; + let slow = offline::vari_fi(&x, VariFiChange::SlowDown, VariFiSelection::FitTo, false); + assert_eq!(slow[0].len(), len); + // Slow Down plays the last two thirds: it starts at normal speed one third in and stops at the end. + for u in [0.0, 0.25, 0.5, 0.75, 0.9] { + let n = (u * len as f64) as usize; + near(slow[0][n], 1.0 / 3.0 + 2.0 / 3.0 * slow_down_pos(u), &format!("slow down at {u}")); + } + let step = |a: &[f32], n: usize| f64::from(a[n + 1] - a[n]) * len as f64; + assert!((step(&slow[0], 100) - 1.0).abs() < 0.01, "slow down starts at normal speed: {}", step(&slow[0], 100)); + assert!(step(&slow[0], len - 200) < 0.02, "slow down ends near a stop: {}", step(&slow[0], len - 200)); + + // Speed Up plays the first two thirds: it starts from a stop and reaches normal speed at the end. + let fast = offline::vari_fi(&x, VariFiChange::SpeedUp, VariFiSelection::FitTo, false); + assert_eq!(fast[0].len(), len); + for u in [0.1, 0.25, 0.5, 0.75, 0.99] { + let n = (u * len as f64) as usize; + near(fast[0][n], 2.0 / 3.0 * speed_up_pos(u), &format!("speed up at {u}")); + } + assert!(step(&fast[0], 200) < 0.02, "speed up starts near a stop: {}", step(&fast[0], 200)); + assert!((step(&fast[0], len - 100) - 1.0).abs() < 0.01, "speed up ends at normal speed: {}", step(&fast[0], len - 100)); +} + +#[test] +fn vari_fi_extend_plays_everything_over_150_percent() { + let len = 30_000; + let x = vec![ramp(len), ramp(len)]; + for change in [VariFiChange::SlowDown, VariFiChange::SpeedUp] { + let out = offline::vari_fi(&x, change, VariFiSelection::Extend, false); + assert_eq!(out.len(), 2); + assert_eq!(out[0].len(), 45_000, "{change:?}"); + assert_eq!(out[0], out[1], "{change:?}: both channels are read at the same positions"); + for u in [0.1, 0.25, 0.5, 0.75, 0.97] { + let n = (u * 45_000.0) as usize; + let pos = if change == VariFiChange::SlowDown { slow_down_pos(u) } else { speed_up_pos(u) }; + near(out[0][n], pos, &format!("{change:?} at {u}")); + } + } +} + +#[test] +fn vari_fi_fades_follow_the_speed() { + let len = 30_000; + let x = vec![vec![0.5; len]]; + let slow = offline::vari_fi(&x, VariFiChange::SlowDown, VariFiSelection::FitTo, true); + let fast = offline::vari_fi(&x, VariFiChange::SpeedUp, VariFiSelection::FitTo, true); + for u in [0.25, 0.5, 0.75] { + let n = (u * len as f64) as usize; + near(slow[0][n], 0.5 * (1.0 - u * u), &format!("slow down fades out at {u}")); + near(fast[0][n], 0.5 * (1.0 - (1.0 - u) * (1.0 - u)), &format!("speed up fades in at {u}")); + } + assert!(slow[0][len - 1].abs() < 1e-3 && fast[0][0].abs() < 1e-3, "the fades reach silence at the stop"); + // Fades Off keeps the level. + let off = offline::vari_fi(&x, VariFiChange::SlowDown, VariFiSelection::FitTo, false); + for u in [0.25, 0.5, 0.75, 0.9] { + near(off[0][(u * len as f64) as usize], 0.5, &format!("slow down without fades at {u}")); + } +} + +#[test] +fn vari_fi_pitch_falls_as_the_tape_slows() { + let sr = 48_000.0; + let len = 96_000; + let x = vec![(0..len).map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr).sin() * 0.5).collect::>()]; + let out = offline::vari_fi(&x, VariFiChange::SlowDown, VariFiSelection::Extend, false); + // Upward zero crossings per second in a 0.1 s window starting at `u`. + let rate = |u: f64| { + let a = (u * out[0].len() as f64) as usize; + let w = &out[0][a..a + 4800]; + w.windows(2).filter(|p| p[0] < 0.0 && p[1] >= 0.0).count() as f64 * 10.0 + }; + assert!((rate(0.0) - 1000.0).abs() < 20.0, "starts at the original pitch: {}", rate(0.0)); + let half = 1000.0 * 0.75; + assert!((rate(0.5) - half).abs() < 30.0, "three quarters speed halfway: {}", rate(0.5)); + assert!(rate(0.9) < 250.0, "low near the stop: {}", rate(0.9)); +} + +#[test] +fn vari_fi_handles_empty_and_uneven_channels() { + assert!(offline::vari_fi(&[], VariFiChange::SlowDown, VariFiSelection::Extend, true).is_empty()); + let out = offline::vari_fi(&[vec![], vec![]], VariFiChange::SpeedUp, VariFiSelection::FitTo, true); + assert!(out.iter().all(Vec::is_empty)); + // A shorter channel is read as silence past its end, and every channel gets the same length. + let out = offline::vari_fi(&[vec![0.5; 300], vec![0.5; 30]], VariFiChange::SlowDown, VariFiSelection::Extend, false); + assert_eq!((out[0].len(), out[1].len()), (450, 450)); + assert!(out.iter().flatten().all(|v| v.is_finite())); +} diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index ac2bde9f..60f49719 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -5,6 +5,7 @@ use soundcraft_time::Range; use crate::{Engine, Result, cmd}; use serde_json::{Value, json}; use soundcraft_audio_io::{AudioBuffer, FileFormat}; +use soundcraft_dsp::offline::{VariFiChange, VariFiSelection}; use soundcraft_model::{Clip, ClipContent, TrackKind}; pub fn specs() -> Vec { @@ -13,7 +14,7 @@ pub fn specs() -> Vec { "AudioSuite Process", [], None, - "{process: | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks)", + "{process: | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | vari_fi | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); vari_fi takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)", has_selection, process )] @@ -31,7 +32,16 @@ fn process(e: &mut Engine, p: &Value) -> Result { let known = soundcraft_dsp::plugin_info(&name).is_some() || matches!( name.as_str(), - "normalize" | "reverse" | "invert" | "gain" | "duplicate" | "time_stretch" | "pitch_shift" | "varispeed" | "dc_offset_removal" + "normalize" + | "reverse" + | "invert" + | "gain" + | "duplicate" + | "time_stretch" + | "pitch_shift" + | "varispeed" + | "vari_fi" + | "dc_offset_removal" ); if !known { return Err(bad("audiosuite.process", format!("unknown process `{name}`"))); @@ -50,12 +60,16 @@ fn process(e: &mut Engine, p: &Value) -> Result { let ratio = f64::from(get("ratio", 1.0).clamp(0.25, 4.0)); let semitones = get("semitones", 0.0).clamp(-24.0, 24.0); let to = (sr.as_f64() / f64::from(get("speed", 1.0).clamp(0.25, 4.0))).round().max(1.0) as u32; + let change = if get("change", 0.0) >= 0.5 { VariFiChange::SpeedUp } else { VariFiChange::SlowDown }; + let selection = if get("selection", 0.0) >= 0.5 { VariFiSelection::Extend } else { VariFiSelection::FitTo }; + let fades = get("fades", 1.0) >= 0.5; // These processes cut their output at MAX_OFFLINE_SAMPLES frames. let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize; let most = match name.as_str() { "time_stretch" => longest(ratio), "pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)), "varispeed" => longest(f64::from(to) / sr.as_f64()), + "vari_fi" => longest(if selection == VariFiSelection::Extend { 1.5 } else { 1.0 }), _ => usize::MAX, }; let mut done = 0; @@ -101,6 +115,7 @@ fn process(e: &mut Engine, p: &Value) -> Result { "time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, ratio, srf), "pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, semitones, srf), "varispeed" => soundcraft_dsp::offline::resample(&audio, sr.hz(), to), + "vari_fi" => soundcraft_dsp::offline::vari_fi(&audio, change, selection, fades), plugin => { apply(plugin, ¶ms, &mut audio, srf)?; audio @@ -242,6 +257,39 @@ mod tests { } } + #[test] + fn vari_fi_ramps_the_speed_and_extend_lengthens_the_clip() { + // A mono clip at 480 whose samples rise from 0 to 1, so each output sample shows where it was read. + let run = |params: Value| { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Ramp")); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![(0..4800).map(|i| i as f32 / 4800.0).collect()] }; + let src = crate::io::add_source(&mut s, "ramp", buf, None, FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(&mut s, t, Clip::audio(id, "ramp", src, 0, 480, 4800)); + let mut e = crate::Engine::new(s); + e.execute("audiosuite.process", &json!({"process": "vari_fi", "params": params, "clips": [id.0]})).unwrap(); + let c = e.session().tracks[0].clips()[0].clone(); + let audio = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0].clone(); + (c.start, c.length, c.name, audio) + }; + // The defaults: Slow Down, Fit To, Fades On. Halfway through, the read position is 11/16 of the + // way through the last two thirds, and the gain is the speed there, 3/4. + let (start, length, name, audio) = run(json!({})); + assert_eq!((start, length, name.as_str()), (480, 4800, "ramp-varifi")); + let want = (1.0 / 3.0 + 2.0 / 3.0 * 11.0 / 16.0) * 0.75; + assert!((audio[2400] - want).abs() < 2e-3, "slow down halfway: got {}, want {want}", audio[2400]); + assert!(audio[4790].abs() < 0.01, "the fade-out ends near silence, got {}", audio[4790]); + // Speed Up with Extend and no fades: all of the clip is played over 150 % of its length, and the + // clip still starts at 480. + let (start, length, _, audio) = run(json!({"change": 1, "selection": 1, "fades": 0})); + assert_eq!((start, length), (480, 7200)); + assert!((audio[3600] - 5.0 / 16.0).abs() < 2e-3, "speed up halfway: got {}", audio[3600]); + let r = 1.0 - 7100.0 / 7200.0; + let want = 1.0 - (3.0 * r - r * r * r) / 2.0; + assert!((audio[7100] - want).abs() < 2e-3, "speed up near the end: got {}, want {want}", audio[7100]); + } + #[test] fn signal_generator_fills_the_selection_with_a_clip() { let (mut e, _) = gained_clip(); diff --git a/crates/engine/tests/it/long_renders.rs b/crates/engine/tests/it/long_renders.rs index 5e00f656..03f15cc1 100644 --- a/crates/engine/tests/it/long_renders.rs +++ b/crates/engine/tests/it/long_renders.rs @@ -48,6 +48,21 @@ fn audiosuite_refuses_results_longer_than_one_render() { } } +#[test] +fn vari_fi_refuses_mono_clips_longer_than_one_render() { + // A mono clip may be longer than one offline buffer, so Vari-Fi must refuse it rather than cut its ramp short. + for (frames, selection) in [(MAX_OFFLINE_SAMPLES + 1, 0), (MAX_OFFLINE_SAMPLES * 2 / 3 + 1, 1)] { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Talk")); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 48_000]] }; + let src = soundcraft_engine::io::add_source(&mut s, "talk", buf, None, FileFormat::Wav); + let id = s.new_clip_id(); + soundcraft_engine::edit::place_clip(&mut s, t, Clip::audio(id, "Talk", src, 0, 0, frames as i64)); + let mut e = Engine::new(s); + refused(&mut e, "audiosuite.process", &json!({"process": "vari_fi", "params": {"selection": selection}, "clips": [id.0]})); + } +} + #[test] fn signal_generator_refuses_a_selection_longer_than_one_render() { let (mut e, _) = long_clip(48_000); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d752c16e..35a63659 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -732,6 +732,13 @@ fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) { ("event.tempo".into(), json!({"bpm": bpm, "at": at})) } +/// Vari-Fi's choices in the AudioSuite window: param id, label, default, and (value, name) pairs. +const VARI_FI_CHOICES: [(&str, &str, f32, &[(f32, &str)]); 3] = [ + ("change", "Change", 0.0, &[(0.0, "Slow Down"), (1.0, "Speed Up")]), + ("selection", "Selection", 0.0, &[(0.0, "Fit To"), (1.0, "Extend")]), + ("fades", "Fades", 1.0, &[(1.0, "On"), (0.0, "Off")]), +]; + fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { let Some(process) = app.ui.audiosuite.clone() else { return }; let mut open = true; @@ -762,6 +769,17 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { let v = params.entry("speed".into()).or_insert(1.0); ui.add(egui::Slider::new(v, 0.25..=4.0).text("Speed")); } + "vari_fi" => { + for (id, label, default, choices) in VARI_FI_CHOICES { + let v = params.entry(id.to_string()).or_insert(default); + ui.horizontal(|ui| { + ui.label(label); + for &(value, name) in choices { + ui.radio_value(v, value, name); + } + }); + } + } _ => { ui.label("No parameters."); } @@ -823,6 +841,30 @@ mod tests { assert!(matches!(d.open, Some(Dialog::RenameClip { id, ref name }) if id == clip.id && *name == clip.name)); } + #[test] + fn vari_fi_menu_opens_the_speed_ramp_and_its_choices_reach_the_engine() { + let mut app = SoundApp::new(Engine::default(), None, crate::Services::default()); + crate::menus::invoke_menu(&mut app, "audiosuite.process", "AudioSuite > Pitch Shift > Vari-Fi"); + assert_eq!(app.ui.audiosuite.as_deref(), Some("vari_fi")); + // Renders the Kick clip with `params` and returns its new length and samples. + let render = |params: Value| { + let mut e = soundcraft_engine::demo::demo_engine(); + let clip = e.session().track_by_name("Kick").unwrap().clips()[0].id; + e.execute("audiosuite.process", &json!({"process": "vari_fi", "params": params, "clips": [clip.0]})).unwrap(); + let c = e.session().track_by_name("Kick").unwrap().clips()[0].clone(); + (c.length, e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels.clone()) + }; + let defaults: serde_json::Map = VARI_FI_CHOICES.iter().map(|(id, _, d, _)| (id.to_string(), json!(d))).collect(); + let (length, audio) = render(Value::Object(defaults)); + assert!(render(json!({})) == (length, audio.clone()), "the window's defaults are the engine's"); + let (extended, _) = render(json!({"selection": 1.0})); + assert_eq!(extended, (length as f64 * 1.5).round() as i64, "Extend makes the clip 150 % long"); + let (_, fast) = render(json!({"change": 1.0})); + assert_ne!(fast, audio, "Speed Up renders a different ramp"); + let (_, unfaded) = render(json!({"fades": 0.0})); + assert_ne!(unfaded, audio, "Fades Off renders without the fade"); + } + #[test] fn tempo_change_dialog_runs_a_real_command_at_its_position() { let (cmd, params) = tempo_change_action(96_000, 133.5); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c38ef84d..bd20ac50 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -128,7 +128,7 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[ ("AudioSuite > Pitch Shift > Pitch II", "pitch_shift"), ("AudioSuite > Pitch Shift > Pitch Shift Legacy", "pitch_shift"), ("AudioSuite > Pitch Shift > Time Shift", "time_stretch"), - ("AudioSuite > Pitch Shift > Vari-Fi", "varispeed"), + ("AudioSuite > Pitch Shift > Vari-Fi", "vari_fi"), ("AudioSuite > Reverb > D-Verb", "room_reverb"), ("AudioSuite > Delay > Mod Delay III", "mod_delay"), ("AudioSuite > Modulation > Sci-Fi", "flanger"), diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index 6aa796b5..d4ce12e2 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 (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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (AudioSuite speed ramps (vari_fi); previously trivial: Sampler ping-pong loop phase (#463); 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 @@ -19,7 +19,7 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories. | Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth | | Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `chorus`, `flanger`, `phaser` | Tremolo, auto-pan, ring modulation | | 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 | +| Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift`; the AudioSuite speed ramp (`vari_fi`) slows a clip down to a stop or speeds it up from one, fitted to the clip's length or extended to 150 %, with or without a fade. | Formant control, 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). 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** | | @@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | AudioSuite speed ramps (`vari_fi`): down to a stop and up from one, Fit To or Extend, fades on or off | | 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 | From c5ddbbafd1f801594424c3ccdc063d24af676826 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 02:55:17 -0700 Subject: [PATCH 58/90] Autosave: allow the Unix-only mut, so clippy passes on Windows Autosave::create builds each session folder with a DirBuilder that only the #[cfg(unix)] mode call mutates. On Windows, clippy -D warnings reports the binding as unused_mut, and cargo xtask ci stops at its clippy step. The binding now carries #[cfg_attr(not(unix), allow(unused_mut))], as the recovery test's paths in the same file does. Nothing changes at run time. --- crates/engine/src/autosave.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 09a63f4f..7c6c7c54 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -186,6 +186,7 @@ impl Autosave { let mut n = 1; loop { // Not recursive, so a folder that already exists is reported and the next name tried. + #[cfg_attr(not(unix), allow(unused_mut))] let mut builder = std::fs::DirBuilder::new(); #[cfg(unix)] std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); From b322ff2f13eb7bfee2f4001c687dea3369f40499 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 03:19:55 -0700 Subject: [PATCH 59/90] CLI tests: the several-roots test uses plain paths, so it passes on Windows cli::mcp_automation_roots_take_several_folders built its folders from std::fs::canonicalize, which returns \\?\C:\... on Windows. The server prints its roots without that prefix (file_access::plain), so the stderr check never matched. The test also sent \\?\ paths with `..` in them, which Windows does not normalize, so that call failed with "cannot resolve" instead of "outside the read roots". A test helper now canonicalizes and strips \\?\ (and \\?\UNC\) on Windows, as file_access::plain does, and the test builds every path from it. On macOS and Linux the paths are unchanged. --- apps/soundcraft-cli/tests/it/cli.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/soundcraft-cli/tests/it/cli.rs b/apps/soundcraft-cli/tests/it/cli.rs index ebc92cc5..2921f4a8 100644 --- a/apps/soundcraft-cli/tests/it/cli.rs +++ b/apps/soundcraft-cli/tests/it/cli.rs @@ -24,6 +24,20 @@ fn tmpdir(tag: &str) -> PathBuf { dir } +/// `path` made absolute with links followed (`/var` becomes `/private/var` on macOS), as the server +/// prints a root: without the `\\?\` prefix that `canonicalize` adds on Windows. +fn resolved(path: &Path) -> PathBuf { + let path = std::fs::canonicalize(path).unwrap(); + if !cfg!(windows) { + return path; + } + let text = path.to_str().unwrap(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{rest}")); + } + text.strip_prefix(r"\\?\").map_or(path.clone(), PathBuf::from) +} + /// Send `calls` (tool name, arguments) to `soundcraft-cli mcp `; returns whether it exited /// cleanly, its stderr, and the stdout lines after `initialize`, one per call. fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec) { @@ -114,8 +128,7 @@ fn mcp_automation_roots_confine_the_server() { /// the two roots' common parent, `..` out of a root and a link out of one stay refused. #[test] fn mcp_automation_roots_take_several_folders() { - // Resolved, as the server shows the roots (`/var` is `/private/var` on macOS). - let base = std::fs::canonicalize(tmpdir("several")).unwrap(); + let base = resolved(&tmpdir("several")); for d in ["in", "in2", "out", "out2", "outside"] { std::fs::create_dir_all(base.join(d)).unwrap(); } From 40ced8132bc7744706fa2e3ab357fc84d01a72fb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 04:49:24 -0700 Subject: [PATCH 60/90] Add a built-in Subharmonic plugin The built-in plugins had no subharmonic synthesizer. Subharmonic (`subharmonic`, Harmonic category, also offline as AudioSuite) adds the octave below a lower and an upper band of the input. The Range picks the two band frequencies (120-180, 80-120, 60-90 or 40-60 Hz). Each band band-passes the input at its frequency and multiplies it by a flip-flop that changes sign on every rising zero crossing, which moves the band an octave down at a level that follows the band. A direct band adds the input's own bass. Each band has a gain from -120 to +24 dB and a solo; a solo also mutes the dry signal. Drive raises the summed bands into one of six saturation characters, and HP and LP filters (5 to 1000 Hz, Q 0.1 to 5) shape the result. Below 50 % Mix the dry signal stays at full level, above 50 % the wet signal does, and Wet Solo mutes the dry signal. Input and Output run from -18 to +18 dB. The Total row in docs/plugin-parity.md now counts 25 processors and 3 instruments, the entries in the built-in registry, and its Instruments row lists `sampler` and no longer lists the Sampler as missing. --- README.md | 6 +- ROADMAP.md | 4 +- crates/dsp/src/plugins/harmonic.rs | 294 ++++++++++++++++++++++++++++- crates/dsp/src/plugins/mod.rs | 2 + crates/dsp/tests/it/plugins.rs | 97 ++++++++++ docs/plugin-parity.md | 9 +- 6 files changed, 401 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c31fc87a..101ca792 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,9 @@ where things are. Trim passes recorded live from the faders; thin, glide, coalesce and convert to clip gain. - **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer, channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi, - rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither, - a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG across the - keyboard), all original. **CLAP plugins** load too. Any processor + rectifier, subharmonic, pitch shifter, time shift, gain, trim, invert, DC removal, signal + generator, dither, a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, + MP3 or OGG across the keyboard), all original. **CLAP plugins** load too. Any processor runs offline as AudioSuite, and user presets save per plugin. - **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files, diff --git a/ROADMAP.md b/ROADMAP.md index f214e1e0..94c76e2a 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-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-24 · **Change:** trivial (Subharmonic plugin) · **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-24:** A built-in Subharmonic (`subharmonic`) adds the octave below a lower and an upper band of the input, chosen from four frequency ranges, with a direct bass band, a gain and a solo for each band, Drive with six characters, HP and LP filters, and a Mix that keeps one path at full level. No readiness percentage change. - **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change. - **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-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change. @@ -161,6 +162,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Progress log: Subharmonic plugin; readiness estimates unchanged | | 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged | | 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged | | 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged | diff --git a/crates/dsp/src/plugins/harmonic.rs b/crates/dsp/src/plugins/harmonic.rs index acc3f3b5..4d2ca243 100644 --- a/crates/dsp/src/plugins/harmonic.rs +++ b/crates/dsp/src/plugins/harmonic.rs @@ -1,7 +1,9 @@ -//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction) and Rectifier. +//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction), Rectifier and +//! Subharmonic (octave-down bass synthesis). -use crate::params::{Params, choice, lin, log, param_plumbing}; -use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr}; +use crate::biquad::{Biquad, FilterType}; +use crate::params::{Params, choice, lin, log, param_plumbing, toggle}; +use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr, time_coef}; use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain}; static SAT_PARAMS: [ParamInfo; 5] = [ @@ -307,3 +309,289 @@ impl Plugin for Rectifier { } } } + +// --------------------------------------------------------------------------------------------- + +const SUB_RANGES: &[&str] = &["120-180 Hz", "80-120 Hz", "60-90 Hz", "40-60 Hz"]; +/// Lower and upper band frequency of each range. +const SUB_RANGE_HZ: [(f32, f32); 4] = [(120.0, 180.0), (80.0, 120.0), (60.0, 90.0), (40.0, 60.0)]; +const SUB_CHARACTERS: &[&str] = &["Clean I", "Clean II", "Resonant I", "Resonant II", "Distort I", "Distort II"]; +/// Band gains at or below this are off. +const SUB_OFF_DB: f32 = -120.0; +/// Q of the band-pass that picks each band out of the input. +const SUB_BAND_Q: f32 = 2.0; +/// Cutoff of the low-pass after each divider, as a fraction of the band frequency. +const SUB_SMOOTH: f32 = 0.75; +/// Zero-crossing hysteresis, as a fraction of the band's envelope. +const SUB_HYSTERESIS: f32 = 0.1; +/// Release of the band envelope that sets the hysteresis. +const SUB_ENV_RELEASE_MS: f32 = 50.0; +/// Bias of the asymmetric Distort I curve. +const SUB_ASYM: f32 = 0.25; + +static SUB_PARAMS: [ParamInfo; 17] = [ + lin("input", "Input", -18.0, 18.0, 0.0, Unit::Db), + choice("range", "Range", SUB_RANGES, 2), + lin("lower_gain", "Lower Gain", SUB_OFF_DB, 24.0, 0.0, Unit::Db), + lin("upper_gain", "Upper Gain", SUB_OFF_DB, 24.0, -6.0, Unit::Db), + lin("direct_gain", "Direct Gain", SUB_OFF_DB, 24.0, SUB_OFF_DB, Unit::Db), + toggle("lower_solo", "Lower Solo", false), + toggle("upper_solo", "Upper Solo", false), + toggle("direct_solo", "Direct Solo", false), + lin("drive", "Drive", -18.0, 18.0, 0.0, Unit::Db), + choice("character", "Character", SUB_CHARACTERS, 0), + log("hp_freq", "HP Freq", 5.0, 1000.0, 20.0, Unit::Hz), + log("hp_q", "HP Q", 0.1, 5.0, 0.707, Unit::None), + log("lp_freq", "LP Freq", 5.0, 1000.0, 200.0, Unit::Hz), + log("lp_q", "LP Q", 0.1, 5.0, 0.707, Unit::None), + lin("mix", "Mix", 0.0, 100.0, 50.0, Unit::Percent), + toggle("wet_solo", "Wet Solo", false), + lin("output", "Output", -18.0, 18.0, 0.0, Unit::Db), +]; + +pub static SUB_INFO: PluginInfo = PluginInfo { + id: "subharmonic", + name: "Subharmonic", + short_name: "SubHarm", + category: Category::Harmonic, + params: &SUB_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// One synthesized band: a band-pass, a divide-by-two flip-flop and a smoothing low-pass. +#[derive(Debug, Clone, Copy)] +struct SubBand { + band: Biquad, + smooth: Biquad, + /// Divider output: ±1, flipped on every rising zero crossing of the band. + flip: f32, + /// The band has gone below the negative threshold since the last flip. + armed: bool, + env: f32, +} + +impl SubBand { + fn new() -> Self { + Self { band: Biquad::default(), smooth: Biquad::default(), flip: 1.0, armed: false, env: 0.0 } + } + + fn reset(&mut self) { + self.band.reset(); + self.smooth.reset(); + self.flip = 1.0; + self.armed = false; + self.env = 0.0; + } + + /// The octave below the band's part of `x`; `rel` is the envelope's release coefficient. + #[inline] + fn process(&mut self, x: f32, rel: f32) -> f32 { + let b = self.band.process_sample(x); + let a = b.abs(); + self.env = flush(if a > self.env { a } else { self.env * rel }); + let th = self.env * SUB_HYSTERESIS; + if b < -th { + self.armed = true; + } else if self.armed && b > th { + self.armed = false; + self.flip = -self.flip; + } + self.smooth.process_sample(b * self.flip) + } +} + +/// Filter and divider state of one channel. +#[derive(Debug, Clone, Copy)] +struct SubChannel { + /// The lower and the upper band. + bands: [SubBand; 2], + /// Low-pass that takes the direct bass out of the input. + direct: Biquad, + hp: Biquad, + lp: Biquad, + dc: DcBlocker, +} + +impl SubChannel { + fn new(sr: f32) -> Self { + Self { bands: [SubBand::new(); 2], direct: Biquad::default(), hp: Biquad::default(), lp: Biquad::default(), dc: DcBlocker::new(5.0, sr) } + } + + fn reset(&mut self) { + self.bands.iter_mut().for_each(SubBand::reset); + self.direct.reset(); + self.hp.reset(); + self.lp.reset(); + self.dc.reset(); + } +} + +/// Subharmonic synthesizer. The Range sets a lower and an upper band frequency. Each band +/// band-passes the input at its frequency, and a divide-by-two flip-flop on the band's rising zero +/// crossings multiplies the band, which moves it an octave down at a level that follows the band. +/// The direct band is the input low-passed at the upper band frequency. +/// +/// - The three bands are summed with their gains, raised by Drive and saturated, then +/// shaped by the HP and LP filters. A soloed band plays alone and mutes the dry signal. +/// - Clean I saturates with tanh and Clean II with a softer algebraic curve. Resonant I and II use +/// tanh and raise the Q of the low-pass after each divider to 2 and 4. Distort I bends the curve +/// asymmetrically (even harmonics, DC removed) and Distort II clips hard at full scale. +/// - Below 50 % Mix the dry signal stays at full level and the wet level falls; above 50 % the wet +/// signal stays at full level and the dry level falls. Wet Solo mutes the dry signal. +pub struct SubHarmonic { + p: Params, + sr: f32, + ch: Vec, + input: Smoother, + bands: [Smoother; 3], + drive: Smoother, + dry: Smoother, + wet: Smoother, + out: Smoother, + env_rel: f32, +} + +impl Default for SubHarmonic { + fn default() -> Self { + Self::new() + } +} + +impl SubHarmonic { + pub fn new() -> Self { + let mut s = Self { + p: Params::new(&SUB_PARAMS), + sr: 48_000.0, + ch: Vec::new(), + input: Smoother::new(1.0), + bands: [Smoother::new(0.0); 3], + drive: Smoother::new(1.0), + dry: Smoother::new(1.0), + wet: Smoother::new(1.0), + out: Smoother::new(1.0), + env_rel: 0.0, + }; + s.update(); + s + } + + /// The lower and upper band frequencies of the current range. + fn range_hz(&self) -> (f32, f32) { + SUB_RANGE_HZ.get(self.p.choice(1)).copied().unwrap_or((60.0, 90.0)) + } + + /// Q of the low-pass after each divider for the current Drive Character. + fn smooth_q(&self) -> f32 { + match self.p.choice(9) { + 2 => 2.0, + 3 => 4.0, + _ => std::f32::consts::FRAC_1_SQRT_2, + } + } + + fn update(&mut self) { + let gain = |db: f32| if db <= SUB_OFF_DB { 0.0 } else { db_to_gain(db) }; + let solo = [self.p.on(5), self.p.on(6), self.p.on(7)]; + let any_solo = solo.contains(&true); + for (k, s) in self.bands.iter_mut().enumerate() { + let heard = !any_solo || solo.get(k).copied().unwrap_or(false); + s.set(if heard { gain(self.p.v(2 + k)) } else { 0.0 }); + } + self.input.set(db_to_gain(self.p.v(0))); + self.drive.set(db_to_gain(self.p.v(8))); + self.out.set(db_to_gain(self.p.v(16))); + let m = self.p.v(14) / 100.0; + self.wet.set((2.0 * m).min(1.0)); + self.dry.set(if any_solo || self.p.on(15) { 0.0 } else { (2.0 * (1.0 - m)).min(1.0) }); + self.env_rel = time_coef(SUB_ENV_RELEASE_MS, self.sr); + + let (lo, hi) = self.range_hz(); + let sq = self.smooth_q(); + let sr = self.sr; + let (hp_f, hp_q, lp_f, lp_q) = (self.p.v(10), self.p.v(11), self.p.v(12), self.p.v(13)); + for c in &mut self.ch { + for (b, f) in c.bands.iter_mut().zip([lo, hi]) { + b.band.set(FilterType::BandPass, f, SUB_BAND_Q, 0.0, sr); + b.smooth.set(FilterType::LowPass, f * SUB_SMOOTH, sq, 0.0, sr); + } + c.direct.set(FilterType::LowPass, hi, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr); + c.hp.set(FilterType::HighPass, hp_f, hp_q, 0.0, sr); + c.lp.set(FilterType::LowPass, lp_f, lp_q, 0.0, sr); + } + } + + fn smoothers(&mut self) -> impl Iterator { + [&mut self.input, &mut self.drive, &mut self.dry, &mut self.wet, &mut self.out].into_iter().chain(self.bands.iter_mut()) + } +} + +/// Saturation curve of each Drive Character (index into `SUB_CHARACTERS`). +fn sub_saturate(character: usize, x: f32) -> f32 { + match character { + 1 => x / (1.0 + x * x).sqrt(), + 4 => ((x + SUB_ASYM).tanh() - SUB_ASYM.tanh()) / (1.0 + SUB_ASYM.tanh()), + 5 => x.clamp(-1.0, 1.0), + _ => x.tanh(), + } +} + +impl Plugin for SubHarmonic { + param_plumbing!(SUB_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.ch = vec![SubChannel::new(self.sr); sane_channels(channels)]; + let sr = self.sr; + for s in self.smoothers() { + s.set_time(20.0, sr); + } + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.ch.iter_mut().for_each(SubChannel::reset); + for s in self.smoothers() { + s.snap(); + } + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.ch.len().min(io.len()); + let frames = frames_in(io, frames, ch); + let character = self.p.choice(9); + let rel = self.env_rel; + for n in 0..frames { + let gin = self.input.next(); + let g = [self.bands[0].next(), self.bands[1].next(), self.bands[2].next()]; + let drive = self.drive.next(); + let dg = self.dry.next(); + let wg = self.wet.next(); + let o = self.out.next(); + for c in 0..ch { + let (Some(buf), Some(st)) = (io.get_mut(c), self.ch.get_mut(c)) else { continue }; + let Some(x) = buf.get_mut(n) else { continue }; + let dry = if x.is_finite() { *x * gin } else { 0.0 }; + let mut sum = st.direct.process_sample(dry) * g[2]; + for (band, gain) in st.bands.iter_mut().zip(g) { + sum += band.process(dry, rel) * gain; + } + let mut y = sub_saturate(character, sum * drive); + if character == 4 { + y = st.dc.process(y); + } + let wet = st.lp.process_sample(st.hp.process_sample(y)); + *x = (dry * dg + wet * wg) * o; + } + } + } + + fn tail_samples(&self) -> usize { + // Time for the slowest, most resonant filter to ring down by 60 dB (6.9 time constants). + let (lo, _) = self.range_hz(); + let f = self.p.v(10).min(self.p.v(12)).min(lo * SUB_SMOOTH).max(1.0); + let q = self.p.v(11).max(self.p.v(13)).max(self.smooth_q()).max(SUB_BAND_Q); + (6.9 * q / (std::f32::consts::PI * f) * self.sr) as usize + } +} diff --git a/crates/dsp/src/plugins/mod.rs b/crates/dsp/src/plugins/mod.rs index aef3e39d..6443dc3d 100644 --- a/crates/dsp/src/plugins/mod.rs +++ b/crates/dsp/src/plugins/mod.rs @@ -32,6 +32,7 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[ &harmonic::SAT_INFO, &harmonic::LOFI_INFO, &harmonic::RECT_INFO, + &harmonic::SUB_INFO, &pitch::PITCH_INFO, &utility::SHIFT_INFO, &utility::GAIN_INFO, @@ -64,6 +65,7 @@ pub(crate) fn instantiate(id: &str) -> Option> { "saturator" => Box::new(harmonic::Saturator::new()), "lofi" => Box::new(harmonic::LoFi::new()), "rectifier" => Box::new(harmonic::Rectifier::new()), + "subharmonic" => Box::new(harmonic::SubHarmonic::new()), "pitch_shifter" => Box::new(pitch::PitchShifter::new()), "time_shift" => Box::new(utility::TimeShift::new()), "gain" => Box::new(utility::Gain::new()), diff --git a/crates/dsp/tests/it/plugins.rs b/crates/dsp/tests/it/plugins.rs index ad55d838..71b2bb5f 100644 --- a/crates/dsp/tests/it/plugins.rs +++ b/crates/dsp/tests/it/plugins.rs @@ -513,6 +513,103 @@ fn saturator_adds_harmonics_and_stays_bounded() { assert!(spec[third - 1..=third + 1].iter().cloned().fold(f32::MIN, f32::max) > -30.0); } +/// Amplitude of the `freq` component of `x` (a whole number of cycles keeps it exact). +fn tone_level(x: &[f32], freq: f32) -> f32 { + let (mut re, mut im) = (0.0f64, 0.0f64); + for (i, v) in x.iter().enumerate() { + let w = f64::from(TAU * freq / SR) * i as f64; + re += f64::from(*v) * w.cos(); + im += f64::from(*v) * w.sin(); + } + (2.0 * (re * re + im * im).sqrt() / x.len() as f64) as f32 +} + +/// A Subharmonic with only the wet path and wide filters, so a band's octave reaches the output. +fn subharmonic_wet() -> Box { + let mut p = prepared("subharmonic"); + p.set_param("mix", 100.0); + p.set_param("hp_freq", 5.0); + p.set_param("lp_freq", 1000.0); + p +} + +#[test] +fn subharmonic_adds_the_octave_below_each_range_band() { + for (range, lower, upper) in [(0.0, 120.0, 180.0), (1.0, 80.0, 120.0), (2.0, 60.0, 90.0), (3.0, 40.0, 60.0)] { + for (band, freq) in [("lower", lower), ("upper", upper)] { + let mut p = subharmonic_wet(); + p.set_param("range", range); + p.set_param("lower_gain", if band == "lower" { 0.0 } else { -120.0 }); + p.set_param("upper_gain", if band == "upper" { 0.0 } else { -120.0 }); + let out = run(p.as_mut(), &[sine(freq, 0.5, 96_000)]); + // One second after the filters settle: a whole number of cycles of both tones. + let tail = &out[0][48_000..]; + let sub = tone_level(tail, freq / 2.0); + assert!(sub > 0.2, "range {range} {band} band: {sub} at {} Hz", freq / 2.0); + assert!(sub > 4.0 * tone_level(tail, freq), "range {range} {band} band keeps its input tone"); + } + } +} + +#[test] +fn subharmonic_mix_keeps_one_path_at_full_level() { + // A 1 kHz tone is far above every band, so the output is the dry path alone. + for (mix, dry) in [(0.0, 1.0), (25.0, 1.0), (50.0, 1.0), (75.0, 0.5), (100.0, 0.0)] { + let mut p = prepared("subharmonic"); + p.set_param("mix", mix); + let out = run(p.as_mut(), &[sine(1000.0, 0.5, 48_000)]); + let level = tone_level(&out[0][24_000..], 1000.0); + assert!((level - 0.5 * dry).abs() < 0.01, "mix {mix}: dry level {level}"); + } + // The wet path is at full level from 50 % up and at half level at 25 %. + let wet = |mix: f32| { + let mut p = prepared("subharmonic"); + p.set_param("mix", mix); + p.set_param("wet_solo", 1.0); + let out = run(p.as_mut(), &[sine(60.0, 0.5, 96_000)]); + tone_level(&out[0][48_000..], 30.0) + }; + let full = wet(50.0); + assert!(full > 0.1, "wet level {full}"); + assert!((wet(100.0) - full).abs() < 0.01 * full); + assert!((wet(25.0) - 0.5 * full).abs() < 0.01 * full); +} + +#[test] +fn subharmonic_solo_plays_one_band_and_mutes_the_dry_signal() { + let level = |solo: &str, gain: f32| { + let mut p = prepared("subharmonic"); + p.set_param("lower_gain", gain); + p.set_param(solo, 1.0); + let x = sine(60.0, 0.5, 96_000).iter().zip(sine(1000.0, 0.5, 96_000)).map(|(a, b)| a + b).collect(); + let out = run(p.as_mut(), &[x]); + let tail = &out[0][48_000..]; + (tone_level(tail, 30.0), tone_level(tail, 1000.0)) + }; + let (sub, dry) = level("lower_solo", 0.0); + assert!(sub > 0.1 && dry < 0.01, "lower solo: sub {sub}, dry {dry}"); + // With the soloed band turned off nothing else plays, though the upper band is on. + let (sub, dry) = level("lower_solo", -120.0); + assert!(sub < 0.01 && dry < 0.01, "silent lower solo: sub {sub}, dry {dry}"); + let (sub, dry) = level("upper_solo", -120.0); + assert!(sub > 0.05 && dry < 0.01, "upper solo: sub {sub}, dry {dry}"); +} + +#[test] +fn subharmonic_drive_keeps_every_character_bounded() { + for character in 0..6 { + let mut p = subharmonic_wet(); + p.set_param("input", 18.0); + p.set_param("lower_gain", 24.0); + p.set_param("upper_gain", 24.0); + p.set_param("drive", 18.0); + p.set_param("character", character as f32); + let out = run(p.as_mut(), &[sine(60.0, 1.0, 48_000)]); + let top = peak(&out[0][24_000..]); + assert!(top > 0.5 && top < 1.5, "character {character}: peak {top}"); + } +} + #[test] fn lofi_quantizes() { let mut p = prepared("lofi"); diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index 6aa796b5..3a90d569 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 (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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (Subharmonic; previously trivial: Sampler ping-pong loop phase (#463); 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 @@ -18,11 +18,11 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories. | Reverb | algorithmic room/plate/hall; convolution (paid tier) | `room_reverb`, `plate_reverb` | Hall/chamber, convolution, early-reflection editor | | Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth | | Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `chorus`, `flanger`, `phaser` | Tremolo, auto-pan, ring modulation | -| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape | `lofi`, `rectifier`, `saturator` | Amp and cabinet models, tape | +| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape, subharmonic synthesis | `lofi`, `rectifier`, `saturator`, `subharmonic` (octave-down bass from a lower and an upper band, direct bass, drive characters, HP and LP filters) | Amp and cabinet models, tape; subharmonic MIDI tuning and surround (LFE) version | | 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). 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** | | +| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth`, `sampler`; `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). | Piano, organ, wavetable, content library | +| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **25 processors + 3 instruments** | | AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in `crates/ui-egui/src/menus.rs`), so the AudioSuite menu is 100 % present. Depth: no preview, @@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Harmonic: Subharmonic (`subharmonic`) | | 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 | From dc9e84978fb3d03ccaffce1846581f346d2670cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 07:19:34 -0700 Subject: [PATCH 61/90] Docs: the built-in plugin counts match the registry, and the sampler is no longer listed as missing The plugin registry in crates/dsp/src/plugins/mod.rs holds 25 processors and 3 instruments, as docs/plugin-parity.md already says, but gaps.md and target-app-parity.md still said 26 processors and 2 instruments, and roadmap.md said 26 built-in processors. At 3bc2d92 the registry held 24 processors and 2 instruments, so the code snapshot in target-app-parity.md now gives those counts. The Sampler merged in #108, but G-20, the beta plan's plugin essentials and the MIDI and instruments row still listed it as missing or as an open pull request. --- docs/gaps.md | 9 +++++---- docs/roadmap.md | 7 ++++--- docs/target-app-parity.md | 9 +++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index ac8587d5..67353d3c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (G-04: Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (G-08 and G-20: built-in plugin count, the sampler shipped) · **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** @@ -34,7 +34,7 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t | G-17 | Video: HEVC/DNx decode, video export, external timecode | Features / Formats | [file-format-parity.md](file-format-parity.md) | 30–50 | | G-18 | Speech-to-text transcription | AI | this file | 30–50 | | G-19 | UI feel: generic plugin windows, shortcut collisions, pro-grade meters and scaling | UI/UX | [ui-parity.md](ui-parity.md) | 60–100 | -| G-20 | MIDI/instrument depth: MIDI learn, sampler, notation editing | Features | [target-app-parity.md](target-app-parity.md) | 40–60 | +| G-20 | MIDI/instrument depth: MIDI learn, notation editing | Features | [target-app-parity.md](target-app-parity.md) | 40–60 | | G-21 | Compressed export (MP3, AAC/M4A, Ogg/Opus) | File formats | [file-format-parity.md](file-format-parity.md) | 10–20 | | G-22 | Avid cloud services (collaboration, Sketch, Splice, sign-in) | Ecosystem | — | not estimated (owner decision) | @@ -137,7 +137,7 @@ launch #142; Linux #45, #107. ### G-08. Built-in plugins -See [`plugin-parity.md`](plugin-parity.md): 26 processors + 2 instruments against Pro Tools' +See [`plugin-parity.md`](plugin-parity.md): 25 processors + 3 instruments against Pro Tools' roughly 80 bundled processors and instruments; no fidelity checks; generic parameter UIs; EQ parametric request #139. ≈ 80–120 h. @@ -204,7 +204,7 @@ See [`ui-parity.md`](ui-parity.md). ≈ 60–100 h. ### G-20. MIDI and instrument depth -MIDI learn, a sampler (PR #108), drum machine depth, notation editing (tuplets, lyrics, +MIDI learn, drum machine depth, notation editing (tuplets, lyrics, articulations, parts). ≈ 40–60 h. ### G-21. Compressed export @@ -221,6 +221,7 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | G-08: 25 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing | | 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained | | 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged | | 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index c24f4255..dc639f92 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # SoundCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (M8 batch limit validation) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (built-in plugin count; the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Forward-looking plan. Status and numbers: [`../ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the ranked work list: [`gaps.md`](gaps.md). @@ -26,7 +26,7 @@ main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are th | Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Default output and input devices only. Fixed 512-sample block. Separate input and output streams. `setup.playback_engine` params ignored by playback. Takes held in RAM until stop (PR #84). No input device choice (#42). | 35–55 | | Record MIDI from a controller and play it back through an instrument | **no** | Playback of drawn and imported MIDI works. No MIDI device layer at all, so nothing can be recorded and instruments can't be played live (G-02). | 20–35 | | Edit: select, trim, fade, comp playlists, Slip/Shuffle/Grid | yes (rough) | Every Edit menu command; property-tested with undo; open bugs #3, #5, #140 don't block the workflow | — | -| Mix with built-in and third-party plugins, sends, busses, automation | partial (passes on CLAP/AU) | 26 built-in processors; CLAP and AU host on the signed macOS build, but its missing entitlement blocks third-party VST3 (#153). No AU plugin windows yet; VST3 windows on macOS only | 1–2 (entitlement) | +| Mix with built-in and third-party plugins, sends, busses, automation | partial (passes on CLAP/AU) | 25 built-in processors; CLAP and AU host on the signed macOS build, but its missing entitlement blocks third-party VST3 (#153). No AU plugin windows yet; VST3 windows on macOS only | 1–2 (entitlement) | | Bounce mix and stems; save and reopen the session | yes (rough) | WAV/BWF/AIFF/FLAC bounce, stems, `.scraft` save/reopen with plugin state; bugs #59, #103 in bus bounce and aux latency (PRs open) | — | | Exchange a session with Pro Tools or a picture editor | **no** | No `.ptx`, AAF or OMF import or export (#40, G-03) | 50–90 | @@ -68,7 +68,7 @@ Beta needs ~75 % ready for real work and no blocking gap in session interchange. | AAF/OMF interchange, disk streaming, record to disk | G-03, G-06 | 65–115 | | Plugin hosting completeness on every OS, sidechain | G-05, G-13 | 35–60 | | Fidelity harness and the fixes it finds | G-07 | 40–60 | -| Built-in plugin essentials (EQ/dynamics/reverb/delay depth, sampler) | G-08 (half) | 40–60 | +| Built-in plugin essentials (EQ/dynamics/reverb/delay depth) | G-08 (half) | 40–60 | | UI feel pass: shortcuts, plugin windows, meters | G-19 (half) | 30–50 | | Localization of the 8 Pro Tools languages (catalog, CJK fonts) | G-09 (part) | 40–60 | @@ -89,6 +89,7 @@ There is no `cargo xtask scorecard` in this repo; the measured number is `cargo | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | 25 built-in processors, as the plugin registry holds; the sampler (#108) is no longer listed under plugin essentials | | 2026-10-14 | trivial | M8: reject oversized MCP batches before execution (#460) | | 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) | | 2026-10-10 | major | Created from ROADMAP.md's milestones and alpha list; added M12–M17 and the beta plan with estimates | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dfdd89a3..e95c0915 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: SoundCraft vs Avid Pro Tools -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95 % for each audience; full number confirmed as an additive weighted sum) · **Target:** Avid Pro Tools Ultimate 2026.4.1 (bundle 26.4.1.179, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (built-in plugin counts; the sampler shipped; previously minor: hours to ~95 % for each audience; full number confirmed as an additive weighted sum) · **Target:** Avid Pro Tools Ultimate 2026.4.1 (bundle 26.4.1.179, macOS) The authoritative assessment of how close SoundCraft is to Pro Tools. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) itemizes every shortfall, and the per-dimension docs go deeper: @@ -63,7 +63,7 @@ size selection, no localization, and a macOS release that cannot load signed thi cloud collaboration, Splice, ARA, HEAT, control surfaces, I/O setup). - **The code on origin/main (3bc2d92, 2026-10-10).** Read and counted with grep: ~70,900 lines of Rust; 524 `#[test]` functions plus proptest suites; ~350 macro-registered engine command ids; - 26 built-in processors and 2 instruments in `crates/dsp/src/plugins`; audio I/O in + 24 built-in processors and 2 instruments in `crates/dsp/src/plugins`; audio I/O in `crates/audio-io`; device I/O in `crates/playback` (cpal default output and default input only); no MIDI device crate (no `midir`/CoreMIDI anywhere); no string catalog. - **Users.** The 37 open and 21 closed GitHub issues, and the 42 merged and 29 open pull @@ -181,9 +181,9 @@ Weights by what Pro Tools users actually spend time on. **Breadth** = the featur | Editing | 15 % | 92 % | **65 %** | 40–60 | Slip/Shuffle/Spot/Grid, every Edit menu command, fades/crossfades, playlists and comping, clip groups, nudge, Tab to transient, separate/heal/consolidate/strip silence. Open bugs: #3, #5 (Multitool handles), #140 (Heal shortcut), #86, #91, #92; no batch fade presets, no field-recorder workflows | | Mixing and routing | 12 % | 85 % | **60 %** | 35–50 | Faders, pan, sends, busses, aux, masters, VCAs, folders, 10 inserts, PDC, solo modes, I/O Setup. Missing: sidechain inputs, hardware-channel I/O mapping, bus/AFL/PFL paths verified, HEAT; bugs #59, #103 | | Automation | 7 % | 80 % | **55 %** | 20–30 | Lanes for volume/pan/mute/sends/plugin params, trim, Write/Touch/Latch, glide/thin/coalesce. Missing: surround-pan and object automation, Touch/Latch prime and preview modes, automation on control surfaces; bug #104 | -| Built-in plugins and AudioSuite | 8 % | 40 % | **35 %** | 80–120 | 26 processors + 2 instruments covering every AudioSuite category; Pro Tools bundles roughly three times as many plugins plus virtual instruments. No fidelity comparison, generic parameter UIs (#139). See [plugin-parity.md](plugin-parity.md) | +| Built-in plugins and AudioSuite | 8 % | 40 % | **35 %** | 80–120 | 25 processors + 3 instruments covering every AudioSuite category; Pro Tools bundles roughly three times as many plugins plus virtual instruments. No fidelity comparison, generic parameter UIs (#139). See [plugin-parity.md](plugin-parity.md) | | Third-party plugin hosting | 8 % | 70 % | **45 %** | 40–60 | CLAP, VST3, AU: audio, params, latency, notes, state, presets, off-thread lifecycle, CLAP floating GUIs, VST3 editors on macOS. Missing: AU editors, VST3 editors on Windows/Linux, sidechain buses, ARA, out-of-process sandboxing, macOS library-validation entitlement (#153, #149). AAX cannot be hosted (Avid SDK licence, PACE signing) | -| MIDI and instruments | 8 % | 70 % | **30 %** | 50–80 | Piano roll, event list, step input, quantize/transpose, real-time properties, SMF import/export. Missing: **any MIDI device input/output** (no keyboard recording, no external synths, no MIDI clock/MTC out), MIDI learn, instrument depth (Sampler PR #108 open), bug #98 | +| MIDI and instruments | 8 % | 70 % | **30 %** | 50–80 | Piano roll, event list, step input, quantize/transpose, real-time properties, SMF import/export. Missing: **any MIDI device input/output** (no keyboard recording, no external synths, no MIDI clock/MTC out), MIDI learn, instrument depth (no piano, organ or wavetable instrument), bug #98 | | Score and notation | 2 % | 60 % | **35 %** | 20–30 | Grand-staff Score Editor, MusicXML export, printable SVG. Missing: notation editing depth (tuplets, articulations, lyrics, parts layout) | | Elastic Audio, TCE, Beat Detective, pitch | 5 % | 70 % | **35 %** | 40–60 | Pitch-preserving warp on Elastic tracks, TCE, conform to tempo, Beat Detective and Identify Beat, audio-to-MIDI. Missing: per-algorithm choice (polyphonic/rhythmic/monophonic/varispeed/X-Form), warp markers UI depth, Elastic Pitch, quality vs Pro Tools unmeasured | | Surround and immersive | 5 % | 55 % | **35 %** | 50–80 | Formats stereo → 9.1.6 and Ambisonics, surround panner, fold-down, multichannel bounce, object/bed routing. Missing: Dolby Atmos renderer integration and ADM BWF, MPEG-H (new in 2026.4), binaural/headphone monitoring, surround automation; bug #89 | @@ -245,6 +245,7 @@ serializes more, because it needs hands-on testing. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Built-in plugin counts: 25 processors + 3 instruments on `main`, and 24 + 2 in the 3bc2d92 code snapshot; the sampler (#108) is no longer listed as missing. Percentages unchanged | | 2026-10-10 | minor | Readiness-by-audience table: hours to ~95 % for full (950–1,450 h), mainstream (380–600 h) and essentials (110–200 h). The full number is already the additive weighted sum over the written dimension table (40.6 %); method unchanged | | 2026-10-10 | minor | Added the mainstream-practitioner (~36 %) and essentials-user (~57 %) numbers with written weights, discounts and user evidence. Checked the full ~40 %: it is the written dimension weights × values (40.6 %), so it doesn't move | | 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; alpha distance ~110–180 h | From 7b19c3068a311680cd781dac7e97f0304b3380f2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 07:54:18 -0700 Subject: [PATCH 62/90] ROADMAP: set Last reviewed to the date of this update --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1b762c84..928fde16 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-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** trivial (Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **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 From d5be1e6de3aea424bfe621817b0f57228dc5af09 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 08:39:27 -0700 Subject: [PATCH 63/90] Clip > Loop... opens Clip Looping: Number of Loops, Loop Length, or until the next clip or the session end Clip > Loop... and Cmd+Alt+L looped the selected clip four times at once. They now open the Clip Looping dialog. Number of Loops counts the clip and its loops together, 4 by default as before. Loop Length is typed in the main counter's time format, and the last loop is cut short when the length is not a whole number of loops. Loop Until End of Session or Next Clip loops up to the next clip on the track, or else to the end of the last clip in the session. clip.loop takes the new fill option, and its length option now cuts the last loop short instead of rounding down to whole loops. A fill or a length that needs more than 1,000 loops is refused with an error instead of stopping at 1,000 loops. The cut-short loop is trimmed with Clip::trim_end_to, which shortens its fades to fit and removes a sync point that falls past the cut. Unloop takes a shorter last clip as part of the loop when it shares the loop, no clip of the loop starts where it ends, and either a whole copy comes before it or it holds the same material as the origin just before it. It removes a short last loop too, and leaves both halves of a split clip in place. --- ROADMAP.md | 4 +- crates/engine/src/cmd/clip.rs | 50 ++++++++- crates/engine/src/cmd/clip_more.rs | 136 ++++++++++++++++++++++-- crates/ui-egui/src/dialogs.rs | 129 ++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/tests/it/clip_looping.rs | 50 +++++++++ crates/ui-egui/tests/it/main.rs | 1 + 7 files changed, 356 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/tests/it/clip_looping.rs diff --git a/ROADMAP.md b/ROADMAP.md index 92d6b06e..a479e120 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-19 · **Last updated:** 2026-10-23 · **Change:** trivial (Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Clip Looping dialog) · **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-24:** Clip › Loop… (Cmd+Alt+L) opens the Clip Looping dialog: Number of Loops, Loop Length in the main counter's time format, or Loop Until End of Session or Next Clip. A loop length that is not a whole number of loops cuts the last loop short, and Unloop removes that short loop too. No readiness percentage change. - **2026-10-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change. - **2026-10-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. No readiness percentage change. - **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change. @@ -166,6 +167,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Progress log: Clip Looping dialog; readiness estimates unchanged | | 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged | | 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged | | 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged | diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 916c439e..89340187 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -67,7 +67,15 @@ pub fn specs() -> Vec { } Ok(json!({})) }), - cmd!("clip.loop", "Loop...", ["Clip"], Some("Cmd+Alt+L"), "{clips?, count?: n, length?: samples}", has_selection, loop_clips), + cmd!( + "clip.loop", + "Loop...", + ["Clip"], + Some("Cmd+Alt+L"), + "{clips?, count?: n, length?: samples, fill?: bool}: count is the clip plus its loops; length is the whole looped clip, with the last loop cut short to fit; fill loops up to the next clip on the track, or else to the session end", + has_selection, + loop_clips + ), cmd!("clip.rename", "Rename...", ["Clip"], Some("Cmd+Alt+Shift+R"), "{clip?, name}", has_selection, rename), cmd!("clip.gain", "Clip Gain", [], None, "{clips?, db | delta_db}", has_selection, gain), cmd!("clip.gain_nudge_up", "Nudge Clip Gain Up", [], Some("Ctrl+Shift+Up"), "{clips?}", has_selection, |e, p| nudge_gain(e, p, 1.0)), @@ -287,15 +295,46 @@ fn layer(e: &mut Engine, p: &Value, front: bool) -> Result { Ok(json!({})) } +/// The most loops `clip.loop` makes of one clip. +const MAX_LOOPS: i64 = 1000; + fn loop_clips(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let count = i64_or(p, "count", 4).clamp(1, 512); let total = position_param(e, "clip.loop", p, "length")?; + let fill = p.get("fill").and_then(Value::as_bool).unwrap_or(false); let s = e.session_mut(); + let session_end = s.content_end(); let mut made = 0; for id in ids { let Some((tid, mut c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; - let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1).clamp(0, 1000); + if c.length <= 0 { + continue; + } + let len = c.length; + // Where the looped clip ends: the next clip or the session end, or `length` after the start. + let end = if fill { + let next = s.track(tid).and_then(|t| t.clips().iter().filter(|x| x.id != c.id && x.start >= c.end()).map(|x| x.start).min()); + Some(next.unwrap_or(session_end)) + } else { + total.map(|l| c.start.saturating_add(l)) + }; + // Whole iterations, then one cut short to reach `end` exactly. + let (reps, last) = match end { + Some(end) => { + let span = end.saturating_sub(c.end()).max(0); + let (whole, rest) = (span / len, span % len); + let reps = whole.saturating_add(i64::from(rest > 0)); + if reps > MAX_LOOPS { + return Err(bad( + "clip.loop", + format!("“{}” would need {reps} loops to fill that; a clip loops at most {MAX_LOOPS} times", c.name), + )); + } + (reps, if rest > 0 { rest } else { len }) + } + None => ((count - 1).clamp(0, MAX_LOOPS), len), + }; if reps > 0 && c.loop_id.is_none() { let lid = s.alloc(); if let Some(src) = s.find_clip_mut(id) { @@ -307,10 +346,15 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result { for k in 1..=reps { let mut nc = c.clone(); nc.start = 0; + if k == reps && last < c.length { + // `start` is 0 here, so the end is the length; the trim also shortens the fades to fit and removes a sync point past the cut. + nc.trim_end_to(last); + } copies.push((c.start + c.length * k, nc)); } for (at, nc) in copies { - let _ = crate::edit::paste(s, tid, at, &[nc], c.length, false); + let length = nc.length; + let _ = crate::edit::paste(s, tid, at, &[nc], length, false); made += 1; } } diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index 98bed8c7..a2ebd8b3 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -99,24 +99,40 @@ fn regroup(e: &mut Engine, p: &Value) -> Result { Ok(json!({"group": g, "clips": n})) } -fn same_material(a: &Clip, b: &Clip) -> bool { - a.length == b.length - && (a.loop_id.is_some() && a.loop_id == b.loop_id - || match (&a.content, &b.content) { - (ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2, - (ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2, - (ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2, - _ => false, - }) +fn same_content(a: &Clip, b: &Clip) -> bool { + match (&a.content, &b.content) { + (ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2, + (ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2, + (ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2, + _ => false, + } } -/// The loop a clip belongs to: back-to-back copies of the same material. Origin first. +fn same_material(a: &Clip, b: &Clip) -> bool { + a.length == b.length && (a.loop_id.is_some() && a.loop_id == b.loop_id || same_content(a, b)) +} + +/// Whether `short` is the last iteration of `full`'s loop, cut short at its end. It shares the loop, +/// it is shorter, and no clip of the loop starts where it ends, as the other half of a split clip +/// does. When `full` is a whole copy (an iteration of the same length ends where it starts), that is +/// enough. When `full` is the loop's origin alone, `short` also has to hold `full`'s material. +fn cut_short(clips: &[Clip], full: &Clip, short: &Clip) -> bool { + let whole_copy = || clips.iter().any(|x| x.id != full.id && x.end() == full.start && same_material(x, full)); + full.loop_id.is_some() + && full.loop_id == short.loop_id + && short.length < full.length + && !clips.iter().any(|x| x.loop_id == short.loop_id && x.start == short.end()) + && (whole_copy() || same_content(full, short)) +} + +/// The loop a clip belongs to: back-to-back copies of the same material, the last of which can be +/// cut short. Origin first. fn loop_chain(s: &Session, t: TrackId, id: ClipId) -> Vec { let Some(tr) = s.track(t) else { return Vec::new() }; let clips = tr.clips(); let Some(mut cur) = clips.iter().find(|c| c.id == id) else { return Vec::new() }; let mut guard = 0; - while let Some(prev) = clips.iter().find(|x| x.id != cur.id && x.end() == cur.start && same_material(x, cur)) { + while let Some(prev) = clips.iter().find(|x| x.id != cur.id && x.end() == cur.start && (same_material(x, cur) || cut_short(clips, x, cur))) { cur = prev; guard += 1; if guard > clips.len() { @@ -128,6 +144,9 @@ fn loop_chain(s: &Session, t: TrackId, id: ClipId) -> Vec { chain.push(next.id); cur = next; } + if let Some(last) = clips.iter().find(|x| !chain.contains(&x.id) && x.start == cur.end() && cut_short(clips, cur, x)) { + chain.push(last.id); + } chain } @@ -342,6 +361,101 @@ mod tests { assert!(e.session().track(t).unwrap().clips().iter().all(|c| c.loop_id.is_none() && c.group.is_none())); } + fn spans(e: &Engine, t: TrackId) -> Vec<(Samples, Samples)> { + e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length)).collect() + } + + #[test] + fn loop_length_cuts_the_last_iteration_short() { + let (mut e, t, id) = midi_engine(); + assert_eq!(e.execute("clip.loop", &json!({"clip": id.0, "length": 250})).unwrap()["loops"], 2); + assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 50)]); + // A length that is a whole number of loops leaves no short one. + let (mut e, t, id) = midi_engine(); + assert_eq!(e.execute("clip.loop", &json!({"clip": id.0, "length": 300})).unwrap()["loops"], 2); + assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 100)]); + } + + #[test] + fn a_short_last_iteration_fits_its_fades_and_drops_a_sync_point_past_the_cut() { + let (mut e, t, id) = midi_engine(); + if let Some(c) = e.session_mut().find_clip_mut(id) { + c.sync_point = 80; + c.fade_out.len = 70; + } + e.execute("clip.loop", &json!({"clip": id.0, "length": 150})).unwrap(); + let clips = e.session().track(t).unwrap().clips(); + let last = clips.last().unwrap(); + assert_eq!((last.start, last.length, last.sync_point, last.fade_out.len), (100, 50, 0, 50)); + assert_eq!((clips[0].sync_point, clips[0].fade_out.len), (80, 70)); + } + + #[test] + fn unloop_removes_a_short_last_iteration_too() { + let (mut e, t, id) = midi_engine(); + e.execute("clip.loop", &json!({"clip": id.0, "length": 250})).unwrap(); + e.execute("clip.unloop", &json!({"clip": id.0})).unwrap(); + assert_eq!(spans(&e, t), vec![(0, 100)]); + // An edit to the source note or to the short loop's note. + for edited in [0, 2] { + let (mut e, t, id) = midi_engine(); + e.execute("clip.loop", &json!({"clip": id.0, "length": 250})).unwrap(); + let target = e.session().track(t).unwrap().clips()[edited].id; + if let Some(c) = e.session_mut().find_clip_mut(target) + && let ClipContent::Midi { sequence } = &mut c.content + { + sequence.notes[0].length = 1100; + } + e.execute("clip.unloop", &json!({"clip": id.0})).unwrap(); + assert_eq!(spans(&e, t), vec![(0, 100)], "edited loop {edited}"); + } + } + + #[test] + fn unloop_keeps_both_halves_of_a_split_clip() { + let (mut e, t, ids) = engine_with(&[(0, 100, 0)]); + e.execute("clip.loop", &json!({"clip": ids[0].0, "count": 3})).unwrap(); + crate::edit::separate_at(e.session_mut(), t, 230); + e.execute("clip.unloop", &json!({"clip": ids[0].0})).unwrap(); + assert_eq!(spans(&e, t), vec![(0, 100), (200, 30), (230, 70)]); + // A split loop origin is not a loop. + let (mut e, t, ids) = engine_with(&[(0, 100, 0)]); + e.execute("clip.loop", &json!({"clip": ids[0].0, "count": 2})).unwrap(); + crate::edit::separate_at(e.session_mut(), t, 30); + assert!(e.execute("clip.unloop", &json!({"clip": ids[0].0})).is_err()); + assert_eq!(spans(&e, t), vec![(0, 30), (30, 70), (100, 100)]); + } + + #[test] + fn loop_fill_stops_at_the_next_clip() { + let (mut e, t, ids) = engine_with(&[(0, 100, 0), (430, 100, 0)]); + assert_eq!(e.execute("clip.loop", &json!({"clip": ids[0].0, "fill": true})).unwrap()["loops"], 4); + assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 100), (300, 100), (400, 30), (430, 100)]); + } + + #[test] + fn loop_fill_without_a_next_clip_runs_to_the_session_end() { + let (mut e, t, ids) = engine_with(&[(0, 100, 0)]); + let other = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let far = e.session_mut().new_clip_id(); + crate::edit::place_clip(e.session_mut(), other, Clip::audio(far, "far", SourceId(9), 0, 200, 150)); + assert_eq!(e.execute("clip.loop", &json!({"clip": ids[0].0, "fill": true})).unwrap()["loops"], 3); + assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 100), (300, 50)]); + // The last clip in the session has nothing to fill up to. + assert_eq!(e.execute("clip.loop", &json!({"clip": far.0, "fill": true})).unwrap()["loops"], 0); + } + + #[test] + fn a_fill_or_length_past_the_loop_limit_is_refused_without_an_edit() { + let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100_150, 100, 0)]); + let err = e.execute("clip.loop", &json!({"clip": ids[0].0, "fill": true})).unwrap_err().to_string(); + assert!(err.contains("1001 loops"), "{err}"); + assert!(e.execute("clip.loop", &json!({"clip": ids[0].0, "length": 100_200})).is_err()); + assert_eq!(spans(&e, t), vec![(0, 100), (100_150, 100)]); + // 1000 loops still fit. + assert_eq!(e.execute("clip.loop", &json!({"clip": ids[0].0, "length": 100_100})).unwrap()["loops"], 1000); + } + #[test] fn regroup_reuses_group() { let (mut e, _, ids) = engine_with(&[(0, 100, 0), (500, 100, 0), (900, 100, 0)]); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221653f1..29494bf3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,10 +26,24 @@ pub enum Dialog { // `id: None` creates (Track › Group…); `id: Some` modifies that group (Groups list › Modify Group…). Group { id: Option, name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> }, Session { frame_rate: String, bit_depth: String }, + // Clip › Loop…: `length` is typed in `format`, measured from `start` (the first selected clip's). + // `filled` is the text the field opened with and the exact length it stands for. + Loop { by: LoopBy, count: u32, length: String, format: soundcraft_time::TimeFormat, start: Samples, filled: (String, Samples) }, ScoreSetup { title: String, composer: String, bars_per_system: u32, show_track_names: bool }, SaveChanges { then: (String, Value) }, } +/// The Clip Looping dialog's choice of how far a clip is looped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoopBy { + /// Number of Loops. + Count, + /// Loop Length. + Length, + /// Loop Until End of Session or Next Clip. + Fill, +} + #[derive(Default)] pub struct Dialogs { pub open: Option, @@ -58,6 +72,7 @@ impl Dialogs { Dialog::Group { id: None, .. } => "group", Dialog::Group { id: Some(_), .. } => "modify_group", Dialog::Session { .. } => "session", + Dialog::Loop { .. } => "clip_looping", Dialog::ScoreSetup { .. } => "score_setup", Dialog::SaveChanges { .. } => "save_changes", }) @@ -163,6 +178,15 @@ impl Dialogs { let Some((_, clip)) = target else { return false }; Dialog::RenameClip { id: clip.id, name: clip.name.clone() } } + "clip.loop" => { + let target = soundcraft_engine::cmd::clip_ids_param(e, &json!({})).first().and_then(|id| e.session().find_clip(*id)); + let Some((_, clip)) = target else { return false }; + let s = e.session(); + let format = s.edit.main_counter; + let exact = clip.length.saturating_mul(4); + let length = soundcraft_time::format_length(clip.start, exact, format, s.sample_rate, &s.tempo, s.frame_rate); + Dialog::Loop { by: LoopBy::Count, count: 4, length: length.clone(), format, start: clip.start, filled: (length, exact) } + } _ => match path_dialog(id, &home, &name) { Some(d) => d, None => return false, @@ -335,6 +359,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Group { id: None, .. } => "Create Group", Dialog::Group { id: Some(_), .. } => "Modify Group", Dialog::Session { .. } => "Session Setup", + Dialog::Loop { .. } => "Clip Looping", Dialog::ScoreSetup { .. } => "Score Setup", Dialog::SaveChanges { then } if then.0 == "session.revert" => "Revert to Saved", Dialog::SaveChanges { .. } => "Unsaved Changes", @@ -609,6 +634,23 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some(("setup.session".into(), json!({"frame_rate": frame_rate, "bit_depth": bit_depth}))); } } + Dialog::Loop { by, count, length, format, start, filled } => { + let s = app.engine.session(); + ui.horizontal(|ui| { + ui.radio_value(by, LoopBy::Count, "Number of Loops"); + ui.add_enabled(*by == LoopBy::Count, egui::DragValue::new(count).range(1..=512)); + }); + ui.horizontal(|ui| { + ui.radio_value(by, LoopBy::Length, "Loop Length"); + ui.add_enabled(*by == LoopBy::Length, egui::TextEdit::singleline(length).desired_width(140.0)); + ui.label(format.label()); + }); + ui.radio_value(by, LoopBy::Fill, "Loop Until End of Session or Next Clip"); + let params = loop_params(s, *by, *count, length, *format, *start, filled); + if buttons_enabled(ui, "OK", enter, params.is_some()) { + action = params.map(|p| ("clip.loop".into(), p)); + } + } Dialog::ScoreSetup { title, composer, bars_per_system, show_track_names } => { egui::Grid::new("score_setup").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { ui.label("Title"); @@ -803,6 +845,42 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { } } +/// The `clip.loop` params the Clip Looping dialog's OK sends; None while the typed length does not +/// read as a duration longer than zero. An untouched length field sends the exact length it was +/// filled with, which its text can round. +fn loop_params( + s: &Session, + by: LoopBy, + count: u32, + length: &str, + format: soundcraft_time::TimeFormat, + start: Samples, + filled: &(String, Samples), +) -> Option { + match by { + LoopBy::Count => Some(json!({"count": count})), + LoopBy::Length if length == filled.0 => Some(json!({"length": filled.1})), + LoopBy::Length => loop_length(s, length, format, start).filter(|l| *l > 0).map(|l| json!({"length": l})), + LoopBy::Fill => Some(json!({"fill": true})), + } +} + +/// A duration typed in `format`, the way `format_length` writes it, for a span that starts at `start`. +fn loop_length(s: &Session, text: &str, format: soundcraft_time::TimeFormat, start: Samples) -> Option { + if format != soundcraft_time::TimeFormat::BarsBeats { + return soundcraft_time::parse_position(text, format, s.sample_rate, &s.tempo, s.frame_rate, 0, 0).ok(); + } + let parts: Vec = text.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(str::parse::).collect::>().ok()?; + if parts.is_empty() || parts.len() > 3 || parts.iter().any(|p| *p < 0) { + return None; + } + let from = s.tempo.samples_to_ticks(start, s.sample_rate); + let meter = s.tempo.meter_at_tick(from); + let part = |i: usize, unit: i64| parts.get(i).copied().unwrap_or(0).checked_mul(unit); + let ticks = part(0, meter.ticks_per_bar())?.checked_add(part(1, meter.ticks_per_beat())?)?.checked_add(part(2, 1)?)?; + Some(s.tempo.tick_to_samples(from.checked_add(ticks)?, s.sample_rate).saturating_sub(start)) +} + #[cfg(test)] mod tests { use super::*; @@ -881,4 +959,55 @@ mod tests { "Remove 2 files from the session? They are used by 4 clips on the timeline, which are removed too. The files stay on disk." ); } + + #[test] + fn clip_loop_opens_clip_looping_for_the_selected_clip() { + let mut e = soundcraft_engine::demo::demo_engine(); + let mut d = Dialogs::default(); + e.execute("edit.select_none", &json!({})).unwrap(); + assert!(!d.open_for_command(&e, "clip.loop")); + let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone(); + e.execute("edit.select", &json!({"clips": [clip.id.0]})).unwrap(); + assert!(d.open_for_command(&e, "clip.loop")); + assert_eq!(d.open_name(), Some("clip_looping")); + let Some(Dialog::Loop { by, count, length, format, start, filled }) = &d.open else { panic!("{:?}", d.open) }; + assert_eq!((*by, *count, *start), (LoopBy::Count, 4, clip.start)); + assert_eq!(*format, e.session().edit.main_counter); + // OK on the untouched field loops exactly four times, whatever its text rounds to. + let sent = loop_params(e.session(), LoopBy::Length, *count, length, *format, *start, filled); + assert_eq!(sent, Some(json!({"length": clip.length * 4}))); + } + + #[test] + fn loop_length_reads_a_duration_in_every_time_format() { + let e = soundcraft_engine::demo::demo_engine(); + let s = e.session(); + let start = s.sample_rate.samples(3.0); + for format in soundcraft_time::TimeFormat::ALL { + for len in [s.sample_rate.samples(2.0), s.sample_rate.samples(7.5)] { + let text = soundcraft_time::format_length(start, len, format, s.sample_rate, &s.tempo, s.frame_rate); + assert_eq!(loop_length(s, &text, format, start), Some(len), "{format:?} {text}"); + } + } + let bars = soundcraft_time::TimeFormat::BarsBeats; + assert_eq!(loop_length(s, "1| 0| 000", bars, start), loop_length(s, "1", bars, start)); + assert_eq!(loop_length(s, "-1| 0| 000", bars, start), None); + assert_eq!(loop_length(s, "two bars", bars, start), None); + } + + #[test] + fn clip_looping_sends_the_chosen_option() { + let e = soundcraft_engine::demo::demo_engine(); + let s = e.session(); + let samples = soundcraft_time::TimeFormat::Samples; + let filled = ("0:01.000".to_string(), 48_001); + let send = |by, length: &str| loop_params(s, by, 3, length, samples, 0, &filled); + assert_eq!(send(LoopBy::Count, ""), Some(json!({"count": 3}))); + assert_eq!(send(LoopBy::Length, "4800"), Some(json!({"length": 4800}))); + assert_eq!(send(LoopBy::Length, "0:01.000"), Some(json!({"length": 48_001}))); + assert_eq!(send(LoopBy::Length, "0"), None); + assert_eq!(send(LoopBy::Length, "soon"), None); + assert_eq!(send(LoopBy::Fill, "soon"), Some(json!({"fill": true}))); + assert!(soundcraft_engine::command_specs().iter().any(|c| c.id == "clip.loop")); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cd879a72..5233f74b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -167,6 +167,7 @@ const DIALOG_COMMANDS: &[&str] = &[ "event.transpose", "edit.strip_silence", "clip.rename", + "clip.loop", ]; /// Shared menu hierarchy for egui and native desktop hosts. diff --git a/crates/ui-egui/tests/it/clip_looping.rs b/crates/ui-egui/tests/it/clip_looping.rs new file mode 100644 index 00000000..310859e3 --- /dev/null +++ b/crates/ui-egui/tests/it/clip_looping.rs @@ -0,0 +1,50 @@ +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +/// Clip > Loop... opens Clip Looping instead of looping right away, and Loop Until End of Session +/// or Next Clip fills the gap up to the next clip, the last loop cut short to meet it. +#[test] +fn clip_looping_fills_up_to_the_next_clip() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap(); + let (track, clip) = (kick.id, kick.clips()[0].clone()); + let after = kick.clips().iter().filter(|c| c.start >= clip.end()).min_by_key(|c| c.start).unwrap().clone(); + // Trim the next clip's start so the gap is not a whole number of loops. + let next = after.start + clip.length / 2; + assert!(next < after.end()); + app.run("edit.trim_clip", json!({"clip": after.id.0, "start": next})).unwrap(); + assert_ne!((next - clip.end()) % clip.length, 0); + app.run("edit.select", json!({"clips": [clip.id.0]})).unwrap(); + let before = app.engine.session().track(track).unwrap().clips().len(); + menus::invoke_menu(&mut app, "clip.loop", "Clip > Loop..."); + assert_eq!(app.dialogs.open_name(), Some("clip_looping")); + assert_eq!(app.engine.session().track(track).unwrap().clips().len(), before); + + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_role_and_label(Role::RadioButton, "Loop Until End of Session or Next Clip").click(); + h.run_steps(2); + h.get_by_role_and_label(Role::Button, "OK").click(); + h.run_steps(2); + + assert!(h.state().dialogs.open_name().is_none()); + let clips = h.state().engine.session().track(track).unwrap().clips(); + let loops: Vec<_> = clips.iter().filter(|c| c.start >= clip.end() && c.start < next).collect(); + let whole = (next - clip.end()) / clip.length; + assert_eq!(loops.len() as i64, whole + 1); + assert!(loops.iter().all(|c| c.loop_id.is_some() && c.loop_id == loops[0].loop_id)); + assert!(loops.windows(2).all(|w| w[0].end() == w[1].start)); + let last = loops.last().unwrap(); + assert_eq!(last.end(), next); + assert_eq!(last.length, (next - clip.end()) % clip.length); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 6214f0a0..f4049126 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -8,6 +8,7 @@ mod audio_device; mod bounce; mod bounce_dialog; mod clip_gain_fader; +mod clip_looping; mod clip_menu; mod command_reachability; mod commands_filter; From 3e56738290c82196359349caa92dded1598eb625 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 10:26:42 -0700 Subject: [PATCH 64/90] Vari-Fi: hold the first and last samples at the ends of the input Slow Down reads up to the end of the input as it stops, and Speed Up starts reading from its first sample. The interpolation dropped the taps past either end, so with Fades Off a Slow Down faded to silence at the stop and a Speed Up started about 1.5% low. Those taps now read the first or last sample; a channel shorter than the others stays silent past its end. --- ROADMAP.md | 2 +- crates/dsp/src/offline.rs | 11 ++++++----- crates/dsp/tests/it/vari_fi.rs | 8 ++++++++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4a534e0f..1a7493eb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,7 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log -- **2026-10-24:** AudioSuite ▸ Pitch Shift ▸ Vari-Fi slows the selected clips down to a complete stop or speeds them up from one, and the pitch follows the speed. Fit To keeps each clip's length and plays two thirds of it; Extend plays all of it and makes the clip 150 % long. Fades On fades the audio out with the slowdown or in with the speedup. Before, the menu item resampled the clip at a constant speed. No readiness percentage change. +- **2026-10-24:** AudioSuite ▸ Pitch Shift ▸ Vari-Fi slows the selected clips down to a complete stop or speeds them up from one, and the pitch follows the speed. Fit To keeps each clip's length and plays two-thirds of it; Extend plays all of it and makes the clip 150 % long. Fades On fades the audio out with the slowdown or in with the speedup. Before, the menu item resampled the clip at a constant speed. No readiness percentage change. - **2026-10-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change. - **2026-10-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. No readiness percentage change. - **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change. diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index d7f4072e..80be7105 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -475,7 +475,7 @@ pub enum VariFiSelection { /// the speed. The speed is `1 - u²` for Slow Down and `1 - (1 - u)²` for Speed Up, where `u` /// runs from 0 to 1 over the result; both curves average 2/3 of normal speed. With `fades`, the /// gain follows the same curve: a fade-out for Slow Down and a fade-in for Speed Up. Every channel -/// is read at the same positions. +/// is read at the same positions, and a channel shorter than the others is silent past its end. pub fn vari_fi(ch: &[Vec], change: VariFiChange, selection: VariFiSelection, fades: bool) -> Vec> { let len = max_len(ch) as f64; let (total, first, played) = match selection { @@ -498,19 +498,20 @@ pub fn vari_fi(ch: &[Vec], change: VariFiChange, selection: VariFiSelection } } }; + // The interpolation reads the first or last sample of the input for taps before or after it. + let last = (len as i64 - 1).max(0); ch.iter() .map(|x| { - let last = x.len() as i64 - 1; (0..out_len) .map(|n| { let (pos, speed) = at(n as f64 / total); let t = first + played * pos; - let lo = (t - hw).ceil().max(0.0) as i64; - let hi = ((t + hw).floor() as i64).min(last); + let lo = (t - hw).ceil() as i64; + let hi = (t + hw).floor() as i64; let mut acc = 0.0f32; let mut j = lo; while j <= hi { - if let Some(&v) = x.get(j as usize) { + if let Some(&v) = x.get(j.clamp(0, last) as usize) { acc += v * table.at(t - j as f64); } j += 1; diff --git a/crates/dsp/tests/it/vari_fi.rs b/crates/dsp/tests/it/vari_fi.rs index 6fe4075a..99c92c82 100644 --- a/crates/dsp/tests/it/vari_fi.rs +++ b/crates/dsp/tests/it/vari_fi.rs @@ -78,6 +78,12 @@ fn vari_fi_fades_follow_the_speed() { for u in [0.25, 0.5, 0.75, 0.9] { near(off[0][(u * len as f64) as usize], 0.5, &format!("slow down without fades at {u}")); } + // ... at the stop too, where the reads reach either end of the input. + let up = offline::vari_fi(&x, VariFiChange::SpeedUp, VariFiSelection::FitTo, false); + near(up[0][0], 0.5, "speed up without fades from the stop"); + near(off[0][len - 1], 0.5, "slow down without fades at the stop"); + let off = offline::vari_fi(&x, VariFiChange::SlowDown, VariFiSelection::Extend, false); + near(off[0][off[0].len() - 1], 0.5, "slow down with Extend and without fades at the stop"); } #[test] @@ -107,4 +113,6 @@ fn vari_fi_handles_empty_and_uneven_channels() { let out = offline::vari_fi(&[vec![0.5; 300], vec![0.5; 30]], VariFiChange::SlowDown, VariFiSelection::Extend, false); assert_eq!((out[0].len(), out[1].len()), (450, 450)); assert!(out.iter().flatten().all(|v| v.is_finite())); + near(out[0][449], 0.5, "the longer channel at the stop"); + assert!(out[1][200..].iter().all(|v| v.abs() < 1e-3), "the shorter channel is silent past its end"); } From 0ca492e0ab4a18549061a74b6789beba8811eb37 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 12:46:00 -0700 Subject: [PATCH 65/90] Strip Silence: Extract keeps the silence, and Separate cuts the clips without removing any The Strip Silence window had only a Strip button. It now also has Extract and Separate. Extract removes the parts of the selection that hold audio and keeps the silent parts. Separate separates the clips at the same points Strip does and does not remove any clip. Enter on a focused Extract or Separate runs that button, and Enter presses Strip only when neither was clicked. edit.strip_silence takes a mode param: strip (the default), extract or separate. An unknown mode is an error. The Minimum Strip Duration slider now goes up to 4,000 ms. --- ROADMAP.md | 4 +- crates/engine/src/cmd/edit.rs | 8 +- crates/engine/src/io.rs | 94 +++++++++++++++++-- crates/ui-egui/src/dialogs.rs | 66 +++++++++++-- crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/strip_silence_dialog.rs | 91 ++++++++++++++++++ 6 files changed, 248 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/tests/it/strip_silence_dialog.rs diff --git a/ROADMAP.md b/ROADMAP.md index 92d6b06e..158beb88 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-19 · **Last updated:** 2026-10-23 · **Change:** trivial (Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Strip Silence Extract and Separate) · **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-24:** The Strip Silence window has Extract and Separate buttons. Extract removes the audio and keeps the silent parts of the selection, and Separate separates the clips at the edges of the silence without removing anything. Minimum Strip Duration goes up to 4,000 ms. No readiness percentage change. - **2026-10-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change. - **2026-10-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. No readiness percentage change. - **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change. @@ -166,6 +167,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Progress log: Strip Silence Extract and Separate; readiness estimates unchanged | | 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged | | 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged | | 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 5536668b..5e6f0099 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -121,7 +121,7 @@ pub fn specs() -> Vec { "Strip Silence", ["Edit"], Some("Cmd+U"), - "{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20}", + "{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, mode?: strip|extract|separate} (strip removes the silence; extract removes the audio and keeps the silence; separate separates the clips at the edges of the silence and does not remove any)", has_range, strip_silence ), @@ -796,10 +796,14 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result { let sr = e.session().sample_rate; let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0); let (min_len, pre, post) = (ms("min_length_ms", 50.0), ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0)); + let mode = match str_param(p, "mode") { + None => crate::io::StripMode::Strip, + Some(m) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?, + }; let s = e.session_mut(); let mut kept = 0; for t in &tracks { - kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post)?; + kept += crate::io::strip_silence(s, *t, r, thr, min_len, (pre, post), mode)?; } Ok(json!({"clips": kept})) } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index b1df9e25..7012d58f 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -733,10 +733,36 @@ pub fn transients_in(s: &Session, t: TrackId, r: Range, sens: f32) -> Vec Result { +/// What Strip Silence does with the clips inside the selection once it has found the silence. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StripMode { + /// Remove the silent parts and keep the parts that hold audio. + Strip, + /// Remove the parts that hold audio and keep the silent parts. + Extract, + /// Separate the clips at the boundaries of the silent parts without removing any clip. + Separate, +} + +impl StripMode { + pub fn from_id(id: &str) -> Option { + match id { + "strip" => Some(Self::Strip), + "extract" => Some(Self::Extract), + "separate" => Some(Self::Separate), + _ => None, + } + } +} + +/// Strip Silence: separate the clips inside `r` where silence starts and ends, then keep only the +/// non-silent ranges (`Strip`), only the silent ranges (`Extract`), or every piece (`Separate`). +/// `pad` is the clip start and end pad added around each non-silent range. Returns the number of +/// ranges kept: non-silent ranges for `Strip` and `Separate`, silent ranges that still hold a clip for +/// `Extract`. +pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pad: (Samples, Samples), mode: StripMode) -> Result { let r = bounded_to_track(s, t, r); - let keep = non_silent(s, t, r, thr, min_len, pre, post)?; + let keep = non_silent(s, t, r, thr, min_len, pad.0, pad.1)?; let mut cut_points = vec![r.start]; for (a, b) in &keep { cut_points.push(*a); @@ -746,7 +772,9 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S for p in &cut_points { crate::edit::separate_at(s, t, *p); } - // Remove pieces that fall in the gaps. + if mode == StripMode::Separate { + return Ok(keep.len()); + } let mut gaps = Vec::new(); let mut prev = r.start; for (a, b) in &keep { @@ -758,8 +786,13 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S if prev < r.end { gaps.push(Range::new(prev, r.end)); } - if let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) { - pl.clips.retain(|c| !gaps.iter().any(|g| c.start >= g.start && c.end() <= g.end)); + let audio: Vec = keep.iter().map(|(a, b)| Range::new(*a, *b)).collect(); + let remove = if mode == StripMode::Extract { &audio } else { &gaps }; + let inside = |c: &Clip, g: &Range| c.start >= g.start && c.end() <= g.end; + let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) else { return Ok(if mode == StripMode::Extract { 0 } else { keep.len() }) }; + pl.clips.retain(|c| !remove.iter().any(|g| inside(c, g))); + if mode == StripMode::Extract { + return Ok(gaps.iter().filter(|g| pl.clips.iter().any(|c| inside(c, g))).count()); } Ok(keep.len()) } @@ -2277,6 +2310,55 @@ mod strip_tests { assert!(e.session().track(t).unwrap().clips().iter().any(|c| c.start >= far)); } + /// A stereo track with one three-second clip: a second of tone, a second of silence, a second of tone. + fn tone_gap_tone() -> (Engine, TrackId) { + let mut e = Engine::default(); + e.execute("track.new", &json!({"format": "stereo"})).unwrap(); + let t = e.session().tracks[0].id; + let s = e.session_mut(); + let mut ch = vec![0.5; 144_000]; + ch[48_000..96_000].fill(0.0); + let src = add_source(s, "tone gap tone", AudioBuffer { sample_rate: 48_000, channels: vec![ch.clone(), ch] }, None, FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(s, t, Clip::audio(id, "tone gap tone", src, 0, 0, 144_000)); + (e, t) + } + + fn strip_mode(mode: &str) -> (Value, Vec<(Samples, Samples)>) { + let (mut e, t) = tone_gap_tone(); + let p = json!({"track": t.0, "start": 0, "end": 144_000, "pad_before_ms": 0, "pad_after_ms": 0, "mode": mode}); + let r = e.execute("edit.strip_silence", &p).unwrap(); + (r, e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect()) + } + + #[test] + fn strip_keeps_the_audio_and_removes_the_silence() { + let (r, clips) = strip_mode("strip"); + assert_eq!(r["clips"], 2); + assert_eq!(clips, vec![(0, 48_000), (96_000, 144_000)]); + } + + #[test] + fn extract_keeps_the_silence_and_removes_the_audio() { + let (r, clips) = strip_mode("extract"); + assert_eq!(r["clips"], 1); + assert_eq!(clips, vec![(48_000, 96_000)]); + } + + #[test] + fn separate_cuts_at_the_silence_and_removes_nothing() { + let (r, clips) = strip_mode("separate"); + assert_eq!(r["clips"], 2); + assert_eq!(clips, vec![(0, 48_000), (48_000, 96_000), (96_000, 144_000)]); + } + + #[test] + fn an_unknown_strip_mode_is_refused() { + let (mut e, t) = tone_gap_tone(); + assert!(e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": 144_000, "mode": "remove"})).is_err()); + assert_eq!(e.session().track(t).unwrap().clips().len(), 1); + } + #[test] fn more_than_a_day_of_clips_is_refused() { let (mut e, t) = tones(&[0], 48_000 * 86_401); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221653f1..46ad9627 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -555,15 +555,27 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms } => { ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB")); - ui.add(egui::Slider::new(min_ms, 0.0..=2000.0).text("Min strip duration ms")); + ui.add(egui::Slider::new(min_ms, 0.0..=4000.0).text("Min strip duration ms")); ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms")); ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms")); - if buttons(ui, "Strip", enter) { - action = Some(( - "edit.strip_silence".into(), - json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms}), - )); - } + let values = [*threshold, *min_ms, *pre_ms, *post_ms]; + ui.separator(); + ui.horizontal(|ui| { + let mut mode = None; + if ui.button("Extract").clicked() { + mode = Some("extract"); + } + if ui.button("Separate").clicked() { + mode = Some("separate"); + } + // Enter on a focused Extract or Separate clicks that button, so Enter presses Strip only when neither was clicked. + if buttons(ui, "Strip", enter) && mode.is_none() { + mode = Some("strip"); + } + if let Some(mode) = mode { + action = Some(strip_silence_action(mode, values)); + } + }); } Dialog::Group { id, name, edit, mix, members } => { ui.horizontal(|ui| { @@ -714,6 +726,16 @@ fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result (String, Value) { + let [threshold, min_ms, pre_ms, post_ms] = values; + ( + "edit.strip_silence".into(), + json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms, "mode": mode}), + ) +} + /// Cancel/OK row; returns true when OK was pressed (or Enter). fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool { buttons_enabled(ui, ok, enter, true) @@ -850,6 +872,36 @@ mod tests { assert_eq!(params["bpm"], 133.5); } + #[test] + fn strip_silence_buttons_send_strip_extract_and_separate() { + let mut e = soundcraft_engine::demo::demo_engine(); + let kick = e.session().track_by_name("Kick").unwrap().id; + let end = e.session().content_end(); + e.execute("edit.select", &json!({"tracks": [kick.0], "start": 0, "end": end})).unwrap(); + let mut d = Dialogs::default(); + assert!(d.open_for_command(&e, "edit.strip_silence")); + let Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) = d.open.take() else { + panic!("Strip Silence did not open its dialog") + }; + let values = [threshold, min_ms, pre_ms, post_ms]; + let clips = |e: &Engine| e.session().track(kick).unwrap().clips().iter().map(|c| (c.start, c.end())).collect::>(); + let covered = |c: &[(i64, i64)]| c.iter().map(|(a, b)| b - a).sum::(); + let before = clips(&e); + let mut run = |mode: &str| { + let (cmd, params) = strip_silence_action(mode, values); + assert_eq!(params["mode"], mode); + e.execute(&cmd, ¶ms).unwrap(); + let after = clips(&e); + e.execute("edit.undo", &json!({})).unwrap(); + after + }; + let (strip, extract, separate) = (run("strip"), run("extract"), run("separate")); + assert!(separate.len() > before.len(), "Separate cuts the clips"); + assert_eq!(covered(&separate), covered(&before), "Separate keeps all the audio"); + assert!(!strip.is_empty() && !extract.is_empty()); + assert_eq!(covered(&strip) + covered(&extract), covered(&before), "Strip and Extract keep opposite parts"); + } + #[test] fn clear_always_asks_and_names_the_timeline_clips_that_go_too() { let mut e = soundcraft_engine::demo::demo_engine(); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 6214f0a0..262e4216 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -45,6 +45,7 @@ mod shared_controls; mod shortcuts; mod side_lists_access; mod side_lists_text; +mod strip_silence_dialog; mod toolbar_scroll; mod toolbar_tempo; mod track_height; diff --git a/crates/ui-egui/tests/it/strip_silence_dialog.rs b/crates/ui-egui/tests/it/strip_silence_dialog.rs new file mode 100644 index 00000000..40c06190 --- /dev/null +++ b/crates/ui-egui/tests/it/strip_silence_dialog.rs @@ -0,0 +1,91 @@ +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_model::TrackId; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +/// Selects the whole Kick track and opens Edit > Strip Silence from the menu. +fn strip_silence_dialog() -> (Harness<'static, SoundApp>, TrackId, Vec<(i64, i64)>) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let track = app.engine.session().track_by_name("Kick").unwrap().id; + let end = app.engine.session().content_end(); + app.run("edit.select", json!({"tracks": [track.0], "start": 0, "end": end})).unwrap(); + let before = app.engine.session().track(track).unwrap().clips().iter().map(|c| (c.start, c.end())).collect(); + menus::invoke_menu(&mut app, "edit.strip_silence", "Edit > Strip Silence"); + assert_eq!(app.dialogs.open_name(), Some("strip_silence")); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + (h, track, before) +} + +fn clips(h: &Harness<'_, SoundApp>, track: TrackId) -> Vec<(i64, i64)> { + h.state().engine.session().track(track).map(|t| t.clips().iter().map(|c| (c.start, c.end())).collect()).unwrap_or_default() +} + +fn covered(clips: &[(i64, i64)]) -> i64 { + clips.iter().map(|(a, b)| b - a).sum() +} + +/// Strip on the same selection, run as a command, for comparison. +fn stripped(track: TrackId) -> Vec<(i64, i64)> { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let end = app.engine.session().content_end(); + app.run("edit.select", json!({"tracks": [track.0], "start": 0, "end": end})).unwrap(); + app.run("edit.strip_silence", json!({})).unwrap(); + app.engine.session().track(track).unwrap().clips().iter().map(|c| (c.start, c.end())).collect() +} + +#[test] +fn strip_silence_extract_keeps_what_strip_removes() { + let (mut h, track, before) = strip_silence_dialog(); + h.get_by_role_and_label(Role::Button, "Extract").click(); + h.run_steps(2); + assert_eq!(h.state().dialogs.open_name(), None); + let extracted = clips(&h, track); + let strip = stripped(track); + assert!(!extracted.is_empty() && !strip.is_empty()); + assert_eq!(covered(&extracted) + covered(&strip), covered(&before)); +} + +#[test] +fn strip_silence_separate_cuts_the_clips_and_removes_nothing() { + let (mut h, track, before) = strip_silence_dialog(); + h.get_by_role_and_label(Role::Button, "Separate").click(); + h.run_steps(2); + assert_eq!(h.state().dialogs.open_name(), None); + let separated = clips(&h, track); + assert!(separated.len() > before.len()); + assert_eq!(covered(&separated), covered(&before)); +} + +#[test] +fn enter_on_a_focused_separate_separates_and_does_not_strip() { + let (mut h, track, before) = strip_silence_dialog(); + h.get_by_role_and_label(Role::Button, "Separate").focus(); + h.run_steps(2); + h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().dialogs.open_name(), None); + let separated = clips(&h, track); + assert!(separated.len() > before.len()); + assert_eq!(covered(&separated), covered(&before), "Enter on Separate keeps all the audio"); +} + +#[test] +fn strip_silence_strip_removes_the_silence() { + let (mut h, track, before) = strip_silence_dialog(); + h.get_by_role_and_label(Role::Button, "Strip").click(); + h.run_steps(2); + assert_eq!(h.state().dialogs.open_name(), None); + let strip = clips(&h, track); + assert_eq!(strip, stripped(track)); + assert!(covered(&strip) < covered(&before)); +} From 37e155c8287b8ffbdcc1cc7de35842edf9aaee52 Mon Sep 17 00:00:00 2001 From: Forward Audio <134808501+forwardaudio@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:58:36 +0200 Subject: [PATCH 66/90] Meters: keep every block's peak until the UI takes it The audio thread overwrote the shared meter snapshot every block (~94 blocks/s at 512 frames, 48 kHz) while the UI read it 20-60 times a second, so it only ever saw the latest block and short transients were under-read. Blocks are now folded together (max of each channel's peak and of the gain reduction; plugin failures keep the latest count) until the UI takes them with Player::take_meters, which also starts a new period. Blocks rendered while the UI holds the lock wait in an audio-side accumulator instead of being lost; the audio thread still only try_locks. Stopping, starting and locating now zero the meters in place instead of clearing the maps, so playing again no longer re-allocates every track's entry on the audio thread. The UI thread works out which tracks a session update deleted and tells the audio thread, which drops just their entries. The Mix window and the Renderer now read the snapshot the app takes once per poll instead of reading the player themselves, so a period's peaks are not split between readers. Co-Authored-By: Claude Opus 5.5 --- crates/mix/src/lib.rs | 48 +++++- crates/playback/src/lib.rs | 257 +++++++++++++++++++++++++--- crates/ui-egui/src/extra_windows.rs | 2 +- crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/src/mix_window.rs | 5 +- 5 files changed, 288 insertions(+), 31 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index cf094cfc..da877df3 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -148,7 +148,9 @@ pub fn create_instance(id: &str, state: Option<&[u8]>, sample_rate: f32, max_blo Some(NewInstance { plugin, refused_state }) } -/// Peak meter values for one strip (linear, per channel, max over the last block). +/// Peak meter values for one strip (linear, per channel). [`MixEngine`] measures one block; a +/// realtime host folds blocks together with [`StripMeter::accumulate`] so a reader polling less +/// often than blocks arrive sees the highest peak since it last looked, not just the latest block. #[derive(Debug, Clone, Default, PartialEq)] pub struct StripMeter { /// First two channels (mono strips repeat channel 0), for stereo meters. @@ -186,6 +188,33 @@ impl StripMeter { self.plugin_failures = o.plugin_failures; } + /// Fold another (later) measurement in: per-channel peaks and the gain reduction keep their + /// maximum, plugin failures take the later value (the count only grows). Allocation-free once + /// the channel count is stable, so it can run on the audio thread every block. + pub fn accumulate(&mut self, o: &StripMeter) { + if self.peaks.len() != o.peaks.len() { + // The strip's format changed (a document edit): earlier channels no longer line up. + self.peaks.clone_from(&o.peaks); + } else { + for (p, q) in self.peaks.iter_mut().zip(&o.peaks) { + *p = max_level(*p, *q); + } + } + for (p, q) in self.peak.iter_mut().zip(o.peak) { + *p = max_level(*p, q); + } + self.gain_reduction_db = max_level(self.gain_reduction_db, o.gain_reduction_db); + self.plugin_failures = o.plugin_failures; + } + + /// Zero the levels (peaks and gain reduction) after a reader took them, keeping the channel + /// count and the plugin failure record. Never allocates. + pub fn reset_levels(&mut self) { + self.peak = [0.0; 2]; + self.peaks.iter_mut().for_each(|p| *p = 0.0); + self.gain_reduction_db = 0.0; + } + fn measure(&mut self, bufs: &[Vec], frames: usize, gr: f32) { if self.peaks.len() != bufs.len() { self.peaks.resize(bufs.len(), 0.0); @@ -199,6 +228,16 @@ impl StripMeter { } } +/// The larger of two meter levels; a NaN (from a misbehaving plugin) never wins or sticks. +fn max_level(a: f32, b: f32) -> f32 { + match (a.is_finite(), b.is_finite()) { + (true, true) => a.max(b), + (true, false) => a, + (false, true) => b, + (false, false) => 0.0, + } +} + /// A bus: its format and one buffer per channel. struct BusBuf { fmt: ChannelFormat, @@ -544,8 +583,10 @@ impl MixEngine { fx.reset(); } } - self.meters.clear(); - self.main_meter = StripMeter::default(); + // Zeroed in place: rebuilding the entries would allocate on the audio thread at every + // play, stop and locate. + self.meters.values_mut().for_each(StripMeter::reset_levels); + self.main_meter.reset_levels(); } /// Bring plugin instances in line with the session (creates/destroys as needed). Allocates; @@ -560,6 +601,7 @@ impl MixEngine { let mut cx = SlotCtx { sr: self.sample_rate, mb, external: self.external, adopt: &mut self.adopt, retired: &mut self.retired }; let gone: Vec = self.strips.keys().filter(|id| s.track(**id).is_none()).copied().collect(); for id in gone { + self.meters.remove(&id); if let Some(mut st) = self.strips.remove(&id) { for p in st.plugins.drain(..) { cx.retire(p); diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index ffb8b832..0d6cad37 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -45,11 +45,53 @@ const OLD_SESSIONS: usize = 16; /// renders on worker threads; the cpal callbacks here set it. pub use soundcraft_mix::{mark_audio_thread, on_audio_thread}; -/// Meter values for the UI. +/// Meter values for the UI: the highest peak (and gain reduction) of every block rendered since +/// the UI last took them with [`Player::take_meters`]. The audio thread renders blocks faster than +/// the UI draws (about 94 blocks a second at 512 frames and 48 kHz against 20-60 polls), so +/// keeping only the latest block would miss most transients. #[derive(Debug, Clone, Default)] pub struct MeterSnapshot { pub tracks: HashMap, pub main: StripMeter, + /// Something was written since the UI last took the values. + fresh: bool, +} + +impl MeterSnapshot { + /// Fold one rendered block's meters in (max). Allocation-free once the tracks are known. + fn accumulate(&mut self, main: &StripMeter, tracks: &HashMap) { + self.main.accumulate(main); + for (k, v) in tracks { + match self.tracks.get_mut(k) { + Some(d) => d.accumulate(v), + None => { + self.tracks.insert(*k, v.clone()); + } + } + } + self.fresh = true; + } + + /// Start a new accumulation period, keeping every entry and its storage. + fn reset_levels(&mut self) { + self.main.reset_levels(); + self.tracks.values_mut().for_each(StripMeter::reset_levels); + self.fresh = false; + } + + /// Nothing is playing or monitored: every meter reads silence. Levels are zeroed in place, + /// so starting again does not allocate the tracks' entries anew. + fn silence(&mut self) { + self.reset_levels(); + self.fresh = true; + } + + /// Drop the entries of deleted tracks. + fn forget(&mut self, gone: &[TrackId]) { + for id in gone { + self.tracks.remove(id); + } + } } /// Plugin states read from the live instances: `(track, slot or INSTRUMENT_SLOT, state)`. @@ -72,6 +114,8 @@ enum Cmd { }, Stop, Locate(Samples), + /// Tracks the latest session no longer has (worked out on the UI thread): their meters go. + Forget(Vec), } struct Shared { @@ -171,6 +215,11 @@ struct AudioState { /// Retired plugin instances go back to the UI thread to be dropped there. garbage: Sender>>, old_sessions: Option>>, + /// Meters of blocks rendered while the UI held the shared snapshot (`try_lock` failed), so + /// their peaks reach it with the next block instead of being lost. + unsent: MeterSnapshot, + /// Deleted tracks still to drop from the shared snapshot (it was locked when they went). + forget_shared: Vec, } fn new_engine(sr: f32) -> MixEngine { @@ -202,6 +251,8 @@ impl AudioState { device_rate, input: None, recording: false, + unsent: MeterSnapshot::default(), + forget_shared: Vec::new(), } } @@ -210,6 +261,15 @@ impl AudioState { while let Ok(c) = self.rx.try_recv() { match c { Cmd::Input(r) => self.input = Some(r), + Cmd::Forget(gone) => { + self.unsent.forget(&gone); + if self.forget_shared.is_empty() { + self.forget_shared = gone; + } else { + self.forget_shared.extend_from_slice(&gone); + } + self.forget_meters(); + } Cmd::Recording(r) => self.recording = r, Cmd::Session(s, instances) => { changed = true; @@ -310,23 +370,34 @@ impl AudioState { self.mix.render(&self.session, self.pos, n, &mut self.pending); self.send_garbage(); self.pending_len = n; - if let Ok(mut m) = self.shared.meters.try_lock() { - m.main.copy_from(&self.mix.main_meter); - for (k, v) in &self.mix.meters { - match m.tracks.get_mut(k) { - Some(d) => d.copy_from(v), - None => { - m.tracks.insert(*k, v.clone()); - } - } - } - } + self.publish_meters(); if self.playing { self.pos += n as i64; self.shared.position.store(self.pos, Ordering::Relaxed); } } + /// Drop deleted tracks' meters from the shared snapshot, unless the UI holds it right now. + fn forget_meters(&mut self) { + if !self.forget_shared.is_empty() + && let Ok(mut m) = self.shared.meters.try_lock() + { + m.forget(&self.forget_shared); + self.forget_shared.clear(); + } + } + + /// Hand this block's meters to the UI without blocking: they are folded (max) into what the + /// UI has not taken yet. If the UI holds the snapshot right now, they wait in `unsent`. + fn publish_meters(&mut self) { + self.unsent.accumulate(&self.mix.main_meter, &self.mix.meters); + self.forget_meters(); + if let Ok(mut m) = self.shared.meters.try_lock() { + m.accumulate(&self.unsent.main, &self.unsent.tracks); + self.unsent.reset_levels(); + } + } + /// Fill an interleaved device buffer. fn fill(&mut self, out: &mut [f32], channels: usize) { self.drain(); @@ -334,9 +405,10 @@ impl AudioState { let monitoring = self.input.is_some() && self.session.tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm); if !self.playing && !monitoring { out.iter_mut().for_each(|x| *x = 0.0); + self.unsent.silence(); + self.forget_meters(); if let Ok(mut m) = self.shared.meters.try_lock() { - m.main = StripMeter::default(); - m.tracks.clear(); + m.silence(); } return; } @@ -418,11 +490,30 @@ struct Side { refused: HashSet<(TrackId, usize)>, garbage: Receiver>>, old_sessions: Option>>, + /// The tracks of the session last sent, to tell the audio thread which ones went. + tracks: HashSet, } impl Side { fn new(rate: f32, garbage: Receiver>>) -> Side { - Side { shadow: HashMap::new(), rate, editors: HashMap::new(), states: BTreeMap::new(), refused: HashSet::new(), garbage, old_sessions: None } + Side { + shadow: HashMap::new(), + rate, + editors: HashMap::new(), + states: BTreeMap::new(), + refused: HashSet::new(), + garbage, + old_sessions: None, + tracks: HashSet::new(), + } + } + + /// Note `s`'s tracks; the ones the previous session had and `s` doesn't. + fn gone_tracks(&mut self, s: &Session) -> Vec { + let now: HashSet = s.tracks.iter().map(|t| t.id).collect(); + let gone = self.tracks.difference(&now).copied().collect(); + self.tracks = now; + gone } /// Drops retired instances (closing their editors first, inside the plugin's drop) and @@ -521,7 +612,11 @@ impl Player { let want = session.sample_rate.hz(); let want_ch = u16::try_from(main_channels(&session)).unwrap_or(2); let side_rate = session.sample_rate.as_f64() as f32; - let new_side = |garbage: Receiver>>| Mutex::new(Side::new(side_rate, garbage)); + let new_side = |garbage: Receiver>>| { + let mut side = Side::new(side_rate, garbage); + side.gone_tracks(&session); + Mutex::new(side) + }; match open_device(want, want_ch, cfg) { Ok((device, config, format, name)) => { // The chosen buffer size first; if the device refuses it, its default. @@ -678,8 +773,14 @@ impl Player { /// Sends a new session snapshot to the audio thread, with freshly created (state-restored, /// prepared) instances of any third-party plugin it newly needs. Call on the UI thread. pub fn update_session(&self, s: Arc) { - let instances = self.side().prepare(&s); + let (instances, gone) = { + let mut side = self.side(); + (side.prepare(&s), side.gone_tracks(&s)) + }; let _ = self.tx.send(Cmd::Session(s, instances)); + if !gone.is_empty() { + let _ = self.tx.send(Cmd::Forget(gone)); + } } /// Reads the state of every live third-party plugin, for saving the session. @@ -801,18 +902,33 @@ impl Player { self.shared.playing.load(Ordering::Relaxed) } + /// A copy of the meters accumulated since the last [`Player::take_meters`], leaving them in + /// place (for inspection; the UI takes them instead). pub fn meters(&self) -> MeterSnapshot { self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner).clone() } - /// Copy meters into reusable UI storage. Once track/channel counts are stable this performs - /// no allocation, unlike cloning a new snapshot every video frame. - pub fn copy_meters(&self, out: &mut MeterSnapshot) { - let source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner); - copy_meter_snapshot(&source, out); + /// Take the meters accumulated since the last call into reusable UI storage and start a new + /// period: `out` gets the highest peak of every block rendered in between. If no block was + /// rendered since (a large device buffer renders several blocks per callback), `out` keeps + /// the previous values rather than dropping to silence. Call it from one place only (two + /// readers would split the peaks between them). Once track/channel counts are stable this + /// performs no allocation. The audio thread only ever `try_lock`s, so holding the lock here + /// never blocks it; blocks rendered meanwhile reach the next call. + pub fn take_meters(&self, out: &mut MeterSnapshot) { + let mut source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner); + take_meter_snapshot(&mut source, out); } } +fn take_meter_snapshot(source: &mut MeterSnapshot, out: &mut MeterSnapshot) { + if !source.fresh { + return; + } + copy_meter_snapshot(source, out); + source.reset_levels(); +} + fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) { out.tracks.retain(|id, _| source.tracks.contains_key(id)); for (id, meter) in &source.tracks { @@ -1136,6 +1252,103 @@ mod tests { assert_eq!(out.main, source.main); } + /// A mono track playing a quiet signal with one short spike in the block starting at + /// `spike_at`, and an audio thread ready to play it. + fn spike_session(spike_at: usize) -> (Arc, AudioState, std::sync::mpsc::Sender, TrackId) { + use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind}; + let mut s = soundcraft_model::Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut x = vec![0.01f32; 48_000]; + x[spike_at..spike_at + 32].fill(0.9); + s.pool.insert(SourceId(5), Arc::new(SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![x] }))); + let id = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "x", SourceId(5), 0, 0, 48_000)); + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0); + tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap(); + (shared, st, tx, t) + } + + #[test] + fn a_spike_in_one_block_among_quiet_ones_reaches_the_ui() { + // Eight 512-frame blocks per callback, as between two UI polls; the spike is in the 3rd. + let (shared, mut st, _tx, t) = spike_session(2 * 512 + 100); + let mut out = vec![0.0f32; 2 * 8 * 512]; + st.fill(&mut out, 2); + // The last block alone (what the UI used to see) is quiet. + assert!(st.mix.meters[&t].peak[0] < 0.05); + let mut ui = MeterSnapshot::default(); + take_meter_snapshot(&mut shared.meters.lock().unwrap(), &mut ui); + assert!(ui.tracks[&t].peak[0] > 0.85, "{:?}", ui.tracks[&t]); + assert!(ui.tracks[&t].peaks[0] > 0.85); + assert!(ui.main.peak[0] > 0.5 && ui.main.peaks.iter().all(|p| *p > 0.5), "{:?}", ui.main); + // Taking starts a new period: the next quiet blocks read quiet. + st.fill(&mut out, 2); + take_meter_snapshot(&mut shared.meters.lock().unwrap(), &mut ui); + assert!(ui.tracks[&t].peak[0] < 0.05 && ui.main.peak[0] < 0.05); + // No block since the last take: the reading stays rather than dropping to silence. + take_meter_snapshot(&mut shared.meters.lock().unwrap(), &mut ui); + assert!(ui.tracks[&t].peak[0] > 0.005); + } + + #[test] + fn a_spike_rendered_while_the_ui_holds_the_meters_arrives_later() { + let (shared, mut st, _tx, t) = spike_session(100); + let mut out = vec![0.0f32; 2 * 512]; + { + let _held = shared.meters.lock().unwrap(); + st.fill(&mut out, 2); + } + st.fill(&mut out, 2); + let mut ui = MeterSnapshot::default(); + take_meter_snapshot(&mut shared.meters.lock().unwrap(), &mut ui); + assert!(ui.tracks[&t].peak[0] > 0.85, "{:?}", ui.tracks[&t]); + } + + #[test] + fn stopping_zeroes_meters_in_place_and_removed_tracks_are_pruned() { + let (shared, mut st, tx, t) = spike_session(100); + let mut out = vec![0.0f32; 2 * 512]; + st.fill(&mut out, 2); + tx.send(Cmd::Stop).unwrap(); + st.fill(&mut out, 2); + { + let m = shared.meters.lock().unwrap(); + let e = m.tracks.get(&t).expect("kept while stopped, so playing again does not allocate it"); + assert!(e.peak == [0.0; 2] && e.peaks.iter().all(|p| *p == 0.0) && e.peaks.len() == 1, "{e:?}"); + assert!(m.fresh, "the UI sees the silence"); + } + // The track is deleted: the UI side works out which tracks went and says so. + let mut side = Side::new(48_000.0, channel().1); + side.gone_tracks(&st.session); + let empty = soundcraft_model::Session::default(); + let gone = side.gone_tracks(&empty); + assert_eq!(gone, vec![t]); + tx.send(Cmd::Session(Arc::new(empty), Vec::new())).unwrap(); + tx.send(Cmd::Forget(gone)).unwrap(); + tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap(); + st.fill(&mut out, 2); + assert!(!shared.meters.lock().unwrap().tracks.contains_key(&t)); + assert!(!st.unsent.tracks.contains_key(&t) && !st.mix.meters.contains_key(&t)); + } + + #[test] + fn gain_reduction_keeps_its_maximum_until_taken() { + let mut acc = StripMeter::default(); + for gr in [1.0, 9.0, 2.0, f32::NAN] { + acc.accumulate(&StripMeter { peak: [0.1, 0.1], peaks: vec![0.1], gain_reduction_db: gr, ..StripMeter::default() }); + } + assert_eq!(acc.gain_reduction_db, 9.0); + acc.reset_levels(); + assert_eq!((acc.gain_reduction_db, acc.peak, acc.peaks.len()), (0.0, [0.0; 2], 1)); + } + #[test] fn a_block_that_panics_goes_silent_and_is_reported_once() { let shared = Shared { diff --git a/crates/ui-egui/src/extra_windows.rs b/crates/ui-egui/src/extra_windows.rs index df43024b..c021edbc 100644 --- a/crates/ui-egui/src/extra_windows.rs +++ b/crates/ui-egui/src/extra_windows.rs @@ -90,7 +90,7 @@ fn renderer(app: &mut SoundApp, ctx: &egui::Context) { } let t = Tokens::current(); let fmt = app.engine.session().main_format(); - let peaks: Vec = app.player.as_ref().map(|p| p.meters().main.peaks).unwrap_or_default(); + let peaks: Vec = if app.player.is_some() { app.meter_snapshot.main.peaks.clone() } else { Vec::new() }; let objects: Vec<(String, f32, f32, f32)> = app .engine .session() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 140355c8..57532af8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -287,8 +287,9 @@ pub struct SoundApp { pub dialogs: dialogs::Dialogs, pub meters: HashMap, pub main_meter: MeterDisplay, - /// Reused multichannel snapshot; populated at an adaptive rate by `logic`. - meter_snapshot: soundcraft_playback::MeterSnapshot, + /// Reused meter snapshot, taken from the player at an adaptive rate by `logic`: the highest + /// peaks since the previous take. The only reader of the player's meters; windows read this. + pub(crate) meter_snapshot: soundcraft_playback::MeterSnapshot, pub gesture: Option, pub edit_layout: edit_window::EditLayout, pub midi: midi_editor::MidiEditorState, @@ -1171,7 +1172,7 @@ impl SoundApp { let now = self.last_frame.unwrap_or_default(); let meter_interval = if self.ui.window == MainWindow::Mix { 1.0 / 60.0 } else { 1.0 / 20.0 }; if now - self.last_meter_poll >= meter_interval { - p.copy_meters(&mut self.meter_snapshot); + p.take_meters(&mut self.meter_snapshot); self.last_meter_poll = now; } let snap = &self.meter_snapshot; diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 1540bcfb..665b5f54 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -45,8 +45,9 @@ pub fn separate_closed(app: &mut SoundApp) { pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) { let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect(); - // Per-channel peaks for multichannel meters (read once per frame). - let snap = app.player.as_ref().filter(|_| app.meters_live()).map(|p| p.meters()); + // Per-channel peaks for multichannel meters, from the snapshot `logic` takes from the player + // (taking them here too would split each period's peaks between two readers). + let snap = (app.player.is_some() && app.meters_live()).then(|| app.meter_snapshot.clone()); // Taken for this frame only: a strip that isn't drawn (hidden track) must not scroll the Mix // whenever it comes back. A request made during this frame (the strip's menu) waits for the next. let scroll_to = app.mix_scroll_into_view.take(); From ee050db59c82316433a2bfa61ba5b8461c9339e8 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:18:19 -0400 Subject: [PATCH 67/90] ui: move panel docking onto craft-ui's shared Workspace and DockCommand (#1035) * ui: move panel docking onto craft-ui's shared Workspace and DockCommand Pin every craft-libs crate at 16fb3d4 (craft-libs main after #285). The Edit and Mix workspaces are craft_ui::docking::Workspace. ui.dock and ui.dock.reset parse into DockCommand; the app keeps its panels, permissions, defaults, the document-view and Mix rules, the beside-the-canvas fallback for panels without a recorded place, and Arrange. The canvas accepts splits so panels still open beside it. Saved v1 workspaces (no version, floating windows as {panels, active, rect}) load unchanged. Tests cover loading one and dragging a group by its drag bar. * ui: test that activating a hidden panel shows it * ui: name the field in ui.dock operation errors; document the shared panel menu and zones --- Cargo.lock | 10 +- Cargo.toml | 6 +- README.md | 2 +- apps/soundcraft/src/config.rs | 31 +- crates/ui-egui/src/docking.rs | 459 ++++++++----------- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/menus.rs | 6 +- crates/ui-egui/src/windows.rs | 2 +- crates/ui-egui/tests/it/docking_workflows.rs | 146 +++++- crates/ui-egui/tests/theme_docking.rs | 2 +- docs/control-protocol.md | 2 +- 11 files changed, 373 insertions(+), 295 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 94af97cf..103770e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -904,12 +904,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" [[package]] name = "craft-media" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" +source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" dependencies = [ "thiserror 2.0.21", ] @@ -917,9 +917,13 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" dependencies = [ "egui", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] diff --git a/Cargo.toml b/Cargo.toml index f5637624..1c1c0988 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,13 +45,13 @@ zip = { version = "9", default-features = false } cpal = "0.18" # Which file paths stored in a session may be opened. -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } # Video containers and bitstreams, shared with the other craft apps. -craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" } +craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } # ui -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde"] } # egui 0.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands. egui = "0.36" eframe = { version = "0.36", default-features = false } diff --git a/README.md b/README.md index 5d5e3a7b..67c73049 100644 --- a/README.md +++ b/README.md @@ -387,4 +387,4 @@ Forks and modified versions must remove them. Made by the ArtCraft team and community.

-Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window. +Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float Panel, Move Panel to Group, and Close Panel; the tab group's menu adds Float Tab Group and Close Tab Group. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window. diff --git a/apps/soundcraft/src/config.rs b/apps/soundcraft/src/config.rs index 5efab9a7..bd8f6639 100644 --- a/apps/soundcraft/src/config.rs +++ b/apps/soundcraft/src/config.rs @@ -3,6 +3,8 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; +use soundcraft_ui_egui::docking; + /// Portable mode (#268): this file beside the executable keeps settings, autosaves, presets and /// logs in [`PORTABLE_DATA_DIR`] next to it instead of the user profile. const PORTABLE_MARKER: &str = "portable.txt"; @@ -88,19 +90,18 @@ fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result(value.clone()) { - Ok(workspace) => if mix { workspace.validate_mix() } else { workspace.validate() }.is_err(), - Err(_) => true, - }); + let unreadable = fields.get(field).is_some_and(|value| match serde_json::from_value::(value.clone()) { + Ok(workspace) => if mix { docking::validate_mix(&workspace) } else { docking::validate(&workspace) }.is_err(), + Err(_) => true, + }); if unreadable { fields.remove(field); } } } let state: soundcraft_ui_egui::UiState = serde_json::from_value(saved).map_err(|error| format!("Settings could not be restored: {error}"))?; - state.docking.validate()?; - state.mix_docking.validate_mix()?; + docking::validate(&state.docking)?; + docking::validate_mix(&state.mix_docking)?; Ok(Some(state)) } @@ -111,8 +112,8 @@ pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Resu use std::io::Write as _; // Loading can recover a future layout for this session, but saving must preserve its source. read_ui(path, false)?; - ui.docking.validate()?; - ui.mix_docking.validate_mix()?; + docking::validate(&ui.docking)?; + docking::validate_mix(&ui.mix_docking)?; let mut saved = ui.clone(); saved.plugin_windows.clear(); saved.status.clear(); @@ -160,18 +161,18 @@ mod config_tests { app.ui = load_ui(&path).unwrap().unwrap(); assert_eq!(app.ui.theme, soundcraft_ui_egui::theme::ThemeMode::Light); assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), edit); - assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]); + assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]); app.ui.window = MainWindow::Edit; app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap(); - assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]); + assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]); let mut invalid_mix = app.ui.clone(); - invalid_mix.mix_docking = soundcraft_ui_egui::docking::Workspace::default(); + invalid_mix.mix_docking = soundcraft_ui_egui::docking::edit_default(); let invalid_bytes = serde_json::to_vec(&invalid_mix).unwrap(); std::fs::write(&path, &invalid_bytes).unwrap(); let recovered = load_ui(&path).unwrap().unwrap(); assert_eq!(recovered.theme, app.ui.theme); assert_eq!(serde_json::to_value(recovered.docking).unwrap(), serde_json::to_value(&app.ui.docking).unwrap()); - recovered.mix_docking.validate_mix().unwrap(); + soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap(); assert!(save_ui(&path, &app.ui).is_err()); assert_eq!(std::fs::read(&path).unwrap(), invalid_bytes); std::fs::write(&path, b"damaged settings").unwrap(); @@ -201,8 +202,8 @@ mod config_tests { let recovered = load_ui(&path).unwrap().unwrap(); assert_eq!(recovered.theme, app.ui.theme); assert!(recovered.narrow_mix); - recovered.docking.validate().unwrap(); - recovered.mix_docking.validate_mix().unwrap(); + soundcraft_ui_egui::docking::validate(&recovered.docking).unwrap(); + soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap(); let other = if field == "docking" { "mix_docking" } else { "docking" }; assert_eq!(serde_json::to_value(&recovered).unwrap()[other], saved[other]); assert!(save_ui(&path, &recovered).is_err()); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index f2668fa8..3e0a5f28 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -1,7 +1,10 @@ //! Application panel identities and persistence around the shared docking renderer. //! Document contents remain owned by their existing view implementations. -use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone}; +use craft_ui::docking::{ + Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Floating, Layout, Node, PanelLimits, Permissions, Placement, + UiDock, Zone, ZoneName, +}; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -19,16 +22,6 @@ pub enum Panel { impl Panel { pub const ALL: &[Self] = &[Self::Canvas, Self::Tracks, Self::Clips, Self::Midi, Self::Memory, Self::History]; - pub fn id(self) -> &'static str { - match self { - Self::Canvas => "canvas", - Self::Tracks => "tracks", - Self::Clips => "clips", - Self::Midi => "midi", - Self::Memory => "memory", - Self::History => "history", - } - } fn label(self) -> &'static str { match self { Self::Canvas => "Session", @@ -49,15 +42,16 @@ impl Panel { Self::History => Zone::Right, } } - fn parse(id: &str) -> Result { - Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}")) - } } +/// An Edit or Mix panel arrangement, saved with the UI preferences. +pub type Workspace = craft_ui::docking::Workspace; + const MIDI_TAB_HEIGHT: f32 = 26.0; +/// The document view stays docked and open, and panels open in splits beside it. fn permissions(panel: &Panel) -> Permissions { - if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() } + if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() } } fn tabs(panel: Panel) -> Node { @@ -68,232 +62,149 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node, - /// Closed panels retain their most recent group, side, size or floating rectangle. - pub hidden: Vec<(Panel, Location)>, +/// The dock area that draws the Edit or Mix workspace. +pub fn area_id(window: crate::MainWindow) -> egui::Id { + egui::Id::new(("soundcraft-docking", window == crate::MainWindow::Edit)) } -impl Default for Workspace { - fn default() -> Self { - Self { - layout: Layout { - root: Some(split( - SplitAxis::Vertical, - SplitSize::FixedSecond(320.0 + MIDI_TAB_HEIGHT), - split( - SplitAxis::Horizontal, - SplitSize::FixedFirst(168.0), - tabs(Panel::Tracks), - split(SplitAxis::Horizontal, SplitSize::FixedSecond(230.0), tabs(Panel::Canvas), tabs(Panel::Clips)), - ), - tabs(Panel::Midi), - )), - floating: vec![ - craft_ui::docking::Floating { panels: vec![Panel::Memory], active: 0, rect: [160.0, 120.0, 480.0, 280.0] }, - craft_ui::docking::Floating { panels: vec![Panel::History], active: 0, rect: [680.0, 120.0, 260.0, 300.0] }, - ], - }, - hidden: Vec::new(), - } - } +fn area(edit: bool) -> egui::Id { + area_id(if edit { crate::MainWindow::Edit } else { crate::MainWindow::Mix }) } -impl Workspace { - pub fn validate(&self) -> Result<(), String> { - self.layout.validate().map_err(|error| error.to_string())?; - if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() { - return Err("The panel layout must contain its document view and bounded hidden panels".into()); - } - // Saved settings cannot turn the protected document view into a floating or closable tab. - if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) { - return Err("The document view cannot float".into()); - } - let mut nodes: Vec<_> = self.layout.root.iter().collect(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => { - nodes.push(first); - nodes.push(second); - } - Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { - return Err("The document view cannot share a tab group".into()); - } - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { - return Err("The document view cannot be collapsed".into()); - } - _ => {} - } - } - let mut seen = std::collections::HashSet::new(); - for (panel, location) in &self.hidden { - if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) { - return Err("A hidden panel has an invalid or duplicate identity".into()); - } - if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { - return Err("A hidden panel cannot restore over the document view".into()); - } - let mut probe = self.layout.clone(); - if let Err(error) = probe.restore(*panel, location) - && error != craft_ui::docking::DockError::MissingTarget - { - return Err(error.to_string()); - } - } - Ok(()) - } - - pub fn validate_mix(&self) -> Result<(), String> { - self.validate()?; - if self.layout.contains(&Panel::Clips) || self.layout.contains(&Panel::Midi) { - return Err("Clips and MIDI panels belong to the Edit window".into()); - } - Ok(()) - } - - pub fn apply(&mut self, action: Action) -> Result<(), String> { - self.validate()?; - let mut next = self.clone(); - if let Action::Close { panel } = &action { - let location = next.layout.location(panel).map_err(|error| error.to_string())?; - next.hidden.retain(|(id, _)| id != panel); - next.hidden.push((*panel, location)); - } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action { - next.hidden.retain(|(id, _)| id != panel); - } - next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?; - next.validate()?; - *self = next; - Ok(()) - } - - pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> { - if self.layout.contains(&panel) == visible { - return Ok(()); - } - if !visible { - return self.apply(Action::Close { panel }); - } - self.validate()?; - let mut next = self.clone(); - let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); - next.hidden.retain(|(id, _)| *id != panel); - if let Some(saved) = &mut location - && saved.floating.is_some() - && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor))) - { - // The other members of a floating group may have been closed too. - saved.anchor = None; - saved.placement = Placement::Tab { before: None }; - } - let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok()); - if !restored { - next.layout - .apply_with_permissions( - Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, - permissions, - ) - .map_err(|error| error.to_string())?; - } - next.validate()?; - *self = next; - Ok(()) - } - - fn command(&mut self, params: &Value) -> Result<(), String> { - self.validate()?; - if let Some(action) = params.get("action") { - let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?; - return self.apply(action); - } - let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?; - if operation == "resizeSplit" { - let path: Vec = - serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?; - let size: SplitSize = - serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?; - return self.apply(Action::ResizeSplit { path, size }); - } - let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?; - let action = match operation { - "open" => return self.set_visible(panel, true), - "close" => Action::Close { panel }, - "activate" => Action::Activate { panel }, - "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? }, - "resizeStack" => { - let value = params.get("height").ok_or("height is required")?; - let height = if value.is_null() { - None - } else { - Some( - value - .as_f64() - .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height)) - .ok_or("height must be finite and bounded")? as f32, - ) - }; - Action::ResizeStack { panel, height } - } - "float" | "moveFloating" => { - let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?; - let mut values = [0.0f32; 4]; - for (output, value) in values.iter_mut().zip(rect) { - *output = value - .as_f64() - .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) - .ok_or("rect values must be finite and bounded")? as f32; - } - if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } } - } - "move" => { - let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?; - let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") { - "center" => Placement::Split(Zone::Center), - "left" => Placement::Split(Zone::Left), - "right" => Placement::Split(Zone::Right), - "top" => Placement::Split(Zone::Top), - "bottom" => Placement::Split(Zone::Bottom), - _ => return Err("zone must be center, left, right, top or bottom".into()), - }; - let placement = if let Some(before) = params.get("before") { - if !matches!(placement, Placement::Split(Zone::Center)) { - return Err("before only applies to a center move".into()); - } - Placement::Tab { - before: if before.is_null() { - None - } else { - Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?) - }, - } - } else { - placement - }; - Action::Move { panel, anchor, placement } - } - _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()), - }; - self.apply(action) - } +/// The Edit window's built-in arrangement. +pub fn edit_default() -> Workspace { + Workspace::new(Layout { + root: Some(split( + SplitAxis::Vertical, + SplitSize::FixedSecond(320.0 + MIDI_TAB_HEIGHT), + split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(168.0), + tabs(Panel::Tracks), + split(SplitAxis::Horizontal, SplitSize::FixedSecond(230.0), tabs(Panel::Canvas), tabs(Panel::Clips)), + ), + tabs(Panel::Midi), + )), + // Fixed ids: the default is rebuilt every frame and must stay the same windows. + floating: vec![ + Floating::single(vec![Panel::Memory], 0, [160.0, 120.0, 480.0, 280.0]).with_id(1), + Floating::single(vec![Panel::History], 0, [680.0, 120.0, 260.0, 300.0]).with_id(2), + ], + }) } /// Mix retains its own arrangement; the timeline-only Clips and MIDI panels stay in Edit. pub fn mix_default() -> Workspace { - let mut workspace = Workspace::default(); - let _ = workspace.set_visible(Panel::Clips, false); - let _ = workspace.set_visible(Panel::Midi, false); + let mut workspace = edit_default(); + let _ = set_visible(&mut workspace, Panel::Clips, false); + let _ = set_visible(&mut workspace, Panel::Midi, false); workspace } +/// The document view where the Edit window's rules keep it: in the docked frames, alone in +/// its tab group. `None` when `node` does not hold it. +fn canvas_placement(node: &Node) -> Option> { + match node { + Node::Split { first, second, .. } => canvas_placement(first).or_else(|| canvas_placement(second)), + Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) => { + Some(if panels.len() == 1 { Ok(()) } else { Err("The document view cannot share a tab group") }) + } + Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => Some(Err("The document view cannot be collapsed")), + Node::Dock { columns, .. } if columns.iter().flat_map(|column| &column.groups).any(|group| group.panels.contains(&Panel::Canvas)) => { + Some(Err("The document view cannot join a dock column")) + } + _ => None, + } +} + +/// Check a workspace against craft-ui's rules and SoundCraft's own: saved settings cannot +/// float, hide, collapse or share the protected document view. +pub fn validate(workspace: &Workspace) -> Result<(), String> { + workspace.validate().map_err(|error| error.to_string())?; + let layout = &workspace.layout; + if !layout.contains(&Panel::Canvas) || workspace.hidden.len() > Panel::ALL.len() { + return Err("The panel layout must contain its document view and bounded hidden panels".into()); + } + if layout.floating.iter().any(|window| window.contains(&Panel::Canvas)) { + return Err("The document view cannot float".into()); + } + layout.root.as_ref().and_then(canvas_placement).unwrap_or(Err("The document view cannot float"))?; + for (panel, location) in &workspace.hidden { + if *panel == Panel::Canvas { + return Err("A hidden panel has an invalid or duplicate identity".into()); + } + if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { + return Err("A hidden panel cannot restore over the document view".into()); + } + let mut probe = layout.clone(); + if let Err(error) = probe.restore(*panel, location) + && error != DockError::MissingTarget + { + return Err(error.to_string()); + } + } + Ok(()) +} + +/// [`validate`] plus the Mix window's rule: Clips and MIDI stay in Edit. +pub fn validate_mix(workspace: &Workspace) -> Result<(), String> { + validate(workspace)?; + if workspace.layout.contains(&Panel::Clips) || workspace.layout.contains(&Panel::Midi) { + return Err("Clips and MIDI panels belong to the Edit window".into()); + } + Ok(()) +} + +/// Change a copy of `workspace` and keep it only when it passes [`validate`]. +fn checked(workspace: &mut Workspace, change: impl FnOnce(&mut Workspace) -> Result<(), DockError>) -> Result<(), String> { + validate(workspace)?; + let mut next = workspace.clone(); + change(&mut next).map_err(|error| error.to_string())?; + validate(&next)?; + *workspace = next; + Ok(()) +} + +fn apply(workspace: &mut Workspace, action: Action) -> Result<(), String> { + checked(workspace, |next| next.apply(action, permissions).map(drop)) +} + +/// Show a panel at its recorded place, else beside the document view on its default side. +fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> { + if workspace.layout.contains(&panel) == visible { + return Ok(()); + } + checked(workspace, |next| { + if !visible { + return next.apply(Action::Close { panel }, permissions).map(drop); + } + if next.set_visible(panel, true, None, permissions).is_ok() { + return Ok(()); + } + next.apply(Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, permissions).map(drop) + }) +} + +/// Run a shared docking command. A hidden panel that is opened or activated comes back with +/// [`set_visible`], so it gets SoundCraft's default place when it has no recorded one. +fn run(workspace: &mut Workspace, command: &DockCommand) -> Result<(), String> { + if let DockCommand::Activate { panel } | DockCommand::UiDock(UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel })) = + command + && !workspace.layout.contains(panel) + { + return set_visible(workspace, *panel, true); + } + // Only `window.panel.float` reads the float space, and SoundCraft does not register it. + checked(workspace, |next| command.apply(next, false, &FloatSpace::new(egui::Rect::NOTHING), permissions).map(drop)) +} + fn sync(workspace: &mut Workspace, app: &crate::SoundApp, edit: bool) -> Result<(), String> { - workspace.set_visible(Panel::Tracks, app.ui.show_tracks_list)?; - workspace.set_visible(Panel::Memory, app.ui.show_memory_locations)?; - workspace.set_visible(Panel::History, app.ui.show_undo_history)?; + set_visible(workspace, Panel::Tracks, app.ui.show_tracks_list)?; + set_visible(workspace, Panel::Memory, app.ui.show_memory_locations)?; + set_visible(workspace, Panel::History, app.ui.show_undo_history)?; if edit { - workspace.set_visible(Panel::Clips, app.ui.show_clip_list)?; - workspace.set_visible(Panel::Midi, app.ui.show_midi_editor)?; + set_visible(workspace, Panel::Clips, app.ui.show_clip_list)?; + set_visible(workspace, Panel::Midi, app.ui.show_midi_editor)?; } Ok(()) } @@ -316,20 +227,57 @@ fn store(app: &mut crate::SoundApp, workspace: Workspace, edit: bool) { } } -pub fn command(app: &mut crate::SoundApp, reset: bool, params: &Value) -> Result { +/// A `ui.dock` operation. Panels and zones are checked first so an error names the field; +/// parsed through the untagged [`UiDock`], every mistake reads "did not match any variant". +fn operation(fields: &serde_json::Map) -> Result, String> { + let name = fields.get("operation").ok_or("operation is required")?.as_str().ok_or("operation must be a string")?; + if name != "resizeSplit" && !fields.contains_key("panel") { + return Err("panel is required".into()); + } + for field in ["panel", "target"] { + if let Some(value) = fields.get(field) { + let id = value.as_str().ok_or_else(|| format!("{field} must be a panel ID"))?; + serde_json::from_value::(json!(id)).map_err(|_| format!("Unknown panel: {id}"))?; + } + } + if let Some(zone) = fields.get("zone") + && serde_json::from_value::(zone.clone()).is_err() + { + return Err("zone must be tab, center, left, right, top, bottom, above or below".into()); + } + serde_json::from_value(Value::Object(fields.clone())).map_err(|error| error.to_string()) +} + +/// `ui.dock` and `ui.dock.reset`: the shared [`DockCommand`] vocabulary, applied to the +/// front window's workspace. +pub fn command(app: &mut crate::SoundApp, id: &str, params: &Value) -> Result { let edit = app.ui.window == crate::MainWindow::Edit; + if !edit && ["panel", "target"].iter().any(|field| matches!(params.get(*field).and_then(Value::as_str), Some("clips" | "midi"))) { + return Err("Clips and MIDI panels belong to the Edit window".into()); + } + let mut fields = match params { + Value::Object(fields) => fields.clone(), + Value::Null => serde_json::Map::new(), + _ => return Err("Docking params must be an object".into()), + }; + let command = if id == "ui.dock" && !fields.contains_key("action") { + DockCommand::UiDock(UiDock::Operation(operation(&fields)?)) + } else { + fields.insert("command".into(), json!(id)); + serde_json::from_value(Value::Object(fields)).map_err(|error| error.to_string())? + }; let previous = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() }; - let result = command_for(app, reset, params, edit)?; + let result = command_for(app, &command, edit)?; let next = if edit { &app.ui.docking } else { &app.ui.mix_docking }; cancel_replaced_workspace(app, &previous, next, edit); Ok(result) } fn cancel_replaced_workspace(app: &crate::SoundApp, previous: &Workspace, next: &Workspace, edit: bool) { - if (previous.layout != next.layout || previous.hidden != next.hidden) + if previous != next && let Some(ctx) = &app.docking_context { - craft_ui::docking::cancel_drag::(ctx, egui::Id::new(("soundcraft-docking", edit))); + craft_ui::docking::cancel_drag::(ctx, area(edit)); } } @@ -339,22 +287,23 @@ pub(crate) fn cancel_replaced_state(app: &crate::SoundApp, next: &crate::UiState cancel_replaced_workspace(app, &app.ui.mix_docking, &next.mix_docking, false); } -fn command_for(app: &mut crate::SoundApp, reset: bool, params: &Value, edit: bool) -> Result { - if !edit && ["panel", "target"].iter().any(|field| matches!(params.get(*field).and_then(Value::as_str), Some("clips" | "midi"))) { - return Err("Clips and MIDI panels belong to the Edit window".into()); - } +fn command_for(app: &mut crate::SoundApp, command: &DockCommand, edit: bool) -> Result { let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() }; - if !reset && !edit { - workspace.validate_mix()?; - } - if reset { - workspace = if edit { Workspace::default() } else { mix_default() }; + if command.is_app_owned() { + // Reset is the only application-owned command SoundCraft registers. + if !matches!(command, DockCommand::ResetWorkspace {}) { + return Err("Named panel workspaces are not supported".into()); + } + workspace = if edit { edit_default() } else { mix_default() }; sync(&mut workspace, app, edit)?; } else { - sync(&mut workspace, app, edit)?; - workspace.command(params)?; if !edit { - workspace.validate_mix()?; + validate_mix(&workspace)?; + } + sync(&mut workspace, app, edit)?; + run(&mut workspace, command)?; + if !edit { + validate_mix(&workspace)?; } } publish_visibility(app, &workspace, edit); @@ -368,16 +317,16 @@ pub(crate) fn arrange(app: &mut crate::SoundApp) -> Result<(), String> { let edit = app.ui.window == crate::MainWindow::Edit; let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() }; if edit { - workspace.validate()?; + validate(&workspace)?; } else { - workspace.validate_mix()?; + validate_mix(&workspace)?; } let previous = workspace.clone(); - let floating: Vec<_> = workspace.layout.floating.iter().filter_map(|group| group.panels.first().map(|panel| (*panel, group.rect))).collect(); + let floating: Vec<_> = workspace.layout.floating.iter().filter_map(|window| window.panels().next().map(|panel| (*panel, window.rect))).collect(); for (index, (panel, mut rect)) in floating.into_iter().enumerate() { rect[0] = 32.0 + index as f32 * 28.0; rect[1] = 80.0 + index as f32 * 28.0; - workspace.apply(Action::MoveFloating { panel, rect })?; + apply(&mut workspace, Action::MoveFloating { panel, rect })?; } cancel_replaced_workspace(app, &previous, &workspace, edit); store(app, workspace, edit); @@ -388,11 +337,12 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin app.docking_context = Some(ui.ctx().clone()); let t = crate::theme::Tokens::current(); let edit = window == crate::MainWindow::Edit; - let mut workspace = if edit { std::mem::take(&mut app.ui.docking) } else { std::mem::replace(&mut app.ui.mix_docking, mix_default()) }; + let mut workspace = + if edit { std::mem::replace(&mut app.ui.docking, edit_default()) } else { std::mem::replace(&mut app.ui.mix_docking, mix_default()) }; let before_sync = workspace.clone(); - if let Err(error) = (if edit { workspace.validate() } else { workspace.validate_mix() }).and_then(|()| sync(&mut workspace, app, edit)) { + if let Err(error) = (if edit { validate(&workspace) } else { validate_mix(&workspace) }).and_then(|()| sync(&mut workspace, app, edit)) { app.ui.status = format!("Panel layout could not be restored: {error}"); - workspace = if edit { Workspace::default() } else { mix_default() }; + workspace = if edit { edit_default() } else { mix_default() }; let _ = sync(&mut workspace, app, edit); } cancel_replaced_workspace(app, &before_sync, &workspace, edit); @@ -407,7 +357,7 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin style.border = egui::Stroke::new(1.0, t.border); style.accent = t.accent; style.font = crate::theme::regular(11.5); - let output = DockArea::new(egui::Id::new(("soundcraft-docking", edit))).show_with_limits( + let output = DockArea::new(area(edit)).show_with_limits( ui, &workspace.layout, &style, @@ -437,10 +387,7 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin publish_visibility(app, &workspace, edit); store(app, workspace, edit); for action in output.actions { - if let Err(error) = serde_json::to_value(action) - .map_err(|error| error.to_string()) - .and_then(|action| command_for(app, false, &json!({"action": action}), edit)) - { + if let Err(error) = command_for(app, &DockCommand::Layout { action }, edit) { app.ui.status = format!("Panel move was not applied: {error}"); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 140355c8..0d2dea2c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -133,7 +133,7 @@ impl Default for UiState { fn default() -> Self { UiState { recent_sessions: Vec::new(), - docking: docking::Workspace::default(), + docking: docking::edit_default(), mix_docking: docking::mix_default(), window: MainWindow::Edit, show_tracks_list: true, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cd879a72..a2a01304 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -504,7 +504,7 @@ fn wants_dialog(id: &str) -> bool { /// Handle UI-layer commands. Returns None when `id` is not a UI command. pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option> { if matches!(id, "ui.dock" | "ui.dock.reset") { - return Some(crate::docking::command(app, id == "ui.dock.reset", p)); + return Some(crate::docking::command(app, id, p)); } if let Some(v) = crate::extra_windows::run(app, id, p) { return Some(Ok(v)); @@ -591,7 +591,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option toggle(&mut app.ui.show_configurations), "window.config_new" | "window.config_update" => { - if let Err(error) = app.ui.docking.validate().and_then(|()| app.ui.mix_docking.validate_mix()) { + if let Err(error) = crate::docking::validate(&app.ui.docking).and_then(|()| crate::docking::validate_mix(&app.ui.mix_docking)) { return Some(Err(error)); } let mut snap = app.ui.clone(); @@ -687,7 +687,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option(cur) { Ok(ns) => { - if let Err(error) = ns.docking.validate().and_then(|()| ns.mix_docking.validate_mix()) { + if let Err(error) = crate::docking::validate(&ns.docking).and_then(|()| crate::docking::validate_mix(&ns.mix_docking)) { return Some(Err(error)); } crate::docking::cancel_replaced_state(app, &ns); diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 3b60cd75..7cd3467f 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -391,7 +391,7 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) { if ui.button(format!("{} {name}", i + 1)).clicked() && let Ok(mut st) = serde_json::from_value::(v.clone()) { - if let Err(error) = st.docking.validate().and_then(|()| st.mix_docking.validate_mix()) { + if let Err(error) = crate::docking::validate(&st.docking).and_then(|()| crate::docking::validate_mix(&st.mix_docking)) { app.ui.status = format!("Window configuration could not be restored: {error}"); } else { st.configurations = app.ui.configurations.clone(); diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index c2cdf33a..d0013494 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -43,25 +43,29 @@ fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() { assert_eq!(h.state().ui.docking.layout.location(&Panel::Clips).unwrap().anchor, Some(Panel::Tracks)); h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary); h.run_steps(3); - h.get_by_label("Float panel").click(); + h.get_by_label("Float Panel").click(); h.run_steps(5); - let rect = h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect; + let rect = h.state().ui.docking.layout.floating.iter().find(|g| g.contains(&Panel::Clips)).unwrap().rect; h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary); h.run_steps(3); - h.get_by_label("Close panel").click(); + h.get_by_label("Close Panel").click(); h.run_steps(4); assert!(!h.state().ui.show_clip_list); h.state_mut().run("ui.dock", json!({"operation":"open","panel":"clips"})).unwrap(); h.run_steps(4); - assert_eq!(h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect, rect); + assert_eq!(h.state().ui.docking.layout.floating.iter().find(|g| g.contains(&Panel::Clips)).unwrap().rect, rect); let saved = serde_json::to_string(&h.state().ui).unwrap(); let expected = serde_json::to_value(&h.state().ui.docking).unwrap(); let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0); assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected); reloaded.state_mut().run("ui.dock", json!({"operation":"move","panel":"clips","target":"tracks","zone":"center"})).unwrap(); reloaded.run_steps(4); - reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").click(); + // The narrow Tracks frame shows the active Clips tab and lists Tracks under "…". + reloaded.get_by_label("Panels").click(); reloaded.run_steps(3); + reloaded.get_by_label("Tracks and Groups").click(); + reloaded.run_steps(3); + assert_eq!(reloaded.state().ui.docking.layout.panel_state(&Panel::Tracks), craft_ui::docking::PanelState::Front); reloaded.get_by_label("Trim (F6)").click(); reloaded.run_steps(3); assert_eq!(reloaded.state().engine.session().edit.tool, soundcraft_model::Tool::Trim); @@ -82,6 +86,18 @@ fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() { assert!(!reloaded.state().engine.transport.recording); } +#[test] +fn activating_a_hidden_panel_shows_it_and_its_window_flag() { + let mut h = harness(None, 1400.0, 1.0); + h.state_mut().run("ui.dock", json!({"operation":"close","panel":"tracks"})).unwrap(); + h.run_steps(4); + assert!(!h.state().ui.show_tracks_list); + h.state_mut().run("ui.dock", json!({"operation":"activate","panel":"tracks"})).unwrap(); + h.run_steps(4); + assert!(h.state().ui.show_tracks_list); + assert_eq!(h.state().ui.docking.layout.panel_state(&Panel::Tracks), craft_ui::docking::PanelState::Front); +} + #[test] fn invalid_docking_requests_are_atomic_in_edit_and_mix() { let mut h = harness(None, 1400.0, 1.0); @@ -118,8 +134,8 @@ fn docking_renders_edit_and_mix_default_floating_and_redocked() { h.state_mut().run("ui.dock", json!({"operation":"move","panel":"tracks","target":"canvas","zone":"left"})).unwrap(); } h.run_steps(5); - h.state().ui.docking.validate().unwrap(); - h.state().ui.mix_docking.validate().unwrap(); + soundcraft_ui_egui::docking::validate(&h.state().ui.docking).unwrap(); + soundcraft_ui_egui::docking::validate(&h.state().ui.mix_docking).unwrap(); if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { std::fs::create_dir_all(&directory).unwrap(); h.render().unwrap().save(format!("{directory}/sound-{window:?}-{width}-{scale}-{state}.png")).unwrap(); @@ -143,7 +159,7 @@ fn reset_is_enabled_and_resolves_in_native_and_in_window_menu_trees() { assert!(menus::item_state(&app, path, Some("ui.dock.reset")).0); app.run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[50,60,260,420]})).unwrap(); menus::invoke_menu(&mut app, "ui.dock.reset", path); - assert!(!app.ui.docking.layout.floating.iter().any(|group| group.panels.contains(&Panel::Tracks))); + assert!(!app.ui.docking.layout.floating.iter().any(|group| group.contains(&Panel::Tracks))); } #[test] @@ -154,6 +170,116 @@ fn legacy_preferences_restore_the_theme_and_validate_default_docking() { object.remove("mix_docking"); let restored: UiState = serde_json::from_value(value).unwrap(); assert_eq!(restored.theme, soundcraft_ui_egui::theme::ThemeMode::Light); - restored.docking.validate().unwrap(); - restored.mix_docking.validate_mix().unwrap(); + soundcraft_ui_egui::docking::validate(&restored.docking).unwrap(); + soundcraft_ui_egui::docking::validate_mix(&restored.mix_docking).unwrap(); +} + +/// The tab group holding `panel` in the docked frames. +fn docked_group(node: &craft_ui::docking::Node, panel: Panel) -> Option> { + use craft_ui::docking::Node; + match node { + Node::Split { first, second, .. } => docked_group(first, panel).or_else(|| docked_group(second, panel)), + Node::Tabs { panels, .. } if panels.contains(&panel) => Some(panels.clone()), + _ => None, + } +} + +#[test] +fn v1_workspace_settings_load_with_their_layout_and_hidden_places() { + use craft_ui::docking::Node; + // A workspace as releases before the shared docking model saved it: no version, and a + // floating window as one `{panels, active, rect}` group. + let v1 = json!({ + "layout": { + "root": {"Split": { + "axis": "Horizontal", + "size": {"FixedFirst": 200.0}, + "first": {"Tabs": {"panels": ["tracks"], "active": 0}}, + "second": {"Tabs": {"panels": ["canvas"], "active": 0}} + }}, + "floating": [{"panels": ["clips", "midi"], "active": 1, "rect": [300.0, 120.0, 420.0, 360.0]}] + }, + "hidden": [ + ["memory", {"floating": [160.0, 120.0, 480.0, 280.0], "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null}], + ["history", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 260.0}, "stack": null, "sibling": ["canvas"]}] + ] + }); + let mut settings = serde_json::to_value(UiState::default()).unwrap(); + settings["docking"] = v1; + settings["show_memory_locations"] = json!(false); + settings["show_undo_history"] = json!(false); + settings["show_midi_editor"] = json!(true); + let state: UiState = serde_json::from_value(settings).unwrap(); + soundcraft_ui_egui::docking::validate(&state.docking).unwrap(); + let floating = &state.docking.layout.floating; + assert_eq!(floating.len(), 1); + assert_eq!(floating[0].root, Node::Tabs { panels: vec![Panel::Clips, Panel::Midi], active: 1 }); + assert_eq!(floating[0].rect, [300.0, 120.0, 420.0, 360.0]); + let root = state.docking.layout.root.clone().unwrap(); + assert!(matches!(&root, Node::Split { size: craft_ui::layout::SplitSize::FixedFirst(200.0), .. })); + + let mut h = harness(Some(state), 1400.0, 1.0); + assert_eq!(h.state().ui.docking.layout.root.as_ref(), Some(&root), "drawing the loaded layout must not rearrange it"); + assert_eq!(h.state().ui.docking.layout.floating[0].rect, [300.0, 120.0, 420.0, 360.0]); + h.state_mut().run("ui.dock", json!({"operation":"open","panel":"memory"})).unwrap(); + h.state_mut().run("ui.dock", json!({"operation":"open","panel":"history"})).unwrap(); + h.run_steps(3); + let layout = &h.state().ui.docking.layout; + assert_eq!(layout.floating.iter().find(|w| w.contains(&Panel::Memory)).unwrap().rect, [160.0, 120.0, 480.0, 280.0]); + assert_eq!(docked_group(layout.root.as_ref().unwrap(), Panel::History), Some(vec![Panel::History])); + assert!(h.state().ui.show_memory_locations && h.state().ui.show_undo_history); + + // Written back, a one-group window keeps the v1 shape older builds read. + let saved = serde_json::to_value(&h.state().ui.docking).unwrap(); + let window = saved["layout"]["floating"].as_array().unwrap().iter().find(|w| w["panels"] == json!(["clips", "midi"])).unwrap(); + assert_eq!(window["active"], json!(1)); + assert_eq!(window["rect"], json!([300.0, 120.0, 420.0, 360.0])); +} + +#[test] +fn dragging_a_group_by_its_drag_bar_joins_it_to_another_group() { + use craft_ui::docking::{DockArea, Hit}; + let mut h = harness(None, 1400.0, 1.0); + let root = h.state().ui.docking.layout.root.clone().unwrap(); + assert_eq!(docked_group(&root, Panel::Tracks), Some(vec![Panel::Tracks])); + let hits = DockArea::::hit_rects(&h.ctx, soundcraft_ui_egui::docking::area_id(MainWindow::Edit)); + let bar = hits.iter().find(|(hit, _)| *hit == Hit::DragBar(Panel::Clips)).expect("the Clips group has a drag bar").1; + let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").rect().center(); + drag(&mut h, bar.center(), target); + let layout = &h.state().ui.docking.layout; + let group = docked_group(layout.root.as_ref().unwrap(), Panel::Tracks).unwrap(); + assert_eq!(group.len(), 2, "the dropped group joins the Tracks tabs: {group:?}"); + assert!(group.contains(&Panel::Clips)); + assert!(h.state().ui.show_clip_list); + soundcraft_ui_egui::docking::validate(&h.state().ui.docking).unwrap(); +} + +#[test] +fn malformed_dock_operations_name_the_field() { + let mut h = harness(None, 1400.0, 1.0); + for (params, error) in [ + (json!({"operation":"close"}), "panel is required"), + (json!({"operation":"close","panel":"mixer"}), "Unknown panel: mixer"), + ( + json!({"operation":"move","panel":"clips","target":"tracks","zone":"middle"}), + "zone must be tab, center, left, right, top, bottom, above or below", + ), + ] { + assert_eq!(h.state_mut().run("ui.dock", params).unwrap_err(), error); + } +} + +#[test] +fn a_panel_without_a_recorded_place_opens_beside_the_session_view() { + use craft_ui::docking::{Action, Permissions, Placement, Zone}; + let mut state = UiState::default(); + state.docking.apply(Action::Close { panel: Panel::Tracks }, |_| Permissions::default()).unwrap(); + state.docking.hidden.clear(); + state.show_tracks_list = false; + let mut h = harness(Some(state), 1400.0, 1.0); + h.state_mut().run("ui.dock", json!({"operation":"open","panel":"tracks"})).unwrap(); + h.run_steps(4); + let location = h.state().ui.docking.layout.location(&Panel::Tracks).unwrap(); + assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Left))); + assert!(h.state().ui.show_tracks_list); } diff --git a/crates/ui-egui/tests/theme_docking.rs b/crates/ui-egui/tests/theme_docking.rs index ed324153..54495c22 100644 --- a/crates/ui-egui/tests/theme_docking.rs +++ b/crates/ui-egui/tests/theme_docking.rs @@ -36,7 +36,7 @@ fn shared_panels_and_controls_follow_manual_and_live_system_appearance() { assert_eq!(theme::is_light(), light); assert_eq!(h.state().ui.theme, mode); assert_eq!(*h.state().engine.session(), session); - h.state().ui.docking.validate().unwrap(); + soundcraft_ui_egui::docking::validate(&h.state().ui.docking).unwrap(); if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) { std::fs::create_dir_all(&directory).unwrap(); h.render().unwrap().save(directory.join(format!("sound-{name}-{width_name}-{scale}x.png"))).unwrap(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 72904533..a8428179 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -99,7 +99,7 @@ Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use mo Panel IDs: `tracks`, `clips`, `midi`, `memory`, `history`. The protected center is `canvas`. -`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. +`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: tab|center|left|right|top|bottom|above|below`; PascalCase names such as `Left` also parse). `open` and `activate` show a hidden panel at its saved location, or beside the session view when it has none, and bring a visible panel to the front. Center and `tab` group with the target; side zones split beside it; `above` and `below` stack the panel in a new group above or below a floating target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tracks","rect":[100,100,320,440]}}`. From 1e9a0e9c882a828a414e5ff973ec131f7be947d6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 13:46:21 -0700 Subject: [PATCH 68/90] Strip Silence: a mode that is not a string is refused edit.strip_silence read a null, boolean or number mode as no mode and ran Strip. It now returns an error and leaves the track unchanged, as it does for an unknown mode name. --- crates/engine/src/cmd/edit.rs | 5 +++-- crates/engine/src/io.rs | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 5e6f0099..8e0f0588 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -796,9 +796,10 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result { let sr = e.session().sample_rate; let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0); let (min_len, pre, post) = (ms("min_length_ms", 50.0), ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0)); - let mode = match str_param(p, "mode") { + let mode = match p.get("mode") { None => crate::io::StripMode::Strip, - Some(m) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?, + Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?, + Some(_) => return Err(bad("edit.strip_silence", "mode must be strip, extract or separate")), }; let s = e.session_mut(); let mut kept = 0; diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 7012d58f..c42231f1 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -2355,8 +2355,10 @@ mod strip_tests { #[test] fn an_unknown_strip_mode_is_refused() { let (mut e, t) = tone_gap_tone(); - assert!(e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": 144_000, "mode": "remove"})).is_err()); - assert_eq!(e.session().track(t).unwrap().clips().len(), 1); + for mode in [json!("remove"), json!(null), json!(true), json!(1)] { + assert!(e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": 144_000, "mode": mode})).is_err()); + assert_eq!(e.session().track(t).unwrap().clips().len(), 1); + } } #[test] From 698507cf30e63c601dbb4e23111f289f2504f898 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:06:41 -0700 Subject: [PATCH 69/90] Strip Silence: Rename sets the names of the clips it leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Strip Silence window has a Rename… button. It opens Rename Selected Clips, where Name, Number, Zeros and Suffix set the names that Strip, Extract and Separate give the clips they leave in the selection: the name, that many zeros, a number counting up from Number, and the suffix. SFX, 23, 1 and .Reel1 give SFX023.Reel1, SFX024.Reel1 and so on. The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. OK, Cancel and Escape go back to Strip Silence with its slider values. edit.strip_silence takes name, number, zeros and suffix. Numbering runs on from one track to the next, and the result reports how many clips were named. Without a name the clips keep their names. A name or suffix that is not text, a number out of range, or a number, zeros or suffix without a name is an error and leaves the tracks unchanged. --- ROADMAP.md | 4 +- crates/engine/src/cmd/edit.rs | 40 +++++- crates/engine/src/io.rs | 113 +++++++++++++++ crates/ui-egui/src/dialogs.rs | 117 ++++++++++++++-- crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/strip_silence_rename.rs | 131 ++++++++++++++++++ 6 files changed, 395 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/tests/it/strip_silence_rename.rs diff --git a/ROADMAP.md b/ROADMAP.md index 158beb88..342a23a1 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-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Strip Silence Extract and Separate) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Strip Silence Rename) · **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-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change. - **2026-10-24:** The Strip Silence window has Extract and Separate buttons. Extract removes the audio and keeps the silent parts of the selection, and Separate separates the clips at the edges of the silence without removing anything. Minimum Strip Duration goes up to 4,000 ms. No readiness percentage change. - **2026-10-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change. - **2026-10-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. No readiness percentage change. @@ -167,6 +168,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence Extract and Separate; readiness estimates unchanged | | 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged | | 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8e0f0588..e1823c15 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -121,7 +121,7 @@ pub fn specs() -> Vec { "Strip Silence", ["Edit"], Some("Cmd+U"), - "{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, mode?: strip|extract|separate} (strip removes the silence; extract removes the audio and keeps the silence; separate separates the clips at the edges of the silence and does not remove any)", + "{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, mode?: strip|extract|separate, name?, number?: 1, zeros?: 0, suffix?} (strip removes the silence; extract removes the audio and keeps the silence; separate separates the clips at the edges of the silence and does not remove any; with a name, the clips left in the selection are named name, zeros, a number counting up from number, suffix)", has_range, strip_silence ), @@ -801,12 +801,48 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result { Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?, Some(_) => return Err(bad("edit.strip_silence", "mode must be strip, extract or separate")), }; + let names = strip_names(p)?; let s = e.session_mut(); let mut kept = 0; + let mut named = 0; for t in &tracks { kept += crate::io::strip_silence(s, *t, r, thr, min_len, (pre, post), mode)?; + if let Some(names) = &names { + named += crate::io::name_stripped_clips(s, *t, r, names, named); + } } - Ok(json!({"clips": kept})) + Ok(json!({"clips": kept, "named": named})) +} + +/// The naming scheme in `edit.strip_silence`'s `name`, `number`, `zeros` and `suffix` params, or +/// `None` when `name` is missing or blank. +fn strip_names(p: &Value) -> Result> { + use crate::io::StripNames; + let cmd = "edit.strip_silence"; + let text = |k: &str| match p.get(k) { + None => Ok(None), + Some(Value::String(v)) => Ok(Some(v.clone())), + Some(_) => Err(bad(cmd, format!("`{k}` must be text"))), + }; + let whole = |k: &str, max: u64| match p.get(k) { + None => Ok(None), + Some(v) => v.as_u64().filter(|n| *n <= max).map(Some).ok_or_else(|| bad(cmd, format!("`{k}` must be a whole number from 0 to {max}"))), + }; + let (name, suffix) = (text("name")?, text("suffix")?); + let (number, zeros) = (whole("number", StripNames::MAX_NUMBER)?, whole("zeros", StripNames::MAX_ZEROS as u64)?); + let Some(name) = name.filter(|n| !n.trim().is_empty()) else { + if suffix.is_some() || number.is_some() || zeros.is_some() { + return Err(bad(cmd, "`number`, `zeros` and `suffix` need a `name`")); + } + return Ok(None); + }; + let d = StripNames::default(); + Ok(Some(StripNames { + name, + number: number.unwrap_or(d.number), + zeros: zeros.and_then(|z| usize::try_from(z).ok()).unwrap_or(d.zeros), + suffix: suffix.unwrap_or(d.suffix), + })) } fn fades_create(e: &mut Engine, p: &Value) -> Result { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index c42231f1..8600ae59 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -755,6 +755,49 @@ impl StripMode { } } +/// The names Strip Silence gives the clips it leaves in the selection: `name`, then `zeros` zeros, +/// then a number that starts at `number` and counts up one clip at a time, then `suffix`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StripNames { + pub name: String, + pub number: u64, + pub zeros: usize, + pub suffix: String, +} + +impl Default for StripNames { + fn default() -> Self { + Self { name: String::new(), number: 1, zeros: 0, suffix: String::new() } + } +} + +impl StripNames { + /// The largest first number. + pub const MAX_NUMBER: u64 = 999_999_999; + /// The most zeros before the number. + pub const MAX_ZEROS: usize = 9; + + /// The name of the clip `i` places after the first one. + pub fn clip_name(&self, i: u64) -> String { + format!("{}{}{}{}", self.name, "0".repeat(self.zeros), self.number.saturating_add(i), self.suffix) + } +} + +/// Names the audio clips that lie inside `r` on `t` with `names`, in time order, the first of them +/// `next` places after `names.number`. Returns how many clips it named. +pub fn name_stripped_clips(s: &mut Session, t: TrackId, r: Range, names: &StripNames, next: u64) -> u64 { + let r = bounded_to_track(s, t, r); + let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) else { return 0 }; + let mut inside: Vec<&mut Clip> = pl.clips.iter_mut().filter(|c| c.is_audio() && c.start >= r.start && c.end() <= r.end).collect(); + inside.sort_by_key(|c| (c.start, c.id.0)); + let mut named = 0; + for c in inside { + c.name = names.clip_name(next.saturating_add(named)); + named += 1; + } + named +} + /// Strip Silence: separate the clips inside `r` where silence starts and ends, then keep only the /// non-silent ranges (`Strip`), only the silent ranges (`Extract`), or every piece (`Separate`). /// `pad` is the clip start and end pad added around each non-silent range. Returns the number of @@ -2361,6 +2404,76 @@ mod strip_tests { } } + fn names(e: &Engine, t: TrackId) -> Vec { + e.session().track(t).unwrap().clips().iter().map(|c| c.name.clone()).collect() + } + + #[test] + fn strip_names_follow_the_guide_example() { + let n = StripNames { name: "SFX".into(), number: 23, zeros: 1, suffix: ".Reel1".into() }; + assert_eq!((0..4).map(|i| n.clip_name(i)).collect::>(), ["SFX023.Reel1", "SFX024.Reel1", "SFX025.Reel1", "SFX026.Reel1"]); + } + + #[test] + fn strip_names_the_clips_it_keeps() { + let (mut e, t) = tone_gap_tone(); + let p = json!({"track": t.0, "start": 0, "end": 144_000, "pad_before_ms": 0, "pad_after_ms": 0, "name": "SFX", "number": 23, "zeros": 1, "suffix": ".Reel1"}); + let r = e.execute("edit.strip_silence", &p).unwrap(); + assert_eq!(r["named"], 2); + assert_eq!(names(&e, t), ["SFX023.Reel1", "SFX024.Reel1"]); + } + + #[test] + fn separate_names_every_piece_in_time_order() { + let (mut e, t) = tone_gap_tone(); + let p = json!({"track": t.0, "start": 0, "end": 144_000, "pad_before_ms": 0, "pad_after_ms": 0, "mode": "separate", "name": "Take "}); + e.execute("edit.strip_silence", &p).unwrap(); + assert_eq!(names(&e, t), ["Take 1", "Take 2", "Take 3"]); + } + + #[test] + fn strip_numbers_run_on_from_one_track_to_the_next() { + let (mut e, a) = tone_gap_tone(); + e.execute("track.new", &json!({"format": "stereo"})).unwrap(); + let b = e.session().tracks[1].id; + let s = e.session_mut(); + let src = s.sources[0].id; + let id = s.new_clip_id(); + crate::edit::place_clip(s, b, Clip::audio(id, "copy", src, 0, 0, 144_000)); + let p = json!({"tracks": [a.0, b.0], "start": 0, "end": 144_000, "pad_before_ms": 0, "pad_after_ms": 0, "name": "Hit", "zeros": 2}); + let r = e.execute("edit.strip_silence", &p).unwrap(); + assert_eq!(r["named"], 4); + assert_eq!(names(&e, a), ["Hit001", "Hit002"]); + assert_eq!(names(&e, b), ["Hit003", "Hit004"]); + } + + #[test] + fn strip_without_a_name_keeps_the_clip_names() { + let (mut e, t) = tone_gap_tone(); + let r = e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": 144_000, "name": " "})).unwrap(); + assert_eq!(r["named"], 0); + assert_eq!(names(&e, t), ["tone gap tone", "tone gap tone"]); + } + + #[test] + fn bad_naming_params_are_refused() { + for bad in [ + json!({"suffix": ".Reel1"}), + json!({"name": "", "number": 2}), + json!({"name": 7}), + json!({"name": "SFX", "number": -1}), + json!({"name": "SFX", "number": StripNames::MAX_NUMBER + 1}), + json!({"name": "SFX", "zeros": StripNames::MAX_ZEROS + 1}), + json!({"name": "SFX", "suffix": null}), + ] { + let (mut e, t) = tone_gap_tone(); + let mut p = json!({"track": t.0, "start": 0, "end": 144_000}); + p.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(e.execute("edit.strip_silence", &p).is_err(), "{bad} was taken"); + assert_eq!(names(&e, t), ["tone gap tone"], "{bad} changed the track"); + } + } + #[test] fn more_than_a_day_of_clips_is_refused() { let (mut e, t) = tones(&[0], 48_000 * 86_401); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 46ad9627..fc02c85d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5,6 +5,7 @@ use crate::theme::{Tokens, bold}; use egui::{Align2, vec2}; use serde_json::{Value, json}; use soundcraft_engine::Engine; +use soundcraft_engine::io::StripNames; use soundcraft_model::{ClipId, GroupId, Session, SourceId, TrackId}; use soundcraft_time::Samples; @@ -23,6 +24,8 @@ pub enum Dialog { Spot(crate::spot::Spot), Fades { shape: String }, StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 }, + // Strip Silence › Rename: `strip` holds the Strip Silence values to return to. + StripSilenceRename { strip: [f64; 4], names: StripNames }, // `id: None` creates (Track › Group…); `id: Some` modifies that group (Groups list › Modify Group…). Group { id: Option, name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> }, Session { frame_rate: String, bit_depth: String }, @@ -37,6 +40,8 @@ pub struct Dialogs { after_save: Option<(String, Value)>, /// A session the browser read while the open one had changes, waiting for `SaveChanges`. pending_session: Option<(String, Vec)>, + /// The naming scheme Strip Silence gives the clips it leaves, kept from one use to the next. + pub strip_names: StripNames, } impl Dialogs { @@ -55,6 +60,7 @@ impl Dialogs { Dialog::Spot(_) => "spot", Dialog::Fades { .. } => "fades", Dialog::StripSilence { .. } => "strip_silence", + Dialog::StripSilenceRename { .. } => "strip_silence_rename", Dialog::Group { id: None, .. } => "group", Dialog::Group { id: Some(_), .. } => "modify_group", Dialog::Session { .. } => "session", @@ -319,6 +325,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { let mut action: Option<(String, Value)> = None; let mut save_first: Option<(String, Value)> = None; let confirmed = matches!(d, Dialog::SaveChanges { .. }); + // Strip Silence's Rename button, and the naming scheme its dialog's OK keeps. + let mut rename_from: Option<[f64; 4]> = None; + let mut renamed: Option = None; + let strip_names = app.dialogs.strip_names.clone(); let title = match &d { Dialog::NewTracks { .. } => "New Tracks", Dialog::NewSession { .. } => "New Session", @@ -332,6 +342,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Spot(_) => "Spot", Dialog::Fades { .. } => "Fades", Dialog::StripSilence { .. } => "Strip Silence", + Dialog::StripSilenceRename { .. } => "Rename Selected Clips", Dialog::Group { id: None, .. } => "Create Group", Dialog::Group { id: Some(_), .. } => "Modify Group", Dialog::Session { .. } => "Session Setup", @@ -559,12 +570,20 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms")); ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms")); let values = [*threshold, *min_ms, *pre_ms, *post_ms]; + if !strip_names.name.trim().is_empty() { + let names = format!("New clips are named {}, {} and so on.", strip_names.clip_name(0), strip_names.clip_name(1)); + ui.label(egui::RichText::new(names).small().color(Tokens::current().text_dim)); + } ui.separator(); ui.horizontal(|ui| { let mut mode = None; + let mut rename = false; if ui.button("Extract").clicked() { mode = Some("extract"); } + if ui.button("Rename…").clicked() { + rename = true; + } if ui.button("Separate").clicked() { mode = Some("separate"); } @@ -572,11 +591,25 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { if buttons(ui, "Strip", enter) && mode.is_none() { mode = Some("strip"); } - if let Some(mode) = mode { - action = Some(strip_silence_action(mode, values)); + // Enter on a focused Rename… also presses Strip, so Rename… goes first. + if rename { + rename_from = Some(values); + } else if let Some(mode) = mode { + action = Some(strip_silence_action(mode, values, &strip_names)); } }); } + Dialog::StripSilenceRename { names, .. } => { + strip_name_fields(ui, names); + let cleared = ui.button("Clear").clicked(); + if cleared { + *names = StripNames::default(); + } + // Enter on a focused Clear clicks it, and leaves the dialog open. + if buttons(ui, "OK", enter) && !cleared { + renamed = Some(names.clone()); + } + } Dialog::Group { id, name, edit, mix, members } => { ui.horizontal(|ui| { ui.label("Name"); @@ -675,6 +708,26 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { app.dialogs.pending_session = None; ctx.memory_mut(|m| m.data.remove::(egui::Id::new("dlg_cancel"))); } + if let Some(strip) = rename_from + && keep + { + d = Dialog::StripSilenceRename { strip, names: strip_names }; + } + // OK, Cancel and Escape in Rename Selected Clips go back to Strip Silence; OK keeps the names. + // Enter on a focused Cancel also presses OK, and `keep` is false then, so Cancel wins. + let back = match &d { + Dialog::StripSilenceRename { strip, .. } if !keep || renamed.is_some() => Some(*strip), + _ => None, + }; + if let Some([threshold, min_ms, pre_ms, post_ms]) = back { + if let Some(names) = renamed + && keep + { + app.dialogs.strip_names = names; + } + d = Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }; + keep = true; + } if let Some(then) = save_first { keep = false; if !app.dialogs.open_for_command(&app.engine, "session.save") { @@ -728,12 +781,41 @@ fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result (String, Value) { +/// `names` names the clips it leaves when its name is not blank. +fn strip_silence_action(mode: &str, values: [f64; 4], names: &StripNames) -> (String, Value) { let [threshold, min_ms, pre_ms, post_ms] = values; - ( - "edit.strip_silence".into(), - json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms, "mode": mode}), - ) + let mut params = json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms, "mode": mode}); + if !names.name.trim().is_empty() { + params["name"] = json!(names.name); + params["number"] = json!(names.number); + params["zeros"] = json!(names.zeros); + params["suffix"] = json!(names.suffix); + } + ("edit.strip_silence".into(), params) +} + +/// The Name, Number, Zeros and Suffix fields of Rename Selected Clips, with the first name they give. +fn strip_name_fields(ui: &mut egui::Ui, names: &mut StripNames) { + egui::Grid::new("strip_names").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + ui.label("Name"); + ui.text_edit_singleline(&mut names.name); + ui.end_row(); + ui.label("Number"); + ui.add(egui::DragValue::new(&mut names.number).range(0..=StripNames::MAX_NUMBER)); + ui.end_row(); + ui.label("Zeros"); + ui.add(egui::DragValue::new(&mut names.zeros).range(0..=StripNames::MAX_ZEROS)); + ui.end_row(); + ui.label("Suffix"); + ui.text_edit_singleline(&mut names.suffix); + ui.end_row(); + }); + let example = if names.name.trim().is_empty() { + "Without a name, the clips keep their names.".to_string() + } else { + format!("First clip: {}", names.clip_name(0)) + }; + ui.label(egui::RichText::new(example).small().color(Tokens::current().text_dim)); } /// Cancel/OK row; returns true when OK was pressed (or Enter). @@ -888,7 +970,7 @@ mod tests { let covered = |c: &[(i64, i64)]| c.iter().map(|(a, b)| b - a).sum::(); let before = clips(&e); let mut run = |mode: &str| { - let (cmd, params) = strip_silence_action(mode, values); + let (cmd, params) = strip_silence_action(mode, values, &StripNames::default()); assert_eq!(params["mode"], mode); e.execute(&cmd, ¶ms).unwrap(); let after = clips(&e); @@ -902,6 +984,25 @@ mod tests { assert_eq!(covered(&strip) + covered(&extract), covered(&before), "Strip and Extract keep opposite parts"); } + #[test] + fn strip_silence_sends_the_naming_scheme_only_with_a_name() { + let values = [-48.0, 50.0, 5.0, 20.0]; + let (_, plain) = strip_silence_action("strip", values, &StripNames::default()); + assert!(plain.get("name").is_none() && plain.get("suffix").is_none()); + let names = StripNames { name: "SFX".into(), number: 23, zeros: 1, suffix: ".Reel1".into() }; + let (cmd, params) = strip_silence_action("separate", values, &names); + assert_eq!((params["name"].clone(), params["number"].clone(), params["zeros"].clone()), (json!("SFX"), json!(23), json!(1))); + assert_eq!(params["suffix"], ".Reel1"); + let mut e = soundcraft_engine::demo::demo_engine(); + let kick = e.session().track_by_name("Kick").unwrap().id; + let end = e.session().content_end(); + e.execute("edit.select", &json!({"tracks": [kick.0], "start": 0, "end": end})).unwrap(); + e.execute(&cmd, ¶ms).unwrap(); + let named: Vec = e.session().track(kick).unwrap().clips().iter().map(|c| c.name.clone()).collect(); + assert!(named.len() > 1); + assert_eq!(named[..2], ["SFX023.Reel1", "SFX024.Reel1"]); + } + #[test] fn clear_always_asks_and_names_the_timeline_clips_that_go_too() { let mut e = soundcraft_engine::demo::demo_engine(); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 262e4216..9a8ea30b 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -46,6 +46,7 @@ mod shortcuts; mod side_lists_access; mod side_lists_text; mod strip_silence_dialog; +mod strip_silence_rename; mod toolbar_scroll; mod toolbar_tempo; mod track_height; diff --git a/crates/ui-egui/tests/it/strip_silence_rename.rs b/crates/ui-egui/tests/it/strip_silence_rename.rs new file mode 100644 index 00000000..9f20c33d --- /dev/null +++ b/crates/ui-egui/tests/it/strip_silence_rename.rs @@ -0,0 +1,131 @@ +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_engine::io::StripNames; +use soundcraft_model::TrackId; +use soundcraft_ui_egui::dialogs::Dialog; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +/// Selects the whole Kick track and opens Edit > Strip Silence from the menu. +fn strip_silence_dialog() -> (Harness<'static, SoundApp>, TrackId) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let track = app.engine.session().track_by_name("Kick").unwrap().id; + let end = app.engine.session().content_end(); + app.run("edit.select", json!({"tracks": [track.0], "start": 0, "end": end})).unwrap(); + menus::invoke_menu(&mut app, "edit.strip_silence", "Edit > Strip Silence"); + assert_eq!(app.dialogs.open_name(), Some("strip_silence")); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + (h, track) +} + +fn sfx() -> StripNames { + StripNames { name: "SFX".into(), number: 23, zeros: 1, suffix: ".Reel1".into() } +} + +/// Puts `names` in the open Rename Selected Clips dialog, as typing them would. +fn type_names(h: &mut Harness<'_, SoundApp>, names: StripNames) { + let Some(Dialog::StripSilenceRename { names: open, .. }) = &mut h.state_mut().dialogs.open else { panic!("Rename Selected Clips is not open") }; + *open = names; + h.run_steps(2); +} + +/// Clicks a button, then lets the window that comes up settle where it is drawn. +fn click(h: &mut Harness<'_, SoundApp>, label: &str) { + h.get_by_role_and_label(Role::Button, label).click(); + h.run_steps(3); +} + +#[test] +fn rename_names_the_clips_strip_silence_leaves() { + let (mut h, track) = strip_silence_dialog(); + click(&mut h, "Rename…"); + assert_eq!(h.state().dialogs.open_name(), Some("strip_silence_rename")); + type_names(&mut h, sfx()); + click(&mut h, "OK"); + assert_eq!(h.state().dialogs.open_name(), Some("strip_silence"), "OK goes back to Strip Silence"); + assert_eq!(h.state().dialogs.strip_names, sfx()); + click(&mut h, "Strip"); + assert_eq!(h.state().dialogs.open_name(), None); + let names: Vec = h.state().engine.session().track(track).unwrap().clips().iter().map(|c| c.name.clone()).collect(); + assert!(names.len() > 1); + assert_eq!(names[..2], ["SFX023.Reel1", "SFX024.Reel1"]); + assert_eq!(h.state().dialogs.strip_names, sfx(), "the naming scheme is kept for the next Strip Silence"); +} + +#[test] +fn cancel_in_rename_goes_back_and_keeps_the_names_it_had() { + let (mut h, _) = strip_silence_dialog(); + if let Some(Dialog::StripSilence { threshold, .. }) = &mut h.state_mut().dialogs.open { + *threshold = -30.0; + } + click(&mut h, "Rename…"); + type_names(&mut h, sfx()); + click(&mut h, "Cancel"); + assert!(matches!(h.state().dialogs.open, Some(Dialog::StripSilence { threshold, .. }) if threshold == -30.0)); + assert_eq!(h.state().dialogs.strip_names, StripNames::default()); +} + +#[test] +fn clear_in_rename_empties_the_naming_scheme() { + let (mut h, track) = strip_silence_dialog(); + h.state_mut().dialogs.strip_names = sfx(); + h.run_steps(2); + h.get_by_label("New clips are named SFX023.Reel1, SFX024.Reel1 and so on."); + click(&mut h, "Rename…"); + assert!(matches!(&h.state().dialogs.open, Some(Dialog::StripSilenceRename { names, .. }) if *names == sfx()), "the dialog shows the kept names"); + click(&mut h, "Clear"); + assert!(matches!(&h.state().dialogs.open, Some(Dialog::StripSilenceRename { names, .. }) if *names == StripNames::default())); + click(&mut h, "OK"); + assert_eq!(h.state().dialogs.strip_names, StripNames::default()); + let before: Vec = h.state().engine.session().track(track).unwrap().clips().iter().map(|c| c.name.clone()).collect(); + click(&mut h, "Separate"); + let after = h.state().engine.session().track(track).unwrap().clips().iter().map(|c| c.name.clone()).collect::>(); + assert!(after.len() > before.len() && after.iter().all(|n| before.contains(n)), "without a name the clips keep their names"); +} + +#[test] +fn enter_on_a_focused_cancel_goes_back_without_the_new_names() { + let (mut h, _) = strip_silence_dialog(); + click(&mut h, "Rename…"); + type_names(&mut h, sfx()); + h.get_by_role_and_label(Role::Button, "Cancel").focus(); + h.run_steps(2); + h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().dialogs.open_name(), Some("strip_silence")); + assert_eq!(h.state().dialogs.strip_names, StripNames::default()); +} + +#[test] +fn enter_on_a_focused_clear_clears_and_leaves_rename_open() { + let (mut h, _) = strip_silence_dialog(); + click(&mut h, "Rename…"); + type_names(&mut h, sfx()); + h.get_by_role_and_label(Role::Button, "Clear").focus(); + h.run_steps(2); + h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(matches!(&h.state().dialogs.open, Some(Dialog::StripSilenceRename { names, .. }) if *names == StripNames::default())); + assert_eq!(h.state().dialogs.strip_names, StripNames::default()); +} + +#[test] +fn enter_on_a_focused_rename_opens_it_and_does_not_strip() { + let (mut h, track) = strip_silence_dialog(); + let before = h.state().engine.session().track(track).unwrap().clips().len(); + h.get_by_role_and_label(Role::Button, "Rename…").focus(); + h.run_steps(2); + h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().dialogs.open_name(), Some("strip_silence_rename")); + assert_eq!(h.state().engine.session().track(track).unwrap().clips().len(), before); +} From a41f254aa097a4fb673f54e3d883033fc686a96f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 15:19:28 -0700 Subject: [PATCH 70/90] Fades: Create refuses a shape that is not a string over a selection edit.fades_create over tracks and a range read a null, boolean or number shape as absent and made Equal Power fades. It now returns an error that lists the accepted shapes, as the clip form already did, and leaves the session and the undo history unchanged. --- crates/engine/src/cmd/edit.rs | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 5536668b..698fb1df 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -805,9 +805,13 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result { } fn fades_create(e: &mut Engine, p: &Value) -> Result { - let shape = str_param(p, "shape") - .map_or(Some(FadeShape::EqualPower), FadeShape::from_id) - .ok_or_else(|| bad("edit.fades_create", "unknown fade shape"))?; + let shape = match p.get("shape") { + None => FadeShape::EqualPower, + Some(v) => v + .as_str() + .and_then(FadeShape::from_id) + .ok_or_else(|| bad("edit.fades_create", "shape must be linear|equal power|s-curve|exponential|logarithmic"))?, + }; if p.get("clip").is_some() || p.get("side").is_some() { let curvature = match p.get("curvature") { None => 0.0, @@ -835,9 +839,6 @@ fn fades_create(e: &mut Engine, p: &Value) -> Result { if available <= 0 { return Err(bad("edit.fades_create", "clip has no length")); } - if p.get("shape").is_some_and(|v| !v.is_string()) { - return Err(bad("edit.fades_create", "shape must be a fade shape name")); - } let fade = Fade { len: length.min(available), shape, curvature }; let clip = e.session_mut().find_clip_mut(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?; if fade_in { @@ -1074,6 +1075,19 @@ mod tests { assert_eq!(e.session(), &before); } + #[test] + fn range_fades_refuse_a_shape_that_is_not_a_shape_name() { + for shape in [json!("bad"), json!(null), json!(true), json!(1)] { + let (mut e, t, _) = trim_session(&[(0, 10_000, 0), (10_000, 10_000, 10_000)]); + let before = e.session().clone(); + let history = e.undo_history(); + let params = json!({"tracks": [t], "start": 9_500, "end": 10_500, "shape": shape}); + assert!(e.execute("edit.fades_create", ¶ms).is_err(), "{params}"); + assert_eq!(e.session(), &before, "{params}"); + assert_eq!(e.undo_history(), history, "{params}"); + } + } + #[test] fn targeted_fades_support_one_sample_clips() { let mut e = crate::demo::demo_engine(); From fe4cd8095d284fd84ce4834cbf16767338953b56 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 15:48:05 -0700 Subject: [PATCH 71/90] Clip List: Auto Rename sets the names of the clips separated from a clip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clip List › Auto Rename… opens Rename Selected Clips for the selected clips. Name, Starting Number, Number of Places and Suffix set the names that editing gives the clips it separates from them: the name, as many zeros as Number of Places, a number counting up from Starting Number, and the suffix. The piece after each cut is the new clip and takes the next name. The piece before it keeps its name. The clips separated from one clip, and from every clip that got the scheme together, count up one number sequence. The guide does not say how Strip Silence's own Rename fields and Auto Rename combine. Here, when Strip Silence's own Rename fields are set, they name the audio pieces it cuts inside its range, and those pieces take no Auto Rename name and do not count the Auto Rename number up. In Strip and Extract, only the pieces that Strip Silence keeps take Auto Rename names, in time order, so their numbers have no gaps. clip.auto_rename takes clips, name, number, zeros and suffix, with the same limits and errors as edit.strip_silence. Without a name the clips get no scheme. Cut and Copy keep a copy of the copied clips' schemes, and a paste uses the session's scheme with the same id or adds the copy under a new key, so a pasted copy keeps its names after an undo or in another session. Saved sessions hold the schemes, and opening a session repairs them and drops the ones no clip uses. Import Session Data brings them in under new keys. Clips placed from a clip group file get no scheme. --- ROADMAP.md | 4 +- crates/engine/src/clip_group_file.rs | 17 +++ crates/engine/src/cmd/auto_rename.rs | 200 +++++++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 27 +++- crates/engine/src/cmd/edit_more.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/setup_more.rs | 33 ++++ crates/engine/src/edit.rs | 46 +++++- crates/engine/src/io.rs | 77 ++++++++-- crates/engine/src/lib.rs | 47 ++++++ crates/model/src/clip.rs | 5 + crates/model/src/lib.rs | 4 +- crates/model/src/session.rs | 44 ++++++ crates/ui-egui/src/dialogs.rs | 56 ++++++- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/panels.rs | 6 + crates/ui-egui/tests/it/auto_rename.rs | 89 +++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 18 files changed, 630 insertions(+), 31 deletions(-) create mode 100644 crates/engine/src/cmd/auto_rename.rs create mode 100644 crates/ui-egui/tests/it/auto_rename.rs diff --git a/ROADMAP.md b/ROADMAP.md index 342a23a1..a0424ab6 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-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Strip Silence Rename) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Clip List Auto Rename) · **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-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change. - **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change. - **2026-10-24:** The Strip Silence window has Extract and Separate buttons. Extract removes the audio and keeps the silent parts of the selection, and Separate separates the clips at the edges of the silence without removing anything. Minimum Strip Duration goes up to 4,000 ms. No readiness percentage change. - **2026-10-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change. @@ -168,6 +169,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence Extract and Separate; readiness estimates unchanged | | 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged | diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index 9a44cca5..18102bc4 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -176,6 +176,8 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - c.sync_point = scale(c.sync_point); c.gain_env.iter_mut().for_each(|(at, _)| *at = scale(*at)); c.group = Some(group_id); + // The file does not hold the Auto Rename schemes of the session it came from. + c.auto_name = None; c.sanitize(); placed.push(c.id); prepared.push(c); @@ -231,6 +233,21 @@ mod tests { 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())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("named.scgrp").to_string_lossy().to_string(); + let mut e = crate::demo::demo_engine(); + let kick = e.session().track_by_name("Kick").unwrap().clips()[0].id.0; + e.execute("clip.auto_rename", &json!({"clips": [kick], "name": "Hit"})).unwrap(); + e.execute("file.export_clip_groups", &json!({"path": path, "clips": [kick]})).unwrap(); + let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 30_000_000, "track": "Bass"})).unwrap(); + let placed = soundcraft_model::ClipId(r["clips"][0].as_u64().unwrap()); + assert_eq!(e.session().find_clip(placed).unwrap().1.auto_name, None); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn positions_in_a_group_file_are_kept_within_reach() { let dir = std::env::temp_dir().join(format!("sc-grp-far-{}", std::process::id())); diff --git a/crates/engine/src/cmd/auto_rename.rs b/crates/engine/src/cmd/auto_rename.rs new file mode 100644 index 00000000..4a8dd044 --- /dev/null +++ b/crates/engine/src/cmd/auto_rename.rs @@ -0,0 +1,200 @@ +//! Clip List › Auto Rename: the names that editing gives the clips it separates from a clip. +use super::{CommandSpec, bad}; + +use crate::{Engine, Result, cmd}; +use serde::Deserialize; +use serde_json::{Value, json}; +use soundcraft_model::ClipId; +use std::collections::BTreeSet; + +const AUTO_RENAME: &str = "clip.auto_rename"; + +pub fn specs() -> Vec { + vec![cmd!( + "clip.auto_rename", + "Auto Rename...", + [], + None, + "{clips?: [id], name?, number?: 0..999999999, zeros?: 0..9, suffix?}: sets the names that editing gives the clips it separates from these clips (default: the Clip List's selected clips): the piece after each cut is named `name`, then `zeros` zeros, then a number counting up from `number` (default 1), then `suffix`, in one number sequence for all of them. Without a name, the clips keep their names when they are separated", + has_clip_selection, + auto_rename + )] +} + +fn has_clip_selection(e: &Engine) -> std::result::Result<(), String> { + if e.session().edit.selected_clips.is_empty() { Err("select a clip in the Clip List".into()) } else { Ok(()) } +} + +fn auto_rename(e: &mut Engine, p: &Value) -> Result { + let s = e.session(); + let clips: BTreeSet = match p.get("clips") { + None => s.edit.selected_clips.iter().copied().collect(), + Some(v) => BTreeSet::deserialize(v).map_err(|_| bad(AUTO_RENAME, "`clips` must be an array of ids"))?, + }; + if clips.is_empty() { + return Err(bad(AUTO_RENAME, "select a clip in the Clip List, or pass `clips`")); + } + let known: BTreeSet = s.all_clips().map(|c| c.id).collect(); + if let Some(c) = clips.iter().find(|c| !known.contains(c)) { + return Err(bad(AUTO_RENAME, format!("no clip with id {c}"))); + } + let names = super::edit::strip_names(AUTO_RENAME, p)?; + let s = e.session_mut(); + let key = names.map(|n| { + let (key, id) = (s.alloc(), crate::new_scheme_id(s)); + s.auto_names.insert(key, soundcraft_model::AutoName { id, ..n.into() }); + key + }); + for c in s.tracks.iter_mut().flat_map(|t| t.playlists.iter_mut()).flat_map(|pl| pl.clips.iter_mut()) { + if clips.contains(&c.id) { + c.auto_name = key; + } + } + // Earlier schemes stay while the session is open: copies on the clipboard can still use them. + Ok(json!({"clips": clips.len(), "named": key.is_some()})) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The first Kick clip: its track, id, start and length. + fn first_kick(e: &Engine) -> (soundcraft_model::TrackId, ClipId, i64, i64) { + let s = e.session(); + let t = s.track_by_name("Kick").unwrap(); + let c = &t.clips()[0]; + (t.id, c.id, c.start, c.length) + } + + /// The names of the clips on `t` that lie inside `[start, end)`, in time order. + fn names_in(e: &Engine, t: soundcraft_model::TrackId, start: i64, end: i64) -> Vec { + e.session().track(t).unwrap().clips().iter().filter(|c| c.start >= start && c.end() <= end).map(|c| c.name.clone()).collect() + } + + #[test] + fn separating_an_auto_renamed_clip_names_each_new_piece() { + let mut e = crate::demo::demo_engine(); + let (t, id, start, len) = first_kick(&e); + let old = e.session().find_clip(id).unwrap().1.name.clone(); + let r = e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX", "number": 23, "zeros": 1, "suffix": ".Reel1"})).unwrap(); + assert_eq!(r, json!({"clips": 1, "named": true})); + assert_eq!(e.undo_label(), Some("Auto Rename...")); + assert_eq!(names_in(&e, t, start, start + len), [old.as_str()], "setting the scheme leaves the clip's name"); + // Cut the middle, then each half: every piece after a cut is a new clip with the next name. + for at in [start + len / 2, start + len / 4, start + len * 3 / 4] { + e.execute("edit.separate", &json!({"tracks": [t], "at": at})).unwrap(); + } + assert_eq!(names_in(&e, t, start, start + len), [old.as_str(), "SFX024.Reel1", "SFX023.Reel1", "SFX025.Reel1"]); + assert!(e.undo()); + assert_eq!(names_in(&e, t, start, start + len).len(), 3); + assert_eq!(e.session().auto_names.values().next().map(|a| a.number), Some(25), "undo takes the number back"); + } + + #[test] + fn auto_rename_uses_the_clip_list_selection_and_one_number_sequence() { + let mut e = crate::demo::demo_engine(); + let s = e.session(); + let t = s.track_by_name("Kick").unwrap(); + let (track, a, b) = (t.id, t.clips()[0].clone(), t.clips()[1].clone()); + e.execute("edit.select", &json!({"clips": [a.id, b.id]})).unwrap(); + e.execute("clip.auto_rename", &json!({"name": "Hit"})).unwrap(); + e.execute("edit.separate", &json!({"tracks": [track], "at": b.start + b.length / 2})).unwrap(); + e.execute("edit.separate", &json!({"tracks": [track], "at": a.start + a.length / 2})).unwrap(); + assert_eq!(names_in(&e, track, b.start, b.end())[1], "Hit1"); + assert_eq!(names_in(&e, track, a.start, a.end())[1], "Hit2", "both clips count up one sequence"); + assert_eq!(e.session().auto_names.len(), 1); + } + + #[test] + fn auto_rename_without_a_name_removes_the_scheme() { + let mut e = crate::demo::demo_engine(); + let (t, id, start, len) = first_kick(&e); + let old = e.session().find_clip(id).unwrap().1.name.clone(); + e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap(); + let r = e.execute("clip.auto_rename", &json!({"clips": [id], "name": " "})).unwrap(); + assert_eq!(r["named"], false); + assert_eq!(e.session().find_clip(id).unwrap().1.auto_name, None); + e.execute("edit.separate", &json!({"tracks": [t], "at": start + len / 2})).unwrap(); + assert_eq!(names_in(&e, t, start, start + len), [old.clone(), old]); + } + + #[test] + fn a_pasted_copy_keeps_the_scheme_it_was_copied_with() { + let mut e = crate::demo::demo_engine(); + let (t, id, start, len) = first_kick(&e); + let old = e.session().find_clip(id).unwrap().1.name.clone(); + e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap(); + e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap(); + e.execute("clip.auto_rename", &json!({"clips": [id]})).unwrap(); + let at = e.session().content_end() + 48_000; + e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap(); + e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 2})).unwrap(); + assert_eq!(names_in(&e, t, at, at + len)[1], "SFX1", "the copy's scheme outlives the original clip's"); + // Pasted into another session, the copy brings its scheme along and counts on from it. + e.execute("edit.copy", &json!({"tracks": [t], "start": at, "end": at + len})).unwrap(); + e.replace_session(crate::demo::demo_engine().session().clone()); + let t = first_kick(&e).0; + e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap(); + e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 4})).unwrap(); + assert_eq!(names_in(&e, t, at, at + len), [old.as_str(), "SFX2", "SFX1"]); + } + + #[test] + fn a_copy_keeps_its_scheme_when_undo_hands_the_key_to_another() { + let mut e = crate::demo::demo_engine(); + let (t, id, start, len) = first_kick(&e); + let bass = e.session().track_by_name("Bass").unwrap().clips()[0].id; + e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap(); + e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap(); + assert!(e.undo()); + // The same name under the key the undo freed is still another scheme. + e.execute("clip.auto_rename", &json!({"clips": [bass], "name": "SFX", "number": 100})).unwrap(); + let at = e.session().content_end() + 48_000; + e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap(); + e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 2})).unwrap(); + assert_eq!(names_in(&e, t, at, at + len)[1], "SFX1"); + let s = e.session(); + assert_eq!(s.auto_names.len(), 2); + let key = s.find_clip(bass).unwrap().1.auto_name.unwrap(); + assert_eq!(s.auto_names[&key].number, 100, "the other scheme's count is untouched"); + } + + #[test] + fn bad_auto_rename_params_are_refused() { + let mut e = crate::demo::demo_engine(); + let (_, id, _, _) = first_kick(&e); + for p in [ + json!({"name": "SFX"}), + json!({"clips": [], "name": "SFX"}), + json!({"clips": [999_999], "name": "SFX"}), + json!({"clips": "Kick", "name": "SFX"}), + json!({"clips": [id], "name": 5}), + json!({"clips": [id], "name": "SFX", "zeros": 10}), + json!({"clips": [id], "name": "SFX", "number": 1_000_000_000}), + json!({"clips": [id], "number": 3}), + ] { + assert!(e.execute("clip.auto_rename", &p).is_err(), "{p}"); + } + assert!(e.session().auto_names.is_empty()); + assert!(e.session().find_clip(id).unwrap().1.auto_name.is_none()); + } + + #[test] + fn auto_rename_schemes_are_saved_and_repaired_on_open() { + let mut e = crate::demo::demo_engine(); + let (t, id, start, len) = first_kick(&e); + e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX", "number": 7})).unwrap(); + let mut v: Value = serde_json::from_str(&e.session().to_json().unwrap()).unwrap(); + let key = *e.session().auto_names.keys().next().unwrap(); + v["auto_names"][key.to_string()]["zeros"] = json!(usize::MAX); + v["auto_names"]["999999999"] = json!({"name": "Unused", "number": 1, "zeros": 0, "suffix": ""}); + let mut s = soundcraft_model::Session::from_json(&v.to_string()).unwrap(); + assert_eq!(s.auto_names.len(), 1, "a scheme no clip uses is dropped"); + assert_eq!(s.auto_names[&key].zeros, soundcraft_model::AutoName::MAX_ZEROS); + assert!(s.next_id > key); + crate::edit::separate_at(&mut s, t, start + len / 2); + let names: Vec = + s.track(t).unwrap().clips().iter().filter(|c| c.start >= start && c.end() <= start + len).map(|c| c.name.clone()).collect(); + assert_eq!(names[1], "SFX0000000007"); + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e1823c15..69889ab2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -280,6 +280,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { .unwrap_or_default(); cb.automation.push(lanes); } + cb.keep_auto_names(e.session()); cb.markers = e .session() .markers @@ -316,10 +317,21 @@ fn cut_track(s: &mut Session, t: TrackId, r: soundcraft_time::Range, sh: bool, f ops::clear_range(s, t, r, sh); } +/// The clipboard for a paste, with the Auto Rename schemes of its clips put into the session first +/// ([`Clipboard::adopt_auto_names`]). +pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard { + let mut cb = e.clipboard.clone(); + if cb.tracks.iter().flatten().any(|c| c.auto_name.is_some()) { + cb.adopt_auto_names(e.session_mut()); + e.clipboard = cb.clone(); + } + cb +} + fn paste(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "edit.paste", p)?; let given = position_param(e, "edit.paste", p, "at")?; - let cb = e.clipboard.clone(); + let cb = clipboard_for_paste(e); let s = e.session(); let at = match given { Some(at) => at, @@ -417,7 +429,7 @@ fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result { let range = range_param(e, "edit.paste_repeat_to_fill", p)?; let len = e.clipboard.length.max(1); let tracks = tracks_required(e, "edit.paste_repeat_to_fill", p)?; - let cb = e.clipboard.clone(); + let cb = clipboard_for_paste(e); let s = e.session_mut(); let mut at = range.start; let mut n = 0; @@ -801,12 +813,12 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result { Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?, Some(_) => return Err(bad("edit.strip_silence", "mode must be strip, extract or separate")), }; - let names = strip_names(p)?; + let names = strip_names("edit.strip_silence", p)?; let s = e.session_mut(); let mut kept = 0; let mut named = 0; for t in &tracks { - kept += crate::io::strip_silence(s, *t, r, thr, min_len, (pre, post), mode)?; + kept += crate::io::strip_silence(s, *t, r, thr, min_len, (pre, post), mode, names.is_some())?; if let Some(names) = &names { named += crate::io::name_stripped_clips(s, *t, r, names, named); } @@ -814,11 +826,10 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result { Ok(json!({"clips": kept, "named": named})) } -/// The naming scheme in `edit.strip_silence`'s `name`, `number`, `zeros` and `suffix` params, or -/// `None` when `name` is missing or blank. -fn strip_names(p: &Value) -> Result> { +/// The naming scheme in the `name`, `number`, `zeros` and `suffix` params of `cmd` +/// (`edit.strip_silence`, `clip.auto_rename`), or `None` when `name` is missing or blank. +pub(super) fn strip_names(cmd: &str, p: &Value) -> Result> { use crate::io::StripNames; - let cmd = "edit.strip_silence"; let text = |k: &str| match p.get(k) { None => Ok(None), Some(Value::String(v)) => Ok(Some(v.clone())), diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs index 57e134aa..693c0ef1 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -452,7 +452,7 @@ fn paste_merge_midi(e: &mut Engine, p: &Value) -> Result { let id = "edit.paste_merge_midi"; let tracks = tracks_required(e, id, p)?; let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start).max(0); - let cb = e.clipboard.clone(); + let cb = super::edit::clipboard_for_paste(e); let s = e.session_mut(); let tempo = s.tempo.clone(); let sr = s.sample_rate; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 41edf68c..5afc5819 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -8,6 +8,7 @@ use std::sync::OnceLock; mod audio_midi; mod audiosuite; +mod auto_rename; mod clap; mod clip; mod clip_list; @@ -147,6 +148,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(track_more::specs()); v.extend(clip_more::specs()); v.extend(clip_list::specs()); + v.extend(auto_rename::specs()); v.extend(event_more::specs()); v.extend(setup_more::specs()); v.extend(clap::specs()); diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index 369e1542..cad1fc4f 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -461,6 +461,8 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result { s.sources.push(ns); } let mut out = Vec::new(); + // The donor's Auto Rename schemes come along under new keys, shared as they were. + let mut names: std::collections::BTreeMap = Default::default(); for mut tr in picked { tr.id = TrackId(s.alloc()); tr.name = s.unique_track_name(&tr.name); @@ -474,6 +476,14 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result { { *source = *n; } + c.auto_name = c.auto_name.and_then(|k| { + let scheme = other.auto_names.get(&k)?; + Some(*names.entry(k).or_insert_with(|| { + let (key, id) = (s.alloc(), crate::new_scheme_id(s)); + s.auto_names.insert(key, soundcraft_model::AutoName { id, ..scheme.clone() }); + key + })) + }); } } // The donor's source ids mean nothing here: remap the sample, or drop it when it didn't come along. @@ -601,6 +611,29 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn import_session_data_brings_auto_rename_schemes_under_new_keys() { + let dir = std::env::temp_dir().join(format!("sc-import-names-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("donor.scraft").to_string_lossy().into_owned(); + let mut donor = crate::demo::demo_engine(); + let kick: Vec<_> = donor.session().track_by_name("Kick").unwrap().clips().iter().map(|c| c.id.0).collect(); + donor.execute("clip.auto_rename", &json!({"clips": kick, "name": "Hit", "number": 5})).unwrap(); + crate::io::save_session(&mut donor, &path).unwrap(); + let mut e = crate::demo::demo_engine(); + let bass = e.session().track_by_name("Bass").unwrap().clips()[0].id; + e.execute("clip.auto_rename", &json!({"clips": [bass], "name": "Low"})).unwrap(); + e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Kick"]})).unwrap(); + let s = e.session(); + assert_eq!(s.auto_names.len(), 2, "the donor's scheme comes in beside this session's"); + let imported = s.tracks.iter().rfind(|t| t.name.starts_with("Kick")).unwrap(); + let keys: std::collections::BTreeSet<_> = imported.clips().iter().map(|c| c.auto_name).collect(); + assert_eq!(keys.len(), 1, "the imported clips still share one scheme"); + let key = keys.into_iter().next().flatten().unwrap(); + assert_eq!((s.auto_names[&key].name.as_str(), s.auto_names[&key].number), ("Hit", 5)); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn import_session_data_brings_a_samplers_sample() { let dir = std::env::temp_dir().join(format!("sc-import-sampler-{}", std::process::id())); diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 698a6624..4017aa00 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -78,8 +78,16 @@ fn rebase_midi_start(clip: &mut Clip, old_start: Samples, t: TimeCtx<'_>) { } } -/// Split every clip on a track's active playlist that spans `at`. +/// Split every clip on a track's active playlist that spans `at`. The piece before `at` keeps the +/// clip's name. The piece after it is a new clip, and takes the next name of the clip's Auto Rename +/// scheme when it has one. pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize { + separate_at_if(s, track, at, |_| true) +} + +/// [`separate_at`], giving the piece after `at` the next Auto Rename name only when `auto_name` +/// returns true for it. Any other piece keeps the clip's name, and its scheme does not count up. +pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool) -> usize { let tc_tempo = s.tempo.clone(); let sr = s.sample_rate; let t = TimeCtx { tempo: &tc_tempo, sr }; @@ -88,15 +96,22 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize { for _ in 0..n_split { ids.push(s.new_clip_id()); } - let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; + let mut schemes = std::mem::take(&mut s.auto_names); + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { + s.auto_names = schemes; + return 0; + }; let mut out = Vec::with_capacity(pl.clips.len() + ids.len()); let mut count = 0; for c in pl.clips.drain(..) { if at > c.start && at < c.end() && let Some(id) = ids.pop() - && let Some((l, r)) = split_clip(&c, at, id, t) + && let Some((l, mut r)) = split_clip(&c, at, id, t) { + if auto_name(&r) { + take_auto_name(&mut schemes, &mut r); + } out.push(l); out.push(r); count += 1; @@ -106,9 +121,34 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize { } pl.clips = out; pl.sort(); + s.auto_names = schemes; count } +/// Gives `c` the next name of its Auto Rename scheme, when it has one, and counts the scheme up. +fn take_auto_name(schemes: &mut std::collections::BTreeMap, c: &mut Clip) { + if let Some(scheme) = c.auto_name.and_then(|k| schemes.get_mut(&k)) { + c.name = crate::io::StripNames::from(&*scheme).clip_name(0); + scheme.number = scheme.number.saturating_add(1); + } +} + +/// Gives each clip on a track's active playlist whose id is not in `old`, and for which `auto_name` +/// returns true, the next name of its Auto Rename scheme, in time order. Pieces cut with +/// [`separate_at_if`] and `|_| false` take their names here once the caller has removed the ones it +/// does not keep. +pub fn auto_name_new_clips(s: &mut Session, track: TrackId, old: &HashSet, auto_name: impl Fn(&Clip) -> bool) { + let mut schemes = std::mem::take(&mut s.auto_names); + if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { + let mut new: Vec<&mut Clip> = pl.clips.iter_mut().filter(|c| !old.contains(&c.id) && auto_name(c)).collect(); + new.sort_by_key(|c| (c.start, c.id.0)); + for c in new { + take_auto_name(&mut schemes, c); + } + } + s.auto_names = schemes; +} + /// Remove material inside `range` on a track. In Shuffle mode later clips move left, up to the /// first locked clip (see [`ripple`]). Returns the removed material as clips positioned relative /// to `range.start`. diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 8600ae59..c2b6f7d8 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -773,9 +773,9 @@ impl Default for StripNames { impl StripNames { /// The largest first number. - pub const MAX_NUMBER: u64 = 999_999_999; + pub const MAX_NUMBER: u64 = soundcraft_model::AutoName::MAX_NUMBER; /// The most zeros before the number. - pub const MAX_ZEROS: usize = 9; + pub const MAX_ZEROS: usize = soundcraft_model::AutoName::MAX_ZEROS; /// The name of the clip `i` places after the first one. pub fn clip_name(&self, i: u64) -> String { @@ -783,6 +783,18 @@ impl StripNames { } } +impl From<&soundcraft_model::AutoName> for StripNames { + fn from(a: &soundcraft_model::AutoName) -> Self { + Self { name: a.name.clone(), number: a.number, zeros: a.zeros, suffix: a.suffix.clone() } + } +} + +impl From for soundcraft_model::AutoName { + fn from(n: StripNames) -> Self { + Self { name: n.name, number: n.number, zeros: n.zeros, suffix: n.suffix, id: 0 } + } +} + /// Names the audio clips that lie inside `r` on `t` with `names`, in time order, the first of them /// `next` places after `names.number`. Returns how many clips it named. pub fn name_stripped_clips(s: &mut Session, t: TrackId, r: Range, names: &StripNames, next: u64) -> u64 { @@ -802,8 +814,20 @@ pub fn name_stripped_clips(s: &mut Session, t: TrackId, r: Range, names: &StripN /// non-silent ranges (`Strip`), only the silent ranges (`Extract`), or every piece (`Separate`). /// `pad` is the clip start and end pad added around each non-silent range. Returns the number of /// ranges kept: non-silent ranges for `Strip` and `Separate`, silent ranges that still hold a clip for -/// `Extract`. -pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pad: (Samples, Samples), mode: StripMode) -> Result { +/// `Extract`. The new pieces that are kept take Auto Rename names in time order, and the removed +/// ones do not. When `named` is true the caller names the audio clips left inside `r` with +/// [`name_stripped_clips`], so those clips do not take Auto Rename names either. +#[allow(clippy::too_many_arguments)] +pub fn strip_silence( + s: &mut Session, + t: TrackId, + r: Range, + thr: f32, + min_len: Samples, + pad: (Samples, Samples), + mode: StripMode, + named: bool, +) -> Result { let r = bounded_to_track(s, t, r); let keep = non_silent(s, t, r, thr, min_len, pad.0, pad.1)?; let mut cut_points = vec![r.start]; @@ -812,15 +836,23 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S cut_points.push(*b); } cut_points.push(r.end); + let old: std::collections::HashSet<_> = s.track(t).map(|x| x.clips().iter().map(|c| c.id).collect()).unwrap_or_default(); for p in &cut_points { - crate::edit::separate_at(s, t, *p); + crate::edit::separate_at_if(s, t, *p, |_| false); } + let kept = strip_pieces(s, t, r, &keep, mode); + crate::edit::auto_name_new_clips(s, t, &old, |c| !(named && c.is_audio() && c.start >= r.start && c.end() <= r.end)); + Ok(kept) +} + +/// Removes the pieces that Strip Silence does not keep in `mode` and returns the number of ranges kept. +fn strip_pieces(s: &mut Session, t: TrackId, r: Range, keep: &[(Samples, Samples)], mode: StripMode) -> usize { if mode == StripMode::Separate { - return Ok(keep.len()); + return keep.len(); } let mut gaps = Vec::new(); let mut prev = r.start; - for (a, b) in &keep { + for (a, b) in keep { if *a > prev { gaps.push(Range::new(prev, *a)); } @@ -832,12 +864,12 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S let audio: Vec = keep.iter().map(|(a, b)| Range::new(*a, *b)).collect(); let remove = if mode == StripMode::Extract { &audio } else { &gaps }; let inside = |c: &Clip, g: &Range| c.start >= g.start && c.end() <= g.end; - let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) else { return Ok(if mode == StripMode::Extract { 0 } else { keep.len() }) }; + let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) else { return if mode == StripMode::Extract { 0 } else { keep.len() } }; pl.clips.retain(|c| !remove.iter().any(|g| inside(c, g))); if mode == StripMode::Extract { - return Ok(gaps.iter().filter(|g| pl.clips.iter().any(|c| inside(c, g))).count()); + return gaps.iter().filter(|g| pl.clips.iter().any(|c| inside(c, g))).count(); } - Ok(keep.len()) + keep.len() } /// Most samples (over all channels) Strip Silence renders at once. @@ -2455,6 +2487,31 @@ mod strip_tests { assert_eq!(names(&e, t), ["tone gap tone", "tone gap tone"]); } + #[test] + fn strip_names_leave_the_auto_rename_sequence_unbroken() { + let (mut e, t) = tone_gap_tone(); + let id = e.session().track(t).unwrap().clips()[0].id; + e.execute("clip.auto_rename", &json!({"clips": [id], "name": "Take"})).unwrap(); + let p = json!({"track": t.0, "start": 0, "end": 120_000, "pad_before_ms": 0, "pad_after_ms": 0, "mode": "separate", "name": "SFX"}); + assert_eq!(e.execute("edit.strip_silence", &p).unwrap()["named"], 3); + // Strip Silence does not name the piece after the range, so that piece takes the first Auto Rename name. + assert_eq!(names(&e, t), ["SFX1", "SFX2", "SFX3", "Take1"]); + e.execute("edit.separate", &json!({"tracks": [t], "at": 132_000})).unwrap(); + assert_eq!(names(&e, t)[4], "Take2"); + } + + #[test] + fn only_the_pieces_strip_silence_keeps_take_auto_rename_numbers() { + for (mode, kept) in [("strip", ["tone gap tone", "Take1", "Take2"].as_slice()), ("extract", ["Take1", "Take2"].as_slice())] { + let (mut e, t) = tone_gap_tone(); + let id = e.session().track(t).unwrap().clips()[0].id; + e.execute("clip.auto_rename", &json!({"clips": [id], "name": "Take"})).unwrap(); + let p = json!({"track": t.0, "start": 0, "end": 120_000, "pad_before_ms": 0, "pad_after_ms": 0, "mode": mode}); + e.execute("edit.strip_silence", &p).unwrap(); + assert_eq!(names(&e, t), kept, "{mode}"); + } + } + #[test] fn bad_naming_params_are_refused() { for bad in [ diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b3ec5f4f..1c1ba241 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -33,6 +33,22 @@ pub fn shutdown_plugin_hosts() { soundcraft_vst3_host::shutdown(); } +/// A new [`soundcraft_model::AutoName::id`] for a scheme in `s`: a hash of a counter, never 0 and +/// not held by another scheme in `s`. Native builds seed the hash from the system, so each run of +/// the app gives other ids. +pub fn new_scheme_id(s: &Session) -> u64 { + use std::hash::{BuildHasher, Hasher}; + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + loop { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + let id = h.finish(); + if id != 0 && !s.auto_names.values().any(|a| a.id == id) { + return id; + } + } +} + #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum EngineError { #[error("unknown command `{0}`")] @@ -94,6 +110,8 @@ pub struct Clipboard { pub grid_offset: Samples, /// Copy Special › Automation: pasting it leaves the clips alone, in Shuffle mode too. pub automation_only: bool, + /// The Auto Rename schemes of the copied clips, keyed as in their `auto_name`. + pub auto_names: std::collections::BTreeMap, } impl Clipboard { @@ -102,6 +120,35 @@ impl Clipboard { let line = s.edit.grid.line_at_or_before(range.start, s.sample_rate, &s.tempo, s.frame_rate); Clipboard { length: range.len(), grid_offset: range.start.saturating_sub(line), ..Clipboard::default() } } + + /// Keep a copy of the Auto Rename schemes that the copied clips use, as `s` holds them. + pub fn keep_auto_names(&mut self, s: &Session) { + self.auto_names = self.tracks.iter().flatten().filter_map(|c| c.auto_name).filter_map(|k| Some((k, s.auto_names.get(&k)?.clone()))).collect(); + } + + /// Before a paste into `s`: point the copied clips at schemes that `s` holds. A scheme that `s` + /// holds with the same [`AutoName::id`](soundcraft_model::AutoName::id) is used as it is, and + /// its numbers count on. Any other goes into `s` under a new key, counting on from where it was + /// when it was copied. + pub fn adopt_auto_names(&mut self, s: &mut Session) { + let mut keys = std::collections::BTreeMap::new(); + for (k, a) in &self.auto_names { + let held = s.auto_names.iter().find(|(_, h)| a.id != 0 && h.id == a.id).map(|(key, _)| *key); + let key = match held { + Some(key) => key, + None => { + let key = s.alloc(); + s.auto_names.insert(key, a.clone()); + key + } + }; + keys.insert(*k, key); + } + for c in self.tracks.iter_mut().flatten() { + c.auto_name = c.auto_name.and_then(|k| keys.get(&k).copied()); + } + self.auto_names = keys.values().filter_map(|k| Some((*k, s.auto_names.get(k)?.clone()))).collect(); + } } #[derive(Debug, Clone)] diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index f5be30b2..5b191424 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -126,6 +126,10 @@ pub struct Clip { /// Optional colour override (RGB). #[serde(default)] pub color: Option<[u8; 3]>, + /// The key in [`crate::Session::auto_names`] of the names that editing gives the clips it + /// separates from this one (Clip List › Auto Rename). `None` keeps the clip's name. + #[serde(default)] + pub auto_name: Option, /// Elastic / Vari-speed ratio (1.0 = none) — applied by rendered processing. #[serde(default = "one")] pub stretch: f64, @@ -160,6 +164,7 @@ impl Clip { rating: 0, sync_point: 0, color: None, + auto_name: None, stretch: 1.0, group: None, loop_id: None, diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 61596b76..98a7b0b1 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -23,8 +23,8 @@ pub use mixer::{ Bus, ChannelFormat, INSERT_SLOTS, Insert, Mixer, OutputPath, Route, SEND_SLOTS, SendSlot, Speaker, SurroundPan, fader_db_to_pos, fader_pos_to_db, }; pub use session::{ - BitDepthSetting, EditMode, EditState, FEET_FRAMES_START, MAX_COMMENT_CHARS, MAX_NAME_CHARS, SESSION_EXTENSION, Session, SessionError, Tool, - TrackView, ZoomState, ZoomToggle, truncate_chars, + AutoName, BitDepthSetting, EditMode, EditState, FEET_FRAMES_START, MAX_COMMENT_CHARS, MAX_NAME_CHARS, SESSION_EXTENSION, Session, SessionError, + Tool, TrackView, ZoomState, ZoomToggle, truncate_chars, }; pub use source::{Source, SourceAudio, SourcePool, VideoSource}; pub use track::{Playlist, TRACK_COLORS, Track, TrackHeight, TrackKind}; diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index c6ec7823..d14971ab 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -295,6 +295,36 @@ impl Default for EditState { } } +/// Clip List › Auto Rename: the names that editing gives the clips it separates from a clip. +/// Each new clip is named `name`, then `zeros` zeros, then `number`, then `suffix`, and `number` +/// counts up by one for the next clip. +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +pub struct AutoName { + pub name: String, + /// The number the next new clip gets. + pub number: u64, + pub zeros: usize, + pub suffix: String, + /// Tells this scheme from another one under the same key after an undo or in another session, + /// for pastes from the clipboard. 0 matches no scheme. + #[serde(default)] + pub id: u64, +} + +impl AutoName { + /// The largest starting number. + pub const MAX_NUMBER: u64 = 999_999_999; + /// The most zeros before the number. + pub const MAX_ZEROS: usize = 9; + + /// Repair a scheme read from a file: names that can be drawn and at most [`Self::MAX_ZEROS`] zeros. + pub fn sanitize(&mut self) { + truncate_chars(&mut self.name, MAX_NAME_CHARS); + truncate_chars(&mut self.suffix, MAX_NAME_CHARS); + self.zeros = self.zeros.min(Self::MAX_ZEROS); + } +} + /// The document. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Session { @@ -317,6 +347,10 @@ pub struct Session { pub outputs: Vec, pub markers: Vec, pub groups: Vec, + /// Clip List › Auto Rename schemes, keyed by the ids in [`Clip::auto_name`]. The clips separated + /// from one clip share its scheme, so they count up one number sequence. + #[serde(default)] + pub auto_names: std::collections::BTreeMap, pub edit: EditState, pub next_id: u64, #[serde(default)] @@ -358,6 +392,7 @@ impl Session { outputs: vec![OutputPath { name: "Out 1-2".into(), first_channel: 0, format: ChannelFormat::Stereo }], markers: Vec::new(), groups: Vec::new(), + auto_names: std::collections::BTreeMap::new(), edit: EditState::default(), next_id: 1, comments: String::new(), @@ -526,6 +561,12 @@ impl Session { ClipId(self.alloc()) } + /// Drop the Auto Rename schemes that no clip on any playlist uses. + pub fn prune_auto_names(&mut self) { + let used: std::collections::BTreeSet = self.all_clips().filter_map(|c| c.auto_name).collect(); + self.auto_names.retain(|k, _| used.contains(k)); + } + // ---- persistence --------------------------------------------------------------------- pub fn to_json(&self) -> Result { @@ -553,6 +594,7 @@ impl Session { .map(|t| t.id.0) .chain(self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0)))) .chain(self.sources.iter().map(|s| s.id.0)) + .chain(self.auto_names.keys().copied()) .chain(self.videos.iter().map(|v| v.id.0)) .chain(self.busses.iter().map(|b| b.id.0)) .chain(self.markers.iter().map(|m| m.id.0)) @@ -626,6 +668,8 @@ impl Session { let clip_ids: std::collections::BTreeSet = self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))).collect(); self.midi_originals.retain(|id, _| clip_ids.contains(id)); + self.prune_auto_names(); + self.auto_names.values_mut().for_each(crate::AutoName::sanitize); if !self.edit.zoom.samples_per_px.is_finite() || self.edit.zoom.samples_per_px <= 0.0 { self.edit.zoom.samples_per_px = 1024.0; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fc02c85d..6735cadf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,6 +26,8 @@ pub enum Dialog { StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 }, // Strip Silence › Rename: `strip` holds the Strip Silence values to return to. StripSilenceRename { strip: [f64; 4], names: StripNames }, + // Clip List › Auto Rename: the names that editing gives the clips it separates from `clips`. + AutoRename { clips: Vec, names: StripNames }, // `id: None` creates (Track › Group…); `id: Some` modifies that group (Groups list › Modify Group…). Group { id: Option, name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> }, Session { frame_rate: String, bit_depth: String }, @@ -61,6 +63,7 @@ impl Dialogs { Dialog::Fades { .. } => "fades", Dialog::StripSilence { .. } => "strip_silence", Dialog::StripSilenceRename { .. } => "strip_silence_rename", + Dialog::AutoRename { .. } => "auto_rename", Dialog::Group { id: None, .. } => "group", Dialog::Group { id: Some(_), .. } => "modify_group", Dialog::Session { .. } => "session", @@ -98,6 +101,16 @@ impl Dialogs { true } + /// Clip List › Auto Rename: the clips' naming scheme, filled in from the first clip's. False + /// when none of the clips is in the session. + pub fn open_auto_rename(&mut self, e: &Engine, clips: Vec) -> bool { + let s = e.session(); + let Some((_, first)) = clips.iter().find_map(|id| s.find_clip(*id)) else { return false }; + let names = first.auto_name.and_then(|k| s.auto_names.get(&k)).map_or_else(StripNames::default, StripNames::from); + self.open = Some(Dialog::AutoRename { clips, names }); + true + } + /// Open the dialog for a command; false when the command has no dialog. pub fn open_for_command(&mut self, e: &Engine, id: &str) -> bool { self.after_save = None; @@ -164,6 +177,7 @@ impl Dialogs { let s = soundcraft_engine::score::ScoreSetup::from_session(e.session()); Dialog::ScoreSetup { title: s.title, composer: s.composer, bars_per_system: s.bars_per_system, show_track_names: s.show_track_names } } + "clip.auto_rename" => return self.open_auto_rename(e, e.session().edit.selected_clips.clone()), "clip.rename" => { let target = soundcraft_engine::cmd::clip_ids_param(e, &json!({})).first().and_then(|id| e.session().find_clip(*id)); let Some((_, clip)) = target else { return false }; @@ -342,7 +356,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Spot(_) => "Spot", Dialog::Fades { .. } => "Fades", Dialog::StripSilence { .. } => "Strip Silence", - Dialog::StripSilenceRename { .. } => "Rename Selected Clips", + Dialog::StripSilenceRename { .. } | Dialog::AutoRename { .. } => "Rename Selected Clips", Dialog::Group { id: None, .. } => "Create Group", Dialog::Group { id: Some(_), .. } => "Modify Group", Dialog::Session { .. } => "Session Setup", @@ -600,7 +614,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { }); } Dialog::StripSilenceRename { names, .. } => { - strip_name_fields(ui, names); + strip_name_fields(ui, names, STRIP_NAME_LABELS); let cleared = ui.button("Clear").clicked(); if cleared { *names = StripNames::default(); @@ -610,6 +624,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { renamed = Some(names.clone()); } } + Dialog::AutoRename { clips, names } => { + ui.label("Text for naming the clips that editing makes from the selected clips:"); + strip_name_fields(ui, names, AUTO_NAME_LABELS); + if buttons(ui, "OK", enter) { + action = Some(auto_rename_action(clips, names)); + } + } Dialog::Group { id, name, edit, mix, members } => { ui.horizontal(|ui| { ui.label("Name"); @@ -708,6 +729,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { app.dialogs.pending_session = None; ctx.memory_mut(|m| m.data.remove::(egui::Id::new("dlg_cancel"))); } + // Enter on a focused Cancel also presses OK. In Auto Rename, the dialog then closes without running the command. + if !keep && matches!(d, Dialog::AutoRename { .. }) { + action = None; + } if let Some(strip) = rename_from && keep { @@ -794,16 +819,35 @@ fn strip_silence_action(mode: &str, values: [f64; 4], names: &StripNames) -> (St ("edit.strip_silence".into(), params) } -/// The Name, Number, Zeros and Suffix fields of Rename Selected Clips, with the first name they give. -fn strip_name_fields(ui: &mut egui::Ui, names: &mut StripNames) { +/// The command Auto Rename's OK runs: the scheme for `clips`, or no scheme when the name is blank. +fn auto_rename_action(clips: &[ClipId], names: &StripNames) -> (String, Value) { + let mut params = json!({"clips": clips}); + if !names.name.trim().is_empty() { + params["name"] = json!(names.name); + params["number"] = json!(names.number); + params["zeros"] = json!(names.zeros); + params["suffix"] = json!(names.suffix); + } + ("clip.auto_rename".into(), params) +} + +/// The labels of the number and zeros fields in Strip Silence's Rename Selected Clips. +const STRIP_NAME_LABELS: [&str; 2] = ["Number", "Zeros"]; +/// The labels of the number and zeros fields in Auto Rename's Rename Selected Clips. +const AUTO_NAME_LABELS: [&str; 2] = ["Starting Number", "Number of Places"]; + +/// The Name, number, zeros and Suffix fields of Rename Selected Clips, with the first name they +/// give. `labels` names the number and zeros fields. +fn strip_name_fields(ui: &mut egui::Ui, names: &mut StripNames, labels: [&str; 2]) { + let [number, zeros] = labels; egui::Grid::new("strip_names").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { ui.label("Name"); ui.text_edit_singleline(&mut names.name); ui.end_row(); - ui.label("Number"); + ui.label(number); ui.add(egui::DragValue::new(&mut names.number).range(0..=StripNames::MAX_NUMBER)); ui.end_row(); - ui.label("Zeros"); + ui.label(zeros); ui.add(egui::DragValue::new(&mut names.zeros).range(0..=StripNames::MAX_ZEROS)); ui.end_row(); ui.label("Suffix"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cd879a72..dd6fe64d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -166,6 +166,7 @@ const DIALOG_COMMANDS: &[&str] = &[ "event.quantize", "event.transpose", "edit.strip_silence", + "clip.auto_rename", "clip.rename", ]; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 778af47b..d7a2c06b 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -274,6 +274,12 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { } ui.separator(); } + if !whole && ui.button("Auto Rename…").clicked() { + // The selected clips when the row is one of them, otherwise the row's clip. + let clips = if selected { app.engine.session().edit.selected_clips.clone() } else { vec![ClipId(id)] }; + app.dialogs.open_auto_rename(&app.engine, clips); + ui.close(); + } if ui.button("Clear…").clicked() { let (clips, sources) = if whole { (vec![], vec![SourceId(id)]) } else { (vec![ClipId(id)], vec![]) }; app.dialogs.open_clear(&app.engine, clips, sources); diff --git a/crates/ui-egui/tests/it/auto_rename.rs b/crates/ui-egui/tests/it/auto_rename.rs new file mode 100644 index 00000000..3dfd675c --- /dev/null +++ b/crates/ui-egui/tests/it/auto_rename.rs @@ -0,0 +1,89 @@ +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_engine::io::StripNames; +use soundcraft_model::{ClipId, TrackId}; +use soundcraft_ui_egui::dialogs::Dialog; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +/// Selects the first Kick clip in the Clip List and opens Auto Rename for it. +fn auto_rename_dialog() -> (Harness<'static, SoundApp>, TrackId, ClipId) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap(); + let (track, clip) = (kick.id, kick.clips()[0].id); + app.run("edit.select", json!({"clips": [clip.0]})).unwrap(); + menus::invoke_menu(&mut app, "clip.auto_rename", ""); + assert_eq!(app.dialogs.open_name(), Some("auto_rename")); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + (h, track, clip) +} + +fn sfx() -> StripNames { + StripNames { name: "SFX".into(), number: 23, zeros: 1, suffix: ".Reel1".into() } +} + +/// Puts `names` in the open Auto Rename dialog, as typing them would. +fn type_names(h: &mut Harness<'_, SoundApp>, names: StripNames) { + let Some(Dialog::AutoRename { names: open, .. }) = &mut h.state_mut().dialogs.open else { panic!("Auto Rename is not open") }; + *open = names; + h.run_steps(2); +} + +/// Separates the clip `clip` at its middle and returns the name of the piece after the cut. +fn separate_middle(h: &mut Harness<'_, SoundApp>, track: TrackId, clip: ClipId) -> String { + let (start, len) = { + let c = h.state().engine.session().find_clip(clip).unwrap().1; + (c.start, c.length) + }; + h.state_mut().run("edit.separate", json!({"tracks": [track.0], "at": start + len / 2})).unwrap(); + let s = h.state().engine.session(); + s.track(track).unwrap().clips().iter().find(|c| c.start == start + len / 2).unwrap().name.clone() +} + +#[test] +fn ok_sets_the_names_of_the_clips_separated_from_it() { + let (mut h, track, clip) = auto_rename_dialog(); + h.get_by_label("Starting Number"); + h.get_by_label("Number of Places"); + type_names(&mut h, sfx()); + h.get_by_label("First clip: SFX023.Reel1"); + h.get_by_role_and_label(Role::Button, "OK").click(); + h.run_steps(2); + assert_eq!(h.state().dialogs.open_name(), None); + assert_eq!(separate_middle(&mut h, track, clip), "SFX023.Reel1"); +} + +#[test] +fn the_dialog_opens_with_the_clips_scheme() { + let (mut h, _, clip) = auto_rename_dialog(); + type_names(&mut h, sfx()); + h.get_by_role_and_label(Role::Button, "OK").click(); + h.run_steps(2); + let app = h.state_mut(); + assert!(app.dialogs.open_auto_rename(&app.engine, vec![clip])); + assert!(matches!(&app.dialogs.open, Some(Dialog::AutoRename { names, clips }) if *names == sfx() && *clips == vec![clip])); + assert!(!app.dialogs.open_auto_rename(&app.engine, vec![ClipId(999_999)]), "no dialog for a clip that is gone"); +} + +#[test] +fn enter_on_a_focused_cancel_sets_no_scheme() { + let (mut h, track, clip) = auto_rename_dialog(); + let old = h.state().engine.session().find_clip(clip).unwrap().1.name.clone(); + type_names(&mut h, sfx()); + h.get_by_role_and_label(Role::Button, "Cancel").focus(); + h.run_steps(2); + h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().dialogs.open_name(), None); + assert!(h.state().engine.session().auto_names.is_empty()); + assert_eq!(separate_middle(&mut h, track, clip), old); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 9a8ea30b..3d66552e 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -5,6 +5,7 @@ mod appearance; mod audio_device; +mod auto_rename; mod bounce; mod bounce_dialog; mod clip_gain_fader; From 746a825ed599d77e9546bff587c4c1a5f96ddee6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 17:42:53 -0700 Subject: [PATCH 72/90] Strip Silence: rectangles show what Strip keeps while the window is open While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks: the audio above the threshold, padded by Clip Start Pad and Clip End Pad. The rectangles follow the sliders and stay while Rename Selected Clips is open. They are removed when the window closes. The audio and the clips do not change. The scan runs again when a value or the session changes. Outside the web build it runs on a worker thread, one scan at a time, and the rectangles last found stay until it ends. A scan for older values, or one still running after the window closes, stops before its next window of audio. The new query command edit.strip_silence_preview returns the same ranges for each track. It reads the threshold, minimum strip duration and pads with the defaults and limits of edit.strip_silence, and both use the same scan. --- ROADMAP.md | 4 +- crates/engine/src/cmd/edit.rs | 42 +++++- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/io.rs | 96 +++++++++++++- crates/ui-egui/src/dialogs.rs | 121 ++++++++++++++++++ crates/ui-egui/src/edit_window.rs | 23 ++++ crates/ui-egui/tests/it/main.rs | 1 + .../tests/it/strip_silence_rectangles.rs | 111 ++++++++++++++++ 8 files changed, 391 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/tests/it/strip_silence_rectangles.rs diff --git a/ROADMAP.md b/ROADMAP.md index 342a23a1..4a4d7e04 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-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Strip Silence Rename) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Strip Silence rectangles) · **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-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change. - **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change. - **2026-10-24:** The Strip Silence window has Extract and Separate buttons. Extract removes the audio and keeps the silent parts of the selection, and Separate separates the clips at the edges of the silence without removing anything. Minimum Strip Duration goes up to 4,000 ms. No readiness percentage change. - **2026-10-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change. @@ -168,6 +169,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence Extract and Separate; readiness estimates unchanged | | 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e1823c15..1bc64add 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -125,6 +125,15 @@ pub fn specs() -> Vec { has_range, strip_silence ), + cmd!( + query "edit.strip_silence_preview", + "Strip Silence Rectangles", + [], + None, + "{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)", + has_range, + strip_silence_preview + ), cmd!( "edit.fades_create", "Create...", @@ -789,13 +798,38 @@ fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result { Ok(json!({"tracks": tracks.len()})) } -fn strip_silence(e: &mut Engine, p: &Value) -> Result { - let tracks = tracks_required(e, "edit.strip_silence", p)?; - let r = range_param(e, "edit.strip_silence", p)?; +/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads. +type StripSettings = (Vec, soundcraft_time::Range, f32, Samples, (Samples, Samples)); + +/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the +/// command in errors. +fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result { + let tracks = tracks_required(e, cmd, p)?; + let r = range_param(e, cmd, p)?; let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0); let sr = e.session().sample_rate; let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0); - let (min_len, pre, post) = (ms("min_length_ms", 50.0), ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0)); + Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0)))) +} + +/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit +/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a +/// worker thread outside the web build, and on the frame thread in the web build or when no thread +/// can be started. +pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result { + let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?; + Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() }) +} + +fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result { + let found = strip_silence_scan(e, p)?.run()?; + let tracks: Vec = + found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::>()})).collect(); + Ok(json!({"tracks": tracks})) +} + +fn strip_silence(e: &mut Engine, p: &Value) -> Result { + let (tracks, r, thr, min_len, (pre, post)) = strip_settings(e, "edit.strip_silence", p)?; let mode = match p.get("mode") { None => crate::io::StripMode::Strip, Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?, diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 41edf68c..cb7f9468 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -62,6 +62,7 @@ mod panic_probe { pub use file::bounce_job; pub(crate) use edit::remember_selection; +pub use edit::strip_silence_scan; pub type Run = fn(&mut Engine, &Value) -> Result; /// `Err(reason)` = disabled. diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 8600ae59..c73bc32b 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -8,6 +8,7 @@ use soundcraft_time::{Range, Samples}; use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; /// Largest import we accept (samples per channel × channels), ~4 GiB of f32. pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30; @@ -805,7 +806,7 @@ pub fn name_stripped_clips(s: &mut Session, t: TrackId, r: Range, names: &StripN /// `Extract`. pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pad: (Samples, Samples), mode: StripMode) -> Result { let r = bounded_to_track(s, t, r); - let keep = non_silent(s, t, r, thr, min_len, pad.0, pad.1)?; + let keep = non_silent(s, t, r, thr, min_len, pad, &AtomicBool::new(false))?; let mut cut_points = vec![r.start]; for (a, b) in &keep { cut_points.push(*a); @@ -840,13 +841,63 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S Ok(keep.len()) } +/// The ranges [`strip_silence`] finds on `t` inside `r` with the same settings: the non-silent ranges +/// with `pad` added, which Strip keeps. The session is read and not changed. Setting `stop` ends the +/// scan with an error before the next window of audio is rendered. +pub fn strip_silence_ranges( + s: &Session, + t: TrackId, + r: Range, + thr: f32, + min_len: Samples, + pad: (Samples, Samples), + stop: &AtomicBool, +) -> Result> { + let r = bounded_to_track(s, t, r); + Ok(non_silent(s, t, r, thr, min_len, pad, stop)?.into_iter().map(|(a, b)| Range::new(a, b)).collect()) +} + +/// A Strip Silence scan of a session's tracks, which can run on another thread +/// (`cmd::strip_silence_scan` makes one from Strip Silence's params). +#[derive(Clone)] +pub struct StripScan { + pub session: Arc, + pub tracks: Vec, + pub range: Range, + pub threshold_db: f32, + pub min_len: Samples, + pub pad: (Samples, Samples), + /// Set from another thread to end [`StripScan::run`] early, with an error. + pub stop: Arc, +} + +impl StripScan { + /// The ranges Strip keeps on each track ([`strip_silence_ranges`]). + pub fn run(&self) -> Result)>> { + let s = &self.session; + self.tracks + .iter() + .map(|t| Ok((*t, strip_silence_ranges(s, *t, self.range, self.threshold_db, self.min_len, self.pad, &self.stop)?))) + .collect() + } +} + /// Most samples (over all channels) Strip Silence renders at once. const STRIP_WINDOW_SAMPLES: usize = 1 << 24; /// The non-silent ranges of `t`'s clips in `r`. The audio is rendered a window at a time where /// audio clips play, so all of `r` is scanned however long it is (one render stops at -/// `MAX_RENDER_SAMPLES`); at most a day of clips, Pro Tools' longest session. -fn non_silent(s: &Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> Result> { +/// `MAX_RENDER_SAMPLES`); at most a day of clips, Pro Tools' longest session. `pad` is the clip start +/// and end pad. Setting `stop` ends the scan with an error before the next window is rendered. +fn non_silent( + s: &Session, + t: TrackId, + r: Range, + thr: f32, + min_len: Samples, + (pre, post): (Samples, Samples), + stop: &AtomicBool, +) -> Result> { let Some(tr) = s.track(t) else { return Ok(Vec::new()) }; let mut clips: Vec = tr.clips().iter().filter(|c| c.is_audio() && !c.muted).filter_map(|c| c.range().intersect(&r)).collect(); clips.sort_by_key(|x| x.start); @@ -868,6 +919,9 @@ fn non_silent(s: &Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre scan.skip(len(x.start - at)); at = x.start; while at < x.end { + if stop.load(Ordering::Relaxed) { + return Err(EngineError::Disabled("edit.strip_silence_preview".into(), "the scan was stopped".into())); + } let end = at.saturating_add(window).min(x.end); scan.push(&soundcraft_mix::render_clips(s, t, Range::new(at, end))); at = end; @@ -2395,6 +2449,42 @@ mod strip_tests { assert_eq!(clips, vec![(0, 48_000), (48_000, 96_000), (96_000, 144_000)]); } + #[test] + fn the_rectangles_are_what_strip_keeps_and_change_nothing() { + let (mut e, t) = tone_gap_tone(); + let p = json!({"track": t.0, "start": 0, "end": 144_000, "pad_before_ms": 10, "pad_after_ms": 20}); + let rev = e.revision; + let r = e.execute("edit.strip_silence_preview", &p).unwrap(); + assert_eq!(e.revision, rev, "the preview changed the session"); + assert_eq!(e.session().track(t).unwrap().clips().len(), 1); + assert_eq!(r["tracks"][0]["track"], t.0); + let ranges: Vec<(Samples, Samples)> = + r["tracks"][0]["ranges"].as_array().unwrap().iter().map(|x| (x[0].as_i64().unwrap(), x[1].as_i64().unwrap())).collect(); + assert_eq!(ranges, vec![(0, 48_960), (95_520, 144_000)]); + e.execute("edit.strip_silence", &p).unwrap(); + let kept: Vec<(Samples, Samples)> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect(); + assert_eq!(kept, ranges); + } + + #[test] + fn a_stopped_scan_ends_with_an_error() { + let (e, t) = tone_gap_tone(); + let scan = crate::cmd::strip_silence_scan(&e, &json!({"track": t.0, "start": 0, "end": 144_000})).unwrap(); + assert_eq!(scan.run().unwrap()[0].1.len(), 2); + scan.stop.store(true, Ordering::Relaxed); + assert!(scan.run().is_err()); + } + + #[test] + fn the_rectangles_follow_the_threshold() { + let (mut e, t) = tone_gap_tone(); + // The tone plays at -6 dB. + let r = e.execute("edit.strip_silence_preview", &json!({"track": t.0, "start": 0, "end": 144_000, "threshold_db": -3})).unwrap(); + assert_eq!(r["tracks"][0]["ranges"], json!([])); + let r = e.execute("edit.strip_silence_preview", &json!({"track": t.0, "start": 0, "end": 144_000, "threshold_db": -12})).unwrap(); + assert_eq!(r["tracks"][0]["ranges"].as_array().map(Vec::len), Some(2)); + } + #[test] fn an_unknown_strip_mode_is_refused() { let (mut e, t) = tone_gap_tone(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fc02c85d..72c7ff02 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8,6 +8,7 @@ use soundcraft_engine::Engine; use soundcraft_engine::io::StripNames; use soundcraft_model::{ClipId, GroupId, Session, SourceId, TrackId}; use soundcraft_time::Samples; +use std::sync::Arc; #[derive(Debug, Clone)] pub enum Dialog { @@ -42,6 +43,39 @@ pub struct Dialogs { pending_session: Option<(String, Vec)>, /// The naming scheme Strip Silence gives the clips it leaves, kept from one use to the next. pub strip_names: StripNames, + /// The Strip Silence rectangles the Edit window draws while Strip Silence is open. + strip_rectangles: StripRectangles, +} + +/// What Strip keeps on each track: the Strip Silence rectangles. +pub type StripRects = Arc)>>; + +/// The session revision and the Strip Silence values (as bits) that rectangles were found for. +type StripKey = (u64, [u64; 4]); + +/// The Strip Silence rectangles last found, and the scan running for newer values. +#[derive(Default)] +struct StripRectangles { + shown: Option<(StripKey, StripRects)>, + #[cfg(not(target_arch = "wasm32"))] + running: Option, +} + +/// A Strip Silence scan on a worker thread: what it was started for, where its result arrives, and +/// the flag that stops it. +#[cfg(not(target_arch = "wasm32"))] +struct StripScanning { + key: StripKey, + rx: std::sync::mpsc::Receiver, + stop: Arc, +} + +/// A scan whose rectangles nobody will draw stops before its next window of audio. +#[cfg(not(target_arch = "wasm32"))] +impl Drop for StripScanning { + fn drop(&mut self) { + self.stop.store(true, std::sync::atomic::Ordering::Relaxed); + } } impl Dialogs { @@ -69,6 +103,15 @@ impl Dialogs { }) } + /// Whether a worker thread is finding Strip Silence rectangles. + pub fn strip_scan_running(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + if self.strip_rectangles.running.is_some() { + return true; + } + false + } + pub fn open_rename_track(&mut self, id: TrackId, name: &str) { self.open = Some(Dialog::RenameTrack { id, name: name.to_string() }); } @@ -565,11 +608,16 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms } => { + let before = [*threshold, *min_ms, *pre_ms, *post_ms]; ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB")); ui.add(egui::Slider::new(min_ms, 0.0..=4000.0).text("Min strip duration ms")); ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms")); ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms")); let values = [*threshold, *min_ms, *pre_ms, *post_ms]; + // The Edit window is drawn before this window, so it draws the rectangles for new values in the next frame. + if values != before { + ui.ctx().request_repaint(); + } if !strip_names.name.trim().is_empty() { let names = format!("New clips are named {}, {} and so on.", strip_names.clip_name(0), strip_names.clip_name(1)); ui.label(egui::RichText::new(names).small().color(Tokens::current().text_dim)); @@ -779,6 +827,79 @@ fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result StripRects { + let values = match &app.dialogs.open { + Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) => [*threshold, *min_ms, *pre_ms, *post_ms], + Some(Dialog::StripSilenceRename { strip, .. }) => *strip, + _ => { + app.dialogs.strip_rectangles = StripRectangles::default(); + return Arc::default(); + } + }; + let key: StripKey = (app.engine.revision, values.map(f64::to_bits)); + let r = &mut app.dialogs.strip_rectangles; + #[cfg(not(target_arch = "wasm32"))] + if let Some(job) = &r.running { + match job.rx.try_recv() { + Ok(rects) => { + r.shown = Some((job.key, rects)); + r.running = None; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => r.running = None, + // Values changed since it started: stop it, and the next scan starts when it has ended. + Err(std::sync::mpsc::TryRecvError::Empty) if job.key != key => job.stop.store(true, std::sync::atomic::Ordering::Relaxed), + Err(std::sync::mpsc::TryRecvError::Empty) => {} + } + } + let shown = r.shown.as_ref().map(|(k, rects)| (*k == key, Arc::clone(rects))); + #[cfg(not(target_arch = "wasm32"))] + let busy = r.running.is_some(); + #[cfg(target_arch = "wasm32")] + let busy = false; + if let Some((true, rects)) = shown { + return rects; + } + if busy { + return shown.map(|(_, rects)| rects).unwrap_or_default(); + } + let [threshold, min_ms, pre_ms, post_ms] = values; + let params = json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms}); + let Ok(scan) = soundcraft_engine::cmd::strip_silence_scan(&app.engine, ¶ms) else { + app.dialogs.strip_rectangles.shown = Some((key, Arc::default())); + return Arc::default(); + }; + let scan = Arc::new(scan); + #[cfg(not(target_arch = "wasm32"))] + { + let (tx, rx) = std::sync::mpsc::channel(); + let (ctx, job) = (ctx.clone(), Arc::clone(&scan)); + let work = move || { + let found = job.run(); + // A stopped scan sends nothing: its sender drops, and the rectangles last found stay. + if !job.stop.load(std::sync::atomic::Ordering::Relaxed) { + let _ = tx.send(Arc::new(found.unwrap_or_default())); + } + ctx.request_repaint(); + }; + if std::thread::Builder::new().name("strip-silence-scan".into()).spawn(work).is_ok() { + app.dialogs.strip_rectangles.running = Some(StripScanning { key, rx, stop: Arc::clone(&scan.stop) }); + return shown.map(|(_, rects)| rects).unwrap_or_default(); + } + } + #[cfg(target_arch = "wasm32")] + let _ = ctx; + // The browser has no threads, and without a thread to spare the scan runs on this one. + let rects: StripRects = Arc::new(scan.run().unwrap_or_default()); + app.dialogs.strip_rectangles.shown = Some((key, Arc::clone(&rects))); + rects +} + /// The command a Strip Silence button runs: `mode` is strip, extract or separate, and `values` holds /// the threshold in dB, the minimum strip duration and the clip start and end pads in milliseconds. /// `names` names the clips it leaves when its name is not blank. diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index df90bd7d..9190620f 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -944,12 +944,35 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, painter.rect_filled(Rect::from_min_max(pos2(x0, lane.min.y), pos2(x1, lane.max.y)), 0.0, t.selection); } } + let strip = crate::dialogs::strip_silence_rectangles(app, ui.ctx()); + if let Some((_, ranges)) = strip.iter().find(|(t, _)| *t == id) { + draw_strip_silence(&painter, &s, tl, lane, ranges); + } lane_interaction(app, ui, track, lane, tl); if track.view == "playlists" { playlist_lanes(app, ui, track, row, main_h, tl); } } +/// The outline of the Strip Silence rectangles. +pub fn strip_silence_stroke() -> Stroke { + Stroke::new(1.5, Tokens::current().accent) +} + +/// The Strip Silence rectangles on one lane, while the Strip Silence window is open: one around each +/// range Strip keeps. +fn draw_strip_silence(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect, ranges: &[Range]) { + let stroke = strip_silence_stroke(); + for r in ranges { + let x0 = x_of(s, tl, r.start).max(lane.min.x); + let x1 = x_of(s, tl, r.end).min(lane.max.x); + if x1 > x0 { + let rect = Rect::from_min_max(pos2(x0, lane.min.y + 1.0), pos2(x1, lane.max.y - 1.0)); + painter.rect(rect, 0.0, stroke.color.gamma_multiply(0.12), stroke, StrokeKind::Inside); + } + } +} + /// Alternate playlists as lanes under the main one; clicking a clip promotes it (comping). fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, main_h: f32, tl: Rect) { let t = Tokens::current(); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 9a8ea30b..efe1e366 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -46,6 +46,7 @@ mod shortcuts; mod side_lists_access; mod side_lists_text; mod strip_silence_dialog; +mod strip_silence_rectangles; mod strip_silence_rename; mod toolbar_scroll; mod toolbar_tempo; diff --git a/crates/ui-egui/tests/it/strip_silence_rectangles.rs b/crates/ui-egui/tests/it/strip_silence_rectangles.rs new file mode 100644 index 00000000..4ee12174 --- /dev/null +++ b/crates/ui-egui/tests/it/strip_silence_rectangles.rs @@ -0,0 +1,111 @@ +use egui::accesskit::Role; +use egui::{Shape, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_model::TrackId; +use soundcraft_time::Range; +use soundcraft_ui_egui::dialogs::Dialog; +use soundcraft_ui_egui::{Services, SoundApp, edit_window, menus}; + +/// Selects the whole Kick track and opens Edit > Strip Silence from the menu. +fn strip_silence_dialog() -> (Harness<'static, SoundApp>, TrackId) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let track = app.engine.session().track_by_name("Kick").unwrap().id; + let end = app.engine.session().content_end(); + app.run("edit.select", json!({"tracks": [track.0], "start": 0, "end": end})).unwrap(); + menus::invoke_menu(&mut app, "edit.strip_silence", "Edit > Strip Silence"); + assert_eq!(app.dialogs.open_name(), Some("strip_silence")); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + settle(&mut h); + (h, track) +} + +/// Runs frames until no Strip Silence scan is running, then one more so the Edit window draws what it found. +fn settle(h: &mut Harness<'_, SoundApp>) { + h.run_steps(3); + for _ in 0..2000 { + if !h.state().dialogs.strip_scan_running() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + h.run_steps(1); + } + assert!(!h.state().dialogs.strip_scan_running(), "the scan did not end"); + h.run_steps(2); +} + +/// The Strip Silence rectangles in the last frame. +fn rectangles(h: &Harness<'_, SoundApp>) -> usize { + let stroke = edit_window::strip_silence_stroke(); + h.output().shapes.iter().filter(|c| matches!(&c.shape, Shape::Rect(r) if r.stroke == stroke)).count() +} + +/// The ranges `edit.strip_silence_preview` finds on `track` with the dialog's default values. +fn preview(h: &mut Harness<'_, SoundApp>, track: TrackId) -> Vec { + let p = json!({"threshold_db": -48.0, "min_length_ms": 50.0, "pad_before_ms": 5.0, "pad_after_ms": 20.0}); + let r = h.state_mut().engine.execute("edit.strip_silence_preview", &p).unwrap(); + let ranges = r["tracks"].as_array().unwrap().iter().find(|t| t["track"] == track.0).unwrap()["ranges"].clone(); + ranges.as_array().unwrap().iter().map(|x| Range::new(x[0].as_i64().unwrap(), x[1].as_i64().unwrap())).collect() +} + +fn clips(h: &Harness<'_, SoundApp>, track: TrackId) -> Vec<(i64, i64)> { + h.state().engine.session().track(track).map(|t| t.clips().iter().map(|c| (c.start, c.end())).collect()).unwrap_or_default() +} + +#[test] +fn strip_silence_draws_rectangles_around_what_strip_keeps() { + let (mut h, track) = strip_silence_dialog(); + let before = clips(&h, track); + let ctx = h.ctx.clone(); + let shown = soundcraft_ui_egui::dialogs::strip_silence_rectangles(h.state_mut(), &ctx); + let ranges = preview(&mut h, track); + assert!(!ranges.is_empty()); + assert_eq!(shown.iter().find(|(t, _)| *t == track).map(|(_, r)| r.clone()), Some(ranges.clone())); + let drawn = rectangles(&h); + assert!(drawn > 0 && drawn <= ranges.len(), "{drawn} rectangles for {} ranges", ranges.len()); + assert_eq!(clips(&h, track), before, "the rectangles changed the clips"); + assert!(!h.state().engine.can_undo(), "the rectangles made an undo step"); +} + +#[test] +fn the_rectangles_follow_the_sliders() { + let (mut h, _) = strip_silence_dialog(); + assert!(rectangles(&h) > 0); + if let Some(Dialog::StripSilence { threshold, .. }) = &mut h.state_mut().dialogs.open { + *threshold = 0.0; + } + settle(&mut h); + assert_eq!(rectangles(&h), 0, "nothing is louder than 0 dB, so Strip would keep nothing"); + if let Some(Dialog::StripSilence { threshold, .. }) = &mut h.state_mut().dialogs.open { + *threshold = -48.0; + } + settle(&mut h); + assert!(rectangles(&h) > 0); +} + +#[test] +fn the_rectangles_stay_while_rename_is_open_and_go_when_the_window_closes() { + let (mut h, track) = strip_silence_dialog(); + let before = clips(&h, track); + let drawn = rectangles(&h); + assert!(drawn > 0); + h.get_by_role_and_label(Role::Button, "Rename…").click(); + h.run_steps(3); + assert_eq!(h.state().dialogs.open_name(), Some("strip_silence_rename")); + assert_eq!(rectangles(&h), drawn); + h.get_by_role_and_label(Role::Button, "Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().dialogs.open_name(), Some("strip_silence")); + h.get_by_role_and_label(Role::Button, "Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().dialogs.open_name(), None); + assert_eq!(rectangles(&h), 0); + assert_eq!(clips(&h, track), before); +} From 06f30628bbc623cead0fed583705fb5ad510115f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:37:54 -0700 Subject: [PATCH 73/90] Clip List: a paste reuses a held Auto Rename scheme only when its text matches too A paste reused the scheme of the session it went into whenever the copied scheme's id matched, so an unrelated scheme that had the same id could rename the pasted clip and count its numbers on. A scheme is now reused only when its name, zeros and suffix match as well; any other is added under a new key. --- crates/engine/src/cmd/auto_rename.rs | 29 ++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 12 ++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/auto_rename.rs b/crates/engine/src/cmd/auto_rename.rs index 4a8dd044..bd0d69e0 100644 --- a/crates/engine/src/cmd/auto_rename.rs +++ b/crates/engine/src/cmd/auto_rename.rs @@ -159,6 +159,35 @@ mod tests { assert_eq!(s.auto_names[&key].number, 100, "the other scheme's count is untouched"); } + #[test] + fn a_copy_keeps_its_scheme_when_another_session_holds_its_id_with_other_text() { + let mut e = crate::demo::demo_engine(); + let (t, id, start, len) = first_kick(&e); + e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap(); + let copied = e.session().auto_names.values().next().unwrap().id; + e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap(); + // Another session holds a scheme under the same id with another name. + e.replace_session(crate::demo::demo_engine().session().clone()); + let bass = e.session().track_by_name("Bass").unwrap().clips()[0].id; + let s = e.session_mut(); + let key = s.alloc(); + s.auto_names.insert(key, soundcraft_model::AutoName { name: "Other".into(), number: 100, zeros: 0, suffix: String::new(), id: copied }); + s.tracks + .iter_mut() + .flat_map(|t| t.playlists.iter_mut()) + .flat_map(|pl| pl.clips.iter_mut()) + .filter(|c| c.id == bass) + .for_each(|c| c.auto_name = Some(key)); + let t = first_kick(&e).0; + let at = e.session().content_end() + 48_000; + e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap(); + e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 2})).unwrap(); + assert_eq!(names_in(&e, t, at, at + len)[1], "SFX1"); + let s = e.session(); + assert_eq!(s.auto_names.len(), 2); + assert_eq!(s.auto_names[&key].number, 100, "the other scheme's count is untouched"); + } + #[test] fn bad_auto_rename_params_are_refused() { let mut e = crate::demo::demo_engine(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1c1ba241..b0ec0939 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -127,13 +127,17 @@ impl Clipboard { } /// Before a paste into `s`: point the copied clips at schemes that `s` holds. A scheme that `s` - /// holds with the same [`AutoName::id`](soundcraft_model::AutoName::id) is used as it is, and - /// its numbers count on. Any other goes into `s` under a new key, counting on from where it was - /// when it was copied. + /// holds with the same [`AutoName::id`](soundcraft_model::AutoName::id), name, zeros and suffix + /// is used as it is, and its numbers count on. Any other goes into `s` under a new key, counting + /// on from where it was when it was copied. pub fn adopt_auto_names(&mut self, s: &mut Session) { let mut keys = std::collections::BTreeMap::new(); for (k, a) in &self.auto_names { - let held = s.auto_names.iter().find(|(_, h)| a.id != 0 && h.id == a.id).map(|(key, _)| *key); + let held = s + .auto_names + .iter() + .find(|(_, h)| a.id != 0 && h.id == a.id && h.name == a.name && h.zeros == a.zeros && h.suffix == a.suffix) + .map(|(key, _)| *key); let key = match held { Some(key) => key, None => { From 3a139d4bed9c6c64e358c4a47f98b918d337f967 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 02:31:48 +0000 Subject: [PATCH 74/90] Integration: AudioSuite speed ramp: name SoundCraft's ids speed_ramp The process id, types, function, test module and clip suffix use speed_ramp; the menu label stays as the catalog has it. --- crates/dsp/src/offline.rs | 20 +++++----- crates/dsp/tests/it/fuzz.rs | 6 +-- crates/dsp/tests/it/main.rs | 2 +- .../tests/it/{vari_fi.rs => speed_ramp.rs} | 40 +++++++++---------- crates/engine/src/cmd/audiosuite.rs | 20 +++++----- crates/engine/tests/it/long_renders.rs | 6 +-- crates/ui-egui/src/dialogs.rs | 16 ++++---- crates/ui-egui/src/menus.rs | 2 +- docs/plugin-parity.md | 6 +-- 9 files changed, 59 insertions(+), 59 deletions(-) rename crates/dsp/tests/it/{vari_fi.rs => speed_ramp.rs} (70%) diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index 80be7105..a5be2cd6 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -452,18 +452,18 @@ pub fn pitch_shift(ch: &[Vec], semitones: f32, sample_rate: f32) -> Vec], change: VariFiChange, selection: VariFiSelection, fades: bool) -> Vec> { +pub fn speed_ramp(ch: &[Vec], change: SpeedRampChange, selection: SpeedRampSelection, fades: bool) -> Vec> { let len = max_len(ch) as f64; let (total, first, played) = match selection { - VariFiSelection::Extend => ((len * 1.5).round(), 0.0, len), - VariFiSelection::FitTo => { + SpeedRampSelection::Extend => ((len * 1.5).round(), 0.0, len), + SpeedRampSelection::FitTo => { let played = len * 2.0 / 3.0; - (len, if change == VariFiChange::SlowDown { len - played } else { 0.0 }, played) + (len, if change == SpeedRampChange::SlowDown { len - played } else { 0.0 }, played) } }; let out_len = (total as usize).min(MAX_OFFLINE_SAMPLES); @@ -491,8 +491,8 @@ pub fn vari_fi(ch: &[Vec], change: VariFiChange, selection: VariFiSelection // The fraction of the played input reached at `u`, and the speed there (0 to 1). let at = |u: f64| -> (f64, f64) { match change { - VariFiChange::SlowDown => ((3.0 * u - u * u * u) / 2.0, 1.0 - u * u), - VariFiChange::SpeedUp => { + SpeedRampChange::SlowDown => ((3.0 * u - u * u * u) / 2.0, 1.0 - u * u), + SpeedRampChange::SpeedUp => { let r = 1.0 - u; (1.0 - (3.0 * r - r * r * r) / 2.0, 1.0 - r * r) } diff --git a/crates/dsp/tests/it/fuzz.rs b/crates/dsp/tests/it/fuzz.rs index 56659af4..561e195b 100644 --- a/crates/dsp/tests/it/fuzz.rs +++ b/crates/dsp/tests/it/fuzz.rs @@ -46,9 +46,9 @@ proptest! { let _ = offline::detect_transients(&ch, a, a); let _ = offline::time_stretch(&ch, r, a); let _ = offline::pitch_shift(&ch, a, a); - let change = if n % 2 == 0 { offline::VariFiChange::SpeedUp } else { offline::VariFiChange::SlowDown }; - let selection = if n % 3 == 0 { offline::VariFiSelection::Extend } else { offline::VariFiSelection::FitTo }; - let _ = offline::vari_fi(&ch, change, selection, n % 5 == 0); + let change = if n % 2 == 0 { offline::SpeedRampChange::SpeedUp } else { offline::SpeedRampChange::SlowDown }; + let selection = if n % 3 == 0 { offline::SpeedRampSelection::Extend } else { offline::SpeedRampSelection::FitTo }; + let _ = offline::speed_ramp(&ch, change, selection, n % 5 == 0); let _ = offline::resample(&ch, n as u32, (n as u32).wrapping_mul(3) % 200_000); let mut m = ch.clone(); offline::normalize(&mut m, a, n % 2 == 0); diff --git a/crates/dsp/tests/it/main.rs b/crates/dsp/tests/it/main.rs index fe26ba94..51a452bb 100644 --- a/crates/dsp/tests/it/main.rs +++ b/crates/dsp/tests/it/main.rs @@ -5,5 +5,5 @@ mod fuzz; mod offline_meters; mod offline_normalize; mod plugins; +mod speed_ramp; mod transients; -mod vari_fi; diff --git a/crates/dsp/tests/it/vari_fi.rs b/crates/dsp/tests/it/speed_ramp.rs similarity index 70% rename from crates/dsp/tests/it/vari_fi.rs rename to crates/dsp/tests/it/speed_ramp.rs index 99c92c82..0d035418 100644 --- a/crates/dsp/tests/it/vari_fi.rs +++ b/crates/dsp/tests/it/speed_ramp.rs @@ -1,4 +1,4 @@ -use soundcraft_dsp::offline::{self, VariFiChange, VariFiSelection}; +use soundcraft_dsp::offline::{self, SpeedRampChange, SpeedRampSelection}; /// A channel whose samples rise from 0 to 1, so each output sample shows where it was read. fn ramp(len: usize) -> Vec { @@ -19,10 +19,10 @@ fn near(got: f32, want: f64, what: &str) { } #[test] -fn vari_fi_fit_to_keeps_the_length_and_plays_two_thirds() { +fn speed_ramp_fit_to_keeps_the_length_and_plays_two_thirds() { let len = 30_000; let x = vec![ramp(len)]; - let slow = offline::vari_fi(&x, VariFiChange::SlowDown, VariFiSelection::FitTo, false); + let slow = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, false); assert_eq!(slow[0].len(), len); // Slow Down plays the last two thirds: it starts at normal speed one third in and stops at the end. for u in [0.0, 0.25, 0.5, 0.75, 0.9] { @@ -34,7 +34,7 @@ fn vari_fi_fit_to_keeps_the_length_and_plays_two_thirds() { assert!(step(&slow[0], len - 200) < 0.02, "slow down ends near a stop: {}", step(&slow[0], len - 200)); // Speed Up plays the first two thirds: it starts from a stop and reaches normal speed at the end. - let fast = offline::vari_fi(&x, VariFiChange::SpeedUp, VariFiSelection::FitTo, false); + let fast = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, false); assert_eq!(fast[0].len(), len); for u in [0.1, 0.25, 0.5, 0.75, 0.99] { let n = (u * len as f64) as usize; @@ -45,28 +45,28 @@ fn vari_fi_fit_to_keeps_the_length_and_plays_two_thirds() { } #[test] -fn vari_fi_extend_plays_everything_over_150_percent() { +fn speed_ramp_extend_plays_everything_over_150_percent() { let len = 30_000; let x = vec![ramp(len), ramp(len)]; - for change in [VariFiChange::SlowDown, VariFiChange::SpeedUp] { - let out = offline::vari_fi(&x, change, VariFiSelection::Extend, false); + for change in [SpeedRampChange::SlowDown, SpeedRampChange::SpeedUp] { + let out = offline::speed_ramp(&x, change, SpeedRampSelection::Extend, false); assert_eq!(out.len(), 2); assert_eq!(out[0].len(), 45_000, "{change:?}"); assert_eq!(out[0], out[1], "{change:?}: both channels are read at the same positions"); for u in [0.1, 0.25, 0.5, 0.75, 0.97] { let n = (u * 45_000.0) as usize; - let pos = if change == VariFiChange::SlowDown { slow_down_pos(u) } else { speed_up_pos(u) }; + let pos = if change == SpeedRampChange::SlowDown { slow_down_pos(u) } else { speed_up_pos(u) }; near(out[0][n], pos, &format!("{change:?} at {u}")); } } } #[test] -fn vari_fi_fades_follow_the_speed() { +fn speed_ramp_fades_follow_the_speed() { let len = 30_000; let x = vec![vec![0.5; len]]; - let slow = offline::vari_fi(&x, VariFiChange::SlowDown, VariFiSelection::FitTo, true); - let fast = offline::vari_fi(&x, VariFiChange::SpeedUp, VariFiSelection::FitTo, true); + let slow = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, true); + let fast = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, true); for u in [0.25, 0.5, 0.75] { let n = (u * len as f64) as usize; near(slow[0][n], 0.5 * (1.0 - u * u), &format!("slow down fades out at {u}")); @@ -74,24 +74,24 @@ fn vari_fi_fades_follow_the_speed() { } assert!(slow[0][len - 1].abs() < 1e-3 && fast[0][0].abs() < 1e-3, "the fades reach silence at the stop"); // Fades Off keeps the level. - let off = offline::vari_fi(&x, VariFiChange::SlowDown, VariFiSelection::FitTo, false); + let off = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, false); for u in [0.25, 0.5, 0.75, 0.9] { near(off[0][(u * len as f64) as usize], 0.5, &format!("slow down without fades at {u}")); } // ... at the stop too, where the reads reach either end of the input. - let up = offline::vari_fi(&x, VariFiChange::SpeedUp, VariFiSelection::FitTo, false); + let up = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, false); near(up[0][0], 0.5, "speed up without fades from the stop"); near(off[0][len - 1], 0.5, "slow down without fades at the stop"); - let off = offline::vari_fi(&x, VariFiChange::SlowDown, VariFiSelection::Extend, false); + let off = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false); near(off[0][off[0].len() - 1], 0.5, "slow down with Extend and without fades at the stop"); } #[test] -fn vari_fi_pitch_falls_as_the_tape_slows() { +fn speed_ramp_pitch_falls_as_the_tape_slows() { let sr = 48_000.0; let len = 96_000; let x = vec![(0..len).map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr).sin() * 0.5).collect::>()]; - let out = offline::vari_fi(&x, VariFiChange::SlowDown, VariFiSelection::Extend, false); + let out = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false); // Upward zero crossings per second in a 0.1 s window starting at `u`. let rate = |u: f64| { let a = (u * out[0].len() as f64) as usize; @@ -105,12 +105,12 @@ fn vari_fi_pitch_falls_as_the_tape_slows() { } #[test] -fn vari_fi_handles_empty_and_uneven_channels() { - assert!(offline::vari_fi(&[], VariFiChange::SlowDown, VariFiSelection::Extend, true).is_empty()); - let out = offline::vari_fi(&[vec![], vec![]], VariFiChange::SpeedUp, VariFiSelection::FitTo, true); +fn speed_ramp_handles_empty_and_uneven_channels() { + assert!(offline::speed_ramp(&[], SpeedRampChange::SlowDown, SpeedRampSelection::Extend, true).is_empty()); + let out = offline::speed_ramp(&[vec![], vec![]], SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, true); assert!(out.iter().all(Vec::is_empty)); // A shorter channel is read as silence past its end, and every channel gets the same length. - let out = offline::vari_fi(&[vec![0.5; 300], vec![0.5; 30]], VariFiChange::SlowDown, VariFiSelection::Extend, false); + let out = offline::speed_ramp(&[vec![0.5; 300], vec![0.5; 30]], SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false); assert_eq!((out[0].len(), out[1].len()), (450, 450)); assert!(out.iter().flatten().all(|v| v.is_finite())); near(out[0][449], 0.5, "the longer channel at the stop"); diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index 60f49719..ff6d74a1 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -5,7 +5,7 @@ use soundcraft_time::Range; use crate::{Engine, Result, cmd}; use serde_json::{Value, json}; use soundcraft_audio_io::{AudioBuffer, FileFormat}; -use soundcraft_dsp::offline::{VariFiChange, VariFiSelection}; +use soundcraft_dsp::offline::{SpeedRampChange, SpeedRampSelection}; use soundcraft_model::{Clip, ClipContent, TrackKind}; pub fn specs() -> Vec { @@ -14,7 +14,7 @@ pub fn specs() -> Vec { "AudioSuite Process", [], None, - "{process: | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | vari_fi | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); vari_fi takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)", + "{process: | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)", has_selection, process )] @@ -40,7 +40,7 @@ fn process(e: &mut Engine, p: &Value) -> Result { | "time_stretch" | "pitch_shift" | "varispeed" - | "vari_fi" + | "speed_ramp" | "dc_offset_removal" ); if !known { @@ -60,8 +60,8 @@ fn process(e: &mut Engine, p: &Value) -> Result { let ratio = f64::from(get("ratio", 1.0).clamp(0.25, 4.0)); let semitones = get("semitones", 0.0).clamp(-24.0, 24.0); let to = (sr.as_f64() / f64::from(get("speed", 1.0).clamp(0.25, 4.0))).round().max(1.0) as u32; - let change = if get("change", 0.0) >= 0.5 { VariFiChange::SpeedUp } else { VariFiChange::SlowDown }; - let selection = if get("selection", 0.0) >= 0.5 { VariFiSelection::Extend } else { VariFiSelection::FitTo }; + let change = if get("change", 0.0) >= 0.5 { SpeedRampChange::SpeedUp } else { SpeedRampChange::SlowDown }; + let selection = if get("selection", 0.0) >= 0.5 { SpeedRampSelection::Extend } else { SpeedRampSelection::FitTo }; let fades = get("fades", 1.0) >= 0.5; // These processes cut their output at MAX_OFFLINE_SAMPLES frames. let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize; @@ -69,7 +69,7 @@ fn process(e: &mut Engine, p: &Value) -> Result { "time_stretch" => longest(ratio), "pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)), "varispeed" => longest(f64::from(to) / sr.as_f64()), - "vari_fi" => longest(if selection == VariFiSelection::Extend { 1.5 } else { 1.0 }), + "speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }), _ => usize::MAX, }; let mut done = 0; @@ -115,7 +115,7 @@ fn process(e: &mut Engine, p: &Value) -> Result { "time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, ratio, srf), "pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, semitones, srf), "varispeed" => soundcraft_dsp::offline::resample(&audio, sr.hz(), to), - "vari_fi" => soundcraft_dsp::offline::vari_fi(&audio, change, selection, fades), + "speed_ramp" => soundcraft_dsp::offline::speed_ramp(&audio, change, selection, fades), plugin => { apply(plugin, ¶ms, &mut audio, srf)?; audio @@ -258,7 +258,7 @@ mod tests { } #[test] - fn vari_fi_ramps_the_speed_and_extend_lengthens_the_clip() { + fn speed_ramp_ramps_the_speed_and_extend_lengthens_the_clip() { // A mono clip at 480 whose samples rise from 0 to 1, so each output sample shows where it was read. let run = |params: Value| { let mut s = Session::default(); @@ -268,7 +268,7 @@ mod tests { let id = s.new_clip_id(); crate::edit::place_clip(&mut s, t, Clip::audio(id, "ramp", src, 0, 480, 4800)); let mut e = crate::Engine::new(s); - e.execute("audiosuite.process", &json!({"process": "vari_fi", "params": params, "clips": [id.0]})).unwrap(); + e.execute("audiosuite.process", &json!({"process": "speed_ramp", "params": params, "clips": [id.0]})).unwrap(); let c = e.session().tracks[0].clips()[0].clone(); let audio = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0].clone(); (c.start, c.length, c.name, audio) @@ -276,7 +276,7 @@ mod tests { // The defaults: Slow Down, Fit To, Fades On. Halfway through, the read position is 11/16 of the // way through the last two thirds, and the gain is the speed there, 3/4. let (start, length, name, audio) = run(json!({})); - assert_eq!((start, length, name.as_str()), (480, 4800, "ramp-varifi")); + assert_eq!((start, length, name.as_str()), (480, 4800, "ramp-speedramp")); let want = (1.0 / 3.0 + 2.0 / 3.0 * 11.0 / 16.0) * 0.75; assert!((audio[2400] - want).abs() < 2e-3, "slow down halfway: got {}, want {want}", audio[2400]); assert!(audio[4790].abs() < 0.01, "the fade-out ends near silence, got {}", audio[4790]); diff --git a/crates/engine/tests/it/long_renders.rs b/crates/engine/tests/it/long_renders.rs index 03f15cc1..15197efa 100644 --- a/crates/engine/tests/it/long_renders.rs +++ b/crates/engine/tests/it/long_renders.rs @@ -49,8 +49,8 @@ fn audiosuite_refuses_results_longer_than_one_render() { } #[test] -fn vari_fi_refuses_mono_clips_longer_than_one_render() { - // A mono clip may be longer than one offline buffer, so Vari-Fi must refuse it rather than cut its ramp short. +fn speed_ramp_refuses_mono_clips_longer_than_one_render() { + // A mono clip may be longer than one offline buffer, so the speed ramp must refuse it rather than cut it short. for (frames, selection) in [(MAX_OFFLINE_SAMPLES + 1, 0), (MAX_OFFLINE_SAMPLES * 2 / 3 + 1, 1)] { let mut s = Session::default(); let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Talk")); @@ -59,7 +59,7 @@ fn vari_fi_refuses_mono_clips_longer_than_one_render() { let id = s.new_clip_id(); soundcraft_engine::edit::place_clip(&mut s, t, Clip::audio(id, "Talk", src, 0, 0, frames as i64)); let mut e = Engine::new(s); - refused(&mut e, "audiosuite.process", &json!({"process": "vari_fi", "params": {"selection": selection}, "clips": [id.0]})); + refused(&mut e, "audiosuite.process", &json!({"process": "speed_ramp", "params": {"selection": selection}, "clips": [id.0]})); } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b8ac9563..5b218e16 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -751,8 +751,8 @@ fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) { ("event.tempo".into(), json!({"bpm": bpm, "at": at})) } -/// Vari-Fi's choices in the AudioSuite window: param id, label, default, and (value, name) pairs. -const VARI_FI_CHOICES: [(&str, &str, f32, &[(f32, &str)]); 3] = [ +/// The speed ramp's choices in the AudioSuite window: param id, label, default, and (value, name) pairs. +const SPEED_RAMP_CHOICES: [(&str, &str, f32, &[(f32, &str)]); 3] = [ ("change", "Change", 0.0, &[(0.0, "Slow Down"), (1.0, "Speed Up")]), ("selection", "Selection", 0.0, &[(0.0, "Fit To"), (1.0, "Extend")]), ("fades", "Fades", 1.0, &[(1.0, "On"), (0.0, "Off")]), @@ -788,8 +788,8 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { let v = params.entry("speed".into()).or_insert(1.0); ui.add(egui::Slider::new(v, 0.25..=4.0).text("Speed")); } - "vari_fi" => { - for (id, label, default, choices) in VARI_FI_CHOICES { + "speed_ramp" => { + for (id, label, default, choices) in SPEED_RAMP_CHOICES { let v = params.entry(id.to_string()).or_insert(default); ui.horizontal(|ui| { ui.label(label); @@ -861,19 +861,19 @@ mod tests { } #[test] - fn vari_fi_menu_opens_the_speed_ramp_and_its_choices_reach_the_engine() { + fn speed_ramp_menu_opens_the_window_and_its_choices_reach_the_engine() { let mut app = SoundApp::new(Engine::default(), None, crate::Services::default()); crate::menus::invoke_menu(&mut app, "audiosuite.process", "AudioSuite > Pitch Shift > Vari-Fi"); - assert_eq!(app.ui.audiosuite.as_deref(), Some("vari_fi")); + assert_eq!(app.ui.audiosuite.as_deref(), Some("speed_ramp")); // Renders the Kick clip with `params` and returns its new length and samples. let render = |params: Value| { let mut e = soundcraft_engine::demo::demo_engine(); let clip = e.session().track_by_name("Kick").unwrap().clips()[0].id; - e.execute("audiosuite.process", &json!({"process": "vari_fi", "params": params, "clips": [clip.0]})).unwrap(); + e.execute("audiosuite.process", &json!({"process": "speed_ramp", "params": params, "clips": [clip.0]})).unwrap(); let c = e.session().track_by_name("Kick").unwrap().clips()[0].clone(); (c.length, e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels.clone()) }; - let defaults: serde_json::Map = VARI_FI_CHOICES.iter().map(|(id, _, d, _)| (id.to_string(), json!(d))).collect(); + let defaults: serde_json::Map = SPEED_RAMP_CHOICES.iter().map(|(id, _, d, _)| (id.to_string(), json!(d))).collect(); let (length, audio) = render(Value::Object(defaults)); assert!(render(json!({})) == (length, audio.clone()), "the window's defaults are the engine's"); let (extended, _) = render(json!({"selection": 1.0})); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fd22f2de..ee907d3f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -128,7 +128,7 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[ ("AudioSuite > Pitch Shift > Pitch II", "pitch_shift"), ("AudioSuite > Pitch Shift > Pitch Shift Legacy", "pitch_shift"), ("AudioSuite > Pitch Shift > Time Shift", "time_stretch"), - ("AudioSuite > Pitch Shift > Vari-Fi", "vari_fi"), + ("AudioSuite > Pitch Shift > Vari-Fi", "speed_ramp"), ("AudioSuite > Reverb > D-Verb", "room_reverb"), ("AudioSuite > Delay > Mod Delay III", "mod_delay"), ("AudioSuite > Modulation > Sci-Fi", "ring_mod"), diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index a6438ef6..9be6e0a7 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-24 · **Change:** minor (AudioSuite speed ramps (vari_fi); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously 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 @@ -19,7 +19,7 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories. | Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth | | Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `auto_pan`, `chorus`, `flanger`, `phaser`, `ring_mod` (ring, frequency modulation, positive and negative resonators; LFO, envelope or sample-and-hold modulation). Auto Pan pans the mono sum of a stereo track with an LFO (free, single, beat clock, envelope or manual trigger; rate in BPM or linked to the session tempo) or an envelope follower. | Tremolo, frequency shift; Auto Pan on a mono track (inserts on a mono track have one channel), LCR, quad and 5.0 outputs with Angle, Place and Spread, circular surround paths, output meters, side-chain input | | Harmonic | lo-fi, rectifier, amp simulation, saturation, tape, subharmonic synthesis | `lofi`, `rectifier`, `saturator`, `subharmonic` (octave-down bass from a lower and an upper band, direct bass, drive characters, HP and LP filters) | Amp and cabinet models, tape; subharmonic MIDI tuning and surround (LFE) version | -| Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift`; the AudioSuite speed ramp (`vari_fi`) slows a clip down to a stop or speeds it up from one, fitted to the clip's length or extended to 150 %, with or without a fade. | Formant control, pitch correction | +| Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift`; the AudioSuite speed ramp (`speed_ramp`) slows a clip down to a stop or speeds it up from one, fitted to the clip's length or extended to 150 %, with or without a fade. | Formant control, 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`, `sampler`; `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). | Piano, organ, wavetable, content library | | **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **27 processors + 3 instruments** | | @@ -58,7 +58,7 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---| -| 2026-10-24 | minor | AudioSuite speed ramps (`vari_fi`): down to a stop and up from one, Fit To or Extend, fades on or off | +| 2026-10-24 | minor | AudioSuite speed ramps (`speed_ramp`): down to a stop and up from one, Fit To or Extend, fades on or off | | 2026-10-24 | minor | Auto Pan: LFO and envelope panning of stereo tracks; built-in count 27 processors + 3 instruments | | 2026-10-24 | trivial | Harmonic: Subharmonic (`subharmonic`); built-in count 26 processors + 3 instruments | | 2026-10-23 | trivial | Modulation: Ring Modulator (`ring_mod`), also used by the AudioSuite Modulation menu | From 835de9f038db5e8241b6b5c9cf7177845678741e Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 02:32:54 +0000 Subject: [PATCH 75/90] Integration: Strip Silence naming: refuse a name and suffix too long for a clip name A name and suffix longer than MAX_NAME_CHARS - 29 leave no room for the number, and a session truncates such names on open. --- crates/engine/src/cmd/edit.rs | 8 +++++++- crates/engine/src/io.rs | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9bc09f6b..769e000c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -837,11 +837,17 @@ fn strip_names(p: &Value) -> Result> { return Ok(None); }; let d = StripNames::default(); + let suffix = suffix.unwrap_or(d.suffix); + // 29 characters stay free for the number: 9 zeros and the 20 digits of a u64. + let max_text = soundcraft_model::MAX_NAME_CHARS - 29; + if name.chars().count() + suffix.chars().count() > max_text { + return Err(bad(cmd, format!("`name` and `suffix` together must be at most {max_text} characters"))); + } Ok(Some(StripNames { name, number: number.unwrap_or(d.number), zeros: zeros.and_then(|z| usize::try_from(z).ok()).unwrap_or(d.zeros), - suffix: suffix.unwrap_or(d.suffix), + suffix, })) } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 8600ae59..4179e401 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -2465,6 +2465,7 @@ mod strip_tests { json!({"name": "SFX", "number": StripNames::MAX_NUMBER + 1}), json!({"name": "SFX", "zeros": StripNames::MAX_ZEROS + 1}), json!({"name": "SFX", "suffix": null}), + json!({"name": "S".repeat(300)}), ] { let (mut e, t) = tone_gap_tone(); let mut p = json!({"track": t.0, "start": 0, "end": 144_000}); From e954639900a04bad8f9a0d45a96a46927f62683b Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 02:33:30 +0000 Subject: [PATCH 76/90] Integration: docs: built-in plugin counts are 27 processors + 3 instruments The registry holds Auto Pan and Subharmonic as well; the 3bc2d92 snapshot keeps its own count. --- docs/gaps.md | 4 ++-- docs/roadmap.md | 4 ++-- docs/target-app-parity.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 67353d3c..4bc186ec 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -137,7 +137,7 @@ launch #142; Linux #45, #107. ### G-08. Built-in plugins -See [`plugin-parity.md`](plugin-parity.md): 25 processors + 3 instruments against Pro Tools' +See [`plugin-parity.md`](plugin-parity.md): 27 processors + 3 instruments against Pro Tools' roughly 80 bundled processors and instruments; no fidelity checks; generic parameter UIs; EQ parametric request #139. ≈ 80–120 h. @@ -221,7 +221,7 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---| -| 2026-10-24 | trivial | G-08: 25 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing | +| 2026-10-24 | trivial | G-08: 27 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing | | 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained | | 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged | | 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index dc639f92..035b2f7f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -26,7 +26,7 @@ main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are th | Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Default output and input devices only. Fixed 512-sample block. Separate input and output streams. `setup.playback_engine` params ignored by playback. Takes held in RAM until stop (PR #84). No input device choice (#42). | 35–55 | | Record MIDI from a controller and play it back through an instrument | **no** | Playback of drawn and imported MIDI works. No MIDI device layer at all, so nothing can be recorded and instruments can't be played live (G-02). | 20–35 | | Edit: select, trim, fade, comp playlists, Slip/Shuffle/Grid | yes (rough) | Every Edit menu command; property-tested with undo; open bugs #3, #5, #140 don't block the workflow | — | -| Mix with built-in and third-party plugins, sends, busses, automation | partial (passes on CLAP/AU) | 25 built-in processors; CLAP and AU host on the signed macOS build, but its missing entitlement blocks third-party VST3 (#153). No AU plugin windows yet; VST3 windows on macOS only | 1–2 (entitlement) | +| Mix with built-in and third-party plugins, sends, busses, automation | partial (passes on CLAP/AU) | 27 built-in processors; CLAP and AU host on the signed macOS build, but its missing entitlement blocks third-party VST3 (#153). No AU plugin windows yet; VST3 windows on macOS only | 1–2 (entitlement) | | Bounce mix and stems; save and reopen the session | yes (rough) | WAV/BWF/AIFF/FLAC bounce, stems, `.scraft` save/reopen with plugin state; bugs #59, #103 in bus bounce and aux latency (PRs open) | — | | Exchange a session with Pro Tools or a picture editor | **no** | No `.ptx`, AAF or OMF import or export (#40, G-03) | 50–90 | @@ -89,7 +89,7 @@ There is no `cargo xtask scorecard` in this repo; the measured number is `cargo | Date | Change | Summary | |---|---|---| -| 2026-10-24 | trivial | 25 built-in processors, as the plugin registry holds; the sampler (#108) is no longer listed under plugin essentials | +| 2026-10-24 | trivial | 27 built-in processors, as the plugin registry holds; the sampler (#108) is no longer listed under plugin essentials | | 2026-10-14 | trivial | M8: reject oversized MCP batches before execution (#460) | | 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) | | 2026-10-10 | major | Created from ROADMAP.md's milestones and alpha list; added M12–M17 and the beta plan with estimates | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e95c0915..db69e3b3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -181,7 +181,7 @@ Weights by what Pro Tools users actually spend time on. **Breadth** = the featur | Editing | 15 % | 92 % | **65 %** | 40–60 | Slip/Shuffle/Spot/Grid, every Edit menu command, fades/crossfades, playlists and comping, clip groups, nudge, Tab to transient, separate/heal/consolidate/strip silence. Open bugs: #3, #5 (Multitool handles), #140 (Heal shortcut), #86, #91, #92; no batch fade presets, no field-recorder workflows | | Mixing and routing | 12 % | 85 % | **60 %** | 35–50 | Faders, pan, sends, busses, aux, masters, VCAs, folders, 10 inserts, PDC, solo modes, I/O Setup. Missing: sidechain inputs, hardware-channel I/O mapping, bus/AFL/PFL paths verified, HEAT; bugs #59, #103 | | Automation | 7 % | 80 % | **55 %** | 20–30 | Lanes for volume/pan/mute/sends/plugin params, trim, Write/Touch/Latch, glide/thin/coalesce. Missing: surround-pan and object automation, Touch/Latch prime and preview modes, automation on control surfaces; bug #104 | -| Built-in plugins and AudioSuite | 8 % | 40 % | **35 %** | 80–120 | 25 processors + 3 instruments covering every AudioSuite category; Pro Tools bundles roughly three times as many plugins plus virtual instruments. No fidelity comparison, generic parameter UIs (#139). See [plugin-parity.md](plugin-parity.md) | +| Built-in plugins and AudioSuite | 8 % | 40 % | **35 %** | 80–120 | 27 processors + 3 instruments covering every AudioSuite category; Pro Tools bundles roughly three times as many plugins plus virtual instruments. No fidelity comparison, generic parameter UIs (#139). See [plugin-parity.md](plugin-parity.md) | | Third-party plugin hosting | 8 % | 70 % | **45 %** | 40–60 | CLAP, VST3, AU: audio, params, latency, notes, state, presets, off-thread lifecycle, CLAP floating GUIs, VST3 editors on macOS. Missing: AU editors, VST3 editors on Windows/Linux, sidechain buses, ARA, out-of-process sandboxing, macOS library-validation entitlement (#153, #149). AAX cannot be hosted (Avid SDK licence, PACE signing) | | MIDI and instruments | 8 % | 70 % | **30 %** | 50–80 | Piano roll, event list, step input, quantize/transpose, real-time properties, SMF import/export. Missing: **any MIDI device input/output** (no keyboard recording, no external synths, no MIDI clock/MTC out), MIDI learn, instrument depth (no piano, organ or wavetable instrument), bug #98 | | Score and notation | 2 % | 60 % | **35 %** | 20–30 | Grand-staff Score Editor, MusicXML export, printable SVG. Missing: notation editing depth (tuplets, articulations, lyrics, parts layout) | @@ -245,7 +245,7 @@ serializes more, because it needs hands-on testing. | Date | Change | Summary | |---|---|---| -| 2026-10-24 | trivial | Built-in plugin counts: 25 processors + 3 instruments on `main`, and 24 + 2 in the 3bc2d92 code snapshot; the sampler (#108) is no longer listed as missing. Percentages unchanged | +| 2026-10-24 | trivial | Built-in plugin counts: 27 processors + 3 instruments on `main`, and 24 + 2 in the 3bc2d92 code snapshot; the sampler (#108) is no longer listed as missing. Percentages unchanged | | 2026-10-10 | minor | Readiness-by-audience table: hours to ~95 % for full (950–1,450 h), mainstream (380–600 h) and essentials (110–200 h). The full number is already the additive weighted sum over the written dimension table (40.6 %); method unchanged | | 2026-10-10 | minor | Added the mainstream-practitioner (~36 %) and essentials-user (~57 %) numbers with written weights, discounts and user evidence. Checked the full ~40 %: it is the written dimension weights × values (40.6 %), so it doesn't move | | 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; alpha distance ~110–180 h | From 9048e04d605a9d4613ef686b56c92e29a16ee256 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 02:46:30 +0000 Subject: [PATCH 77/90] Integration: AudioSuite speed ramp: the clip suffix is speeramp AudioSuite clip suffixes take four letters from each word of the process id, so speed_ramp gives -speeramp. --- crates/engine/src/cmd/audiosuite.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index ff6d74a1..f77681c6 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -276,7 +276,7 @@ mod tests { // The defaults: Slow Down, Fit To, Fades On. Halfway through, the read position is 11/16 of the // way through the last two thirds, and the gain is the speed there, 3/4. let (start, length, name, audio) = run(json!({})); - assert_eq!((start, length, name.as_str()), (480, 4800, "ramp-speedramp")); + assert_eq!((start, length, name.as_str()), (480, 4800, "ramp-speeramp")); let want = (1.0 / 3.0 + 2.0 / 3.0 * 11.0 / 16.0) * 0.75; assert!((audio[2400] - want).abs() < 2e-3, "slow down halfway: got {}, want {want}", audio[2400]); assert!(audio[4790].abs() < 0.01, "the fade-out ends near silence, got {}", audio[4790]); From d7b1562cc1077b6147d5dfb54354db79004abb05 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 02:54:13 +0000 Subject: [PATCH 78/90] Integration: Strip Silence naming: rustfmt --- crates/engine/src/cmd/edit.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9917f27b..a3aacb33 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -888,12 +888,7 @@ pub(super) fn strip_names(cmd: &str, p: &Value) -> Result max_text { return Err(bad(cmd, format!("`name` and `suffix` together must be at most {max_text} characters"))); } - Ok(Some(StripNames { - name, - number: number.unwrap_or(d.number), - zeros: zeros.and_then(|z| usize::try_from(z).ok()).unwrap_or(d.zeros), - suffix, - })) + Ok(Some(StripNames { name, number: number.unwrap_or(d.number), zeros: zeros.and_then(|z| usize::try_from(z).ok()).unwrap_or(d.zeros), suffix })) } fn fades_create(e: &mut Engine, p: &Value) -> Result { From 35905fb90b2feac4b26eb90882c22e6bb1810c76 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 03:53:11 +0000 Subject: [PATCH 79/90] Integration: Auto Rename: a loaded scheme leaves room for its number --- crates/engine/src/cmd/edit.rs | 3 +-- crates/model/src/session.rs | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a3aacb33..a874b81e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -883,8 +883,7 @@ pub(super) fn strip_names(cmd: &str, p: &Value) -> Result max_text { return Err(bad(cmd, format!("`name` and `suffix` together must be at most {max_text} characters"))); } diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index d14971ab..5283ff83 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -317,10 +317,14 @@ impl AutoName { /// The most zeros before the number. pub const MAX_ZEROS: usize = 9; + /// Room for `name` and `suffix` together: 29 characters stay free for 9 zeros and the 20 + /// digits of a u64, so a clip name never loses its number to [`MAX_NAME_CHARS`]. + pub const MAX_TEXT_CHARS: usize = MAX_NAME_CHARS - 29; + /// Repair a scheme read from a file: names that can be drawn and at most [`Self::MAX_ZEROS`] zeros. pub fn sanitize(&mut self) { - truncate_chars(&mut self.name, MAX_NAME_CHARS); - truncate_chars(&mut self.suffix, MAX_NAME_CHARS); + truncate_chars(&mut self.name, Self::MAX_TEXT_CHARS); + truncate_chars(&mut self.suffix, Self::MAX_TEXT_CHARS - self.name.chars().count()); self.zeros = self.zeros.min(Self::MAX_ZEROS); } } @@ -900,4 +904,11 @@ mod tests { assert_eq!(tr.mixer.volume_db, 0.0); assert!(s.next_id > t.0); } + + #[test] + fn a_loaded_auto_name_scheme_leaves_room_for_its_number() { + let mut scheme = crate::AutoName { name: "n".repeat(300), suffix: "s".repeat(300), ..Default::default() }; + scheme.sanitize(); + assert_eq!(scheme.name.chars().count() + scheme.suffix.chars().count(), crate::AutoName::MAX_TEXT_CHARS); + } } From 353ea145aa44edacbfd441abf3d9e1a6cb6be604 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:36:02 +0000 Subject: [PATCH 80/90] Integration: AudioSuite speed ramp: Fit To plays the first two thirds for Slow Down and the last for Speed Up --- crates/dsp/src/offline.rs | 5 +++-- crates/dsp/tests/it/speed_ramp.rs | 11 ++++++----- crates/engine/src/cmd/audiosuite.rs | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index a5be2cd6..89e1e588 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -465,7 +465,8 @@ pub enum SpeedRampChange { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SpeedRampSelection { /// The result is as long as the input. Two thirds of the input are played: the first two - /// thirds for Speed Up, the last two thirds for Slow Down. + /// thirds for Slow Down, the last two thirds for Speed Up, so each joins its neighbour + /// without a jump. FitTo, /// All of the input is played, and the result is 150 % of its length. Extend, @@ -482,7 +483,7 @@ pub fn speed_ramp(ch: &[Vec], change: SpeedRampChange, selection: SpeedRamp SpeedRampSelection::Extend => ((len * 1.5).round(), 0.0, len), SpeedRampSelection::FitTo => { let played = len * 2.0 / 3.0; - (len, if change == SpeedRampChange::SlowDown { len - played } else { 0.0 }, played) + (len, if change == SpeedRampChange::SlowDown { 0.0 } else { len - played }, played) } }; let out_len = (total as usize).min(MAX_OFFLINE_SAMPLES); diff --git a/crates/dsp/tests/it/speed_ramp.rs b/crates/dsp/tests/it/speed_ramp.rs index 0d035418..a62661cd 100644 --- a/crates/dsp/tests/it/speed_ramp.rs +++ b/crates/dsp/tests/it/speed_ramp.rs @@ -24,21 +24,22 @@ fn speed_ramp_fit_to_keeps_the_length_and_plays_two_thirds() { let x = vec![ramp(len)]; let slow = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, false); assert_eq!(slow[0].len(), len); - // Slow Down plays the last two thirds: it starts at normal speed one third in and stops at the end. + // Slow Down plays the first two thirds: it starts at normal speed at the start and stops at the end. for u in [0.0, 0.25, 0.5, 0.75, 0.9] { let n = (u * len as f64) as usize; - near(slow[0][n], 1.0 / 3.0 + 2.0 / 3.0 * slow_down_pos(u), &format!("slow down at {u}")); + near(slow[0][n], 2.0 / 3.0 * slow_down_pos(u), &format!("slow down at {u}")); } - let step = |a: &[f32], n: usize| f64::from(a[n + 1] - a[n]) * len as f64; + // The speed over 50 samples: a one-sample step inside the input also shows the interpolation's ripple. + let step = |a: &[f32], n: usize| f64::from(a[n + 50] - a[n]) * len as f64 / 50.0; assert!((step(&slow[0], 100) - 1.0).abs() < 0.01, "slow down starts at normal speed: {}", step(&slow[0], 100)); assert!(step(&slow[0], len - 200) < 0.02, "slow down ends near a stop: {}", step(&slow[0], len - 200)); - // Speed Up plays the first two thirds: it starts from a stop and reaches normal speed at the end. + // Speed Up plays the last two thirds: it starts from a stop and reaches normal speed at the end. let fast = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, false); assert_eq!(fast[0].len(), len); for u in [0.1, 0.25, 0.5, 0.75, 0.99] { let n = (u * len as f64) as usize; - near(fast[0][n], 2.0 / 3.0 * speed_up_pos(u), &format!("speed up at {u}")); + near(fast[0][n], 1.0 / 3.0 + 2.0 / 3.0 * speed_up_pos(u), &format!("speed up at {u}")); } assert!(step(&fast[0], 200) < 0.02, "speed up starts near a stop: {}", step(&fast[0], 200)); assert!((step(&fast[0], len - 100) - 1.0).abs() < 0.01, "speed up ends at normal speed: {}", step(&fast[0], len - 100)); diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index f77681c6..52f70964 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -274,10 +274,10 @@ mod tests { (c.start, c.length, c.name, audio) }; // The defaults: Slow Down, Fit To, Fades On. Halfway through, the read position is 11/16 of the - // way through the last two thirds, and the gain is the speed there, 3/4. + // way through the first two thirds, and the gain is the speed there, 3/4. let (start, length, name, audio) = run(json!({})); assert_eq!((start, length, name.as_str()), (480, 4800, "ramp-speeramp")); - let want = (1.0 / 3.0 + 2.0 / 3.0 * 11.0 / 16.0) * 0.75; + let want = (2.0 / 3.0 * 11.0 / 16.0) * 0.75; assert!((audio[2400] - want).abs() < 2e-3, "slow down halfway: got {}, want {want}", audio[2400]); assert!(audio[4790].abs() < 0.01, "the fade-out ends near silence, got {}", audio[4790]); // Speed Up with Extend and no fades: all of the clip is played over 150 % of its length, and the From 7d4ae232020f7d59906f108963c5693c577b04eb Mon Sep 17 00:00:00 2001 From: Forward Audio <134808501+forwardaudio@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:26:03 +0100 Subject: [PATCH 81/90] Meters: a channel-count change takes the new stereo peaks; takes drop deleted tracks When a strip's channel count changes, the first-two-channel peaks now take the new values along with the per-channel ones. A take that finds no new block still drops the meters of deleted tracks from the UI's copy. Co-Authored-By: Claude Opus 5.5 --- crates/mix/src/lib.rs | 7 ++++--- crates/playback/src/lib.rs | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index da877df3..8276220e 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -195,13 +195,14 @@ impl StripMeter { if self.peaks.len() != o.peaks.len() { // The strip's format changed (a document edit): earlier channels no longer line up. self.peaks.clone_from(&o.peaks); + self.peak = o.peak; } else { for (p, q) in self.peaks.iter_mut().zip(&o.peaks) { *p = max_level(*p, *q); } - } - for (p, q) in self.peak.iter_mut().zip(o.peak) { - *p = max_level(*p, q); + for (p, q) in self.peak.iter_mut().zip(o.peak) { + *p = max_level(*p, q); + } } self.gain_reduction_db = max_level(self.gain_reduction_db, o.gain_reduction_db); self.plugin_failures = o.plugin_failures; diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 0d6cad37..bb7404ce 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -923,6 +923,8 @@ impl Player { fn take_meter_snapshot(source: &mut MeterSnapshot, out: &mut MeterSnapshot) { if !source.fresh { + // No block since the last take: keep the readings, but not those of deleted tracks. + out.tracks.retain(|id, _| source.tracks.contains_key(id)); return; } copy_meter_snapshot(source, out); @@ -1338,6 +1340,25 @@ mod tests { assert!(!st.unsent.tracks.contains_key(&t) && !st.mix.meters.contains_key(&t)); } + #[test] + fn a_take_without_new_blocks_still_drops_deleted_tracks() { + let gone = TrackId(7); + let mut source = MeterSnapshot::default(); + source.accumulate(&StripMeter::default(), &HashMap::from([(gone, StripMeter::default())])); + let mut ui = MeterSnapshot::default(); + take_meter_snapshot(&mut source, &mut ui); + source.forget(&[gone]); + take_meter_snapshot(&mut source, &mut ui); + assert!(ui.tracks.is_empty()); + } + + #[test] + fn a_channel_count_change_takes_the_new_stereo_peaks() { + let mut acc = StripMeter { peak: [0.9, 0.9], peaks: vec![0.9], ..StripMeter::default() }; + acc.accumulate(&StripMeter { peak: [0.9, 0.0], peaks: vec![0.9, 0.0], ..StripMeter::default() }); + assert_eq!(acc.peak, [0.9, 0.0]); + } + #[test] fn gain_reduction_keeps_its_maximum_until_taken() { let mut acc = StripMeter::default(); From 106678471f2c9317f59cc33423151cd84a41c0f5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 25 Oct 2026 09:26:25 -0400 Subject: [PATCH 82/90] Release 0.13.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 103770e8..7a798066 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3609,7 +3609,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.12.1" +version = "0.13.0" dependencies = [ "chrono", "eframe", @@ -3628,7 +3628,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.12.1" +version = "0.13.0" dependencies = [ "log", "serde", @@ -3638,7 +3638,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.12.1" +version = "0.13.0" dependencies = [ "log", "proptest", @@ -3650,7 +3650,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.12.1" +version = "0.13.0" dependencies = [ "log", "serde_json", @@ -3660,7 +3660,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.12.1" +version = "0.13.0" dependencies = [ "clap-sys", "libloading 0.9.0", @@ -3673,7 +3673,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.12.1" +version = "0.13.0" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3684,7 +3684,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.12.1" +version = "0.13.0" dependencies = [ "log", "proptest", @@ -3695,7 +3695,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.12.1" +version = "0.13.0" dependencies = [ "craft-docpath", "log", @@ -3718,7 +3718,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.12.1" +version = "0.13.0" dependencies = [ "proptest", "serde", @@ -3728,7 +3728,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.12.1" +version = "0.13.0" dependencies = [ "log", "rayon", @@ -3744,7 +3744,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.12.1" +version = "0.13.0" dependencies = [ "proptest", "serde", @@ -3757,7 +3757,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.12.1" +version = "0.13.0" dependencies = [ "cpal", "log", @@ -3772,7 +3772,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.12.1" +version = "0.13.0" dependencies = [ "proptest", "serde", @@ -3782,7 +3782,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.12.1" +version = "0.13.0" dependencies = [ "craft-ui", "egui", @@ -3809,7 +3809,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.12.1" +version = "0.13.0" dependencies = [ "craft-media", "image", @@ -3821,7 +3821,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.12.1" +version = "0.13.0" dependencies = [ "libloading 0.9.0", "log", @@ -3837,7 +3837,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.12.1" +version = "0.13.0" dependencies = [ "eframe", "egui", @@ -5301,7 +5301,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.12.1" +version = "0.13.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 1c1c0988..e05bed9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.12.1" +version = "0.13.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From b48df2a8b9c7ca83625151854e85137172e5cc3b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 25 Oct 2026 09:29:08 -0400 Subject: [PATCH 83/90] Release workflow: grant contents: write on the release job #988 dropped the workflow-level permission for SonarCloud S8233 but never added the job-level one, so the 0.13.0 release run failed at startup: the shared workflow's draft-release job asks for contents: write. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf2e1796..52b76762 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,9 @@ concurrency: jobs: release: + # The shared workflow creates the draft release, which needs write access to contents. + permissions: + contents: write # 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/release.yml@main with: From 01b39850ad16e84b45904ba07bd5cec7cf96c4b7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 25 Oct 2026 09:29:18 -0400 Subject: [PATCH 84/90] Release 0.13.1 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a798066..1b343169 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3609,7 +3609,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.13.0" +version = "0.13.1" dependencies = [ "chrono", "eframe", @@ -3628,7 +3628,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.13.0" +version = "0.13.1" dependencies = [ "log", "serde", @@ -3638,7 +3638,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.13.0" +version = "0.13.1" dependencies = [ "log", "proptest", @@ -3650,7 +3650,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.13.0" +version = "0.13.1" dependencies = [ "log", "serde_json", @@ -3660,7 +3660,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.13.0" +version = "0.13.1" dependencies = [ "clap-sys", "libloading 0.9.0", @@ -3673,7 +3673,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.13.0" +version = "0.13.1" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3684,7 +3684,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.13.0" +version = "0.13.1" dependencies = [ "log", "proptest", @@ -3695,7 +3695,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.13.0" +version = "0.13.1" dependencies = [ "craft-docpath", "log", @@ -3718,7 +3718,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.13.0" +version = "0.13.1" dependencies = [ "proptest", "serde", @@ -3728,7 +3728,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.13.0" +version = "0.13.1" dependencies = [ "log", "rayon", @@ -3744,7 +3744,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.13.0" +version = "0.13.1" dependencies = [ "proptest", "serde", @@ -3757,7 +3757,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.13.0" +version = "0.13.1" dependencies = [ "cpal", "log", @@ -3772,7 +3772,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.13.0" +version = "0.13.1" dependencies = [ "proptest", "serde", @@ -3782,7 +3782,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.13.0" +version = "0.13.1" dependencies = [ "craft-ui", "egui", @@ -3809,7 +3809,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.13.0" +version = "0.13.1" dependencies = [ "craft-media", "image", @@ -3821,7 +3821,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.13.0" +version = "0.13.1" dependencies = [ "libloading 0.9.0", "log", @@ -3837,7 +3837,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.13.0" +version = "0.13.1" dependencies = [ "eframe", "egui", @@ -5301,7 +5301,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.13.0" +version = "0.13.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index e05bed9d..87855faa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.13.0" +version = "0.13.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From e0951aecf92d6260e8262d74dae797e6279fda8b Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 08:48:56 -0600 Subject: [PATCH 85/90] Migrate SoundCraft to egui 0.37 (#1063) * fix(ui): preserve host keys during scripted input and clarify mixer labels * Preserve host input while expiring scripted gestures * Document migrated audio and UI behavior and regression invariants --- ATTRIBUTION.md | 12 +- Cargo.lock | 290 ++- Cargo.toml | 33 +- NOTICE | 277 ++- apps/soundcraft-web/Cargo.toml | 4 +- apps/soundcraft-web/src/web.rs | 11 +- apps/soundcraft/Cargo.toml | 4 +- apps/soundcraft/src/control_server.rs | 40 +- apps/soundcraft/src/main.rs | 12 +- crates/audio-io/src/caf.rs | 2 +- crates/audio-io/src/lib.rs | 7 +- crates/audio-io/src/symph.rs | 564 ++++-- crates/audio-io/tests/it/malformed.rs | 195 ++ crates/playback/src/devices.rs | 105 +- crates/playback/src/lib.rs | 153 +- crates/playback/src/record.rs | 82 +- crates/ui-egui/Cargo.toml | 5 +- crates/ui-egui/examples/ui_shot.rs | 3 +- crates/ui-egui/src/bounce.rs | 2 +- crates/ui-egui/src/control.rs | 1614 +++++++++++++++- crates/ui-egui/src/credits.rs | 12 +- crates/ui-egui/src/dialogs.rs | 77 +- crates/ui-egui/src/docking.rs | 47 +- crates/ui-egui/src/edit_window.rs | 210 +- crates/ui-egui/src/extra_windows.rs | 42 +- crates/ui-egui/src/fonts.rs | 23 + crates/ui-egui/src/icons.rs | 5 + crates/ui-egui/src/import.rs | 2 +- crates/ui-egui/src/lib.rs | 1705 ++++++++++++++++- crates/ui-egui/src/midi_editor.rs | 7 +- crates/ui-egui/src/mix_window.rs | 197 +- crates/ui-egui/src/ops_windows.rs | 18 +- crates/ui-egui/src/panels.rs | 82 +- crates/ui-egui/src/sampler_window.rs | 41 +- crates/ui-egui/src/score_editor.rs | 11 +- crates/ui-egui/src/shortcuts.rs | 21 +- crates/ui-egui/src/spot.rs | 13 +- crates/ui-egui/src/toolbar.rs | 27 +- crates/ui-egui/src/widgets.rs | 106 +- crates/ui-egui/src/windows.rs | 50 +- crates/ui-egui/tests/it/clip_menu.rs | 21 +- crates/ui-egui/tests/it/docking_workflows.rs | 79 +- .../ui-egui/tests/it/edit_window_headers.rs | 11 +- crates/ui-egui/tests/it/menu_search.rs | 28 +- crates/ui-egui/tests/it/midi_panel.rs | 3 +- crates/ui-egui/tests/it/new_midi_clip.rs | 3 +- crates/ui-egui/tests/it/new_tracks.rs | 3 +- crates/ui-egui/tests/it/open_on_insert.rs | 14 +- crates/ui-egui/tests/it/plugin_window.rs | 3 +- crates/ui-egui/tests/it/pro_tools_keys.rs | 6 +- crates/ui-egui/tests/it/sampler_window.rs | 12 +- crates/ui-egui/tests/it/send_window.rs | 18 +- crates/ui-egui/tests/it/shared_controls.rs | 6 +- crates/ui-egui/tests/it/toolbar_scroll.rs | 2 + crates/ui-egui/tests/it/track_name_menu.rs | 3 +- crates/ui-egui/tests/it/trackpad.rs | 12 +- crates/ui-egui/tests/it/tracks_scroll_bar.rs | 4 +- .../ui-egui/tests/mix_instrument_contrast.rs | 4 +- docs/control-protocol.md | 12 +- docs/egui-037-migration-plan.md | 1424 ++++++++++++++ .../flatpak/ai.storyteller.soundcraft.yml | 1 + packaging/linux/package.sh | 1 - packaging/macos/package.sh | 2 + packaging/windows/package.ps1 | 3 + packaging/windows/soundcraft.wxs | 3 + 65 files changed, 6995 insertions(+), 794 deletions(-) create mode 100644 docs/egui-037-migration-plan.md diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 084f4240..e6010d2a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -22,9 +22,19 @@ are bundled. ## Fonts SoundCraft bundles no font files. It uses the default fonts shipped inside the `egui` crate -(Ubuntu-Light: Ubuntu Font Licence 1.0; Hack: MIT; Noto Emoji: OFL-1.1; emoji-icon-font: OFL-1.1/MIT) +(Ubuntu-Light: Ubuntu Font Licence 1.0; Hack: MIT; Noto Emoji: OFL-1.1; emoji-icon-font: MIT) and, at runtime, the operating system's own UI fonts when available (never redistributed). +## Bundled egui font icons + +These dependency fonts are embedded by the pinned egui font definitions; no font file is added +here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts. + +| Asset | Author | Source | Licence | +|---|---|---|---| +| `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in NOTICE) | +| `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in NOTICE) | + ## Files | Path | Author | Source | License | diff --git a/Cargo.lock b/Cargo.lock index 1b343169..1182ef9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,21 +4,21 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -27,43 +27,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -228,6 +223,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -672,7 +668,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -917,7 +913,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=650b1c0b058017c4eb3af06d5b39586d6732e07b#650b1c0b058017c4eb3af06d5b39586d6732e07b" dependencies = [ "egui", "log", @@ -1062,9 +1058,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1072,9 +1067,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1096,6 +1090,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1107,9 +1102,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1127,9 +1121,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1147,31 +1140,34 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1183,9 +1179,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1210,9 +1205,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] @@ -1246,9 +1240,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1264,16 +1257,17 @@ dependencies = [ "self_cell", "skrifa", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1365,6 +1359,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1382,6 +1382,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1553,12 +1559,12 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1698,6 +1704,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1706,7 +1721,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1715,7 +1730,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2011,12 +2026,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -2306,6 +2321,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -2775,6 +2799,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "parking" version = "2.2.1" @@ -2824,10 +2858,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "phf" -version = "0.13.1" +name = "petgraph" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -2836,9 +2881,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -2846,9 +2891,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -2859,9 +2904,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -3391,6 +3436,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -3804,6 +3855,7 @@ dependencies = [ "soundcraft-video", "soundcraft-vst3-host", "toml", + "web-time", "zbus", ] @@ -4299,6 +4351,17 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -4331,6 +4394,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -4343,6 +4412,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4386,9 +4461,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -4401,9 +4476,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -5157,8 +5232,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -5230,6 +5304,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" @@ -5589,3 +5681,23 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 87855faa..3f00bd1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,11 +51,16 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } # ui -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde"] } -# egui 0.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands. -egui = "0.36" -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +# 650b1c0b058017c4eb3af06d5b39586d6732e07b is the revision produced by the separate craft-libs current-main-0135 +# integration: 25da's egui-0.37 port + 6e7f's docking Workspace/DockCommand + final egui/winit +# pins. Raw 6e7f/16fb3d46 contain the older egui 0.36 UI; keep this reviewed +# immutable revision to retain the required docking APIs on egui 0.37. +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "650b1c0b058017c4eb3af06d5b39586d6732e07b", features = ["serde"] } +egui = { version = "0.37" } +eframe = { version = "0.37", default-features = false } +# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent). +web-time = "1.1" +egui_kittest = "0.37" rfd = "0.17" image = { version = "0.25", default-features = false, features = ["png"] } @@ -81,3 +86,21 @@ opt-level = 3 lto = "thin" codegen-units = 16 debug = 0 + +# Keep the complete egui family and native event bridge on reviewed fork commits. +[patch.crates-io] +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 34c26cc3..3f49846c 100644 --- a/NOTICE +++ b/NOTICE @@ -12,6 +12,277 @@ Third-party material This product bundles third-party material under its own licenses, listed below. The per-file list (author, source, license) is in ATTRIBUTION.md. -None: every bundled asset is original to SoundCraft. Icons are drawn in code; demo audio is -synthesised in code. The egui default fonts (Ubuntu-Light, Hack, Noto Emoji, Emoji Icon Font) ship -inside the egui crate under their own open licenses (see ATTRIBUTION.md). +Original SoundCraft material: icons are drawn in code; demo audio is synthesised in code. +The egui default fonts (Ubuntu-Light, Hack, Noto Emoji, Emoji Icon Font) ship inside the egui crate +under their own open licenses (see ATTRIBUTION.md). + +Default egui fonts (dependency assets) + +The pinned egui 0.37 dependency embeds these faces through `default_fonts` and +`monochrome_emoji_fonts`: +https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +No font file is copied into this repository. The full upstream notices follow. + +egui icon font subset (dependency asset) +---------------------------------------- +epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of +emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback +uses the same MIT license. Upstream license text follows unchanged. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Noto Emoji font (dependency asset) +---------------------------------- +epaint_default_fonts 0.37, pinned with egui 0.37 at +https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts, +embeds NotoEmoji-Regular.ttf, the monochrome emoji fallback used when egui's +`monochrome_emoji_fonts` feature is enabled. The font's copyright notice and the +upstream SIL Open Font License 1.1 text follow unchanged. + +Copyright 2013 Google Inc. All Rights Reserved. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Ubuntu Light font (dependency asset) +------------------------------------ +epaint_default_fonts 0.37, pinned with egui 0.37 at +https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts, +embeds Ubuntu-Light.ttf as a default fallback. The font's copyright notice and the +upstream Ubuntu Font Licence 1.0 text follow unchanged. + +Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0 + +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. + +Hack font (dependency asset) +---------------------------- +epaint_default_fonts 0.37, pinned with egui 0.37 at +https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts, +embeds Hack-Regular.ttf as the default monospace fallback. The upstream notice and +licences follow unchanged. + +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" +MIT License + +Copyright (c) 2018 Source Foundry Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. diff --git a/apps/soundcraft-web/Cargo.toml b/apps/soundcraft-web/Cargo.toml index 50ff3aab..501c026a 100644 --- a/apps/soundcraft-web/Cargo.toml +++ b/apps/soundcraft-web/Cargo.toml @@ -15,10 +15,10 @@ path = "src/main.rs" [target.'cfg(target_arch = "wasm32")'.dependencies] soundcraft-engine = { workspace = true } -soundcraft-ui-egui = { workspace = true } +soundcraft-ui-egui.workspace = true soundcraft-playback = { workspace = true } soundcraft-model = { workspace = true } -egui = { workspace = true } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } log = { workspace = true } serde_json = { workspace = true } rfd = { workspace = true } diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs index 0d9b6485..0f3bf2e0 100644 --- a/apps/soundcraft-web/src/web.rs +++ b/apps/soundcraft-web/src/web.rs @@ -137,11 +137,12 @@ impl WebShell { }); } + /// Read files dropped on the browser window asynchronously: audio for an instrument track or a Sampler goes to + /// that sampler, while other drops are classified by extension and handled on a later frame. fn take_drops(&mut self, ctx: &egui::Context) { // In the browser a dropped file's `path()` is only its name: read its bytes instead. // Audio dropped on an instrument track or a Sampler window goes to its sampler instead. - let dropped = self.app.take_dropped_files(ctx); - ctx.input_mut(|i| i.raw.dropped_files.clear()); + let dropped = self.app.take_dropped_files(); for read in self.app.take_sample_reads() { let (samples, ctx) = (self.samples.clone(), ctx.clone()); wasm_bindgen_futures::spawn_local(async move { @@ -208,8 +209,10 @@ impl eframe::App for WebShell { self.take_path_prompt(ctx); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.app.raw_input_hook(raw); + /// Forward raw input to the shared app hook so scripted gestures and host-key protection behave like the desktop + /// shell. + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + self.app.raw_input_hook(ctx, raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { diff --git a/apps/soundcraft/Cargo.toml b/apps/soundcraft/Cargo.toml index 5dd042b9..5e5b2d08 100644 --- a/apps/soundcraft/Cargo.toml +++ b/apps/soundcraft/Cargo.toml @@ -18,8 +18,8 @@ asio = ["soundcraft-playback/asio"] soundcraft-engine.workspace = true soundcraft-ui-egui.workspace = true soundcraft-playback.workspace = true -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] } -egui.workspace = true +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "clipboard"] } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } serde_json.workspace = true log.workspace = true chrono.workspace = true diff --git a/apps/soundcraft/src/control_server.rs b/apps/soundcraft/src/control_server.rs index abff7bbd..469545a1 100644 --- a/apps/soundcraft/src/control_server.rs +++ b/apps/soundcraft/src/control_server.rs @@ -10,7 +10,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel}; use std::time::Duration; /// Longest accepted request line (bytes, without the newline). The largest payload a request @@ -22,9 +22,15 @@ const MAX_LINE: usize = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_cei const _: () = assert!(MAX_LINE <= 96 << 20); /// Connections served at once; further ones get an error line and are closed. const MAX_CONNECTIONS: usize = 16; +/// Requests that may wait for the UI thread. Each connection handles one request at a time, so +/// this matches [`MAX_CONNECTIONS`] and makes the transport's bound explicit instead of relying +/// on an unbounded channel. +const MAX_QUEUED_REQUESTS: usize = MAX_CONNECTIONS; +/// Bind the loopback control listener and return the request receiver. Port 0 picks a free one and the actual port is +/// printed; bind or spawn failures are logged instead of panicking. pub fn start(port: u16, ctx: egui::Context) -> Receiver { - let (tx, rx) = channel::(); + let (tx, rx) = sync_channel::(MAX_QUEUED_REQUESTS); let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { @@ -52,7 +58,9 @@ impl Drop for Slot { } } -fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Context) { +/// Accept until the listener closes, admitting at most [`MAX_CONNECTIONS`] peers; excess peers get one error line. +/// Each admitted connection is served on its own thread, releasing its slot on exit. +fn accept_loop(listener: TcpListener, tx: SyncSender, ctx: egui::Context) { let open = Arc::new(AtomicUsize::new(0)); for mut stream in listener.incoming().flatten() { if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { @@ -94,7 +102,9 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) - Ok(true) } -fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { +/// Answer newline-delimited JSON requests for one connection: bound each line, forward it to the UI thread with a 90 +/// s reply deadline, and stop at EOF, invalid UTF-8 or a closed request queue. Bad JSON still gets an error reply. +fn serve(stream: TcpStream, tx: SyncSender, ctx: egui::Context) { let Ok(read) = stream.try_clone() else { return }; let mut out = stream; let mut reader = BufReader::new(read); @@ -119,15 +129,19 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); let params = msg.get("params").cloned().unwrap_or(json!({})); let (req, rrx) = ControlRequest::new(method, params); - if tx.send(req).is_err() { - break; + match tx.try_send(req) { + Ok(()) => { + ctx.request_repaint(); + let mut r = rrx.recv_timeout(Duration::from_secs(90)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); + if let Some(o) = r.as_object_mut() { + o.insert("id".into(), id); + } + r + } + // Refuse rather than grow or block: the UI drains a bounded batch each tick. + Err(TrySendError::Full(_)) => json!({"ok": false, "id": id, "error": "control queue full"}), + Err(TrySendError::Disconnected(_)) => break, } - ctx.request_repaint(); - let mut r = rrx.recv_timeout(Duration::from_secs(90)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); - if let Some(o) = r.as_object_mut() { - o.insert("id".into(), id); - } - r } Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}), }; @@ -175,7 +189,7 @@ mod tests { fn serve_echo() -> std::net::SocketAddr { let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let addr = listener.local_addr().unwrap(); - let (tx, rx) = channel::(); + let (tx, rx) = sync_channel::(MAX_QUEUED_REQUESTS); std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default())); std::thread::spawn(move || { for req in rx { diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 520c568c..ef929e54 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -27,7 +27,7 @@ struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "mac /// Sends the title only when it changes, remembered here as X11 and Wayland can't read it back. fn set_title(ctx: &egui::Context, title: String) { - let id = egui::Id::new("window_title"); + let id = egui::Id::unique("window_title"); if ctx.data(|d| d.get_temp::(id)).as_deref() != Some(title.as_str()) { ctx.data_mut(|d| d.insert_temp(id, title.clone())); ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); @@ -35,6 +35,8 @@ fn set_title(ctx: &egui::Context, title: String) { } impl eframe::App for App { + /// Per-frame app logic: report audio-thread logs, run the shared app logic with the macOS menu driven around it, + /// then handle dropped paths, quit/close requests, window geometry, recent-list persistence and the window title. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // What the realtime audio thread logged is written here, off that thread. if let Some(logger) = self.1 { @@ -52,7 +54,7 @@ impl eframe::App for App { // Files dropped on the window: audio on an instrument track or a Sampler window loads // the sampler; otherwise sessions open and audio/MIDI import. let dropped: Vec = - self.0.take_dropped_files(ctx).iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect(); + self.0.take_dropped_files().iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect(); for p in dropped { open_path(&mut self.0, &p); } @@ -86,8 +88,10 @@ impl eframe::App for App { }; set_title(ctx, title); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + /// Forward raw input to the shared app hook so scripted gestures and host-key protection behave like the desktop + /// shell. + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + self.0.raw_input_hook(ctx, raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.0.guarded(|app| app.ui(ui)); diff --git a/crates/audio-io/src/caf.rs b/crates/audio-io/src/caf.rs index fe4dda1e..94ae7f04 100644 --- a/crates/audio-io/src/caf.rs +++ b/crates/audio-io/src/caf.rs @@ -14,7 +14,7 @@ fn malformed(what: &str) -> AudioError { AudioError::Malformed(format!("CAF {what} is larger than the file")) } -/// Walk the chunks the way symphonia 0.5 reads them: it advances by the bytes it parses, not by +/// Walk the chunks the way the CAF reader reads them: it advances by the bytes it parses, not by /// the declared size, and after a `data` chunk of unknown size it reads on into the audio. fn chunks(b: &[u8], mut pos: usize) -> Result<()> { // Whether the packet table lists each packet's size and frame count (variable per packet). diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 3fc7372c..60f34d82 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -137,14 +137,17 @@ impl Default for EncodeOptions { } } -/// Size of an ID3v2 tag at the start of `b`, if there is one. +/// Size of an ID3v2 tag at the start of `b`, if there is one. The 10-byte footer flag (`0x10`) +/// exists only in ID3v2.4; in v2.2/v2.3 that bit is reserved and must not extend the skip. fn id3v2_len(b: &[u8]) -> Option { if b.get(..3)? != b"ID3" { return None; } + let version = *b.get(3)?; let flags = *b.get(5)?; let size = b.get(6..10)?.iter().fold(0usize, |acc, &x| (acc << 7) | usize::from(x & 0x7F)); - Some(10 + size + if flags & 0x10 != 0 { 10 } else { 0 }) + let footer = version == 4 && flags & 0x10 != 0; + Some(10 + size + if footer { 10 } else { 0 }) } fn detect_by_magic(b: &[u8], depth: u32) -> Option { diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index d6563e1a..12ae4326 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -1,9 +1,8 @@ //! Decode path for everything that isn't native PCM WAV/AIFF, backed by `symphonia`. -use std::io::Cursor; +use std::io::{Cursor, Seek, SeekFrom}; use std::panic::{AssertUnwindSafe, catch_unwind}; -use symphonia::core::codecs::CodecParameters; use symphonia::core::codecs::audio::well_known::{ CODEC_ID_AAC, CODEC_ID_ADPCM_G722, CODEC_ID_ADPCM_G726, CODEC_ID_ADPCM_G726LE, CODEC_ID_ADPCM_IMA_QT, CODEC_ID_ADPCM_IMA_WAV, CODEC_ID_ADPCM_MS, CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP1, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_OPUS, CODEC_ID_PCM_ALAW, CODEC_ID_PCM_F32BE, CODEC_ID_PCM_F32LE, @@ -11,9 +10,9 @@ use symphonia::core::codecs::audio::well_known::{ }; use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions, CODEC_ID_NULL_AUDIO}; use symphonia::core::errors::Error as SymError; -use symphonia::core::formats::probe::Hint; -use symphonia::core::formats::{FormatOptions, FormatReader}; -use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes}; +use symphonia::core::formats::probe::{Hint, ProbeOptions, ProbeableFormat, Score, Scoreable}; +use symphonia::core::formats::{FormatOptions, FormatReader, Track}; +use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ScopedStream}; use symphonia::core::meta::MetadataOptions; use crate::pcm::{MAX_DECODE_SAMPLES, be_u32}; @@ -23,6 +22,45 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat /// takes its frame length from the file (encoders use 4096). const MAX_PACKET_FRAMES: u64 = 1 << 20; +/// How far [`first_marker`] looks for the first format marker: the same 1 MiB a default symphonia +/// probe scans. +const MAX_PREFIX_SCAN: usize = 1 << 20; + +/// How many MPEG/ADTS candidates [`first_marker`] may hand to the readers' expensive `Scoreable` +/// implementations. A crafted 1 MiB prefix of repeated sync markers otherwise asks for hundreds +/// of thousands of scores, each allowed up to symphonia's 16 KiB score depth. Once the budget is +/// spent, the scan stops at the next audio candidate: a container-like byte string later in an +/// unscored audio payload must not become authoritative. A container marker reached first is still +/// checked; an unchecked one the probe selects is refused after probing. +const MAX_AUDIO_SCORE_ATTEMPTS: usize = 256; + +/// A per-scan budget for `Scoreable::score` calls. Taking a slot consumes it even when the score +/// rejects the candidate, so a stream of false syncs cannot multiply the work. +struct ScoreBudget { + used: usize, +} + +impl ScoreBudget { + /// Start a scan with no expensive score attempts spent. + fn new() -> Self { + Self { used: 0 } + } + + /// Spend one score attempt, returning false once the per-scan budget is exhausted. + fn take(&mut self) -> bool { + if self.exhausted() { + return false; + } + self.used = self.used.saturating_add(1); + true + } + + /// Whether no attempt is left for this scan. + fn exhausted(&self) -> bool { + self.used >= MAX_AUDIO_SCORE_ATTEMPTS + } +} + /// Turn a symphonia error into the audio layer's error, keeping "unsupported" and "too large" /// distinct from a malformed file. fn map_err(e: SymError) -> AudioError { @@ -44,61 +82,84 @@ enum Opened { Empty(AudioInfo), } -/// Skip a leading ID3v2 tag before probing. -/// -/// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims, so the tag is skipped -/// with [`ReadBytes::ignore_bytes`] (which allocates nothing) instead of being handed to the -/// metadata reader. Returns the stream position the container starts at. -fn skip_leading_id3(mss: &mut MediaSourceStream<'_>, bytes: &[u8]) -> Result { +/// Decoder options for every compressed format. Gapless playback used to be a +/// `FormatOptions` flag in symphonia 0.5; in 0.6 the decoder option (on by default) trims the +/// encoder delay and padding that the demuxer reports on each packet. +fn decoder_options() -> AudioDecoderOptions { + AudioDecoderOptions::default().gapless(true) +} + +/// The first byte after any ID3v2 tags at the start of `b`. Symphonia's tag reader allocates +/// whatever a frame claims, so the tags are skipped, not read: they are not used here. +fn skip_id3v2(b: &[u8]) -> usize { + let mut at = 0usize; loop { - let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX); - let head = bytes.get(at..).unwrap_or_default(); - if head.len() < 10 || head.get(..3) != Some(b"ID3") { - return Ok(at); + match b.get(at..).and_then(crate::id3v2_len) { + Some(n) if n > 0 => at = at.saturating_add(n), + _ => return at, } - let size = head.get(6..10).map(|size| size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f))).unwrap_or(0); - mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?; } } -/// How far symphonia's probe scans for a format marker before giving up. -const MAX_PROBE_DEPTH: usize = 1024 * 1024; - -/// The container marker each detected format starts with, if its reader needs size checks first. -fn container_markers(detected: FileFormat) -> &'static [&'static [u8; 4]] { - match detected { - // RF64 and BW64 are the >4 GiB RIFF forms. - FileFormat::Wav => &[b"RIFF", b"RF64", b"BW64"], - FileFormat::Flac => &[b"fLaC"], - FileFormat::Caf => &[b"caff"], - // MP4/M4A put `ftyp` after the first atom's size field. - FileFormat::Aac | FileFormat::Alac => &[b"ftyp"], - // Unknown files may carry junk before the container; symphonia's probe scans for markers. - FileFormat::Other => &[b"caff", b"ftyp", b"fLaC", b"RIFF"], - FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => &[], - } +/// The first format marker the probe can reach from `start`, within its 1 MiB scan limit. +#[derive(Clone, Copy)] +enum FirstMarker { + Flac(usize), + Caf(usize), + Mp4(usize), + Wav(usize), + /// MPEG audio, Ogg, Matroska or AIFF: formats with no separate container size check. + Audio, } -/// The probe short names a container marker stands for. -fn expected_probe_format(marker: &[u8; 4]) -> &'static [&'static str] { - match marker { - b"caff" => &["caf"], - // The isomp4 reader reads the `ftyp` atom. - b"ftyp" => &["isomp4"], - b"fLaC" => &["flac"], - b"RIFF" | b"RF64" | b"BW64" => &["wave"], - _ => &[], - } +/// What the preflight container checks covered before the probe built a reader. +enum Precheck { + /// A zero-frame FLAC: returned without a reader, because Symphonia 0.6's reader needs a first + /// audio frame to resynchronize to. + Empty(AudioInfo), + /// The size checks ran for the container the probe reports under this short name. + Checked(&'static str), + /// No container size check was needed. + Unchecked, } /// Probe short names whose demuxer allocates from sizes in the file, so the matching container /// check has to run before the stream is decoded. const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"]; -/// Find the first container marker at or after `at`, within symphonia's probe depth. A RIFF form -/// other than WAVE is skipped, as the probe skips it and reads a WAVE behind it. -fn find_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> { - let end = bytes.len().min(at.saturating_add(MAX_PROBE_DEPTH)); +/// The container markers to check for the family `detected` names. `None` is a raw audio stream +/// (MP3, Ogg, AIFF), whose payload may contain container-like bytes: the unrestricted scan below +/// finds a container only when one starts the stream, before any audio marker. +fn container_markers(detected: FileFormat) -> Option<&'static [&'static [u8; 4]]> { + match detected { + // RF64 and BW64 are the >4 GiB RIFF forms. + FileFormat::Wav => Some(&[b"RIFF", b"RF64", b"BW64"]), + FileFormat::Flac => Some(&[b"fLaC"]), + FileFormat::Caf => Some(&[b"caff"]), + // MP4/M4A put `ftyp` after the first atom's size field. + FileFormat::Aac | FileFormat::Alac => Some(&[b"ftyp"]), + // Unknown files may carry junk before the container; symphonia's probe scans for markers. + FileFormat::Other => Some(&[b"caff", b"ftyp", b"fLaC", b"RIFF"]), + FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => None, + } +} + +/// The probe short name a container marker stands for. +fn expected_probe_format(marker: &[u8; 4]) -> &'static str { + match marker { + b"caff" => "caf", + // The isomp4 reader reads the `ftyp` atom. + b"ftyp" => "isomp4", + b"fLaC" => "flac", + // RIFF, RF64 and BW64. + _ => "wave", + } +} + +/// Find the first marker of `markers` at or after `at`, within symphonia's probe depth. A RIFF +/// form other than WAVE is skipped, as the probe skips it and reads a WAVE behind it. +fn find_container_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> { + let end = bytes.len().min(at.saturating_add(MAX_PREFIX_SCAN)); for pos in at..end.saturating_sub(3) { let window = bytes.get(pos..pos + 4)?; if let Some(marker) = markers.iter().find(|marker| window == marker.as_slice()) { @@ -111,58 +172,211 @@ fn find_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> None } -/// Probe for a container, checking the container it finds before its reader allocates whatever the -/// file claims. -/// -/// The check has to run on the container the probe actually selects, but the probe reports that -/// container only through the reader it returns. The check therefore runs on the first marker the -/// detected format allows, and the reader is refused if its format is not the checked one: a -/// misleading marker must not let a later container slip through unchecked. -fn open(bytes: &[u8], detected: FileFormat) -> Result { - let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); - let at = skip_leading_id3(&mut mss, bytes)?; - let mut checked: Option<&'static [&'static str]> = None; - if let Some((found, marker)) = find_marker(bytes, at, container_markers(detected)) { - match marker { - b"caff" => { - crate::caf::check(bytes, found)?; - checked = Some(expected_probe_format(marker)); - } - b"ftyp" => { - crate::mp4::check(bytes, found.saturating_sub(4))?; - checked = Some(expected_probe_format(marker)); - } - b"fLaC" => { - crate::flac::check_metadata(bytes, found)?; - if let Some(info) = crate::flac::empty_stream_info(bytes, found) { - return Ok(Opened::Empty(info)); - } - checked = Some(expected_probe_format(marker)); - } - b"RIFF" | b"RF64" | b"BW64" => { - crate::wav::check_info(bytes, found)?; - checked = Some(expected_probe_format(marker)); - } - _ => {} - } +/// The scan variant for a marker found by [`find_container_marker`], with the probe short name its +/// size check stands for. `ftyp` sits four bytes into its atom, so the MP4 check starts there. +fn marker_kind(at: usize, marker: &[u8; 4]) -> (FirstMarker, &'static str) { + match marker { + b"fLaC" => (FirstMarker::Flac(at), expected_probe_format(marker)), + b"caff" => (FirstMarker::Caf(at), expected_probe_format(marker)), + b"ftyp" => (FirstMarker::Mp4(at.saturating_sub(4)), expected_probe_format(marker)), + _ => (FirstMarker::Wav(at), expected_probe_format(marker)), } - let reader = symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?; +} + +/// The probe short name a container scan variant stands for. +fn marker_name(marker: FirstMarker) -> &'static str { + match marker { + FirstMarker::Flac(_) => "flac", + FirstMarker::Caf(_) => "caf", + FirstMarker::Mp4(_) => "isomp4", + FirstMarker::Wav(_) => "wave", + FirstMarker::Audio => "", + } +} + +/// Whether `rest` starts with one of `R`'s exact registered probe markers. +fn has_probe_marker>(rest: &[u8]) -> bool { + R::probe_data().iter().any(|data| data.spec.markers.iter().any(|marker| rest.starts_with(marker))) +} + +/// Score `R` at `at` exactly as symphonia's private probe does: `Supported` wins, EOF and every +/// other scoring error mean "not this format", while a real I/O error aborts the probe. +fn scores_supported(stream: &mut MediaSourceStream<'_>, at: usize, depth: u16) -> Result { + stream.seek(SeekFrom::Start(u64::try_from(at).unwrap_or(u64::MAX)))?; + match R::score(ScopedStream::new(stream, u64::from(depth))) { + Ok(Score::Supported(_)) => Ok(true), + Err(SymError::IoError(e)) if e.kind() != std::io::ErrorKind::UnexpectedEof => Err(AudioError::Malformed(e.to_string())), + _ => Ok(false), + } +} + +/// Whether `b[at..]` is an MPEG/ADTS candidate the probe would actually select: an exact +/// `probe_data()` marker and a `Supported` score from the same reader. A loose `0xFFEx` pair is +/// not enough: `0xFFE0` is neither a registered marker nor a valid frame, so it must not stop the +/// scan before a later FLAC/CAF/MP4 candidate. +fn audio_sync_at<'a>(b: &'a [u8], at: usize, stream: &mut Option>, budget: &mut ScoreBudget) -> Result { + let rest = b.get(at..).unwrap_or_default(); + let adts = has_probe_marker::>(rest); + let mpa = has_probe_marker::>(rest); + if !adts && !mpa { + return Ok(false); + } + if stream.is_none() { + *stream = Some(MediaSourceStream::new(Box::new(Cursor::new(b)), MediaSourceStreamOptions::default())); + } + let Some(mss) = stream.as_mut() else { return Ok(false) }; + let depth = ProbeOptions::default().max_score_depth; + if adts && budget.take() && scores_supported::>(mss, at, depth)? { + return Ok(true); + } + if mpa && budget.take() && scores_supported::>(mss, at, depth)? { + return Ok(true); + } + Ok(false) +} + +/// Find the first marker that can start a container or audio stream. Symphonia's `Probe::probe` +/// constructs the reader itself and exposes no pre-construction hook, while checking every magic +/// in the scan window would validate bytes the probe never reaches (for example a valid MP3's +/// audio payload). Stopping at the first marker avoids that: it is the candidate the probe selects +/// for FLAC, CAF, MP4, Ogg and Matroska, and for WAV/AIFF once their full form is verified below. +/// MPEG/ADTS candidates are checked with the readers' own `probe_data()`/`Scoreable` APIs, so a +/// false sync byte pair cannot stop the scan. If that scoring budget runs out, the scan stops at +/// the next audio candidate instead of trusting a container-like byte string later in the unscored +/// audio payload. A container marker reached before any such candidate is still preflighted; if +/// the probe selects a size-checked container the scan did not check, `open` refuses it. +/// +/// WAV candidates include RF64/BW64 so `check_info` can run, although symphonia 0.6 registers only +/// the RIFF marker. Native PCM RF64/BW64 is parsed by `wav::parse` before this path; a foreign +/// RF64/BW64 stream therefore remains unsupported by the probe rather than misdetected. +fn first_marker(b: &[u8], start: usize) -> Result> { + first_marker_with_budget(b, start, &mut ScoreBudget::new()) +} + +/// [`first_marker`] with an explicit score budget, so a test can prove a prefix crowded with +/// false sync markers performs only the bounded number of reader scores. +fn first_marker_with_budget(b: &[u8], start: usize, budget: &mut ScoreBudget) -> Result> { + let end = b.len().min(start.saturating_add(MAX_PREFIX_SCAN)); + let mut at = start; + let mut audio_probe = None; + while at.saturating_add(4) <= end { + let rest = b.get(at..).unwrap_or_default(); + if rest.get(..4) == Some(b"fLaC".as_slice()) { + return Ok(Some(FirstMarker::Flac(at))); + } + if rest.get(..4) == Some(b"caff".as_slice()) { + return Ok(Some(FirstMarker::Caf(at))); + } + if rest.get(..4) == Some(b"OggS".as_slice()) || rest.get(..4) == Some(b"\x1A\x45\xDF\xA3".as_slice()) || crate::aiff::is_aiff(rest) { + return Ok(Some(FirstMarker::Audio)); + } + if crate::wav::is_wav(rest) { + return Ok(Some(FirstMarker::Wav(at))); + } + if let (Some(&b0), Some(&b1)) = (b.get(at), b.get(at + 1)) + && b0 == 0xFF + && b1 & 0xE0 == 0xE0 + { + // A candidate after the cap cannot be scored. Stop rather than let a container-like + // byte string later in an unscored audio payload become preflight-authoritative; the + // probe will select the reader, and `open` refuses an unchecked size-checked one. + if budget.exhausted() { + return Ok(None); + } + if audio_sync_at(b, at, &mut audio_probe, budget)? { + return Ok(Some(FirstMarker::Audio)); + } + } + if rest.get(4..8) == Some(b"ftyp".as_slice()) { + return Ok(Some(FirstMarker::Mp4(at))); + } + at += 1; + } + Ok(None) +} + +/// Run the early size checks on the container the probe can select. `start` is the first byte +/// after leading ID3v2 tags. A zero-frame FLAC is returned as an empty stream because Symphonia +/// 0.6's reader requires a first audio frame to resynchronize to. +fn precheck(b: &[u8], start: usize, detected: FileFormat) -> Result { + let any_marker = |b: &[u8], start: usize| -> Result> { + Ok(match first_marker(b, start)? { + // Stop at the first marker of any kind, so container magic incidental to an audio + // payload is not mistaken for the stream's container. + Some(FirstMarker::Audio) | None => None, + Some(marker) => Some((marker, marker_name(marker))), + }) + }; + let found = match container_markers(detected) { + // Follow the detection: check the first marker of the family it names, so a misleading + // marker cannot let a differently sized container reach the probe unchecked. A detection + // is only a hint, though: when the stream holds none of its markers (a FLAC behind a + // `.mp3` name), the probe can still select another container, which is checked here. + Some(markers) => match find_container_marker(b, start, markers) { + Some((at, marker)) => Some(marker_kind(at, marker)), + None => any_marker(b, start)?, + }, + None => any_marker(b, start)?, + }; + let Some((marker, expected)) = found else { return Ok(Precheck::Unchecked) }; + match marker { + FirstMarker::Flac(at) => { + crate::flac::check_metadata(b, at)?; + if let Some(info) = crate::flac::empty_stream_info(b, at) { + return Ok(Precheck::Empty(info)); + } + Ok(Precheck::Checked(expected)) + } + FirstMarker::Caf(at) => { + crate::caf::check(b, at)?; + Ok(Precheck::Checked(expected)) + } + FirstMarker::Mp4(at) => { + crate::mp4::check(b, at)?; + Ok(Precheck::Checked(expected)) + } + FirstMarker::Wav(at) => { + crate::wav::check_info(b, at)?; + Ok(Precheck::Checked(expected)) + } + FirstMarker::Audio => Ok(Precheck::Unchecked), + } +} + +/// Skip leading ID3v2 tags, run the container size preflight, then probe with symphonia; a zero-frame FLAC found by +/// the preflight comes back as [`Opened::Empty`] without probing. A size-checked container is refused when probing +/// selects a different reader or one that was never checked, so incidental payload magic cannot bypass those limits. +fn open(bytes: &[u8], detected: FileFormat) -> Result { + let start = skip_id3v2(bytes); + if start > 0 && start >= bytes.len() { + return Err(AudioError::Malformed("ID3v2 tag extends past the end of the file".into())); + } + let checked = precheck(bytes, start, detected)?; + if let Precheck::Empty(info) = checked { + return Ok(Opened::Empty(info)); + } + let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); + // Seek (rather than pre-positioning the cursor) so the stream's absolute position is `start` + // too: container offsets such as MP4's `stco` are absolute file offsets. + mss.seek(SeekFrom::Start(u64::try_from(start).unwrap_or(u64::MAX)))?; + let reader: Box = + symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?; let probed = reader.format_info().short_name; - // Only containers whose readers allocate from sizes in the file need the check. A stream the - // probe reads with another reader (MP3, Ogg, AIFF, ...) has no such size fields, so a marker - // found in its audio data must not reject it. - if let Some(names) = checked.filter(|_| SIZE_CHECKED_PROBE_FORMATS.contains(&probed)) { - if !names.contains(&probed) { + match checked { + // Only containers whose readers allocate from sizes in the file need the check. A stream + // the probe reads with another reader (MP3, Ogg, AIFF, ...) has no such size fields, so a + // marker found in its audio data must not reject it. + Precheck::Checked(expected) if SIZE_CHECKED_PROBE_FORMATS.contains(&probed) && probed != expected => { return Err(AudioError::Malformed(format!("the probed container ({probed}) is not the one whose size checks ran"))); } - } else if checked.is_none() && SIZE_CHECKED_PROBE_FORMATS.contains(&probed) { - return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked"))); + Precheck::Unchecked if SIZE_CHECKED_PROBE_FORMATS.contains(&probed) => { + return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked"))); + } + _ => {} } Ok(Opened::Reader(reader)) } -/// The container `detected` stands for: the detection wins when it knows the file, otherwise the -/// decoded codec names the family. fn format_from_codec(codec: AudioCodecId, detected: FileFormat) -> FileFormat { if detected != FileFormat::Other { return detected; @@ -213,16 +427,19 @@ fn sample_format_of(p: &AudioCodecParameters) -> SampleFormat { } } +/// The audio parameters of `track`, if it carries any. +fn audio_params(track: &Track) -> Option<&AudioCodecParameters> { + track.codec_params.as_ref()?.audio() +} + /// The first track with a decodable audio codec, with the stream information the UI shows. fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> { - let (track, p) = reader + let track = reader .tracks() .iter() - .find_map(|t| match t.codec_params.as_ref().and_then(CodecParameters::audio) { - Some(p) if p.codec != CODEC_ID_NULL_AUDIO => Some((t, p)), - _ => None, - }) + .find(|t| audio_params(t).is_some_and(|p| p.codec != CODEC_ID_NULL_AUDIO)) .ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?; + let p = audio_params(track).ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?; let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?; let channels = p.channels.as_ref().map(|c| c.count()).unwrap_or(0); let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?; @@ -249,28 +466,22 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf } }; 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.num_frames) { + let track = reader.tracks().iter().find(|t| t.id == track_id).ok_or_else(|| AudioError::Malformed("track vanished".into()))?; + if let Some(n) = track.num_frames { let total = n.saturating_mul(u64::from(info.channels.max(1))); if !count_only && total > MAX_DECODE_SAMPLES { return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit"))); } } - let params = reader - .tracks() - .iter() - .find(|t| t.id == track_id) - .and_then(|t| t.codec_params.as_ref()) - .and_then(CodecParameters::audio) - .cloned() - .ok_or_else(|| AudioError::Malformed("track vanished".into()))?; + let params = audio_params(track).cloned().ok_or_else(|| AudioError::Malformed("track vanished".into()))?; let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0)); if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) { return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into())); } - let mut decoder = symphonia::default::get_codecs().make_audio_decoder(¶ms, &AudioDecoderOptions::default()).map_err(map_err)?; + let mut decoder = symphonia::default::get_codecs().make_audio_decoder(¶ms, &decoder_options()).map_err(map_err)?; let mut planar: Vec> = Vec::new(); - let mut scratch: Vec> = Vec::new(); + let mut interleaved: Vec = Vec::new(); let mut frames: u64 = 0; let mut errors = 0usize; loop { @@ -303,8 +514,9 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf Err(SymError::IoError(_)) => break, Err(e) => return Err(map_err(e)), }; - let spec = decoded.spec(); - let ch = spec.channels().count(); + // The interleaved copy below is laid out by planes: use their count so a decoder whose + // channel spec disagrees with its planes cannot trip the copy's length assertion. + let ch = decoded.num_planes(); if ch == 0 { continue; } @@ -321,9 +533,19 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf if count_only { continue; } - decoded.copy_to_vecs_planar::(&mut scratch); - for (dst, src) in planar.iter_mut().zip(&scratch) { - dst.extend(src.iter().map(|&s| if s.is_finite() { s } else { 0.0 })); + let needed = usize::try_from(n) + .ok() + .and_then(|n| n.checked_mul(ch)) + .ok_or_else(|| AudioError::TooLarge("decoded audio exceeds the 4 GiB limit".into()))?; + if interleaved.len() < needed { + interleaved.try_reserve(needed - interleaved.len()).map_err(|_| AudioError::TooLarge("cannot allocate decoded audio".into()))?; + interleaved.resize(needed, 0.0); + } + decoded.copy_to_slice_interleaved(&mut interleaved[..needed]); + for frame in interleaved[..needed].chunks_exact(ch) { + for (dst, &s) in planar.iter_mut().zip(frame) { + dst.push(if s.is_finite() { s } else { 0.0 }); + } } } if info.channels == 0 { @@ -355,3 +577,121 @@ pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result { pub(crate) fn decode(bytes: &[u8], detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> { guarded(|| run(bytes, detected, false)) } + +#[cfg(test)] +mod tests { + use super::*; + use symphonia::core::codecs::audio::well_known::{ + CODEC_ID_AAC, CODEC_ID_ADPCM_MS, CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP3, CODEC_ID_PCM_F32LE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_S16LE, + CODEC_ID_VORBIS, + }; + + /// Build codec parameters for a test, optionally declaring a bit depth. + fn params(codec: AudioCodecId, bits_per_sample: Option) -> AudioCodecParameters { + let mut params = AudioCodecParameters::new(); + params.for_codec(codec); + if let Some(bits) = bits_per_sample { + params.with_bits_per_sample(bits); + } + params + } + + /// Codec ids map to their reported formats, while a format detected from the container wins over the codec. + #[test] + fn codecs_map_to_the_reported_formats() { + assert_eq!(format_from_codec(CODEC_ID_FLAC, FileFormat::Other), FileFormat::Flac); + assert_eq!(format_from_codec(CODEC_ID_MP3, FileFormat::Other), FileFormat::Mp3); + assert_eq!(format_from_codec(CODEC_ID_AAC, FileFormat::Other), FileFormat::Aac); + assert_eq!(format_from_codec(CODEC_ID_ALAC, FileFormat::Other), FileFormat::Alac); + assert_eq!(format_from_codec(CODEC_ID_VORBIS, FileFormat::Other), FileFormat::Ogg); + // The container detected from the bytes wins over the codec. + assert_eq!(format_from_codec(CODEC_ID_ALAC, FileFormat::Aac), FileFormat::Aac); + } + + /// PCM and FLAC keep their declared bit depth; lossy codecs report `Compressed` regardless of their decoded + /// width. + #[test] + fn sample_formats_keep_their_depth_and_lossy_marker() { + assert_eq!(sample_format_of(¶ms(CODEC_ID_PCM_S16LE, Some(16))), SampleFormat::Int16); + assert_eq!(sample_format_of(¶ms(CODEC_ID_FLAC, Some(24))), SampleFormat::Int24); + assert_eq!(sample_format_of(¶ms(CODEC_ID_FLAC, Some(8))), SampleFormat::Int8); + assert_eq!(sample_format_of(¶ms(CODEC_ID_FLAC, Some(32))), SampleFormat::Int32); + assert_eq!(sample_format_of(¶ms(CODEC_ID_PCM_F32LE, Some(32))), SampleFormat::Float32); + assert_eq!(sample_format_of(¶ms(CODEC_ID_PCM_F64LE, Some(64))), SampleFormat::Float64); + // Lossy codecs report no fixed bit depth, whatever their decoded sample format is. + assert_eq!(sample_format_of(¶ms(CODEC_ID_AAC, Some(16))), SampleFormat::Compressed); + assert_eq!(sample_format_of(¶ms(CODEC_ID_ADPCM_MS, Some(4))), SampleFormat::Compressed); + assert_eq!(sample_format_of(¶ms(CODEC_ID_ALAC, None)), SampleFormat::Compressed); + } + + /// Decoder options keep gapless trimming enabled so the encoder delay and padding reported by the demuxer are + /// removed. + #[test] + fn gapless_decoding_stays_enabled() { + assert!(decoder_options().gapless); + } + + /// A complete MPEG frame header is a candidate; loose or unregistered sync pairs are rejected before scoring, + /// while a registered but truncated header is rejected by the reader's score after consuming a budget slot. + #[test] + fn only_the_readers_own_audio_markers_are_candidates() { + /// Whether the preflight would score `b` as an audio candidate at offset 0. + fn candidate(b: &[u8]) -> bool { + let mut stream = None; + let mut budget = ScoreBudget::new(); + matches!(audio_sync_at(b, 0, &mut stream, &mut budget), Ok(true)) + } + let mut mp3_frame = vec![0u8; 417]; + mp3_frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz + assert!(candidate(&[&mp3_frame[..], &mp3_frame].concat())); + for false_sync in [vec![0xFF, 0xE0, 0x00], vec![0xFF, 0xF0, 0x00], vec![0xFF, 0xFB, 0xF0, 0x00]] { + assert!(!candidate(&false_sync), "{false_sync:?} is not a probe candidate"); + } + } + + /// A prefix made of false MPEG sync markers must not ask the readers to score every byte + /// position: the per-scan budget caps the expensive work. A cheap container marker reached + /// before the next audio candidate is still found, but once the cap is spent an audio + /// candidate stops the scan so later payload magic cannot be preflight-authoritative. + #[test] + fn a_prefix_crowded_with_sync_markers_scores_only_the_budget() { + let mut crowded = vec![0u8; 1 << 16]; + for pair in crowded.as_chunks_mut::<2>().0 { + pair.copy_from_slice(&[0xFF, 0xE2]); + } + let mut budget = ScoreBudget::new(); + assert!(matches!(first_marker_with_budget(&crowded, 0, &mut budget), Ok(None))); + assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS, "no candidate after the cap is scored"); + + // At the cap boundary, with no later audio candidate, the cheap marker is still found. + let mut at_cap: Vec = (0..MAX_AUDIO_SCORE_ATTEMPTS).flat_map(|_| [0xFFu8, 0xE2]).collect(); + at_cap.extend_from_slice(b"fLaC"); + let mut budget = ScoreBudget::new(); + assert!( + matches!(first_marker_with_budget(&at_cap, 0, &mut budget), Ok(Some(FirstMarker::Flac(_)))), + "a container marker reached before the next audio candidate is still found" + ); + assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS); + + // One more false candidate between the cap and the magic makes the scan stop unchecked: + // the same magic could instead sit in a valid audio payload. + let mut after_candidate = at_cap[..2 * MAX_AUDIO_SCORE_ATTEMPTS].to_vec(); + after_candidate.extend_from_slice(&[0xFF, 0xE2]); + after_candidate.extend_from_slice(b"fLaC"); + let mut budget = ScoreBudget::new(); + assert!(matches!(first_marker_with_budget(&after_candidate, 0, &mut budget), Ok(None)), "an audio candidate after the cap stops the scan"); + assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS); + } + + /// ID3v2 sizes are read in their 7-bit syncsafe form; consecutive tags are skipped by their declared sizes, and + /// the v2.3 reserved footer bit does not extend a tag. + #[test] + fn leading_id3_tags_are_skipped_by_their_declared_size() { + let tag = [&b"ID3\x04\0\0\0\0\0\x05"[..], b"hello"].concat(); + assert_eq!(skip_id3v2(&[&tag[..], &tag[..]].concat()), 30); + assert_eq!(skip_id3v2(b"fLaC"), 0); + // The 10-byte footer flag exists only in v2.4; the same bit in v2.3 is reserved. + assert_eq!(crate::id3v2_len(b"ID3\x03\0\x10\0\0\0\0"), Some(10)); + assert_eq!(crate::id3v2_len(b"ID3\x04\0\x10\0\0\0\0"), Some(20)); + } +} diff --git a/crates/audio-io/tests/it/malformed.rs b/crates/audio-io/tests/it/malformed.rs index ec862f66..6d00bcdc 100644 --- a/crates/audio-io/tests/it/malformed.rs +++ b/crates/audio-io/tests/it/malformed.rs @@ -13,6 +13,17 @@ fn small(format: FileFormat, depth: BitDepth, channels: usize) -> Vec { encode(&b, &EncodeOptions { format, bit_depth: depth, dither: false, bwf: None }).unwrap() } +/// An ID3v2.4 header whose syncsafe size field declares `size` bytes of tag body. +fn id3_header(size: usize) -> [u8; 10] { + let size = size as u32; + [b'I', b'D', b'3', 4, 0, 0, ((size >> 21) & 0x7F) as u8, ((size >> 14) & 0x7F) as u8, ((size >> 7) & 0x7F) as u8, (size & 0x7F) as u8] +} + +/// Wrap `body` in an ID3v2.4 tag. +fn id3_tag(body: &[u8]) -> Vec { + [&id3_header(body.len())[..], body].concat() +} + /// Little helper for building RIFF files by hand. fn riff(chunks: &[(&[u8; 4], Vec)]) -> Vec { let mut body = b"WAVE".to_vec(); @@ -314,6 +325,190 @@ fn an_id3_tag_before_the_audio_is_skipped_unread() { assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300); } +/// Container magic inside an MP3 frame payload must not change detection or make probing reject the stream. +#[test] +fn a_valid_mp3_with_incidental_container_magic_still_probes() { + let mut frame = vec![0u8; 417]; + frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz, stereo + let mut f: Vec = (0..8).flat_map(|_| frame.clone()).collect(); + let at = 417 * 2 + 10; // Inside frame 2's payload, leaving every frame header intact. + f[at..at + 8].copy_from_slice(b"caff\0\x01\0\0"); + f[at + 8..at + 12].copy_from_slice(b"kuki"); + f[at + 12..at + 20].copy_from_slice(&(1u64 << 50).to_be_bytes()); + assert_eq!(detect_format(&f, None), FileFormat::Mp3); + let info = probe(&f, None).expect("valid MP3 with incidental container magic"); + assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2)); +} + +/// Symphonia 0.6's CAF/FLAC/MP4 `Scoreable` implementations are unconditional +/// (`Supported(255)`), so a container magic before the first audio frame *is* the reader the probe +/// selects; there is no API to ask the probe to skip it. The preflight must therefore keep +/// refusing an oversized CAF cookie instead of ignoring the marker and playing the MPEG frames +/// behind it (which would also let a real malformed container bypass the size guard). +#[test] +fn a_container_marker_before_the_first_audio_frame_is_the_probed_candidate() { + let mut frame = vec![0u8; 417]; + frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz, stereo + let mp3: Vec = (0..8).flat_map(|_| frame.clone()).collect(); + let malformed = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"kuki", 1i64 << 50, vec![0; 8])]); + for f in [[vec![0u8], malformed.clone(), mp3.clone()].concat(), [vec![0u8; 32], malformed.clone(), mp3.clone()].concat()] { + // The hint still names the raw stream, but the probe sees the CAF marker first. + assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3); + assert!(matches!(probe(&f, Some("mp3")), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + assert!(matches!(decode(&f, Some("mp3")), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + } + // Plain leading junk without format magic is tolerated, as before. + let f = [vec![0u8; 32], mp3].concat(); + assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3); + let info = probe(&f, Some("mp3")).expect("junk before a valid MP3"); + assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2)); +} + +/// More false `0xFFEx` pairs than the preflight's scoring budget must not make a later container +/// magic inside the valid audio payload authoritative. Past the cap the scan is unchecked and the +/// probe selects the MPEG reader at the first real frame. +#[test] +fn an_exhausted_audio_score_budget_does_not_reject_the_mp3_behind_it() { + let mut frame = vec![0u8; 417]; + frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); + let mut audio: Vec = (0..8).flat_map(|_| frame.clone()).collect(); + let at = 417 * 2 + 10; // Inside frame 2's payload, leaving every frame header intact. + audio[at..at + 8].copy_from_slice(b"caff\0\x01\0\0"); + audio[at + 8..at + 12].copy_from_slice(b"kuki"); + audio[at + 12..at + 20].copy_from_slice(&(1u64 << 50).to_be_bytes()); + let false_syncs: Vec = (0..256).flat_map(|_| [0xFFu8, 0xE2]).collect(); + let f = [false_syncs, audio].concat(); + assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3); + let info = probe(&f, Some("mp3")).expect("valid MP3 after an exhausted audio score budget"); + assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2)); + let (decoded, buf) = decode(&f, Some("mp3")).expect("decode the same MP3"); + assert_eq!((decoded.format, decoded.sample_rate, decoded.channels), (FileFormat::Mp3, 44_100, 2)); + assert_eq!(decoded.frames, buf.frames() as u64); +} + +/// Past the score budget the preflight does not run, so if the probe then selects a container +/// whose reader allocates from sizes in the file, the post-probe refusal still applies. +#[test] +fn a_container_selected_without_its_size_check_is_refused() { + let flac = small(FileFormat::Flac, BitDepth::Int16, 1); + // Exactly the cap of false candidates, then one more that the reader rejects (invalid + // bit-rate), so the preflight stops unchecked before reaching the FLAC marker behind it. + let mut false_syncs: Vec = (0..256).flat_map(|_| [0xFFu8, 0xE2]).collect(); + false_syncs.extend_from_slice(&[0xFF, 0xFB, 0xF0, 0x00]); + let f = [false_syncs, flac].concat(); + assert_eq!(detect_format(&f, None), FileFormat::Mp3); + assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked"))); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked"))); +} + +/// A zero-frame FLAC after arbitrary bytes or ID3 tags is still detected and reported as an empty stream, not a +/// decode failure. +#[test] +fn zero_frame_flac_after_a_prefix_is_still_empty() { + let flac = + encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, dither: false, bwf: None }) + .unwrap(); + let tag = id3_tag(b"tag"); + for (f, detected) in [ + ([vec![0x7A; 64], flac.clone()].concat(), FileFormat::Other), + ([tag.clone(), vec![0x7A; 16], flac.clone()].concat(), FileFormat::Mp3), + ([tag.clone(), tag, flac.clone()].concat(), FileFormat::Mp3), + ] { + assert_eq!(detect_format(&f, None), detected); + let info = probe(&f, None).expect("empty FLAC after a prefix"); + assert_eq!((info.format, info.sample_rate, info.channels, info.frames), (FileFormat::Flac, 48_000, 1, 0)); + assert_eq!(decode(&f, None).unwrap().1.frames(), 0); + } +} + +/// Build one MPEG-4 AAC-LC ADTS frame at 48 kHz stereo around `payload`, with the 13-bit length covering the 7-byte +/// header. +fn adts_frame(payload: &[u8]) -> Vec { + // MPEG-4 AAC-LC, 48 kHz, stereo; the 13-bit frame length includes this 7-byte header. + let len = 7 + payload.len(); + let mut h = [0xFF, 0xF1, 0x4C, 0x00, 0x00, 0x1F, 0xFC]; + h[3] = 0x80 | ((len >> 11) & 0x03) as u8; // stereo channel config low bits + h[4] = ((len >> 3) & 0xFF) as u8; + h[5] = (((len & 0x07) as u8) << 5) | 0x1F; + [&h[..], payload].concat() +} + +/// False MPEG sync prefixes must not stop a following zero-frame FLAC from probing as empty. +#[test] +fn zero_frame_flac_after_a_false_mpeg_sync_is_still_empty() { + let flac = + encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, dither: false, bwf: None }) + .unwrap(); + for junk in [vec![0xFF, 0xE0, 0x00], vec![0xFF, 0xF0, 0x00], vec![0xFF, 0xFB, 0xF0, 0x00]] { + let f = [junk, flac.clone()].concat(); + let info = probe(&f, None).expect("empty FLAC after a false sync"); + assert_eq!((info.format, info.sample_rate, info.channels, info.frames), (FileFormat::Flac, 48_000, 1, 0)); + assert_eq!(decode(&f, None).unwrap().1.frames(), 0); + } +} + +/// A false MPEG sync prefix must not let malformed CAF or MP4 size fields bypass the container preflight. +#[test] +fn a_false_sync_does_not_bypass_container_checks() { + let bad_caf = caf(&[ + caf_desc(b"aac ", 0, 1024, 2, 0), + (b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])), + (b"data", -1, vec![0; 64]), + ]); + assert!(matches!(probe(&bad_caf, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + let f = [vec![0xFF, 0xE0, 0x00], bad_caf].concat(); + assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + + let pcm = sample_entry(b"sowt", 0, &[], &[]); + let bad_mp4 = mp4(480, &pcm, |kind, mut a| { + if kind == b"stsz" { + a[12..16].copy_from_slice(&u32::MAX.to_be_bytes()); + } + a + }); + assert!(probe(&bad_mp4, None).is_err()); + let f = [vec![0xFF, 0xE0, 0x00], bad_mp4].concat(); + assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); +} + +/// Container magic inside a valid ADTS payload must not override AAC detection or make probing fail. +#[test] +fn a_valid_adts_stream_with_incidental_container_magic_still_probes() { + let payload = [0u8; 400]; + let mut frames: Vec> = (0..8).map(|_| adts_frame(&payload)).collect(); + let at = 100; // Inside frame 2's payload, leaving every frame header intact. + frames[2][7 + at..7 + at + 12].copy_from_slice(b"caff\0\x01\0\0kuki"); + let f: Vec = frames.concat(); + assert_eq!(detect_format(&f, None), FileFormat::Aac); + let info = probe(&f, None).expect("valid ADTS with incidental container magic"); + assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Aac, 48_000, 2)); +} + +/// A later valid CAF must not excuse an earlier malformed container that the preflight or probe rejects. +#[test] +fn an_earlier_malformed_container_is_not_excused_by_a_later_valid_one() { + let malformed = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"kuki", 1i64 << 50, vec![0; 8])]); + let pcm: Vec = (0..32i16).flat_map(|i| (i * 50).to_be_bytes()).collect(); + let data = [vec![0; 4], pcm].concat(); + let valid = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"data", data.len() as i64, data)]); + let f = [vec![0u8; 32], malformed, valid].concat(); + assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); +} + +/// Truncated ID3 bodies, declared tag sizes beyond the file, and a complete tag with no audio after it are malformed +/// for both probe and decode. +#[test] +fn truncated_or_oversized_id3_tags_are_malformed() { + let cases = vec![[&id3_header(100)[..], b"xx"].concat(), id3_header(0x0FFF_FFFF).to_vec(), id3_tag(b"tag")]; + for f in cases { + assert!(matches!(probe(&f, None), Err(AudioError::Malformed(_)))); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(_)))); + } +} + #[test] fn wav_info_entries_beyond_the_file_are_refused() { let alaw = |len: u32| { diff --git a/crates/playback/src/devices.rs b/crates/playback/src/devices.rs index a2847097..e098c6d8 100644 --- a/crates/playback/src/devices.rs +++ b/crates/playback/src/devices.rs @@ -64,11 +64,15 @@ pub struct AudioSystem { pub inputs: Vec, } -/// The name the host gives `d`, if it can produce a description for it. +/// The device's user-facing name. cpal 0.18 removed `DeviceTrait::name()` in favour of the +/// structured `description()`; `Display` for a device also formats that name but reports a +/// formatting error (which `to_string` would turn into a panic), so keep the fallible call here. pub(crate) fn device_name(d: &cpal::Device) -> Option { d.description().ok().map(|description| description.name().to_owned()) } +/// Describe one cpal device as [`DeviceInfo`], or return `None` when its name or supported configurations cannot be +/// read. Only formats the playback path can convert are considered. fn describe(d: &cpal::Device, default_name: Option<&str>, output: bool) -> Option { let name = device_name(d)?; let ranges: Vec = if output { @@ -77,8 +81,7 @@ fn describe(d: &cpal::Device, default_name: Option<&str>, output: bool) -> Optio d.supported_input_configs().ok()?.filter(|c| format_rank(c.sample_format()).is_some()).collect() }; let max_channels = ranges.iter().map(cpal::SupportedStreamConfigRange::channels).max().unwrap_or(0); - let sample_rates = - STANDARD_RATES.iter().copied().filter(|r| ranges.iter().any(|c| c.min_sample_rate() <= *r && c.max_sample_rate() >= *r)).collect(); + let sample_rates = STANDARD_RATES.iter().copied().filter(|r| ranges.iter().any(|c| c.contains_rate(*r))).collect(); let (mut buffer_min, mut buffer_max) = (None::, None::); for c in &ranges { if let cpal::SupportedBufferSize::Range { min, max } = c.buffer_size() { @@ -106,6 +109,45 @@ pub fn audio_systems() -> Vec { .collect() } +/// True when a cpal error means the stream is dead and only a rebuild can restore audio. cpal 0.18 +/// reports every operation through one [`cpal::Error`]: `DeviceNotAvailable` means the device went +/// away, and `StreamInvalidated` explicitly requires the stream to be rebuilt. SoundCraft has no +/// in-place rebuild, so both map to the existing actionable "reconnect audio device" trouble +/// instead of leaving a dead stream silently logging. A rerouted default device +/// (`DeviceChanged`) keeps playing, and an xrun is transient, so neither is a lost stream. +/// `HostUnavailable`, `DeviceBusy` and `BackendError` stay log-only until their retry/rebuild +/// policies exist. +pub(crate) fn stream_lost(e: &cpal::Error) -> bool { + // ErrorKind is non-exhaustive; an equality test avoids a wildcard match arm. + let kind = e.kind(); + kind == cpal::ErrorKind::DeviceNotAvailable || kind == cpal::ErrorKind::StreamInvalidated +} + +/// The device's configuration at `sample_rate` in the best sample format these streams convert. +/// cpal 0.18 ranks `I24` (and `F64`) above `I16` when it picks a default configuration, so a +/// default alone can name a format the callbacks do not decode; this looks for the same rate in a +/// format [`FORMATS`] holds. The exact channel count is preferred; when the device offers none in +/// a usable format at that rate (for example only an 8-channel F32 stream), the closest available +/// channel count is used instead. Both callbacks fold or copy channels, so a wider or narrower +/// device stream is better than falling back to the silent clock. Callers pass a requested count +/// of at least 2 for output; input may ask for mono. +pub(crate) fn supported_format_config( + configs: &[cpal::SupportedStreamConfigRange], + sample_rate: u32, + channels: u16, +) -> Option { + let usable = || configs.iter().filter(|c| c.contains_rate(sample_rate)).filter_map(|c| format_rank(c.sample_format()).map(|rank| (rank, c))); + // Exact count first, then the nearest count (more channels breaks the tie). + let exact = usable().filter(|(_, c)| c.channels() == channels).min_by_key(|(rank, _)| *rank); + let best = exact.or_else(|| { + usable().min_by_key(|(rank, c)| { + let distance = c.channels().abs_diff(channels); + (*rank, distance, std::cmp::Reverse(c.channels())) + }) + }); + best.and_then(|(_, c)| c.try_with_sample_rate(sample_rate)) +} + /// The chosen audio system, else the default one. pub(crate) fn host(cfg: &AudioConfig) -> cpal::Host { cfg.system @@ -163,6 +205,63 @@ mod tests { assert_eq!(serde_json::from_str::(r#"{"output":"X","unknown":1}"#).unwrap().output.as_deref(), Some("X")); } + /// Build a cpal configuration range for tests with a 64..1024-frame buffer. + fn range(channels: u16, min: u32, max: u32, format: cpal::SampleFormat) -> cpal::SupportedStreamConfigRange { + cpal::SupportedStreamConfigRange::new(channels, min, max, cpal::SupportedBufferSize::Range { min: 64, max: 1024 }, format) + } + + /// When the default sample format has no usable conversion, the same rate opens with a supported format; the + /// nearest channel count keeps the device open, and unsupported rates fail. + #[test] + fn a_default_format_the_streams_cannot_convert_uses_a_supported_one_at_the_same_rate() { + let configs = [ + range(2, 44_100, 48_000, cpal::SampleFormat::I24), + range(2, 44_100, 48_000, cpal::SampleFormat::I16), + range(2, 44_100, 48_000, cpal::SampleFormat::F32), + range(2, 96_000, 96_000, cpal::SampleFormat::F32), + ]; + let c = supported_format_config(&configs, 48_000, 2).unwrap(); + assert_eq!((c.sample_rate(), c.channels(), c.sample_format()), (48_000, 2, cpal::SampleFormat::F32)); + assert_eq!(supported_format_config(&configs, 96_000, 2).map(|c| c.sample_format()), Some(cpal::SampleFormat::F32)); + assert_eq!( + supported_format_config(&configs, 48_000, 1).map(|c| (c.channels(), c.sample_format())), + Some((2, cpal::SampleFormat::F32)), + "with no mono stream, the nearest usable channel count keeps the device open" + ); + let i24_only = [range(2, 44_100, 48_000, cpal::SampleFormat::I24)]; + assert!(supported_format_config(&i24_only, 48_000, 2).is_none(), "no supported format at this rate"); + } + + /// cpal 0.18 may name an I24 stereo default while the only format the callbacks decode is a + /// wider F32 stream at another supported rate. The default-rate search finds nothing usable, + /// so the output fallback retries at the session rate over any usable channel count; the mix + /// already folds or copies channels and resamples. + #[test] + fn a_default_format_with_no_usable_same_rate_stream_falls_back_to_the_session_rate() { + let configs = [range(2, 44_100, 44_100, cpal::SampleFormat::I24), range(8, 48_000, 48_000, cpal::SampleFormat::F32)]; + assert!(supported_format_config(&configs, 44_100, 2).is_none(), "the default rate has no usable format"); + let c = supported_format_config(&configs, 48_000, 2).expect("the wider F32 stream at the session rate"); + assert_eq!((c.sample_rate(), c.channels(), c.sample_format()), (48_000, 8, cpal::SampleFormat::F32)); + } + + /// Only device-not-available and invalidated-stream errors stop playback; other cpal errors are treated as + /// recoverable. + #[test] + fn only_a_dead_stream_is_reported() { + let error = |kind| cpal::Error::with_message(kind, "test"); + assert!(stream_lost(&error(cpal::ErrorKind::DeviceNotAvailable))); + assert!(stream_lost(&error(cpal::ErrorKind::StreamInvalidated)), "the stream must be rebuilt"); + for kind in [ + cpal::ErrorKind::DeviceChanged, + cpal::ErrorKind::DeviceBusy, + cpal::ErrorKind::HostUnavailable, + cpal::ErrorKind::Xrun, + cpal::ErrorKind::BackendError, + ] { + assert!(!stream_lost(&error(kind)), "{kind:?} must not stop the stream"); + } + } + #[test] fn buffer_sizes_follow_the_device_range() { let r = cpal::SupportedBufferSize::Range { min: 64, max: 1024 }; diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index ffb8b832..b60dca08 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -87,7 +87,7 @@ struct Shared { pub enum OutputTrouble { /// A block panicked; the output stays silent. Bug, - /// The device went away. + /// The device went away, or the stream was invalidated and must be rebuilt. Gone, /// The device stopped asking for audio while playing. Stalled, @@ -98,6 +98,7 @@ pub enum OutputTrouble { struct Fault { /// A block panicked: output stays silent from then on. failed: AtomicBool, + /// The device went away or the stream was invalidated: only a reconnect can restore audio. gone: AtomicBool, /// Blocks so far, to tell a device that stopped asking for audio. beats: std::sync::atomic::AtomicU64, @@ -511,6 +512,9 @@ impl Player { self.side.lock().unwrap_or_else(PoisonError::into_inner) } + /// Open the output device for `session`, trying the requested buffer size before the device default, then build + /// the shared player state. If no configuration starts or the device cannot open, fall back to a silent clock at + /// the session rate. fn open(session: Arc, cfg: &AudioConfig) -> Player { let shared = Arc::new(Shared { position: AtomicI64::new(0), @@ -539,9 +543,9 @@ impl Player { let state = AudioState::new(rx, Arc::clone(&shared), Arc::clone(&session), f64::from(rate), gtx); let fault = Arc::new(Fault::default()); let stream = match format { - cpal::SampleFormat::I32 => build_output::(&device, config, state, channels, &fault, &shared), - cpal::SampleFormat::I16 => build_output::(&device, config, state, channels, &fault, &shared), - _ => build_output::(&device, config, state, channels, &fault, &shared), + cpal::SampleFormat::I32 => build_output::(&device, *config, state, channels, &fault, &shared), + cpal::SampleFormat::I16 => build_output::(&device, *config, state, channels, &fault, &shared), + _ => build_output::(&device, *config, state, channels, &fault, &shared), }?; Ok(Opened { stream, tx, grx, fault }) }, @@ -953,7 +957,7 @@ fn first_started( /// never allocates), converted to the device's sample type. fn build_output( device: &cpal::Device, - config: &cpal::StreamConfig, + config: cpal::StreamConfig, mut state: AudioState, channels: usize, fault: &Arc, @@ -968,7 +972,7 @@ where let chunk = scratch.len(); let (failed, guard, gone) = (Arc::clone(fault), Arc::clone(shared), Arc::clone(fault)); device.build_output_stream( - *config, + config, move |data: &mut [T], _: &cpal::OutputCallbackInfo| { mark_audio_thread(); for out in data.chunks_mut(chunk) { @@ -983,7 +987,11 @@ where // broken): marked, so the logger hands the record off instead of writing it. move |e: cpal::Error| { mark_audio_thread(); - if e.kind() == cpal::ErrorKind::DeviceNotAvailable { + // cpal 0.18 uses one error type for every operation. A device that went away, or a + // stream that must be rebuilt (`StreamInvalidated`), stops playback until the user + // reconnects; a rerouted default device (`DeviceChanged`) keeps playing on the new + // route, and an xrun is transient, so neither counts as losing the stream. + if devices::stream_lost(&e) { gone.gone.store(true, Ordering::Relaxed); } log::warn!("audio stream error: {e}"); @@ -1004,51 +1012,67 @@ fn open_device(want_rate: u32, want_ch: u16, cfg: &AudioConfig) -> Result<(cpal: // Prefer a config at the chosen (else session) rate; else the device default (we resample). let want_ch = want_ch.max(2); let rates: Vec = cfg.sample_rate.into_iter().chain(Some(want_rate)).collect(); - let mut chosen: Option<(cpal::StreamConfig, cpal::SupportedBufferSize, cpal::SampleFormat)> = None; - // Better: a preferred format first, then more channels. - let better = |c: &cpal::SupportedStreamConfigRange, x: &(cpal::StreamConfig, cpal::SupportedBufferSize, cpal::SampleFormat)| { - let (rc, rx) = (devices::format_rank(c.sample_format()).unwrap_or(usize::MAX), devices::format_rank(x.2).unwrap_or(usize::MAX)); - rc < rx || (rc == rx && c.channels() > x.0.channels) - }; - if let Ok(configs) = device.supported_output_configs() { - let configs: Vec = configs.collect(); - for rate in rates { - for c in &configs { - if devices::format_rank(c.sample_format()).is_some() - && c.min_sample_rate() <= rate - && c.max_sample_rate() >= rate - && c.channels() >= 2 - && c.channels() <= want_ch - && chosen.as_ref().is_none_or(|x| better(c, x)) - { - chosen = Some((c.with_sample_rate(rate).config(), *c.buffer_size(), c.sample_format())); - } - } - if chosen.is_some() { - break; - } - } - } - let (config, format) = match chosen { + let configs: Vec = device.supported_output_configs().map(|configs| configs.collect()).unwrap_or_default(); + let (config, format) = match pick_output_config(&configs, &rates, want_ch) { Some((mut c, buffers, format)) => { c.buffer_size = devices::buffer_size(cfg.buffer_size, &buffers); (c, format) } None => { let d = device.default_output_config().map_err(|e| e.to_string())?; - if devices::format_rank(d.sample_format()).is_none() { - return Err(format!("unsupported device sample format {:?}", d.sample_format())); - } - let buffers = *d.buffer_size(); let format = d.sample_format(); - let mut c = d.config(); - c.buffer_size = devices::buffer_size(cfg.buffer_size, &buffers); - (c, format) + // cpal 0.18's default can name a format these callbacks do not decode (I24, F64); + // open the same rate and channels in a format they do, rather than lose output. + let c = if devices::format_rank(format).is_some() { + d + } else { + // Same rate and channels first; else any usable channel count at the default + // rate; else the session rate (the mix resamples and folds channels). A device + // whose only decoded format is wider or at another rate would otherwise open no + // stream at all. + devices::supported_format_config(&configs, d.sample_rate(), d.channels()) + .or_else(|| devices::supported_format_config(&configs, want_rate, want_ch)) + .ok_or_else(|| format!("unsupported device sample format {format:?}"))? + }; + let mut stream_config = c.config(); + stream_config.buffer_size = devices::buffer_size(cfg.buffer_size, c.buffer_size()); + (stream_config, c.sample_format()) } }; Ok((device, config, format, name)) } +/// Pick the output configuration for the first requested rate the device offers (the configured +/// rate first, then the session rate), preferring the best sample format and then the most +/// channels from stereo up to `want_ch`. `None` when the device offers none of the requested +/// rates in a usable format; the caller then uses the device's default output configuration, +/// which the mix resamples to the session rate. +fn pick_output_config( + configs: &[cpal::SupportedStreamConfigRange], + rates: &[u32], + want_ch: u16, +) -> Option<(cpal::StreamConfig, cpal::SupportedBufferSize, cpal::SampleFormat)> { + for &rate in rates { + let mut best: Option<(cpal::StreamConfig, cpal::SupportedBufferSize, cpal::SampleFormat, usize, u16)> = None; + for c in configs { + let Some(rank) = devices::format_rank(c.sample_format()) else { continue }; + if !c.contains_rate(rate) || c.channels() < 2 || c.channels() > want_ch { + continue; + } + let Some(config) = c.try_with_sample_rate(rate) else { continue }; + // Better: a preferred format first, then more channels (ties keep the earlier range). + let better = (rank, std::cmp::Reverse(c.channels())); + if best.as_ref().is_none_or(|x| better < (x.3, std::cmp::Reverse(x.4))) { + best = Some((config.config(), *c.buffer_size(), c.sample_format(), rank, c.channels())); + } + } + if let Some((config, buffers, format, _, _)) = best { + return Some((config, buffers, format)); + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -1068,13 +1092,13 @@ mod tests { fn pause(&self) -> Result<(), cpal::Error> { Ok(()) } - /// The fake stream claims one fixed buffer. + /// The fake stream has no clock and no fixed buffer; neither is used by these tests. fn buffer_size(&self) -> Result { - Ok(1024) + Ok(0) } - /// The fake stream has no clock. + /// The fake stream has no usable clock; these tests do not read it. fn now(&self) -> cpal::StreamInstant { - cpal::StreamInstant::new(0, 0) + cpal::StreamInstant::from_nanos(0) } } @@ -1106,6 +1130,47 @@ mod tests { assert_eq!(won.map(|(n, _)| n), Some(2)); } + /// Build a cpal configuration range for tests with a 64..1024-frame buffer. + fn range(channels: u16, min: u32, max: u32, format: cpal::SampleFormat) -> cpal::SupportedStreamConfigRange { + cpal::SupportedStreamConfigRange::new(channels, min, max, cpal::SupportedBufferSize::Range { min: 64, max: 1024 }, format) + } + + /// Rates are tried in order, so a stale configured rate falls through to the session rate; when no requested rate + /// is offered the caller uses the device default and resamples. + #[test] + fn output_config_uses_the_first_requested_rate_the_device_offers() { + let configs = [ + range(2, 44_100, 48_000, cpal::SampleFormat::I16), + range(2, 44_100, 96_000, cpal::SampleFormat::F32), + range(8, 44_100, 96_000, cpal::SampleFormat::F32), + ]; + let (config, buffers, format) = pick_output_config(&configs, &[96_000, 48_000], 8).unwrap(); + assert_eq!((config.sample_rate, config.channels, config.buffer_size), (96_000, 8, cpal::BufferSize::Default)); + assert_eq!(format, cpal::SampleFormat::F32); + assert_eq!(buffers, cpal::SupportedBufferSize::Range { min: 64, max: 1024 }); + // 192 kHz is not offered: the session's 48 kHz is tried next. + let (config, _, format) = pick_output_config(&configs, &[192_000, 48_000], 8).unwrap(); + assert_eq!((config.sample_rate, config.channels, format), (48_000, 8, cpal::SampleFormat::F32)); + // No requested rate is offered: the caller uses the device's default config and resamples. + assert!(pick_output_config(&configs, &[192_000], 8).is_none()); + } + + /// At a supported rate, a preferred sample format wins over more channels; output stays between stereo and the + /// requested channel cap, and mono-only or wider devices take the default path. + #[test] + fn output_config_prefers_format_over_channels_and_needs_stereo_within_the_cap() { + let configs = [range(8, 44_100, 48_000, cpal::SampleFormat::I16), range(2, 44_100, 48_000, cpal::SampleFormat::F32)]; + let (config, _, format) = pick_output_config(&configs, &[48_000], 8).unwrap(); + assert_eq!((config.channels, format), (2, cpal::SampleFormat::F32), "a preferred format wins over more channels"); + let (config, _, _) = pick_output_config(&configs, &[48_000], 2).unwrap(); + assert_eq!(config.channels, 2, "the channel cap applies"); + let mono = [range(1, 44_100, 48_000, cpal::SampleFormat::F32)]; + assert!(pick_output_config(&mono, &[48_000], 8).is_none(), "a mono-only device takes the default config path"); + let surround = [range(6, 44_100, 48_000, cpal::SampleFormat::F32)]; + assert!(pick_output_config(&surround, &[48_000], 2).is_none(), "a wider device is not narrowed here"); + assert_eq!(pick_output_config(&surround, &[48_000], 6).map(|(c, _, _)| c.channels), Some(6)); + } + #[test] fn no_config_that_starts_yields_none_so_the_caller_falls_back_to_a_silent_clock() { let mut tried = 0; diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index a49b643c..07c081c1 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -142,7 +142,7 @@ pub struct Recorder { #[cfg(not(target_os = "freebsd"))] fn build_input( device: &cpal::Device, - config: &cpal::StreamConfig, + config: cpal::StreamConfig, shared: &Arc, monitor: &Arc, ) -> Result @@ -158,7 +158,7 @@ where let chunk = scratch.len(); let mut owed = 0; device.build_input_stream( - *config, + config, move |data: &[T], _: &cpal::InputCallbackInfo| { crate::mark_audio_thread(); sh.guarded(|| { @@ -179,7 +179,10 @@ where move |e: cpal::Error| { // Possibly the audio thread (see `crate::mark_audio_thread`). crate::mark_audio_thread(); - if e.kind() == cpal::ErrorKind::DeviceNotAvailable { + // cpal 0.18 reports every operation through one error type; a device that went away + // or a stream that must be rebuilt (`StreamInvalidated`) stops capture. + // `DeviceChanged` (a rerouted default device) and an xrun are not faults. + if crate::devices::stream_lost(&e) { let _ = errors.fault.compare_exchange(0, GONE, Ordering::Relaxed, Ordering::Relaxed); } log::warn!("input stream error: {e}"); @@ -188,6 +191,23 @@ where ) } +/// The input configuration at `rate` when the device offers one: the best sample format first, +/// then the most channels (so a stereo interface is preferred over a mono one). `None` lets the +/// caller use the device's default input configuration (the mix resamples when the rates differ). +fn pick_input_config(configs: &[cpal::SupportedStreamConfigRange], rate: u32) -> Option { + configs + .iter() + .filter_map(|c| { + let rank = crate::devices::format_rank(c.sample_format())?; + if !c.contains_rate(rate) { + return None; + } + c.try_with_sample_rate(rate).map(|config| (rank, std::cmp::Reverse(c.channels()), config)) + }) + .min_by_key(|(rank, channels, _)| (*rank, *channels)) + .map(|(_, _, config)| config) +} + impl Recorder { /// Open the default input device. Errors when there is none (the caller shows a message). pub fn open() -> Result { @@ -205,17 +225,21 @@ impl Recorder { let name = crate::devices::device_name(&device).unwrap_or_else(|| "Input".into()); // At the chosen rate when the device offers it, so input and output run together: the // best format first, then the most channels. - let at_rate = audio.sample_rate.and_then(|r| { - device - .supported_input_configs() - .ok()? - .filter(|c| crate::devices::format_rank(c.sample_format()).is_some() && c.min_sample_rate() <= r && c.max_sample_rate() >= r) - .min_by_key(|c| (crate::devices::format_rank(c.sample_format()), std::cmp::Reverse(c.channels()))) - .map(|c| c.with_sample_rate(r)) - }); + let configs: Vec = device.supported_input_configs().map(|configs| configs.collect()).unwrap_or_default(); + let at_rate = audio.sample_rate.and_then(|r| pick_input_config(&configs, r)); let cfg = match at_rate { Some(c) => c, - None => device.default_input_config().map_err(|e| e.to_string())?, + None => { + let d = device.default_input_config().map_err(|e| e.to_string())?; + // cpal 0.18's default can name a format these callbacks do not decode (I24, F64); + // open the same rate and channels in a format they do. + if crate::devices::format_rank(d.sample_format()).is_some() { + d + } else { + crate::devices::supported_format_config(&configs, d.sample_rate(), d.channels()) + .ok_or_else(|| format!("unsupported input sample format {:?}", d.sample_format()))? + } + } }; let format = cfg.sample_format(); if crate::devices::format_rank(format).is_none() { @@ -227,18 +251,18 @@ impl Recorder { let ring = CaptureRing::new((config.sample_rate as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25)); let shared = Arc::new(Shared::new(ring)); let monitor = InputRing::new(usize::from(config.channels), config.sample_rate); - let build = |config: &cpal::StreamConfig| match format { + let build = |config: cpal::StreamConfig| match format { cpal::SampleFormat::I32 => build_input::(&device, config, &shared, &monitor), cpal::SampleFormat::I16 => build_input::(&device, config, &shared, &monitor), _ => build_input::(&device, config, &shared, &monitor), }; // The chosen buffer size first; if the device refuses it, its default. - let stream = match build(&config) { + let stream = match build(config) { Ok(s) => s, Err(e) if config.buffer_size != cpal::BufferSize::Default => { log::warn!("cannot open the input with buffer {:?}: {e}; using the device default", config.buffer_size); config.buffer_size = cpal::BufferSize::Default; - build(&config).map_err(|e| e.to_string())? + build(config).map_err(|e| e.to_string())? } Err(e) => return Err(e.to_string()), }; @@ -290,7 +314,7 @@ impl Recorder { } let name = &self.device_name; match self.shared.fault.load(Ordering::Relaxed) { - GONE => return Some(Trouble::Device(format!("“{name}” was disconnected"))), + GONE => return Some(Trouble::Device(format!("“{name}” is unavailable"))), BROKEN => return Some(Trouble::Device("the audio input hit an internal error (see the log)".into())), _ => {} } @@ -462,6 +486,28 @@ mod tests { assert_eq!(out, [0.5, 0.0, 0.0, 0.0, -0.5]); } + /// Build a cpal configuration range for tests with a 64..1024-frame buffer. + fn range(channels: u16, min: u32, max: u32, format: cpal::SampleFormat) -> cpal::SupportedStreamConfigRange { + cpal::SupportedStreamConfigRange::new(channels, min, max, cpal::SupportedBufferSize::Range { min: 64, max: 1024 }, format) + } + + /// At the chosen rate, input selection prefers the best supported format, then more channels; a rate the device + /// does not offer returns `None` so the caller can fall back. + #[test] + fn input_config_prefers_the_best_format_then_more_channels_at_the_chosen_rate() { + let configs = [ + range(1, 44_100, 192_000, cpal::SampleFormat::I32), + range(2, 44_100, 96_000, cpal::SampleFormat::I16), + range(2, 48_000, 96_000, cpal::SampleFormat::F32), + range(8, 44_100, 48_000, cpal::SampleFormat::F32), + ]; + let c = pick_input_config(&configs, 96_000).unwrap(); + assert_eq!((c.sample_rate(), c.channels(), c.sample_format()), (96_000, 2, cpal::SampleFormat::F32)); + let c = pick_input_config(&configs, 48_000).unwrap(); + assert_eq!((c.sample_rate(), c.channels(), c.sample_format()), (48_000, 8, cpal::SampleFormat::F32), "more channels win in the same format"); + assert!(pick_input_config(&configs, 192_001).is_none(), "an unsupported rate falls back to the device default"); + } + fn recorder(shared: &Arc) -> Recorder { Recorder { shared: Arc::clone(shared), @@ -476,6 +522,8 @@ mod tests { } } + /// A stalled armed input, a panicking callback and a lost device all surface as `Trouble`; a quiet input that is + /// not recording does not. #[test] fn a_bug_a_stall_or_a_lost_device_is_trouble() { let shared = Arc::new(Shared::new(CaptureRing::new(64))); @@ -496,7 +544,7 @@ mod tests { assert_eq!(r.trouble(), Some(Trouble::Device("the audio input hit an internal error (see the log)".into()))); let gone = Arc::new(Shared::new(CaptureRing::new(64))); gone.fault.store(GONE, Ordering::Relaxed); - assert_eq!(recorder(&gone).trouble(), Some(Trouble::Device("“Mic In” was disconnected".into()))); + assert_eq!(recorder(&gone).trouble(), Some(Trouble::Device("“Mic In” is unavailable".into()))); } #[test] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 874fd2e1..87a64369 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -21,7 +21,10 @@ soundcraft-mix.workspace = true soundcraft-engine.workspace = true soundcraft-playback.workspace = true soundcraft-video.workspace = true -egui.workspace = true +# 0.37 makes the bundled faces opt-in; the app's own font loader starts from +# `FontDefinitions::default()`, and kittest never supplies fonts to this root. +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } +web-time.workspace = true serde.workspace = true serde_json.workspace = true log.workspace = true diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index 073b823d..8afbe683 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -62,7 +62,8 @@ fn main() { READY.store(true, Ordering::Relaxed); let step = |h: &mut egui_kittest::Harness| { let mut raw = std::mem::take(h.input_mut()); - h.state_mut().raw_input_hook(&mut raw); + let ctx = h.ctx.clone(); + h.state_mut().raw_input_hook(&ctx, &mut raw); *h.input_mut() = raw; h.step(); }; diff --git a/crates/ui-egui/src/bounce.rs b/crates/ui-egui/src/bounce.rs index 4368b4eb..480c2a02 100644 --- a/crates/ui-egui/src/bounce.rs +++ b/crates/ui-egui/src/bounce.rs @@ -66,7 +66,7 @@ fn slice() -> impl FnMut() -> bool { pub fn show(app: &mut SoundApp, ctx: &egui::Context) { let Some(job) = &app.bounce else { return }; let mut cancel = ctx.input(|i| i.key_pressed(egui::Key::Escape)); - egui::Modal::new(egui::Id::new("bounce_progress")).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("bounce_progress")).show(ctx, |ui| { ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0); ui.set_width(320.0); let name = job.path().file_name().unwrap_or_default().to_string_lossy(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 13ac4c26..36a222c1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -11,10 +11,16 @@ //! - `ui.key {key, cmd?, shift?, alt?, ctrl?}`, `ui.text {text}` //! - `ui.screenshot {path?}` (PNG; base64 when no path), `ui.set {...UiState fields}` //! - `app.quit` +//! +//! Scripted-input methods reply only after a UI pass applied their events, and later requests wait +//! behind that gesture. The connection's one-request-at-a-time reply order therefore also orders an +//! inspect, command or screenshot after the gesture it follows. use crate::SoundApp; +use craft_ui::capture::CaptureSlot; use serde_json::{Value, json}; use std::sync::mpsc::{Receiver, Sender, channel}; +use web_time::Instant; pub struct ControlRequest { pub method: String, @@ -29,13 +35,319 @@ impl ControlRequest { } } -pub struct PendingShot { +/// One admitted `ui.screenshot` request. The app owns the strong completion slot; the callback the +/// renderer receives holds only a weak reference to it (see `craft_ui::capture`). +pub(crate) struct PendingShot { token: u64, - reply: Sender, + /// Taken once the single reply (success, timeout, cancellation or failure) was sent. + reply: Option>, path: Option, - frames: u32, - sent: bool, - deadline_frames: u32, + /// Geometry the request was admitted with; a change before completion cancels and retries. + geometry: CaptureGeometry, + /// Session identity at admission. A different document never receives these pixels. + document: u64, + deadline: Instant, + retries: u32, + /// Bytes the captured RGBA image may occupy while it is pending or completed but undrained. + image_bytes: usize, + /// Worst-case encode peak reserved for this request (image, PNG buffer and base64 string). + reserved_bytes: usize, + slot: Option, + issued: bool, +} + +impl PendingShot { + /// Send the single reply if it has not been taken; later completions are ignored. + fn reply(&mut self, v: Value) { + if let Some(tx) = self.reply.take() { + let _ = tx.send(v); + } + } + + /// Release the capture slot and send one error reply. + fn fail(&mut self, e: impl std::fmt::Display) { + self.slot = None; + self.reply(err(e)); + } +} + +/// The viewport geometry a screenshot is bound to. Captured from the host's raw input for every +/// host frame (including logic-only ticks), so a hidden resize is seen without a stale UI input. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CaptureGeometry { + pub points: egui::Vec2, + pub pixels_per_point: f32, + pub max_texture_side: usize, + pub focused: bool, +} + +impl CaptureGeometry { + /// Geometry from a host raw-input frame, or `None` when the viewport has no finite, positive size. A missing + /// texture limit falls back to the last one egui saw instead of inventing a smaller cap. + pub(crate) fn from_raw(ctx: &egui::Context, raw: &egui::RawInput) -> Option { + let viewport = raw.viewports.get(&raw.viewport_id); + let points = viewport.and_then(|v| v.inner_rect).or(raw.screen_rect)?.size(); + if !(points.x.is_finite() && points.y.is_finite()) || points.x <= 0.0 || points.y <= 0.0 { + return None; + } + let native = viewport.and_then(|v| v.native_pixels_per_point).unwrap_or(1.0); + Some(Self { + points, + pixels_per_point: (native * ctx.zoom_factor()).max(0.01), + // A host that omits the limit still has it in the last input egui interpreted; using the + // context value avoids inventing a 2048 cap that would reject a larger valid surface. + max_texture_side: raw.max_texture_side.unwrap_or_else(|| ctx.input(|i| i.max_texture_side)), + focused: raw.focused, + }) + } + + /// Geometry from the context's current viewport, used outside a raw-input frame with the same scaling and + /// texture-limit rules. + pub(crate) fn from_context(ctx: &egui::Context) -> Self { + let viewport = ctx.input(|i| i.viewport().clone()); + let points = viewport.inner_rect.map_or_else(|| ctx.viewport_rect().size(), |r| r.size()); + let native = viewport.native_pixels_per_point.unwrap_or(1.0); + Self { + points, + pixels_per_point: (native * ctx.zoom_factor()).max(0.01), + max_texture_side: ctx.input(|i| i.max_texture_side), + focused: ctx.input(|i| i.focused), + } + } + + /// The physical pixel size the capture is expected to have: the viewport points at the current + /// scale, clamped by the renderer's texture limit, with checked arithmetic. + fn expected_size(self) -> Option<[usize; 2]> { + let side = self.max_texture_side.max(1); + let axis = |points: f32| -> Option { + let px = (points * self.pixels_per_point).round(); + if !px.is_finite() || px <= 0.0 { + return None; + } + Some((px as usize).min(side)) + }; + Some([axis(self.points.x)?, axis(self.points.y)?]) + } + + /// Bytes for the captured RGBA image at the expected size, with checked arithmetic. + fn reserved_bytes(self) -> Option { + let [w, h] = self.expected_size()?; + w.checked_mul(h)?.checked_mul(std::mem::size_of::()) + } + + /// The worst-case memory needed to encode this image, not just to hold its pixels. + /// + /// Encoding builds a raw RGBA copy and a PNG buffer while the captured image is still alive; + /// the base64 reply form then keeps the PNG and builds its 4/3-size text. Reserving only the + /// image made the documented aggregate a retained-image budget rather than a peak bound. + fn encode_peak_bytes(self, base64_reply: bool) -> Option { + let image = self.reserved_bytes()?; + let [_, height] = self.expected_size()?; + // PNG adds one filter byte per row and chunk/frame overhead; this also allows for a + // worst-case incompressible deflate stream (stored blocks are far below 1/16 expansion). + let png = image.checked_add(image / 16)?.checked_add(height)?.checked_add(4096)?; + let png_phase = image.checked_add(image)?.checked_add(png)?; + if base64_reply { + let b64 = png.div_ceil(3).checked_mul(4)?; + let b64_phase = image.checked_add(png)?.checked_add(b64)?; + Some(png_phase.max(b64_phase)) + } else { + Some(png_phase) + } + } + + /// The most bytes an image [`check_image`] accepts for this geometry: one pixel larger on each + /// axis, the same tolerance the dimension check uses. Reserving the exact size but rejecting an + /// image the staleness rule accepts would fail a legitimate DPI/surface rounding by one pixel. + fn tolerated_bytes(self) -> Option { + let [w, h] = self.expected_size()?; + w.checked_add(1)?.checked_mul(h.checked_add(1)?)?.checked_mul(std::mem::size_of::()) + } +} + +/// Why a completed image is rejected. A stale image, or one whose dimensions no longer match the +/// current viewport, is retried; a malformed image for the current geometry is reported. +enum ImageProblem { + Stale, + Malformed(String), +} + +/// The smallest uniform scale a returned image may have relative to the capture-time viewport +/// before it is treated as truncated rather than a legitimately reduced `surface_fit` surface. +/// The expected size is already clamped by `max_texture_side`, so a real surface stays near it; +/// this only rejects a partial read (a 1x1 or 2x1 image of a normal window is far below it). +const MIN_CAPTURE_SCALE: f32 = 0.1; + +/// Whether the returned pixels belong to this request's logical viewport. `surface_fit` may render a +/// smaller surface than the window, so a uniformly reduced image is accepted: the effective scale +/// is derived from the image and the capture-time logical size instead of requiring an exact match. +fn check_image(img: &egui::ColorImage, expected: [usize; 2]) -> Result<(), ImageProblem> { + let [w, h] = img.size; + if w == 0 || h == 0 { + return Err(ImageProblem::Malformed("screenshot has zero size".into())); + } + let pixels = w.checked_mul(h).ok_or_else(|| ImageProblem::Malformed("screenshot dimensions overflow".into()))?; + if img.pixels.len() != pixels { + return Err(ImageProblem::Malformed("screenshot pixel count does not match its dimensions".into())); + } + let (ew, eh) = (expected[0].max(1), expected[1].max(1)); + if w > ew + 1 || h > eh + 1 { + return Err(ImageProblem::Stale); + } + let scale_x = w as f32 / ew as f32; + let scale_y = h as f32 / eh as f32; + if (scale_x - scale_y).abs() > 0.02 { + return Err(ImageProblem::Stale); + } + // A 1x1 or otherwise truncated read has a plausible aspect ratio but is not a screenshot of the + // viewport; it must not be written and replied as a successful capture. + if scale_x < MIN_CAPTURE_SCALE || scale_y < MIN_CAPTURE_SCALE { + return Err(ImageProblem::Malformed(format!("screenshot is much smaller than its {ew}x{eh} viewport"))); + } + Ok(()) +} + +/// Re-reserve a changed window's image size, rejecting a growth past the per-image or aggregate +/// bound. `others` is the bytes reserved by the other outstanding requests. +fn rebudget(shot: &mut PendingShot, geometry: CaptureGeometry, others: usize) -> Result<(), String> { + let Some(image_bytes) = geometry.reserved_bytes() else { + return Err("screenshot unavailable: the window has no capturable size yet".into()); + }; + if image_bytes > MAX_SHOT_BYTES { + return Err(format!("screenshot too large: {image_bytes} bytes over the {MAX_SHOT_BYTES}-byte per-image limit")); + } + let Some(bytes) = geometry.encode_peak_bytes(shot.path.is_none()) else { + return Err("screenshot unavailable: the window has no capturable size yet".into()); + }; + let total = others.saturating_add(bytes); + if total > MAX_SHOT_TOTAL_BYTES { + return Err(format!("screenshot too large: {total} bytes of encoding peak over the {MAX_SHOT_TOTAL_BYTES}-byte budget")); + } + shot.image_bytes = image_bytes; + shot.reserved_bytes = bytes; + Ok(()) +} + +const MAX_PENDING_SHOTS: usize = 8; +const MAX_IN_FLIGHT_SHOTS: usize = 4; +const MAX_SHOT_BYTES: usize = 64 << 20; +const MAX_SHOT_TOTAL_BYTES: usize = 192 << 20; +/// A scripted gesture gets this long to reach a UI pass before its request chain is canceled. +const GESTURE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +const MAX_SHOT_RETRIES: u32 = 2; +const SHOT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8); +/// The most scripted events one client may leave waiting for the UI. A drag or scroll request is +/// at most a few hundred events (its `steps` are capped), so this is far above a real gesture and +/// still keeps a client that outruns the UI from growing the queue without bound. +const MAX_SYNTHETIC_EVENTS: usize = 4096; +/// The most text one `ui.text` request may type. Input-derived strings are otherwise bounded only +/// by the transport's ~96 MiB request-line limit. +const MAX_CONTROL_TEXT_BYTES: usize = 64 << 10; +/// The most bytes a screenshot output path may hold; a path is copied into the pending request. +const MAX_SHOT_PATH_BYTES: usize = 4096; + +/// Queue scripted input, refusing a request that would grow the pending event queue past +/// [`MAX_SYNTHETIC_EVENTS`]. Each request is small; the cap bounds a client that sends many. +fn queue_synthetic(app: &mut SoundApp, reply: Sender, response: Value, events: impl IntoIterator) -> Result<(), String> { + let events: Vec = events.into_iter().collect(); + if app.synthetic.len().saturating_add(events.len()) > MAX_SYNTHETIC_EVENTS { + return Err(format!("synthetic input queue full: at most {MAX_SYNTHETIC_EVENTS} events may wait")); + } + app.gesture_acks.push_back(GestureAck { remaining: events.len(), deadline: app.clock_now() + GESTURE_TIMEOUT, reply: Some(reply), response }); + app.synthetic.extend(events); + Ok(()) +} + +/// Queue a gesture and return no immediate reply: [`synthetic_delivered`] sends the result once +/// a UI pass has applied every event. Only a queue-bound refusal replies here. +fn queue_reply(app: &mut SoundApp, req: &ControlRequest, events: Vec, response: Value) -> Option { + match queue_synthetic(app, req.reply.clone(), ok(response), events) { + Ok(()) => None, + Err(e) => Some(err(e)), + } +} + +/// A gesture's reply is held until a UI pass has applied all of its events. This is the ordering +/// barrier for the control channel: the connection thread sends one request at a time, so nothing +/// that follows a click/key/text (inspect, engine command or screenshot) can be admitted before +/// the gesture changed the UI. +pub(crate) struct GestureAck { + remaining: usize, + deadline: Instant, + reply: Option>, + response: Value, +} + +/// True while a scripted gesture has events waiting or an applied batch still needs acknowledging. +pub(crate) fn gesture_pending(app: &SoundApp) -> bool { + !app.gesture_acks.is_empty() || !app.synthetic.is_empty() || app.synthetic_batch.is_some() +} + +/// Count one delivered batch against the front gesture. Batches never straddle requests (a key +/// press/release pair is taken together), so the first unfinished gesture owns it. +pub(crate) fn synthetic_delivered(app: &mut SoundApp, delivered: usize) { + let mut left = delivered; + while left > 0 { + let Some(front) = app.gesture_acks.front_mut() else { break }; + let take = front.remaining.min(left); + front.remaining -= take; + left -= take; + if front.remaining == 0 { + if let Some(mut ack) = app.gesture_acks.pop_front() + && let Some(tx) = ack.reply.take() + { + let _ = tx.send(ack.response); + } + } else { + break; + } + } +} + +/// A gesture that cannot reach a UI pass (a host that never paints) must not wedge every later +/// control request. Cancel the whole pending gesture chain: FIFO means no later request can run +/// while an earlier undelivered click is still waiting. +pub(crate) fn expire_gestures(app: &mut SoundApp) { + let now = app.clock_now(); + if !app.gesture_acks.iter().any(|ack| now >= ack.deadline) { + return; + } + // A batch that the hook already appended to a pass may still be sitting in that pass's + // `InputState`. Mark it so `ui` drops it before shortcuts or widgets dispatch it. + if !app.injected_events.is_empty() { + let scroll = app + .synthetic_batch + .as_ref() + .is_some_and(|batch| batch.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. } | egui::Event::Zoom(_)))); + let keys = app.synthetic_batch.as_ref().is_some_and(|batch| { + batch + .iter() + .any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_) | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_))) + }); + if scroll { + // Removing a wheel/zoom event alone would leave egui's per-pass aggregates and its + // frame-to-frame wheel smoothing carrying the expired gesture. `ui` replays the host + // input from the pre-injection state instead. + app.cancel_synthetic_scroll = app.input_before_scroll.is_some(); + } else if keys { + app.cancel_synthetic_keys = true; + } + } + app.synthetic.clear(); + app.synthetic_batch = None; + if std::mem::take(&mut app.synthetic_pointer_active) { + // A consumed press cannot just lose its queued release: egui would remain down. A + // synthetic release is also wrong (it may click/drop), so cancel before the next input + // pass. Drop app-owned drag previews now, before drive_drag can commit one on !any_down. + app.cancel_synthetic_pointer = true; + app.pointer_down = false; + app.gesture = None; + } + for mut ack in std::mem::take(&mut app.gesture_acks) { + if let Some(tx) = ack.reply.take() { + let _ = tx.send(err("synthetic input was not delivered")); + } + } } fn ok(v: Value) -> Value { @@ -46,13 +358,29 @@ fn err(e: impl std::fmt::Display) -> Value { json!({"ok": false, "error": e.to_string()}) } +/// Run at most 65 queued control requests on the UI thread, stopping while a scripted gesture is undelivered so later +/// requests observe its result. A panicking handler still gets one error reply. pub fn drain(app: &mut SoundApp, ctx: &egui::Context) { let Some(rx) = app.control_rx.take() else { return }; let mut n = 0; - while let Ok(req) = rx.try_recv() { + loop { + // Do not let a request behind an undelivered gesture observe or mutate the state the + // gesture is about to change. The request stays in the bounded transport queue. + if gesture_pending(app) { + break; + } + let Ok(req) = rx.try_recv() else { break }; n += 1; - if let Some(v) = handle(app, ctx, &req) { - let _ = req.reply.send(v); + // A handler that panics must not take the receiver with it: the UI pass catches the panic + // one level up, and a dead control channel afterwards would be a second failure. + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handle(app, ctx, &req))) { + Ok(Some(v)) => { + let _ = req.reply.send(v); + } + Ok(None) => {} + Err(_) => { + let _ = req.reply.send(err("control request failed")); + } } if n > 64 { break; @@ -89,6 +417,26 @@ fn mods(p: &Value) -> egui::Modifiers { } } +/// List engine commands plus UI commands, applying the same case-insensitive id/label filter to both. +fn command_list(app: &mut SoundApp, p: &Value) -> Value { + let mut list = match app.engine.execute("engine.commands", p) { + Ok(v) => v, + Err(e) => return err(e), + }; + // Same predicate as the engine's `engine.commands`: id or label substring, ASCII case-insensitive. + let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase); + let matches = + |id: &str, label: &str| filter.as_deref().is_none_or(|f| id.to_ascii_lowercase().contains(f) || label.to_ascii_lowercase().contains(f)); + if let Some(arr) = list.as_array_mut() { + for (id, label, path, sc) in crate::menus::UI_COMMANDS.iter().filter(|c| matches(c.0, c.1)) { + arr.push(json!({"id": id, "label": label, "menu": path, "shortcut": sc, "enabled": true, "params": "{}"})); + } + } + ok(list) +} + +/// Dispatch one control method to commands, inspection, menus or queued scripted input. Methods that need a UI pass +/// return `None` and reply later; everything else returns the reply for [`drain`] to send. fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Option { let p = &req.params; let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; @@ -103,23 +451,7 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti Err(e) => err(e), } } - "engine.commands" => { - let mut list = match app.engine.execute("engine.commands", p) { - Ok(v) => v, - Err(e) => return Some(err(e)), - }; - // Same predicate as the engine's `engine.commands`: id or label substring, ASCII case-insensitive. - let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase); - let matches = |id: &str, label: &str| { - filter.as_deref().is_none_or(|f| id.to_ascii_lowercase().contains(f) || label.to_ascii_lowercase().contains(f)) - }; - if let Some(arr) = list.as_array_mut() { - for (id, label, path, sc) in crate::menus::UI_COMMANDS.iter().filter(|c| matches(c.0, c.1)) { - arr.push(json!({"id": id, "label": label, "menu": path, "shortcut": sc, "enabled": true, "params": "{}"})); - } - } - ok(list) - } + "engine.commands" => command_list(app, p), "engine.parity" => ok(crate::menus::parity()), "session.inspect" | "document.inspect" => { ok(soundcraft_engine::inspect::session(&app.engine, p.get("detail").and_then(Value::as_str) == Some("full"))) @@ -155,8 +487,8 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti Err(e) => err(e), }, "ui.move" => { - app.synthetic.push(egui::Event::PointerMoved(pos)); - ok(json!({})) + let events = vec![egui::Event::PointerMoved(pos)]; + return queue_reply(app, req, events, json!({})); } "ui.click" => { let button = match p.get("button").and_then(Value::as_str) { @@ -165,73 +497,72 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti }; let m = mods(p); let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3); - app.synthetic.push(egui::Event::PointerMoved(pos)); + let mut events = vec![egui::Event::PointerMoved(pos)]; for _ in 0..count { - app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); - app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); + events.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); + events.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); } - ok(json!({})) + return queue_reply(app, req, events, json!({})); } "ui.drag" => { let (a, b) = (pos, egui::pos2(f("to_x"), f("to_y"))); let m = mods(p); - app.synthetic.push(egui::Event::PointerMoved(a)); - app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); + let mut events = vec![ + egui::Event::PointerMoved(a), + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }, + ]; for i in 1..=steps { let k = i as f32 / steps as f32; - app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * k)); + events.push(egui::Event::PointerMoved(a + (b - a) * k)); } - app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); - ok(json!({})) + events.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); + return queue_reply(app, req, events, json!({})); } "ui.scroll" => { let line = p.get("unit").and_then(Value::as_str) == Some("line"); let unit = if line { egui::MouseWheelUnit::Line } else { egui::MouseWheelUnit::Point }; let d = |k: &str| f(k).clamp(-100_000.0, 100_000.0) / steps as f32; let (delta, modifiers) = (egui::vec2(d("dx"), d("dy")), mods(p)); - let wheel = |phase, delta| egui::Event::MouseWheel { unit, delta, phase, modifiers }; - app.synthetic.push(egui::Event::PointerMoved(pos)); - app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::Start, egui::Vec2::ZERO))); - app.synthetic.extend((0..steps).map(|_| wheel(egui::TouchPhase::Move, delta))); - app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::End, egui::Vec2::ZERO))); - ok(json!({})) + let source = if line { egui::MouseWheelSource::Wheel } else { egui::MouseWheelSource::Trackpad }; + let wheel = |phase, delta| egui::Event::MouseWheel { unit, delta, phase, source, modifiers }; + let mut events = vec![egui::Event::PointerMoved(pos)]; + events.extend((!line).then(|| wheel(egui::TouchPhase::Start, egui::Vec2::ZERO))); + events.extend((0..steps).map(|_| wheel(egui::TouchPhase::Move, delta))); + events.extend((!line).then(|| wheel(egui::TouchPhase::End, egui::Vec2::ZERO))); + let source = if line { "wheel" } else { "trackpad" }; + return queue_reply(app, req, events, json!({"source": source})); } "ui.zoom" => { let Some(factor) = p.get("factor").and_then(Value::as_f64).filter(|v| *v > 0.0) else { return Some(err("ui.zoom needs a positive factor (> 1 zooms in)")); }; let each = factor.clamp(1e-3, 1e3).powf(1.0 / steps as f64) as f32; - app.synthetic.push(egui::Event::PointerMoved(pos)); - app.synthetic.extend((0..steps).map(|_| egui::Event::Zoom(each))); - ok(json!({})) + let mut events = vec![egui::Event::PointerMoved(pos)]; + events.extend((0..steps).map(|_| egui::Event::Zoom(each))); + return queue_reply(app, req, events, json!({})); } "ui.key" => { let name = p.get("key").and_then(Value::as_str).unwrap_or(""); let Some(key) = key_from(name) else { return Some(err(format!("unknown key `{name}`"))) }; let m = mods(p); - app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }); - app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }); - ok(json!({})) + let events = vec![ + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }, + ]; + return queue_reply(app, req, events, json!({})); } "ui.text" => { - let t = p.get("text").and_then(Value::as_str).unwrap_or("").to_string(); - app.synthetic.push(egui::Event::Text(t)); - ok(json!({})) - } - "ui.screenshot" => { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - let token = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - app.pending_shots.push(PendingShot { - token, - reply: req.reply.clone(), - path: p.get("path").and_then(Value::as_str).map(str::to_string), - frames: 0, - sent: false, - deadline_frames: 600, - }); - ctx.request_repaint(); - return None; + let t = p.get("text").and_then(Value::as_str).unwrap_or(""); + if t.len() > MAX_CONTROL_TEXT_BYTES { + return Some(err(format!("ui.text is limited to {MAX_CONTROL_TEXT_BYTES} bytes"))); + } + let events = vec![egui::Event::Text(t.to_string())]; + return queue_reply(app, req, events, json!({})); } + "ui.screenshot" => match admit(app, ctx, req) { + Ok(()) => return None, + Err(e) => err(e), + }, "app.quit" => { app.quit_requested = true; ok(json!({})) @@ -240,47 +571,233 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti }) } -/// Request and collect viewport screenshots for pending `ui.screenshot` calls. -pub fn collect_screenshots(app: &mut SoundApp, ctx: &egui::Context) { +/// Admit a `ui.screenshot` request with a checked byte reservation. +/// +/// The reply stays pending until [`drain_screenshots`] reports success, timeout, cancellation or a +/// failure. Admission is rejected before a capture is issued when the queue, the per-image bound or +/// the aggregate bound would be exceeded; a rejected request writes no file. +fn admit(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Result<(), String> { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + if app.pending_shots.len() >= MAX_PENDING_SHOTS { + return Err("screenshot queue full".into()); + } + let geometry = app.capture_geometry(ctx); + let Some(image_bytes) = geometry.reserved_bytes() else { + return Err("screenshot unavailable: the window has no capturable size yet".into()); + }; + if image_bytes > MAX_SHOT_BYTES { + return Err(format!("screenshot too large: {image_bytes} bytes over the {MAX_SHOT_BYTES}-byte per-image limit")); + } + let path = req.params.get("path").and_then(Value::as_str); + let Some(bytes) = geometry.encode_peak_bytes(path.is_none()) else { + return Err("screenshot unavailable: the window has no capturable size yet".into()); + }; + let reserved: usize = app.pending_shots.iter().map(|s| s.reserved_bytes).sum(); + let total = reserved.saturating_add(bytes); + if total > MAX_SHOT_TOTAL_BYTES { + return Err(format!("screenshot too large: {total} bytes of encoding peak over the {MAX_SHOT_TOTAL_BYTES}-byte budget")); + } + if path.is_some_and(|p| p.len() > MAX_SHOT_PATH_BYTES) { + return Err(format!("screenshot path longer than {MAX_SHOT_PATH_BYTES} bytes")); + } + let token = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + app.pending_shots.push(PendingShot { + token, + reply: Some(req.reply.clone()), + path: path.map(str::to_string), + geometry, + document: app.doc_epoch, + deadline: app.clock_now() + SHOT_TIMEOUT, + retries: 0, + image_bytes, + reserved_bytes: bytes, + slot: None, + issued: false, + }); + ctx.request_repaint(); + Ok(()) +} + +/// Issue admitted screenshots from a known UI pass: after layout, with fonts ready and only when the +/// pass will not be discarded. A request is bound to the geometry and document current at issuance; +/// one-shot callbacks go to the renderer, while the app keeps the strong completion slot. +pub fn issue_screenshots(app: &mut SoundApp, ctx: &egui::Context) { if app.pending_shots.is_empty() { return; } - let images: Vec<(u64, std::sync::Arc)> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - user_data.data.as_ref().and_then(|d| d.downcast_ref::()).map(|t| (*t, image.clone())) - } - _ => None, - }) - .collect() - }); - let mut keep = Vec::new(); - for mut s in std::mem::take(&mut app.pending_shots) { - if let Some((_, img)) = images.iter().find(|(t, _)| *t == s.token) { - let _ = s.reply.send(encode(img, s.path.as_deref())); + if ctx.will_discard() { + ctx.request_repaint(); + return; + } + let now = app.clock_now(); + let geometry = app.capture_geometry(ctx); + let document = app.doc_epoch; + let mut in_flight = app.pending_shots.iter().filter(|s| s.issued).count(); + let mut remaining: usize = app.pending_shots.iter().map(|s| s.reserved_bytes).sum(); + let mut keep = Vec::with_capacity(app.pending_shots.len()); + for mut shot in std::mem::take(&mut app.pending_shots) { + remaining = remaining.saturating_sub(shot.reserved_bytes); + if shot.issued { + keep.push(shot); continue; } - s.frames += 1; - if !s.sent && s.frames >= 3 { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(s.token))); - s.sent = true; - } - if s.frames > s.deadline_frames { - let _ = s.reply.send(err("no frame was presented (is the window visible?)")); + if now >= shot.deadline { + shot.fail("screenshot timed out"); continue; } - keep.push(s); + if shot.document != document { + shot.fail("screenshot canceled: the session changed"); + continue; + } + if geometry.focused != shot.geometry.focused { + shot.fail("screenshot canceled: focus changed"); + continue; + } + if in_flight >= MAX_IN_FLIGHT_SHOTS { + keep.push(shot); + continue; + } + if geometry != shot.geometry { + // The window changed since admission. The retry is counted here and again only if the + // completed image still belongs to different geometry. + shot.retries = shot.retries.saturating_add(1); + if shot.retries > MAX_SHOT_RETRIES { + shot.fail("screenshot stale: the window kept changing"); + continue; + } + let others = keep.iter().map(|s: &PendingShot| s.reserved_bytes).sum::() + remaining; + if let Err(e) = rebudget(&mut shot, geometry, others) { + shot.fail(e); + continue; + } + shot.geometry = geometry; + } + // Bound the retained image by this request's own reservation (plus the same one-pixel + // tolerance the dimension check allows), not by the 64 MiB per-image ceiling: four small + // requests must not each retain a full-size image and exceed the aggregate budget. + let image_bound = shot.geometry.tolerated_bytes().unwrap_or(shot.image_bytes).min(MAX_SHOT_BYTES); + let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), image_bound); + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback)); + shot.slot = Some(slot); + shot.issued = true; + in_flight += 1; + keep.push(shot); } app.pending_shots = keep; ctx.request_repaint(); } +/// Drain completed, cancelled, stale or timed-out requests. Runs in `logic`; it never writes a file +/// while holding a completion-slot lock and always sends exactly one reply per request. +pub fn drain_screenshots(app: &mut SoundApp, ctx: &egui::Context) { + if app.pending_shots.is_empty() { + return; + } + let now = app.clock_now(); + let geometry = app.capture_geometry(ctx); + let document = app.doc_epoch; + let mut remaining: usize = app.pending_shots.iter().map(|s| s.reserved_bytes).sum(); + let mut keep = Vec::with_capacity(app.pending_shots.len()); + for mut shot in std::mem::take(&mut app.pending_shots) { + remaining = remaining.saturating_sub(shot.reserved_bytes); + // The deadline is checked before any completed image is taken or written: a callback that + // arrives too late must not create a file or a second reply. + if now >= shot.deadline { + shot.fail("screenshot timed out"); + continue; + } + if shot.document != document { + shot.fail("screenshot canceled: the session changed"); + continue; + } + if geometry.focused != shot.geometry.focused { + shot.fail("screenshot canceled: focus changed"); + continue; + } + let Some(slot) = shot.slot.take() else { + keep.push(shot); + continue; + }; + let Some(result) = slot.take() else { + shot.slot = Some(slot); + keep.push(shot); + continue; + }; + let image = match result { + Ok(image) => image, + Err(e) => { + shot.fail(e); + continue; + } + }; + // The image is validated against the geometry that is current *now*, not the geometry it + // was requested for. An image can be perfectly well-formed for the size at issuance and + // still be stale; that is why the retry decision comes before writing or replying. + let stale = geometry != shot.geometry || shot.geometry.expected_size().is_none(); + let expected = geometry.expected_size().unwrap_or([1, 1]); + // The slot already enforced this issuance-time bound; check it again so a future slot + // change cannot bypass it. A changed window is stale and retried before this matters. + let tolerance = shot.geometry.tolerated_bytes().unwrap_or(shot.image_bytes).min(MAX_SHOT_BYTES); + let image_bytes = image.size[0].checked_mul(image.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::())); + let problem = match image_bytes { + None => Some(ImageProblem::Malformed("screenshot dimensions overflow".into())), + Some(b) if b > tolerance => Some(ImageProblem::Malformed("screenshot too large: the image exceeded its reservation".into())), + Some(_) => check_image(&image, expected).err(), + }; + // An image rendered for earlier geometry is retried whatever its dimensions: a window that + // grew can make an old image look truncated, and one that shrank can make it look oversized. + // Only a bad image for the current geometry is a permanent failure. + if stale || matches!(&problem, Some(ImageProblem::Stale)) { + if shot.retries >= MAX_SHOT_RETRIES { + shot.fail("screenshot stale: the window changed while it was captured"); + } else { + let others = keep.iter().map(|s: &PendingShot| s.reserved_bytes).sum::() + remaining; + match rebudget(&mut shot, geometry, others) { + Ok(()) => { + shot.retries = shot.retries.saturating_add(1); + shot.issued = false; + shot.geometry = geometry; + keep.push(shot); + } + Err(e) => shot.fail(e), + } + } + continue; + } + if let Some(ImageProblem::Malformed(why)) = problem { + shot.fail(why); + continue; + } + shot.reply(encode(&image, shot.path.as_deref(), shot.token)); + } + app.pending_shots = keep; + if !app.pending_shots.is_empty() { + ctx.request_repaint(); + } +} + +/// Whether a hidden or logic-only tick must ask the host for a UI paint so an admitted capture can +/// be issued (or a settling one collected by a later pass). +pub fn screenshots_need_paint(app: &SoundApp) -> bool { + app.pending_shots.iter().any(|s| !s.issued) +} + +/// Requests retained by the app, for tests and diagnostics. +pub fn pending_shot_count(app: &SoundApp) -> usize { + app.pending_shots.len() +} + +/// Encode a color image as PNG, rejecting an inconsistent or overflowing pixel count and reserving the raw buffer +/// fallibly. pub fn png_bytes(img: &egui::ColorImage) -> Result, String> { let [w, h] = img.size; - let mut raw = Vec::with_capacity(w * h * 4); + let pixels = w.checked_mul(h).ok_or("image dimensions overflow")?; + let len = pixels.checked_mul(4).ok_or("image dimensions overflow")?; + if img.pixels.len() != pixels { + return Err("bad image size".into()); + } + let mut raw = Vec::new(); + raw.try_reserve_exact(len).map_err(|e| format!("cannot allocate {len} bytes for the screenshot: {e}"))?; for c in &img.pixels { raw.extend_from_slice(&c.to_array()); } @@ -291,17 +808,19 @@ pub fn png_bytes(img: &egui::ColorImage) -> Result, String> { Ok(out.into_inner()) } -fn encode(img: &egui::ColorImage, path: Option<&str>) -> Value { +/// Encode a completed screenshot, writing it to `path` or returning base64, and report the image size and request +/// token. +fn encode(img: &egui::ColorImage, path: Option<&str>, token: u64) -> Value { let bytes = match png_bytes(img) { Ok(b) => b, Err(e) => return err(e), }; match path { Some(p) => match std::fs::write(p, &bytes) { - Ok(()) => ok(json!({"path": p, "width": img.size[0], "height": img.size[1]})), + Ok(()) => ok(json!({"request": token, "path": p, "width": img.size[0], "height": img.size[1]})), Err(e) => err(format!("{p}: {e}")), }, - None => ok(json!({"png_base64": base64(&bytes), "width": img.size[0], "height": img.size[1]})), + None => ok(json!({"request": token, "png_base64": base64(&bytes), "width": img.size[0], "height": img.size[1]})), } } @@ -326,10 +845,907 @@ pub fn base64(data: &[u8]) -> String { #[cfg(test)] mod tests { + use super::*; + use crate::Services; + use egui::{Color32, ColorImage, RawInput, Rect, ViewportCommand, ViewportId, ViewportInfo, pos2, vec2}; + use serde_json::json; + use std::path::PathBuf; + use std::sync::Arc; + use std::sync::mpsc::channel; + use std::time::Duration; + + /// A host frame with the given logical size and texture limit, native scale 1.0 and no events. + fn raw_input(width: f32, height: f32, max_texture_side: usize) -> RawInput { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(width, height)); + let viewport = ViewportInfo { inner_rect: Some(rect), native_pixels_per_point: Some(1.0), ..Default::default() }; + RawInput { + screen_rect: Some(rect), + max_texture_side: Some(max_texture_side), + viewports: [(ViewportId::ROOT, viewport)].into_iter().collect(), + ..Default::default() + } + } + + /// A demo-backed app with no player, for control tests. + fn app() -> SoundApp { + SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()) + } + + /// Run one real UI pass: feed raw input, then logic and UI, dropping texture deltas the tests do not inspect. + fn frame(app: &mut SoundApp, ctx: &egui::Context, mut raw: RawInput) -> egui::FullOutput { + app.raw_input_hook(ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out + } + + /// Run four frames so the first layout and demo state have settled. + fn warm(app: &mut SoundApp, ctx: &egui::Context) { + for _ in 0..4 { + let _ = frame(app, ctx, raw_input(800.0, 600.0, 8192)); + } + } + + /// Run real UI passes until every queued scripted gesture has reached one and its reply is sent. + fn deliver_gestures(app: &mut SoundApp, ctx: &egui::Context) { + for _ in 0..MAX_SYNTHETIC_EVENTS + 16 { + if !gesture_pending(app) { + return; + } + let _ = frame(app, ctx, raw_input(800.0, 600.0, 8192)); + } + } + + /// Queue one screenshot request and install its receiver for [`drain`]. + fn request(app: &mut SoundApp, path: Option<&PathBuf>) -> std::sync::mpsc::Receiver { + let (tx, rx) = channel(); + let params = path.map_or_else(|| json!({}), |p| json!({"path": p})); + let (req, reply) = ControlRequest::new("ui.screenshot", params); + tx.send(req).unwrap(); + app.control_rx = Some(rx); + reply + } + + /// First screenshot callback emitted in this frame, if any. + fn take_callback(out: &egui::FullOutput) -> Option { + take_callbacks(out).into_iter().next() + } + + /// All screenshot callbacks emitted for the root viewport in this frame. + fn take_callbacks(out: &egui::FullOutput) -> Vec { + out.viewport_output + .get(&ViewportId::ROOT) + .map(|o| { + o.commands + .iter() + .filter_map(|c| match c { + ViewportCommand::Screenshot(cb) => Some(cb.clone()), + _ => None, + }) + .collect() + }) + .unwrap_or_default() + } + + /// A process-unique temporary PNG path for one test. + fn tmp(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("soundcraft-shot-{}-{name}.png", std::process::id())) + } + + /// A completed capture writes one file and returns its dimensions once; draining the single completion leaves no + /// further reply. #[test] - fn base64_known() { - assert_eq!(super::base64(b"Man"), "TWFu"); - assert_eq!(super::base64(b"Ma"), "TWE="); - assert_eq!(super::base64(b"M"), "TQ=="); + fn a_screenshot_completes_once_and_writes_one_file() { + let (mut a, ctx) = (app(), egui::Context::default()); + warm(&mut a, &ctx); + let path = tmp("success"); + let _ = std::fs::remove_file(&path); + let reply = request(&mut a, Some(&path)); + let out = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + let callback = take_callback(&out).expect("the request is issued from the UI pass"); + callback.complete(Arc::new(ColorImage::filled([800, 600], Color32::RED))); + let _ = ctx.run_logic(&raw_input(800.0, 600.0, 8192), |ctx| a.logic(ctx)); + let v = reply.try_recv().expect("one reply"); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(v["result"]["width"], 800); + assert!(path.exists()); + assert!(reply.try_recv().is_err(), "a late callback must not produce a second reply"); + assert_eq!(pending_shot_count(&a), 0); + let _ = std::fs::remove_file(&path); + } + + /// A callback after the deadline sends a timeout and must not create the requested file. + #[test] + fn a_callback_after_the_deadline_times_out_without_writing() { + let (mut a, ctx) = (app(), egui::Context::default()); + warm(&mut a, &ctx); + let path = tmp("late"); + let reply = request(&mut a, Some(&path)); + let out = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + let callback = take_callback(&out).expect("issued"); + a.advance_test_clock(Duration::from_secs(9)); + callback.complete(Arc::new(ColorImage::filled([800, 600], Color32::RED))); + let _ = ctx.run_logic(&raw_input(800.0, 600.0, 8192), |ctx| a.logic(ctx)); + let v = reply.try_recv().expect("one timeout reply"); + assert_eq!(v["ok"], false); + assert!(v["error"].as_str().unwrap_or_default().contains("timed out"), "{v}"); + assert!(!path.exists(), "a late result must not create a file"); + } + + /// Replacing the document cancels a pending capture and frees its reservation without writing a file. + #[test] + fn a_session_change_cancels_the_request_and_releases_its_pixels() { + let (mut a, ctx) = (app(), egui::Context::default()); + warm(&mut a, &ctx); + let path = tmp("document"); + let reply = request(&mut a, Some(&path)); + let out = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + let callback = take_callback(&out).expect("issued"); + a.doc_epoch = a.doc_epoch.wrapping_add(1); + callback.complete(Arc::new(ColorImage::filled([800, 600], Color32::RED))); + let _ = ctx.run_logic(&raw_input(800.0, 600.0, 8192), |ctx| a.logic(ctx)); + let v = reply.try_recv().expect("one cancellation reply"); + assert_eq!(v["ok"], false); + assert!(v["error"].as_str().unwrap_or_default().contains("session changed"), "{v}"); + assert!(!path.exists()); + } + + /// Admission caps the queue at [`MAX_PENDING_SHOTS`]; the next request is refused before any capture is issued. + #[test] + fn admission_rejects_more_than_eight_pending_requests() { + let (mut a, ctx) = (app(), egui::Context::default()); + warm(&mut a, &ctx); + for i in 0..MAX_PENDING_SHOTS { + let (req, _reply) = ControlRequest::new("ui.screenshot", json!({})); + admit(&mut a, &ctx, &req).unwrap_or_else(|e| panic!("request {i}: {e}")); + } + let (req, _reply) = ControlRequest::new("ui.screenshot", json!({})); + let e = admit(&mut a, &ctx, &req).expect_err("the ninth request is rejected"); + assert_eq!(e, "screenshot queue full"); + } + + /// A single oversized viewport is refused, and aggregate accounting uses encode peak so two medium captures fit + /// while a third does not. + #[test] + fn admission_rejects_an_image_over_the_per_image_and_aggregate_budgets() { + let (mut a, ctx) = (app(), egui::Context::default()); + let mut big = raw_input(5000.0, 5000.0, 8192); + a.raw_input_hook(&ctx, &mut big); + let (req, _reply) = ControlRequest::new("ui.screenshot", json!({})); + let e = admit(&mut a, &ctx, &req).expect_err("5000x5000 does not fit 64 MiB"); + assert!(e.contains("too large"), "{e}"); + + // 24 MB of pixels plus the PNG/base64 copies is about 84 MB of encode peak. Two fit the + // 192 MiB aggregate; a third does not even though the retained images total only 72 MB. + let mut medium = raw_input(3000.0, 2000.0, 8192); + a.raw_input_hook(&ctx, &mut medium); + for _ in 0..2 { + let (req, _reply) = ControlRequest::new("ui.screenshot", json!({})); + admit(&mut a, &ctx, &req).expect("two encode peaks fit the 192 MiB budget"); + } + assert_eq!(a.pending_shots.iter().map(|s| s.image_bytes).sum::(), 2 * 3000 * 2000 * 4); + assert!(a.pending_shots.iter().all(|s| s.reserved_bytes > s.image_bytes)); + let (req, _reply) = ControlRequest::new("ui.screenshot", json!({})); + let e = admit(&mut a, &ctx, &req).expect_err("the third encode peak exceeds the aggregate"); + assert!(e.contains("budget"), "{e}"); + } + + /// A resize while a capture is in flight retries against the new geometry without writing the stale image. + #[test] + fn a_changed_window_retries_once_instead_of_writing_stale_pixels() { + let (mut a, ctx) = (app(), egui::Context::default()); + warm(&mut a, &ctx); + let path = tmp("retry"); + let reply = request(&mut a, Some(&path)); + let out = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + let first = take_callback(&out).expect("issued at 800x600"); + // Resize while the capture is in flight: the completed 800x600 image is stale. + let _ = frame(&mut a, &ctx, raw_input(1000.0, 600.0, 8192)); + first.complete(Arc::new(ColorImage::filled([800, 600], Color32::RED))); + let _ = ctx.run_logic(&raw_input(1000.0, 600.0, 8192), |ctx| a.logic(ctx)); + assert_eq!(pending_shot_count(&a), 1, "the request was retried, not failed"); + let out = frame(&mut a, &ctx, raw_input(1000.0, 600.0, 8192)); + let second = take_callback(&out).expect("retried at 1000x600"); + second.complete(Arc::new(ColorImage::filled([1000, 600], Color32::BLUE))); + let _ = ctx.run_logic(&raw_input(1000.0, 600.0, 8192), |ctx| a.logic(ctx)); + let v = reply.try_recv().expect("one success reply"); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(v["result"]["width"], 1000); + let _ = std::fs::remove_file(&path); + } + + /// A tiny partial image for a normal window is malformed, while a full-size image passes. + #[test] + fn a_truncated_image_is_malformed_not_a_successful_screenshot() { + let tiny = ColorImage::filled([1, 1], Color32::RED); + assert!(matches!(check_image(&tiny, [800, 600]), Err(ImageProblem::Malformed(_)))); + let full = ColorImage::filled([800, 600], Color32::RED); + assert!(check_image(&full, [800, 600]).is_ok()); + } + + /// The byte bound allows the same one-pixel-per-axis tolerance as the dimension check; both encoding paths + /// reserve more than the image itself. + #[test] + fn the_byte_limit_tolerates_the_same_one_pixel_as_the_dimension_check() { + let g = CaptureGeometry { points: egui::vec2(800.0, 600.0), pixels_per_point: 1.0, max_texture_side: 8192, focused: true }; + assert_eq!(g.reserved_bytes(), Some(800 * 600 * 4)); + assert!(g.encode_peak_bytes(true).unwrap_or(0) > g.reserved_bytes().unwrap_or(0), "base64 needs headroom"); + assert!(g.encode_peak_bytes(false).unwrap_or(0) > g.reserved_bytes().unwrap_or(0), "PNG encoding needs headroom"); + assert_eq!(g.tolerated_bytes(), Some(801 * 601 * 4), "one pixel more on each axis"); + let one_pixel_wider = ColorImage::filled([801, 600], Color32::RED); + assert!(check_image(&one_pixel_wider, [800, 600]).is_ok(), "the staleness rule accepts it, so the byte limit must too"); + } + + /// A raw input without a texture limit uses egui's last known limit instead of inventing a smaller default. + #[test] + fn a_missing_max_texture_side_uses_the_last_known_limit() { + let ctx = egui::Context::default(); + let mut first = ctx.run_ui(raw_input(400.0, 300.0, 8192), |_ui| {}); + first.textures_delta.clear(); + let mut raw = raw_input(400.0, 300.0, 8192); + raw.max_texture_side = None; + let g = CaptureGeometry::from_raw(&ctx, &raw).expect("geometry"); + assert_eq!(g.max_texture_side, 8192, "a missing field must not invent the 2048 fallback"); + } + + /// Zero-sized host geometry has no capturable size and is refused at admission. + #[test] + fn a_window_without_geometry_reports_no_capturable_size() { + let (mut a, ctx) = (app(), egui::Context::default()); + a.host_geometry = Some(CaptureGeometry { points: egui::Vec2::ZERO, pixels_per_point: 1.0, max_texture_side: 8192, focused: true }); + let (req, _reply) = ControlRequest::new("ui.screenshot", json!({})); + let e = admit(&mut a, &ctx, &req).expect_err("no capturable size"); + assert!(e.contains("no capturable size"), "{e}"); + } + + /// Recover and a session the browser read replace the document without `SoundApp::run`; both go + /// through `document_replaced`, so a pending capture is still canceled. + #[test] + fn a_document_replaced_outside_run_cancels_a_pending_capture() { + let (mut a, ctx) = (app(), egui::Context::default()); + warm(&mut a, &ctx); + let reply = request(&mut a, None); + let out = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + let callback = take_callback(&out).expect("issued"); + a.document_replaced(); + callback.complete(Arc::new(ColorImage::filled([800, 600], Color32::RED))); + let _ = ctx.run_logic(&raw_input(800.0, 600.0, 8192), |ctx| a.logic(ctx)); + let v = reply.try_recv().expect("one cancellation reply"); + assert_eq!(v["ok"], false); + assert!(v["error"].as_str().unwrap_or_default().contains("session changed"), "{v}"); + } + + /// The control connection sends one request at a time, but it can pipeline requests in one + /// write. A queued click must be applied (its reply delivered) before the following inspect and + /// screenshot run; otherwise the inspect reports the pre-click state and the screenshot captures + /// the old frame. + #[test] + fn a_queued_click_is_applied_before_a_following_inspect_and_screenshot() { + let (mut a, ctx) = (app(), egui::Context::default()); + warm(&mut a, &ctx); + let kick = a.engine.session().track_by_name("Kick").expect("demo has Kick").id; + assert!(!a.engine.session().track(kick).expect("kick").mixer.mute); + let row = a.edit_layout.rows[0].1; + // Mute is the fourth header key (Record, Input, Solo, Mute) at y=20, 16x16. + let pos = egui::pos2(row[0] + 101.0 + 8.0, row[1] + 20.0 + 8.0); + let (tx, rx) = channel(); + let (req, click_reply) = ControlRequest::new("ui.click", json!({"x": pos.x, "y": pos.y})); + tx.send(req).unwrap(); + let (req, inspect_reply) = ControlRequest::new("session.inspect", json!({})); + tx.send(req).unwrap(); + let path = tmp("ordered-gesture"); + let _ = std::fs::remove_file(&path); + let (req, shot_reply) = ControlRequest::new("ui.screenshot", json!({"path": path})); + tx.send(req).unwrap(); + a.control_rx = Some(rx); + + // The first pass admits only the click; the following requests stay queued. + let out = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + assert!(take_callbacks(&out).is_empty(), "no capture before the click was delivered"); + assert!(click_reply.try_recv().is_err(), "click is not acknowledged yet"); + assert!(inspect_reply.try_recv().is_err(), "inspect must wait for the click"); + assert_eq!(pending_shot_count(&a), 0); + + // A click is a move, press and release: one event per UI pass. + let mut clicked = None; + for _ in 0..5 { + let out = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + assert!(take_callbacks(&out).is_empty(), "capture cannot jump the click"); + if let Ok(v) = click_reply.try_recv() { + clicked = Some(v); + break; + } + } + let clicked = clicked.expect("the click reply arrives after all three events were applied"); + assert_eq!(clicked["ok"], true, "{clicked}"); + assert!(a.engine.session().track(kick).expect("kick").mixer.mute, "the click changed the track before its reply"); + + // Now the next pass may run the inspect and admit the screenshot. + let out = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + let inspect = inspect_reply.try_recv().expect("inspect runs after the click"); + let kick_state = inspect["result"]["tracks"] + .as_array() + .and_then(|tracks| tracks.iter().find(|t| t["id"] == json!(kick.0))) + .expect("Kick in the inspected session"); + assert_eq!(kick_state["mute"], true, "inspect saw the clicked state"); + let callback = take_callback(&out).expect("the screenshot is admitted only after the click"); + callback.complete(Arc::new(ColorImage::filled([800, 600], Color32::BLUE))); + let _ = ctx.run_logic(&raw_input(800.0, 600.0, 8192), |ctx| a.logic(ctx)); + let shot = shot_reply.try_recv().expect("one screenshot reply"); + assert_eq!(shot["ok"], true, "{shot}"); + assert!(path.exists()); + let _ = std::fs::remove_file(&path); + } + + /// Each capture slot is bounded by its request's own reservation, not the 64 MiB ceiling: a + /// small request cannot retain a full-size image and blow the documented aggregate budget. + #[test] + fn a_capture_slot_is_bounded_by_its_own_reservation() { + let (mut a, ctx) = (app(), egui::Context::default()); + warm(&mut a, &ctx); + let (tx, rx) = channel(); + let (req, first) = ControlRequest::new("ui.screenshot", json!({})); + tx.send(req).unwrap(); + let (req, second) = ControlRequest::new("ui.screenshot", json!({})); + tx.send(req).unwrap(); + a.control_rx = Some(rx); + let out = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + let mut calls = take_callbacks(&out).into_iter(); + let oversized = calls.next().expect("the first admitted capture is issued"); + let valid = calls.next().expect("the second admitted capture is issued"); + assert!(calls.next().is_none(), "no extra captures"); + assert_eq!(pending_shot_count(&a), 2); + // 12 MB is under the old 64 MiB slot bound but far over this request's ~1.9 MB image. + oversized.complete(Arc::new(ColorImage::filled([2000, 1500], Color32::RED))); + let _ = ctx.run_logic(&raw_input(800.0, 600.0, 8192), |ctx| a.logic(ctx)); + let v = first.try_recv().expect("oversized completion has one reply"); + assert_eq!(v["ok"], false, "{v}"); + assert!(v["error"].as_str().unwrap_or_default().contains("per-image limit"), "{v}"); + assert_eq!(pending_shot_count(&a), 1, "the oversized image was released, the other request kept"); + valid.complete(Arc::new(ColorImage::filled([800, 600], Color32::BLUE))); + let _ = ctx.run_logic(&raw_input(800.0, 600.0, 8192), |ctx| a.logic(ctx)); + let v = second.try_recv().expect("the valid capture still succeeds"); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(pending_shot_count(&a), 0, "no reservation or image outlives its reply"); + } + + /// Overflowing dimensions and a pixel-count mismatch are errors, not allocation overflows. + #[test] + fn png_bytes_checks_its_dimensions_and_allocation() { + let huge = ColorImage { size: [usize::MAX, 2], source_size: egui::vec2(0.0, 0.0), pixels: Vec::new() }; + assert!(png_bytes(&huge).is_err(), "an overflowing size is an error, not a capacity overflow"); + let mismatched = ColorImage { size: [4, 4], source_size: egui::vec2(4.0, 4.0), pixels: vec![Color32::RED; 3] }; + assert!(png_bytes(&mismatched).is_err(), "a short pixel buffer is rejected before it is copied"); + } + + /// The synthetic event queue and text payload are bounded; a queued text gesture is acknowledged only after a UI + /// pass applies it. + #[test] + fn control_input_queues_and_text_are_bounded() { + let (mut a, ctx) = (app(), egui::Context::default()); + a.synthetic = (0..MAX_SYNTHETIC_EVENTS).map(|_| egui::Event::PointerMoved(egui::pos2(0.0, 0.0))).collect(); + let (req, _reply) = ControlRequest::new("ui.move", json!({"x": 1.0, "y": 2.0})); + let v = handle(&mut a, &ctx, &req).expect("the refusal replies immediately"); + assert_eq!(v["ok"], false, "{v}"); + assert!(v["error"].as_str().unwrap_or_default().contains("queue full"), "{v}"); + assert_eq!(a.synthetic.len(), MAX_SYNTHETIC_EVENTS, "a refused request queues nothing"); + + a.synthetic.clear(); + let text = "x".repeat(MAX_CONTROL_TEXT_BYTES + 1); + let (req, _reply) = ControlRequest::new("ui.text", json!({"text": text})); + let v = handle(&mut a, &ctx, &req).expect("the refusal replies immediately"); + assert_eq!(v["ok"], false, "{v}"); + assert!(a.synthetic.is_empty(), "an oversized text request queues no event"); + + let (req, reply) = ControlRequest::new("ui.text", json!({"text": "x"})); + assert!(handle(&mut a, &ctx, &req).is_none(), "text is acknowledged only after delivery"); + assert!(reply.try_recv().is_err()); + deliver_gestures(&mut a, &ctx); + let v = reply.try_recv().expect("text reply arrives after its event was applied"); + assert_eq!(v["ok"], true, "{v}"); + } + + /// A path over the retained-path limit is refused before a screenshot request is queued. + #[test] + fn a_very_long_screenshot_path_is_refused_before_it_is_retained() { + let (mut a, ctx) = (app(), egui::Context::default()); + warm(&mut a, &ctx); + let path = "x".repeat(MAX_SHOT_PATH_BYTES + 1); + let (req, _reply) = ControlRequest::new("ui.screenshot", json!({"path": path})); + let e = admit(&mut a, &ctx, &req).expect_err("the path exceeds the retained-path limit"); + assert!(e.contains("path longer"), "{e}"); + assert_eq!(pending_shot_count(&a), 0); + } + + /// An expired undelivered gesture is canceled with one reply, drops its events and does not block the next + /// request. + #[test] + fn an_undelivered_gesture_is_canceled_without_wedging_the_next_request() { + let (mut a, ctx) = (app(), egui::Context::default()); + let (req, reply) = ControlRequest::new("ui.click", json!({"x": 10.0, "y": 10.0})); + assert!(handle(&mut a, &ctx, &req).is_none(), "the click waits for a UI pass"); + a.advance_test_clock(Duration::from_secs(16)); + expire_gestures(&mut a); + let v = reply.try_recv().expect("the canceled gesture has exactly one reply"); + assert_eq!(v["ok"], false, "{v}"); + assert!(v["error"].as_str().unwrap_or_default().contains("not delivered"), "{v}"); + assert!(a.synthetic.is_empty(), "canceled events are dropped"); + assert!(a.synthetic_batch.is_none()); + // The control channel is not wedged: the next request runs normally. + let (req, reply) = ControlRequest::new("session.inspect", json!({})); + let v = handle(&mut a, &ctx, &req).expect("inspect replies immediately"); + assert_eq!(v["ok"], true, "{v}"); + assert!(reply.try_recv().is_err(), "handle returns the reply for drain to send"); + } + + /// An expired scripted click sends no synthetic release, so it cannot click or drop, and a fresh click still + /// works. + #[test] + fn a_timed_out_consumed_press_cancels_without_clicking_and_the_next_click_works() { + for expires_after_hook in [false, true] { + let (mut a, ctx) = (app(), egui::Context::default()); + a.advance_test_clock(Duration::ZERO); + warm(&mut a, &ctx); + let kick = a.engine.session().track_by_name("Kick").unwrap().id; + let row = a.edit_layout.rows[0].1; + let pos = pos2(row[0] + 109.0, row[1] + 28.0); + let (req, reply) = ControlRequest::new("ui.click", json!({"x": pos.x, "y": pos.y})); + assert!(handle(&mut a, &ctx, &req).is_none()); + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); // move + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); // press + assert!(ctx.input(|i| i.pointer.primary_down()), "the real egui pass consumed the scripted press"); + assert!(!a.engine.session().track(kick).unwrap().mixer.mute); + assert!(reply.try_recv().is_err(), "the release is still pending"); + + if expires_after_hook { + // The deadline can cross after the hook handed egui the release but before logic + // and widgets run. That pass must not turn a timed-out gesture into a click. + let mut raw = raw_input(800.0, 600.0, 8192); + a.raw_input_hook(&ctx, &mut raw); + a.advance_test_clock(Duration::from_secs(16)); + let mut out = ctx.run_ui(raw, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + out.textures_delta.clear(); + } else { + a.advance_test_clock(Duration::from_secs(16)); + let _ = ctx.run_logic(&raw_input(800.0, 600.0, 8192), |ctx| a.logic(ctx)); + } + let canceled = reply.try_recv().expect("one cancellation reply"); + assert_eq!(canceled["ok"], false, "{canceled}"); + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + assert!(!ctx.input(|i| i.pointer.any_down() || i.pointer.any_released()), "canceling is not a synthetic release"); + assert!(ctx.dragged_id().is_none()); + assert!(ctx.drag_stopped_id().is_none(), "canceling cannot dispatch a drop"); + assert!(!a.engine.session().track(kick).unwrap().mixer.mute, "the abandoned release never clicks Mute"); + assert!(reply.try_recv().is_err(), "the canceled gesture replies once"); + + let (req, reply) = ControlRequest::new("ui.click", json!({"x": pos.x, "y": pos.y})); + assert!(handle(&mut a, &ctx, &req).is_none()); + deliver_gestures(&mut a, &ctx); + assert_eq!(reply.try_recv().unwrap()["ok"], true); + assert!(a.engine.session().track(kick).unwrap().mixer.mute, "a completed click still works normally"); + assert!(!a.synthetic_pointer_active && !a.cancel_synthetic_pointer); + } + } + + /// An expired scripted shortcut never reaches widgets or end-of-pass handling, while a simultaneous host shortcut + /// still runs and a later script succeeds. + #[test] + fn a_timed_out_scripted_key_after_the_hook_does_not_run_and_the_next_key_works() { + let (mut a, ctx) = (app(), egui::Context::default()); + a.advance_test_clock(Duration::ZERO); + warm(&mut a, &ctx); + a.run("track.new", json!({"count": 1, "format": "stereo"})).unwrap(); + let depth = a.engine.undo_history().len(); + assert!(depth > 0, "the test needs an undoable edit"); + let applied = a.synthetic_applied_batches; + let quickpunch = a.engine.session().edit.quickpunch; + let (req, reply) = ControlRequest::new("ui.key", json!({"key": "z", "cmd": true})); + assert!(handle(&mut a, &ctx, &req).is_none()); + let mut raw = raw_input(800.0, 600.0, 8192); + // A real, unrelated shortcut in the same pass must still be dispatched after the expired + // scripted batch is removed. + let command_shift = egui::Modifiers { command: true, shift: true, ..egui::Modifiers::NONE }; + raw.events.push(egui::Event::Key { key: egui::Key::P, physical_key: None, pressed: true, repeat: false, modifiers: command_shift }); + raw.events.push(egui::Event::Key { key: egui::Key::P, physical_key: None, pressed: false, repeat: false, modifiers: command_shift }); + a.raw_input_hook(&ctx, &mut raw); + assert!(a.synthetic_batch.is_some(), "the key batch waits for a UI pass"); + + // The deadline crosses after the hook handed the key to egui but before logic and widgets. + a.advance_test_clock(Duration::from_secs(16)); + let mut out = ctx.run_ui(raw, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + out.textures_delta.clear(); + let canceled = reply.try_recv().expect("one cancellation reply"); + assert_eq!(canceled["ok"], false, "{canceled}"); + assert!(a.synthetic_batch.is_none(), "the expired batch is dropped"); + assert_eq!(a.synthetic_applied_batches, applied, "an expired batch is not delivered"); + assert_eq!(a.engine.undo_history().len(), depth, "the expired key must not run its shortcut"); + assert_eq!(a.engine.session().edit.quickpunch, !quickpunch, "a simultaneous host shortcut still runs"); + assert!(!a.cancel_synthetic_keys); + assert!( + ctx.input(|i| !i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Z, .. }))), + "the expired key is gone from the pass, not left for widgets or end-of-pass shortcuts" + ); + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + assert_eq!(a.engine.undo_history().len(), depth, "a later frame must not replay it"); + + // A fresh gesture after the timeout still works and runs exactly once. + let (req, reply) = ControlRequest::new("ui.key", json!({"key": "z", "cmd": true})); + assert!(handle(&mut a, &ctx, &req).is_none()); + deliver_gestures(&mut a, &ctx); + assert_eq!(reply.try_recv().unwrap()["ok"], true); + assert_eq!(a.engine.undo_history().len(), depth - 1, "a completed key still undoes exactly once"); + } + + /// Current timeline zoom, horizontal scroll and vertical scroll, compared before and after a gesture. + fn view(a: &SoundApp) -> (f64, i64, f32) { + let zoom = a.engine.session().edit.zoom; + (zoom.samples_per_px, zoom.scroll, a.edit_layout.scroll_y) + } + + /// The centre of the edit window's timeline. + fn timeline_center(a: &SoundApp) -> egui::Pos2 { + let [x0, y0, x1, y1] = a.edit_layout.timeline; + pos2((x0 + x1) * 0.5, (y0 + y1) * 0.5) + } + + /// A host keyboard event with no modifiers; `pressed` selects press or release. + fn host_key(key: egui::Key, pressed: bool) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE } + } + + /// The same physical wheel notch `ui.scroll {dy:-30, unit:"line", steps:1}` queues. + fn host_line_wheel(dy: f32) -> egui::Event { + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, dy), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + modifiers: egui::Modifiers::NONE, + } + } + + /// A wheel batch that expires after the hook already handed it to egui must not scroll. Draining + /// its event is not enough: egui has folded it into `smooth_scroll_delta` and the wheel smoothing + /// state that continues on later frames. The pass input is replayed from before the injection, so + /// the stale notch is gone while a simultaneous host key still arrives and later input still works. + #[test] + fn a_timed_out_scripted_scroll_after_the_hook_does_not_scroll_or_keep_smoothing() { + let (mut a, ctx) = (app(), egui::Context::default()); + a.advance_test_clock(Duration::ZERO); + warm(&mut a, &ctx); + // Make the track area overflow so a real wheel would actually move the view. + a.run("track.new", json!({"count": 20, "format": "stereo"})).unwrap(); + warm(&mut a, &ctx); + let at = timeline_center(&a); + let before = view(&a); + + let (req, reply) = ControlRequest::new("ui.scroll", json!({"x": at.x, "y": at.y, "dy": -30.0, "unit": "line", "steps": 1})); + assert!(handle(&mut a, &ctx, &req).is_none(), "the reply waits for delivery"); + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); // the hover move is its own batch + assert!(a.synthetic_batch.is_none()); + assert_eq!(a.synthetic.len(), 1, "the wheel event is next"); + + let mut raw = raw_input(800.0, 600.0, 8192); + raw.events.push(host_key(egui::Key::F24, true)); + a.raw_input_hook(&ctx, &mut raw); + assert!(a.synthetic_batch.as_ref().is_some_and(|b| b.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. })))); + assert!(a.input_before_scroll.is_some(), "the pre-injection input was saved for a possible expiry"); + + // The deadline crosses after the hook handed the wheel to egui but before logic and widgets. + a.advance_test_clock(Duration::from_secs(16)); + let mut out = ctx.run_ui(raw, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + out.textures_delta.clear(); + + let canceled = reply.try_recv().expect("one cancellation reply"); + assert_eq!(canceled["ok"], false, "{canceled}"); + assert!(a.synthetic_batch.is_none(), "the expired batch is dropped"); + assert!(!a.cancel_synthetic_scroll); + assert_eq!(view(&a), before, "the expired wheel must not scroll"); + assert!(ctx.input(|i| i.keys_down.contains(&egui::Key::F24)), "a host key pressed in the same pass survives"); + assert!( + ctx.input(|i| i.raw.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::F24, pressed: true, .. }))), + "the host key event is kept in the pass input" + ); + assert!( + !ctx.input(|i| i.raw.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. }))), + "the expired wheel is gone from the pass input" + ); + assert!( + !ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. }))), + "no wheel event reaches widgets or egui's aggregates" + ); + + // Its smoothing state must not keep scrolling in later frames either. + let mut release = raw_input(800.0, 600.0, 8192); + release.events.push(host_key(egui::Key::F24, false)); + let _ = frame(&mut a, &ctx, release); + assert!(!ctx.input(|i| i.keys_down.contains(&egui::Key::F24)), "the host release still applies"); + for _ in 0..12 { + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + } + assert_eq!(view(&a), before, "canceling cannot leave egui smoothing the stale notch"); + + // The control chain is not wedged and the next gesture applies exactly once. + let (req, reply) = ControlRequest::new("ui.zoom", json!({"x": at.x, "y": at.y, "factor": 4.0, "steps": 1})); + assert!(handle(&mut a, &ctx, &req).is_none()); + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); // hover move + deliver_gestures(&mut a, &ctx); + assert_eq!(reply.try_recv().unwrap()["ok"], true); + let after = view(&a); + assert!((after.0 * 4.0 / before.0 - 1.0).abs() < 1e-4, "a later zoom works once: {} -> {}", before.0, after.0); + for _ in 0..6 { + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + } + assert!((view(&a).0 - after.0).abs() < 1e-9, "the later zoom is not replayed"); + } + + /// The same late expiry for a pinch `Zoom` event: no view change, a simultaneous host key is + /// kept, and a fresh zoom afterwards scales the view exactly once. + #[test] + fn a_timed_out_scripted_zoom_after_the_hook_does_not_zoom_and_the_next_zoom_works_once() { + let (mut a, ctx) = (app(), egui::Context::default()); + a.advance_test_clock(Duration::ZERO); + warm(&mut a, &ctx); + let at = timeline_center(&a); + let before = view(&a); + + let (req, reply) = ControlRequest::new("ui.zoom", json!({"x": at.x, "y": at.y, "factor": 4.0, "steps": 1})); + assert!(handle(&mut a, &ctx, &req).is_none(), "the reply waits for delivery"); + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); // the hover move is its own batch + assert!(a.synthetic_batch.is_none()); + assert_eq!(a.synthetic.len(), 1, "the zoom event is next"); + + let mut raw = raw_input(800.0, 600.0, 8192); + raw.events.push(host_key(egui::Key::F24, true)); + a.raw_input_hook(&ctx, &mut raw); + assert!(a.synthetic_batch.as_ref().is_some_and(|b| b.iter().any(|e| matches!(e, egui::Event::Zoom(_))))); + a.advance_test_clock(Duration::from_secs(16)); + let mut out = ctx.run_ui(raw, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + out.textures_delta.clear(); + + let canceled = reply.try_recv().expect("one cancellation reply"); + assert_eq!(canceled["ok"], false, "{canceled}"); + assert_eq!(view(&a), before, "the expired pinch must not zoom"); + assert!(ctx.input(|i| i.keys_down.contains(&egui::Key::F24)), "a host key pressed in the same pass survives"); + assert!( + ctx.input(|i| i.raw.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::F24, pressed: true, .. }))), + "the host key event is kept in the pass input" + ); + assert!(!ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Zoom(_)))), "no zoom event reaches widgets or egui's aggregates"); + assert!(!a.cancel_synthetic_scroll); + + let mut release = raw_input(800.0, 600.0, 8192); + release.events.push(host_key(egui::Key::F24, false)); + let _ = frame(&mut a, &ctx, release); + assert!(!ctx.input(|i| i.keys_down.contains(&egui::Key::F24))); + for _ in 0..4 { + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + } + assert_eq!(view(&a), before, "a later frame must not replay the expired pinch"); + + let (req, reply) = ControlRequest::new("ui.zoom", json!({"x": at.x, "y": at.y, "factor": 4.0, "steps": 1})); + assert!(handle(&mut a, &ctx, &req).is_none()); + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); // hover move + deliver_gestures(&mut a, &ctx); + assert_eq!(reply.try_recv().unwrap()["ok"], true); + let after = view(&a); + assert!((after.0 * 4.0 / before.0 - 1.0).abs() < 1e-4, "a later zoom works once: {} -> {}", before.0, after.0); + for _ in 0..4 { + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + } + assert!((view(&a).0 - after.0).abs() < 1e-9, "the later zoom is not replayed"); + } + + /// After a drawn pass canceled an expired wheel, the next real wheel notch is often byte-for-byte + /// identical to the injected one. Retaining that run would make the next hook's fallback strip + /// the genuine host event. The real notch must still scroll. + #[test] + fn a_fresh_identical_host_wheel_after_a_drawn_expiry_is_not_swallowed() { + let (mut a, ctx) = (app(), egui::Context::default()); + a.advance_test_clock(Duration::ZERO); + warm(&mut a, &ctx); + a.run("track.new", json!({"count": 20, "format": "stereo"})).unwrap(); + warm(&mut a, &ctx); + let at = timeline_center(&a); + let before = view(&a); + + let (req, reply) = ControlRequest::new("ui.scroll", json!({"x": at.x, "y": at.y, "dy": -30.0, "unit": "line", "steps": 1})); + assert!(handle(&mut a, &ctx, &req).is_none()); + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); // hover move + let mut raw = raw_input(800.0, 600.0, 8192); + a.raw_input_hook(&ctx, &mut raw); + assert!(a.synthetic_batch.as_ref().is_some_and(|b| b.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. })))); + + a.advance_test_clock(Duration::from_secs(16)); + let mut out = ctx.run_ui(raw, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(reply.try_recv().expect("one cancellation reply")["ok"], false); + assert_eq!(view(&a), before, "the expired wheel did not scroll"); + assert!(a.injected_events.is_empty(), "the drawn pass consumed the retained injected run"); + + // A genuine host notch with the exact same fields is delivered, not mistaken for that run. + let mut host = raw_input(800.0, 600.0, 8192); + host.events.push(host_line_wheel(-30.0)); + let _ = frame(&mut a, &ctx, host); + for _ in 0..12 { + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + } + assert_ne!(view(&a), before, "the fresh host wheel still scrolls after the expiry"); + } + + /// The replay snapshot starts from the pre-injection `InputState`, so a key the host held from an + /// earlier pass must remain down even though this pass's raw input has no new key event. + #[test] + fn a_host_key_held_across_passes_survives_a_replayed_scroll_cancel() { + let (mut a, ctx) = (app(), egui::Context::default()); + a.advance_test_clock(Duration::ZERO); + warm(&mut a, &ctx); + a.run("track.new", json!({"count": 20, "format": "stereo"})).unwrap(); + warm(&mut a, &ctx); + let at = timeline_center(&a); + let before = view(&a); + + let mut held = raw_input(800.0, 600.0, 8192); + held.events.push(host_key(egui::Key::F24, true)); + let _ = frame(&mut a, &ctx, held); + assert!(ctx.input(|i| i.key_down(egui::Key::F24)), "the host key is held before the scroll pass"); + + let (req, reply) = ControlRequest::new("ui.scroll", json!({"x": at.x, "y": at.y, "dy": -30.0, "unit": "line", "steps": 1})); + assert!(handle(&mut a, &ctx, &req).is_none()); + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); // hover move + let mut raw = raw_input(800.0, 600.0, 8192); // no key event: F24 is still held + a.raw_input_hook(&ctx, &mut raw); + assert!(a.synthetic_batch.as_ref().is_some_and(|b| b.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. })))); + + a.advance_test_clock(Duration::from_secs(16)); + let mut out = ctx.run_ui(raw, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(reply.try_recv().expect("one cancellation reply")["ok"], false); + assert_eq!(view(&a), before, "the expired wheel did not scroll"); + assert!(ctx.input(|i| i.key_down(egui::Key::F24)), "the held host key survives the replay"); + + let mut release = raw_input(800.0, 600.0, 8192); + release.events.push(host_key(egui::Key::F24, false)); + let _ = frame(&mut a, &ctx, release); + assert!(!ctx.input(|i| i.key_down(egui::Key::F24)), "the release still applies"); + } + + /// A scripted wheel that a widget consumed before the acknowledgement leaves the batch pending. + /// If the deadline then crosses in a discarded rerun, replaying the first pass's host raw would + /// dispatch its real Cmd+Z a second time. The later pass must not replay that input. + #[test] + fn an_expiry_in_a_discarded_pass_does_not_replay_host_events() { + let (mut a, ctx) = (app(), egui::Context::default()); + a.advance_test_clock(Duration::ZERO); + warm(&mut a, &ctx); + a.run("track.new", json!({"count": 1, "format": "stereo"})).unwrap(); + warm(&mut a, &ctx); + let depth = a.engine.undo_history().len(); + assert!(depth > 0, "the test needs an undoable edit"); + let at = timeline_center(&a); + + let (req, reply) = ControlRequest::new("ui.scroll", json!({"x": at.x, "y": at.y, "dy": -30.0, "unit": "line", "steps": 1})); + assert!(handle(&mut a, &ctx, &req).is_none()); + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); // hover move + let mut raw = raw_input(800.0, 600.0, 8192); + let command = egui::Modifiers { command: true, ..egui::Modifiers::NONE }; + raw.events.push(egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: command }); + raw.events.push(egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: false, repeat: false, modifiers: command }); + a.raw_input_hook(&ctx, &mut raw); + assert!(a.synthetic_batch.as_ref().is_some_and(|b| b.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. })))); + + let mut passes = 0; + let mut out = ctx.run_ui(raw, |ui| { + passes += 1; + if passes == 2 { + // The deadline crosses only on the rerun, after the first pass ran its host shortcut. + a.advance_test_clock(Duration::from_secs(16)); + } + a.logic(ui.ctx()); + if passes == 1 { + // Another widget consumed the injected wheel from the pass input, so the batch is + // still pending when the first pass asks for a discard. + ui.ctx().input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::MouseWheel { .. }))); + ui.ctx().request_discard("discard the pass whose wheel another widget consumed"); + } + a.ui(ui); + }); + out.textures_delta.clear(); + assert!(passes >= 2, "the regression must actually force a discarded pass"); + assert_eq!(reply.try_recv().expect("one cancellation reply")["ok"], false); + assert_eq!(a.engine.undo_history().len(), depth - 1, "the real Cmd+Z ran once, not again on the rerun"); + assert!(a.injected_events.is_empty()); + assert!(a.input_before_scroll.is_none()); + assert!(a.synthetic_batch.is_none()); + assert!( + !ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. } | egui::Event::Key { key: egui::Key::Z, .. }))), + "the rerun neither replays the wheel nor the first pass's host key" + ); + } + + /// An expired track drag cancels without reordering tracks or dispatching a drop; a fresh drag still drops + /// normally. + #[test] + fn a_timed_out_track_drag_cancels_without_dropping_and_the_next_drag_works() { + let (mut a, ctx) = (app(), egui::Context::default()); + a.advance_test_clock(Duration::ZERO); + warm(&mut a, &ctx); + let before: Vec<_> = a.engine.session().tracks.iter().map(|track| track.id).collect(); + let first = a.edit_layout.rows[0].1; + let third = a.edit_layout.rows[2].1; + let grab = pos2(first[0] + 40.0, first[1] + 12.0); + let release = pos2(grab.x, third[1]); + let params = json!({"x": grab.x, "y": grab.y, "to_x": release.x, "to_y": release.y, "steps": 2}); + let (req, reply) = ControlRequest::new("ui.drag", params.clone()); + assert!(handle(&mut a, &ctx, &req).is_none()); + for _ in 0..3 { + // move, press, first drag motion; final motion/release remain queued + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + } + assert!(ctx.input(|i| i.pointer.primary_down())); + assert!(ctx.dragged_id().is_some(), "the real name plate is being dragged"); + a.advance_test_clock(Duration::from_secs(16)); + let _ = ctx.run_logic(&raw_input(800.0, 600.0, 8192), |ctx| a.logic(ctx)); + assert_eq!(reply.try_recv().unwrap()["ok"], false); + let _ = frame(&mut a, &ctx, raw_input(800.0, 600.0, 8192)); + assert!(!ctx.input(|i| i.pointer.any_down() || i.pointer.any_released())); + assert!(ctx.dragged_id().is_none() && ctx.drag_stopped_id().is_none()); + assert!(a.gesture.is_none()); + assert_eq!(a.engine.session().tracks.iter().map(|track| track.id).collect::>(), before, "canceling must not reorder tracks"); + + let (req, reply) = ControlRequest::new("ui.drag", params); + assert!(handle(&mut a, &ctx, &req).is_none()); + deliver_gestures(&mut a, &ctx); + assert_eq!(reply.try_recv().unwrap()["ok"], true); + let mut expected = before; + expected.swap(0, 1); + assert_eq!(a.engine.session().tracks.iter().map(|track| track.id).collect::>(), expected, "a completed drag still drops normally"); + assert!(!ctx.input(|i| i.pointer.any_down())); + } + + /// Wheel scrolls use line units and report `wheel`; point scrolls use trackpad phases and report `trackpad`, both + /// only after delivery. + #[test] + fn scroll_reports_the_wheel_and_trackpad_sources_after_delivery() { + let (mut a, ctx) = (app(), egui::Context::default()); + warm(&mut a, &ctx); + let (req, reply) = ControlRequest::new("ui.scroll", json!({"x": 10.0, "y": 10.0, "dy": -30.0, "unit": "line", "steps": 2})); + assert!(handle(&mut a, &ctx, &req).is_none(), "the reply waits for delivery"); + assert!( + a.synthetic + .iter() + .all(|e| matches!(e, egui::Event::PointerMoved(_) | egui::Event::MouseWheel { source: egui::MouseWheelSource::Wheel, .. })) + ); + deliver_gestures(&mut a, &ctx); + let v = reply.try_recv().expect("wheel scroll replies after its events were applied"); + assert_eq!(v["result"]["source"], "wheel"); + + let (req, reply) = ControlRequest::new("ui.scroll", json!({"x": 10.0, "y": 10.0, "dy": -30.0, "steps": 2})); + assert!(handle(&mut a, &ctx, &req).is_none(), "the reply waits for delivery"); + assert!(a.synthetic.iter().any(|e| matches!(e, egui::Event::MouseWheel { source: egui::MouseWheelSource::Trackpad, .. }))); + deliver_gestures(&mut a, &ctx); + let v = reply.try_recv().expect("trackpad scroll replies after its events were applied"); + assert_eq!(v["result"]["source"], "trackpad"); } } diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index a53128cb..b8940897 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -210,6 +210,7 @@ impl Default for View { } } +/// Controls for name style, sort key and sort direction above the contributor table. fn contributors_toolbar(ui: &mut egui::Ui, v: &mut View) { ui.horizontal_wrapped(|ui| { ui.label("Show"); @@ -219,8 +220,8 @@ fn contributors_toolbar(ui: &mut egui::Ui, v: &mut View) { } } ui.separator(); - ui.label("Sort"); - egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + let sort = ui.label("Sort"); + let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, k.label().0).clicked() { v.key = k; @@ -228,6 +229,7 @@ fn contributors_toolbar(ui: &mut egui::Ui, v: &mut View) { } } }); + combo.response.labelled_by(sort.id); if ui.button(if v.ascending { "⏶" } else { "⏷" }).on_hover_text("Reverse the order").clicked() { v.ascending = !v.ascending; } @@ -252,9 +254,9 @@ fn contributors_grab_bag(ui: &mut egui::Ui, list: &[&Contributor], names: NameMo }); } -/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. +/// About › Contributors: a name toggle, a sort, and the list as a grab bag or a table. pub fn contributors_ui(ui: &mut egui::Ui) { - let id = egui::Id::new("credits_view"); + let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); contributors_toolbar(ui, &mut v); let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); @@ -302,7 +304,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { }); } -/// About ▸ Models: AI models credited in Co-Authored-By trailers. +/// About › Models: AI models credited in Co-Authored-By trailers. pub fn models_ui(ui: &mut egui::Ui) { if MODELS.is_empty() { ui.label("No model credits were built into this copy."); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e6df9342..30e5360e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -300,6 +300,8 @@ pub fn open_session_bytes(app: &mut SoundApp, name: String, bytes: Vec) { } } +/// Open the session bytes read by the browser after the save prompt, stopping transport first. A successful open +/// marks the document replaced so pending captures cancel and reports any missing media. fn open_pending_session(app: &mut SoundApp) { let Some((name, bytes)) = app.dialogs.pending_session.take() else { return }; if crate::bounce::refuses_session_switch(app) { @@ -307,8 +309,11 @@ fn open_pending_session(app: &mut SoundApp) { } let _ = app.run("transport.stop", json!({})); app.ui.status = match soundcraft_engine::io::open_session_bytes(&mut app.engine, &name, &bytes) { - Ok(missing) if missing.is_empty() => format!("Opened {name}"), - Ok(missing) => format!("Opened {name} ({} missing media)", missing.len()), + Ok(missing) => { + // This path replaces the document without going through `SoundApp::run`. + app.document_replaced(); + if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) } + } Err(e) => e.to_string(), }; } @@ -444,15 +449,16 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { match &mut d { Dialog::NewTracks { count, format, kind, timebase, name } => { ui.horizontal(|ui| { - ui.label("Create"); - ui.add(egui::DragValue::new(count).range(1..=128)); + let create = ui.label("Create"); + ui.add(egui::DragValue::new(count).range(1..=128)).labelled_by(create.id); ui.label("new"); - egui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| { + let fmt = egui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| { for f in soundcraft_model::ChannelFormat::ALL { ui.selectable_value(format, f.label().to_string(), f.label()); } }); - egui::ComboBox::from_id_salt("nt_kind") + fmt.response.accessible_name("Channel format"); + let kind_combo = egui::ComboBox::from_id_salt("nt_kind") .selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label())) .show_ui(ui, |ui| { for k in soundcraft_model::TrackKind::ALL { @@ -462,18 +468,20 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } }); + kind_combo.response.accessible_name("Track kind"); ui.label("in"); - egui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui( + let tb = egui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui( ui, |ui| { ui.selectable_value(timebase, "samples".to_string(), "Samples"); ui.selectable_value(timebase, "ticks".to_string(), "Ticks"); }, ); + tb.response.accessible_name("Timebase"); }); ui.horizontal(|ui| { ui.label("Name:"); - ui.add(egui::TextEdit::singleline(name).desired_width(240.0)); + crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name"); }); if buttons(ui, "Create", enter) { action = @@ -483,7 +491,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::NewSession { name, sample_rate, demo } => { ui.horizontal(|ui| { ui.label("Name"); - ui.text_edit_singleline(name); + crate::widgets::named_text_field(ui.text_edit_singleline(name), "Name"); }); egui::ComboBox::from_label("Sample Rate").selected_text(format!("{sample_rate} Hz")).show_ui(ui, |ui| { for r in soundcraft_time::SampleRate::COMMON { @@ -501,7 +509,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Bounce { path, format, bit_depth, normalize, dither } => { ui.horizontal(|ui| { ui.label("File"); - ui.add(egui::TextEdit::singleline(path).desired_width(420.0)); + crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "File"); if let Some(pick) = &app.services.pick_save && ui.button("Browse…").clicked() && let Some(p) = pick("file.bounce_mix", path) @@ -573,7 +581,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } Dialog::PathPrompt { cmd, path, key, .. } => { - ui.add(egui::TextEdit::singleline(path).desired_width(420.0)); + crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "Path"); if let Some(pick) = &app.services.pick_open && cmd != "session.save_as" && cmd != "session.save_copy" @@ -613,7 +621,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } Dialog::Number { cmd, key, value, suffix, .. } => { ui.horizontal(|ui| { - ui.add(egui::DragValue::new(value).speed(0.1)); + ui.add(egui::DragValue::new(value).speed(0.1)).accessible_name(format!("Value in {suffix}")); ui.label(suffix.as_str()); }); if buttons(ui, "OK", enter) { @@ -624,7 +632,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::TempoChange { at, bpm } => { ui.horizontal(|ui| { // Same limits the engine enforces (time crate `valid_bpm`). - ui.add(egui::DragValue::new(bpm).speed(0.1).range(5.0..=1000.0)); + ui.add(egui::DragValue::new(bpm).speed(0.1).range(5.0..=1000.0)).accessible_name("Tempo (BPM)"); ui.label("bpm"); }); if buttons(ui, "OK", enter) { @@ -707,7 +715,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Group { id, name, edit, mix, members } => { ui.horizontal(|ui| { ui.label("Name"); - ui.text_edit_singleline(name); + crate::widgets::named_text_field(ui.text_edit_singleline(name), "Name"); }); ui.horizontal(|ui| { crate::widgets::checkbox(ui, edit, "Edit group"); @@ -751,12 +759,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Loop { by, count, length, format, start, filled } => { let s = app.engine.session(); ui.horizontal(|ui| { - ui.radio_value(by, LoopBy::Count, "Number of Loops"); - ui.add_enabled(*by == LoopBy::Count, egui::DragValue::new(count).range(1..=512)); + let count_label = ui.radio_value(by, LoopBy::Count, "Number of Loops"); + ui.add_enabled(*by == LoopBy::Count, egui::DragValue::new(count).range(1..=512)).labelled_by(count_label.id); }); ui.horizontal(|ui| { - ui.radio_value(by, LoopBy::Length, "Loop Length"); - ui.add_enabled(*by == LoopBy::Length, egui::TextEdit::singleline(length).desired_width(140.0)); + let length_label = ui.radio_value(by, LoopBy::Length, "Loop Length"); + ui.add_enabled(*by == LoopBy::Length, egui::TextEdit::singleline(length).desired_width(140.0)).labelled_by(length_label.id); ui.label(format.label()); }); ui.radio_value(by, LoopBy::Fill, "Loop Until End of Session or Next Clip"); @@ -768,13 +776,14 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::ScoreSetup { title, composer, bars_per_system, show_track_names } => { egui::Grid::new("score_setup").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { ui.label("Title"); - ui.text_edit_singleline(title); + // The painted label is not an accesskit name; name the field itself. + crate::widgets::named_text_field(ui.text_edit_singleline(title), "Score title"); ui.end_row(); ui.label("Composer"); - ui.text_edit_singleline(composer); + crate::widgets::named_text_field(ui.text_edit_singleline(composer), "Score composer"); ui.end_row(); ui.label("Bars per system"); - ui.add(egui::DragValue::new(bars_per_system).range(1..=16)); + ui.add(egui::DragValue::new(bars_per_system).range(1..=16)).accessible_name("Bars per system"); ui.end_row(); }); crate::widgets::checkbox(ui, show_track_names, "Show track names"); @@ -813,11 +822,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } }); - if esc || ctx.memory(|m| m.data.get_temp::(egui::Id::new("dlg_cancel")).unwrap_or(false)) { + if esc || ctx.memory(|m| m.data.get_temp::(egui::Id::unique("dlg_cancel")).unwrap_or(false)) { keep = false; app.dialogs.after_save = None; app.dialogs.pending_session = None; - ctx.memory_mut(|m| m.data.remove::(egui::Id::new("dlg_cancel"))); + ctx.memory_mut(|m| m.data.remove::(egui::Id::unique("dlg_cancel"))); } // Enter on a focused Cancel also presses OK. In Auto Rename, the dialog then closes without running the command. if !keep && matches!(d, Dialog::AutoRename { .. }) { @@ -1004,17 +1013,17 @@ const AUTO_NAME_LABELS: [&str; 2] = ["Starting Number", "Number of Places"]; fn strip_name_fields(ui: &mut egui::Ui, names: &mut StripNames, labels: [&str; 2]) { let [number, zeros] = labels; egui::Grid::new("strip_names").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - ui.label("Name"); - ui.text_edit_singleline(&mut names.name); + let name = ui.label("Name"); + ui.text_edit_singleline(&mut names.name).labelled_by(name.id); ui.end_row(); - ui.label(number); - ui.add(egui::DragValue::new(&mut names.number).range(0..=StripNames::MAX_NUMBER)); + let number = ui.label(number); + ui.add(egui::DragValue::new(&mut names.number).range(0..=StripNames::MAX_NUMBER)).labelled_by(number.id); ui.end_row(); - ui.label(zeros); - ui.add(egui::DragValue::new(&mut names.zeros).range(0..=StripNames::MAX_ZEROS)); + let zeros = ui.label(zeros); + ui.add(egui::DragValue::new(&mut names.zeros).range(0..=StripNames::MAX_ZEROS)).labelled_by(zeros.id); ui.end_row(); - ui.label("Suffix"); - ui.text_edit_singleline(&mut names.suffix); + let suffix = ui.label("Suffix"); + ui.text_edit_singleline(&mut names.suffix).labelled_by(suffix.id); ui.end_row(); }); let example = if names.name.trim().is_empty() { @@ -1036,7 +1045,7 @@ fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> boo ui.separator(); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { - ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::new("dlg_cancel"), true)); + ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::unique("dlg_cancel"), true)); } let b = egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent); if ui.add_enabled(ready, b).clicked() { @@ -1049,6 +1058,7 @@ fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> boo /// A name field that takes the focus with its text selected, so typing replaces the old name. fn name_field(ui: &mut egui::Ui, name: &mut String) { let mut out = egui::TextEdit::singleline(name).show(ui); + let _ = crate::widgets::named_text_field(out.response.response.clone(), "Name"); if !out.response.has_focus() { out.response.request_focus(); let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count())); @@ -1069,12 +1079,13 @@ const SPEED_RAMP_CHOICES: [(&str, &str, f32, &[(f32, &str)]); 3] = [ ("fades", "Fades", 1.0, &[(1.0, "On"), (0.0, "Off")]), ]; +/// Process-parameter window for the chosen AudioSuite process, with its controls and Render action. fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { let Some(process) = app.ui.audiosuite.clone() else { return }; let mut open = true; let title = soundcraft_dsp::plugin_info(&process).map_or_else(|| process.replace('_', " "), |p| p.name.to_string()); egui::Window::new(format!("AudioSuite · {title}")).open(&mut open).default_width(360.0).show(ctx, |ui| { - let key = egui::Id::new(("as_params", process.clone())); + let key = egui::Id::unique(("as_params", process.clone())); let mut params: std::collections::BTreeMap = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_default(); if let Some(info) = soundcraft_dsp::plugin_info(&process) { for p in info.params { diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 3e0a5f28..95c5a272 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -2,8 +2,8 @@ //! Document contents remain owned by their existing view implementations. use craft_ui::docking::{ - Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Floating, Layout, Node, PanelLimits, Permissions, Placement, - UiDock, Zone, ZoneName, + Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Floating, Layout, Location, Node, PanelLimits, Permissions, + Placement, UiDock, Zone, ZoneName, }; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::{Deserialize, Serialize}; @@ -64,7 +64,7 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node egui::Id { - egui::Id::new(("soundcraft-docking", window == crate::MainWindow::Edit)) + egui::Id::unique(("soundcraft-docking", window == crate::MainWindow::Edit)) } fn area(edit: bool) -> egui::Id { @@ -169,6 +169,41 @@ fn apply(workspace: &mut Workspace, action: Action) -> Result<(), String> checked(workspace, |next| next.apply(action, permissions).map(drop)) } +/// Whether [`Workspace::set_visible`] can actually put `panel` back at its recorded place. +/// It follows the recorded location and the chain of hidden anchors; when none of that +/// restores, craft-ui falls back to an unanchored open, which since craft-ui 650b1c0b chooses +/// a dock group or right edge instead of reporting the missing anchor. Probe each chain +/// location with the public [`Layout::restore`] on a clone, so a deleted anchor is recognized +/// before calling in; craft-ui still performs the restore and rewrites the chain itself. +fn restores(workspace: &Workspace, panel: Panel) -> bool { + let recorded = |p: &Panel| workspace.hidden.iter().find(|(q, _)| q == p).map(|(_, location)| location.clone()); + let mut chain: Vec> = Vec::new(); + let mut seen = vec![panel]; + let mut current = panel; + while let Some(location) = recorded(¤t) { + let anchor = location.anchor; + chain.push(location); + let Some(anchor) = anchor.filter(|anchor| !seen.contains(anchor)) else { break }; + seen.push(anchor); + current = anchor; + } + // Like the chain itself, the panel's own group/column state applies to every attempt. + let group = chain.first().and_then(|location| location.group.clone()); + // Revisit this eligibility probe when updating the pinned craft-ui restore algorithm. + chain + .iter() + .rev() + .find_map(|location| { + let location = Location { group: group.clone().or_else(|| location.group.clone()), ..location.clone() }; + let mut probe = workspace.layout.clone(); + match probe.restore(panel, &location) { + Err(DockError::MissingTarget) => None, + result => Some(result.is_ok()), + } + }) + .unwrap_or(false) +} + /// Show a panel at its recorded place, else beside the document view on its default side. fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> { if workspace.layout.contains(&panel) == visible { @@ -178,7 +213,7 @@ fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result if !visible { return next.apply(Action::Close { panel }, permissions).map(drop); } - if next.set_visible(panel, true, None, permissions).is_ok() { + if next.hidden.iter().any(|(p, _)| *p == panel) && restores(next, panel) && next.set_visible(panel, true, None, permissions).is_ok() { return Ok(()); } next.apply(Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, permissions).map(drop) @@ -333,6 +368,8 @@ pub(crate) fn arrange(app: &mut crate::SoundApp) -> Result<(), String> { Ok(()) } +/// Draw and edit the dock layout for the requested window, falling back to the default layout if validation or panel +/// sync fails, then publish panel visibility, store the workspace and apply the frame's layout actions. pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWindow) { app.docking_context = Some(ui.ctx().clone()); let t = crate::theme::Tokens::current(); @@ -376,7 +413,7 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin Panel::Tracks => crate::panels::tracks_and_groups(app, ui), Panel::Clips => crate::panels::clip_list(app, ui), Panel::Midi => { - ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("soundcraft-midi-body"), ui.max_rect())); + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("soundcraft-midi-body"), ui.max_rect())); minimum_body(ui, egui::vec2(420.0, 260.0), |ui| crate::midi_editor::show(app, ui)); } Panel::Memory => crate::panels::memory_locations(app, ui), diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 9190620f..f76370f7 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -159,7 +159,7 @@ fn universe_runs(s: &Session, r: Rect) -> Vec<(Rect, Color32)> { /// [`Session::content_end`], worked out once per document revision: it walks every clip. fn content_end(app: &SoundApp, ctx: &egui::Context) -> Samples { - let key = egui::Id::new("content_end"); + let key = egui::Id::unique("content_end"); let revision = app.engine.revision; if let Some((at, end)) = ctx.memory(|m| m.data.get_temp::<(u64, Samples)>(key)) && at == revision @@ -174,7 +174,7 @@ fn content_end(app: &SoundApp, ctx: &egui::Context) -> Samples { /// Universe: the whole session in miniature; the box is the visible area (drag or click to move). fn universe(app: &mut SoundApp, ui: &mut Ui) { let r = ui.max_rect().shrink2(vec2(8.0, 4.0)); - let key = egui::Id::new("universe_runs"); + let key = egui::Id::unique("universe_runs"); let revision = app.engine.revision; let runs = match ui.ctx().memory(|m| m.data.get_temp::(key)).filter(|u| u.revision == revision && u.area == r) { Some(u) => u.runs, @@ -199,7 +199,7 @@ fn universe(app: &mut SoundApp, ui: &mut Ui) { let x = r.min.x + app.position() as f32 / total * r.width(); ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Tokens::current().playhead)); } - let resp = ui.interact(r, ui.id().with("universe"), Sense::click_and_drag()); + let resp = ui.interact(r, ui.scope_id().with("universe"), Sense::click_and_drag()); if let Some(p) = resp.interact_pointer_pos() && (resp.clicked() || resp.dragged()) { @@ -209,6 +209,7 @@ fn universe(app: &mut SoundApp, ui: &mut Ui) { } } +/// Three dock toggle tabs for MIDI Editor, Memory Locations and Undo History. fn dock_tabs(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let r = ui.max_rect(); @@ -224,7 +225,7 @@ fn dock_tabs(app: &mut SoundApp, ui: &mut Ui) { ui.painter().rect_filled(tr, 3.0, t.accent_dark); } ui.painter().galley(pos2(tr.min.x + 8.0, tr.center().y - g.size().y * 0.5), g, Color32::WHITE); - if ui.interact(tr, ui.id().with(("dock", label)), Sense::click()).clicked() { + if ui.interact(tr, ui.scope_id().with(("dock", label)), Sense::click()).clicked() { let _ = app.run(id, json!({})); } x = tr.max.x + 6.0; @@ -269,6 +270,7 @@ pub(crate) fn main_area(app: &mut SoundApp, ui: &mut Ui) { crate::docking::minimum_body(ui, minimum, |ui| main_area_content(app, ui)); } +/// Lay out the Edit window: rulers, track headers and lanes, empty-area selection, overlays and both scrollbars. fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let full = ui.max_rect(); @@ -320,7 +322,7 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { // Below the tracks a click clears the selection and a double-click opens New Tracks. let empty = Rect::from_min_max(pos2(full.min.x, y.max(tl.min.y)), tl.max); if empty.height() > 4.0 { - let resp = ui.interact(empty, ui.id().with("empty"), Sense::click()); + let resp = ui.interact(empty, ui.scope_id().with("empty"), Sense::click()); if resp.double_clicked() { let _ = app.dialogs.open_for_command(&app.engine, "track.new"); } else if resp.clicked() { @@ -340,7 +342,7 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { let off = app.edit_layout.scroll_y / content_h; let thumb = Rect::from_min_size(pos2(vs.min.x + 2.0, vs.min.y + vs.height() * off), vec2(vs.width() - 4.0, (vs.height() * frac).max(20.0))); ui.painter().rect_filled(thumb, 3.0, t.button_hi); - let resp = ui.interact(vs, ui.id().with("vscroll"), Sense::drag()); + let resp = ui.interact(vs, ui.scope_id().with("vscroll"), Sense::drag()); if resp.dragged() { app.edit_layout.scroll_by(resp.drag_delta().y / frac, tl.height()); } @@ -447,6 +449,7 @@ fn pan(app: &mut SoundApp, px: f64) { } } +/// Draw and drag the horizontal timeline scrollbar; its thumb reflects the visible sample range plus trailing space. fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { let t = Tokens::current(); ui.painter().rect_filled(sb, 0.0, t.panel_bg); @@ -458,7 +461,7 @@ fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { let off = (s.edit.zoom.scroll as f64 / total).clamp(0.0, 1.0) as f32; let thumb = Rect::from_min_size(pos2(sb.min.x + sb.width() * off, sb.min.y + 3.0), vec2((sb.width() * frac).max(24.0), sb.height() - 6.0)); ui.painter().rect_filled(thumb, 3.0, t.button_hi); - let resp = ui.interact(sb, ui.id().with("hscroll"), Sense::drag()); + let resp = ui.interact(sb, ui.scope_id().with("hscroll"), Sense::drag()); if resp.dragged() { let dx = resp.drag_delta().x / sb.width(); let by_px = f64::from(dx) * total / s.edit.zoom.samples_per_px; @@ -535,7 +538,7 @@ fn tempo_editor(app: &mut SoundApp, ui: &mut Ui, painter: &egui::Painter, s: &Se painter.add(Shape::line(pts, Stroke::new(1.5, Color32::from_rgb(140, 230, 160)))); painter.text(pos2(tl.min.x + 4.0, row.min.y + 8.0), Align2::LEFT_CENTER, format!("{hi:.0}"), regular(9.0), Color32::from_rgb(120, 160, 130)); painter.text(pos2(tl.min.x + 4.0, row.max.y - 8.0), Align2::LEFT_CENTER, format!("{lo:.0}"), regular(9.0), Color32::from_rgb(120, 160, 130)); - let resp = ui.interact(row, ui.id().with("tempo_editor"), Sense::click()); + let resp = ui.interact(row, ui.scope_id().with("tempo_editor"), Sense::click()); if resp.clicked() && let Some(p) = resp.interact_pointer_pos() { @@ -568,6 +571,7 @@ fn nice_step(spp: f64, sr: f64, min_px: f64) -> i64 { (3600.0 * sr) as i64 } +/// Draw each enabled ruler and handle its clicks and double-clicks against the visible sample range. fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[String]) { let t = Tokens::current(); let s = app.engine.session_arc(); @@ -586,7 +590,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ match id.as_str() { "tempo" => { painter.rect_filled(row.shrink2(vec2(0.0, 1.0)), 0.0, t.tempo_ruler); - let tresp = ui.interact(row, ui.id().with("tempo_ruler"), Sense::click()); + let tresp = ui.interact(row, ui.scope_id().with("tempo_ruler"), Sense::click()); if tresp.double_clicked() && let Some(p) = tresp.interact_pointer_pos() { @@ -640,7 +644,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ painter.line_segment([pos2(x, row.min.y + 2.0), pos2(x, row.max.y - 2.0)], Stroke::new(2.0, Color32::from_rgb(200, 180, 240))); painter.text(pos2(x + 5.0, row.center().y), Align2::LEFT_CENTER, k, bold(11.0), Color32::from_rgb(236, 230, 250)); } - let resp = ui.interact(row, ui.id().with("key_ruler"), Sense::click()); + let resp = ui.interact(row, ui.scope_id().with("key_ruler"), Sense::click()); if resp.double_clicked() && let Some(p) = resp.interact_pointer_pos() { @@ -687,7 +691,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ painter.text(pos2(x + 8.0, row.center().y), Align2::LEFT_CENTER, &m.name, bold(11.0), t.marker_text); } // Click on the marker ruler: recall a marker; double-click renames it, or adds one. - let resp = ui.interact(row, ui.id().with(("marker_ruler", lane)), Sense::click()); + let resp = ui.interact(row, ui.scope_id().with(("marker_ruler", lane)), Sense::click()); if let Some(p) = resp.interact_pointer_pos() { let hit = s .markers @@ -712,7 +716,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ if let Some(f) = fmt { timebase_ruler(ui, &painter, &s, tl, row, view, f, s.frame_rate, i + 1 == rulers.len()); // Clicking a timebase ruler locates. - let resp = ui.interact(row, ui.id().with(("ruler", id.as_str())), Sense::click_and_drag()); + let resp = ui.interact(row, ui.scope_id().with(("ruler", id.as_str())), Sense::click_and_drag()); if (resp.clicked() || resp.dragged()) && let Some(p) = resp.interact_pointer_pos() { @@ -865,7 +869,7 @@ fn views_button(app: &mut SoundApp, ui: &mut Ui, rulers: Rect) { let r = Rect::from_min_size(rulers.min + vec2(6.0, 5.0), vec2(50.0, 18.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r)); let resp = crate::widgets::selector_box(&mut child, r.width(), r.height(), "Views", Tokens::current().text); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, child.is_enabled(), "Views")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, child.is_enabled(), "Views")); let resp = resp.on_hover_text("Columns beside the track headers: inserts, sends, I/O…"); egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { let shown = edit_columns(app.engine.session()); @@ -992,7 +996,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai for c in pl.clips.iter().filter(|c| c.range().overlaps(&view)) { draw_clip(app, &painter, &s, track, sorted, c, tl, lane, false); } - let resp = ui.interact(lane, ui.id().with(("pl_lane", track.id.0, i)), Sense::click()); + let resp = ui.interact(lane, ui.scope_id().with(("pl_lane", track.id.0, i)), Sense::click()); if resp.clicked() && let Some(p) = resp.interact_pointer_pos() { @@ -1012,12 +1016,14 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai } } +/// Draw one track header: resize edge, selection and context menu, folder/options control, automation-lane toggle, +/// name and the remaining track controls. fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool, head_bg: Color32) { let t = Tokens::current(); let id = track.id; // Drag the bottom edge to resize (snaps to the height presets). let edge = Rect::from_min_max(pos2(head.min.x, head.max.y - 4.0), pos2(head.max.x, head.max.y + 2.0)); - let eresp = ui.interact(edge, ui.id().with(("hedge", id.0)), Sense::drag()); + let eresp = ui.interact(edge, ui.scope_id().with(("hedge", id.0)), Sense::drag()); if eresp.hovered() || eresp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } @@ -1045,7 +1051,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } let h = head.height(); // Click on header background selects the track. - let bg = ui.interact(head, ui.id().with(("hdr", id.0)), Sense::click()); + let bg = ui.interact(head, ui.scope_id().with(("hdr", id.0)), Sense::click()); if bg.clicked() { click_track(app, id, ui.input(|i| i.modifiers), in_edit_window); } @@ -1055,7 +1061,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele let ink = shade(strip, -0.8); // The strip's round button opens a folder, or any other track's options. let round = at(2.0, 2.0, 10.0, 10.0); - let rresp = ui.interact(round, ui.id().with(("options", id.0)), Sense::click()); + let rresp = ui.interact(round, ui.scope_id().with(("options", id.0)), Sense::click()); ui.painter().circle_filled(round.center(), 5.0, shade(strip, if rresp.hovered() { 0.6 } else { 0.45 })); arrow(ui.painter(), round.center(), !track.is_folder() || track.folder_open, ink); if track.is_folder() { @@ -1067,7 +1073,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } if h >= 40.0 && track.kind.has_playlist() { let ar = at(3.0, h - 12.0, 9.0, 9.0); - let aresp = ui.interact(ar, ui.id().with(("auto_lane", id.0)), Sense::click()); + let aresp = ui.interact(ar, ui.scope_id().with(("auto_lane", id.0)), Sense::click()); let showing = AutoParam::parse(&track.view).is_some(); ui.painter().rect_filled(ar, 1.5, shade(strip, if aresp.hovered() || showing { 0.6 } else { 0.45 })); let (a, b) = (ar.min + vec2(2.5, 2.5), ar.max - vec2(2.5, 2.5)); @@ -1099,7 +1105,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele }; let name_color = if selected { t.text_dark } else { t.text.lerp_to_gamma(t.text_dim, 0.4) }; ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER, &shown_name, bold(12.5), name_color); - let nresp = ui.interact(name_r, ui.id().with(("name", id.0)), Sense::click_and_drag()); + let nresp = ui.interact(name_r, ui.scope_id().with(("name", id.0)), Sense::click_and_drag()); if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); } else if nresp.clicked() { @@ -1120,7 +1126,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele .min_by_key(|y| ((y - p.y).abs() * 10.0) as i64) .unwrap_or(p.y); ui.ctx() - .layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("reorder"))) + .layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("reorder"))) .line_segment([pos2(head.min.x, line_y), pos2(head.max.x + 600.0, line_y)], Stroke::new(3.0, Tokens::current().accent)); } if nresp.drag_stopped() @@ -1138,7 +1144,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } } let pl_r = at(114.0, 1.0, 11.0, 14.5); - let presp = ui.interact(pl_r, ui.id().with(("pl", id.0)), Sense::click()); + let presp = ui.interact(pl_r, ui.scope_id().with(("pl", id.0)), Sense::click()); ui.painter().rect_filled(pl_r, 1.5, shade(head_bg, if presp.hovered() { -0.2 } else { -0.35 })); arrow(ui.painter(), pos2(pl_r.center().x, pl_r.min.y + 5.75), true, t.text); egui::Popup::menu(&presp.on_hover_text("Playlists")).show(|ui| { @@ -1169,19 +1175,27 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele (HeaderKey::Mute, m.mute, implied, "mix.mute", mixes), ]; for (i, (key, lit, implied, cmd, shown)) in keys.into_iter().enumerate() { - if shown && header_toggle(ui, at(23.0 + i as f32 * 26.0, 20.0, 16.0, 16.0), id, key, lit, implied).clicked() { + if shown && header_toggle(ui, at(23.0 + i as f32 * 26.0, 20.0, 16.0, 16.0), id, &track.name, key, lit, implied).clicked() { let _ = app.run(cmd, json!({"track": id.0})); } } } if h >= 58.0 { - let vresp = header_selector(ui, at(17.0, 39.0, 105.0, 17.0), ui.id().with(("view", id.0)), &view_label(&track.view), t.text, true); + let vresp = header_selector( + ui, + at(17.0, 39.0, 105.0, 17.0), + ui.scope_id().with(("view", id.0)), + &view_label(&track.view), + t.text, + true, + &format!("Track view for {}", track.name), + ); egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track)); } if h >= 76.0 { if matches!(track.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Aux) { let r = at(17.0, 57.0, 13.0, 17.0); - let resp = ui.interact(r, ui.id().with(("freeze", id.0)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("freeze", id.0)), Sense::click()); raised_key(ui.painter(), r, if track.frozen { t.slot_bypassed } else { t.slot_face }, resp.hovered()); for a in [0.0f32, 60.0, 120.0] { let d = vec2(a.to_radians().sin(), a.to_radians().cos()) * 3.5; @@ -1193,7 +1207,8 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } if !matches!(track.kind, TrackKind::Master | TrackKind::Vca | TrackKind::Folder) { let (voice, color) = if track.inactive { ("off", t.text_dim) } else { ("dyn", t.text) }; - let resp = header_selector(ui, at(31.0, 57.0, 31.0, 17.0), ui.id().with(("voice", id.0)), voice, color, false); + let voice_name = format!("Voice for {}", track.name); + let resp = header_selector(ui, at(31.0, 57.0, 31.0, 17.0), ui.scope_id().with(("voice", id.0)), voice, color, false, &voice_name); egui::Popup::menu(&resp.on_hover_text("Voice: dyn plays the track, off makes it inactive")).show(|ui| { for (label, inactive) in [("dyn", false), ("off", true)] { if ui.selectable_label(track.inactive == inactive, label).clicked() { @@ -1210,7 +1225,8 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele soundcraft_model::AutomationMode::Latch => t.auto_latch, _ => t.text_dim, }; - let aresp = header_selector(ui, at(63.0, 57.0, 59.0, 17.0), ui.id().with(("auto_mode", id.0)), mode.label(), col, true); + let mode_name = format!("Automation mode for {}", track.name); + let aresp = header_selector(ui, at(63.0, 57.0, 59.0, 17.0), ui.scope_id().with(("auto_mode", id.0)), mode.label(), col, true, &mode_name); egui::Popup::menu(&aresp).show(|ui| { for m in soundcraft_model::AutomationMode::ALL { if ui.selectable_label(m == mode, m.label()).clicked() { @@ -1222,7 +1238,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele if h >= 94.0 { if track.kind.has_playlist() { let r = at(17.0, 75.0, 13.0, 17.0); - let resp = ui.interact(r, ui.id().with(("timebase", id.0)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("timebase", id.0)), Sense::click()); raised_key(ui.painter(), r, t.slot_face, resp.hovered()); let c = r.center(); if track.ticks_timebase { @@ -1249,7 +1265,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } let field = at(30.0, 75.0, 92.0, 17.0); if matches!(track.kind, TrackKind::Audio | TrackKind::Instrument) { - let resp = ui.interact(field, ui.id().with(("field", id.0)), Sense::click()); + let resp = ui.interact(field, ui.scope_id().with(("field", id.0)), Sense::click()); ui.painter().rect_filled(field, 2.0, shade(head_bg, if resp.hovered() { -0.02 } else { -0.1 })); if track.kind == TrackKind::Instrument { let name = @@ -1291,9 +1307,15 @@ fn arrow(p: &egui::Painter, c: Pos2, down: bool, color: Color32) { } /// A header selector (track view, automation mode…): the value centred, and for menus an arrow -/// in the top-right corner. -fn header_selector(ui: &Ui, r: Rect, id: egui::Id, text: &str, color: Color32, menu: bool) -> egui::Response { +/// in the top-right corner. `name` is the accessible meaning of the value (for example +/// "Automation mode"), not the short text painted inside the slot. +fn header_selector(ui: &Ui, r: Rect, id: egui::Id, text: &str, color: Color32, menu: bool, name: &str) -> egui::Response { let resp = ui.interact(r, id, Sense::click()); + resp.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::accesskit::Role::ComboBox, ui.is_enabled(), name); + info.current_text_value = Some(text.to_string()); + info + }); raised_key(ui.painter(), r, Tokens::current().slot_face, resp.hovered()); let tr = if menu { r.with_max_x(r.max.x - 7.0) } else { r }; ui.painter().with_clip_rect(tr.shrink(1.0)).text(tr.center(), Align2::CENTER_CENTER, text, bold(10.0), color); @@ -1313,7 +1335,7 @@ enum HeaderKey { /// Record, input, solo or mute: `lit` gives the key its colour, `implied` a dark shade of it (a /// mute that another track's solo implies). Record keys stay dark red when off, as in Pro Tools. -fn header_toggle(ui: &Ui, r: Rect, track: TrackId, key: HeaderKey, lit: bool, implied: bool) -> egui::Response { +fn header_toggle(ui: &Ui, r: Rect, track: TrackId, track_name: &str, key: HeaderKey, lit: bool, implied: bool) -> egui::Response { let t = Tokens::current(); let (label, color) = match key { HeaderKey::Rec => ("Record enable", t.rec), @@ -1321,8 +1343,9 @@ fn header_toggle(ui: &Ui, r: Rect, track: TrackId, key: HeaderKey, lit: bool, im HeaderKey::Solo => ("Solo", t.solo), HeaderKey::Mute => ("Mute", t.mute), }; - let resp = ui.interact(r, ui.id().with((label, track.0)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), lit, label)); + let name = format!("{label} for {track_name}"); + let resp = ui.interact(r, ui.scope_id().with((label, track.0)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), lit, &name)); let rec = key == HeaderKey::Rec; let (tint, k) = if lit { (color, 0.85) @@ -1393,7 +1416,11 @@ fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: R ui.painter().vline(r.min.x, r.y_range(), Stroke::new(1.0, Tokens::current().border)); let mut c = track.comments.clone(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink(3.0))); - if child.add(egui::TextEdit::multiline(&mut c).desired_width(r.width() - 6.0).font(regular(10.0))).changed() { + let field = crate::widgets::named_text_field( + child.add(egui::TextEdit::multiline(&mut c).desired_width(r.width() - 6.0).font(regular(10.0))), + &format!("Comments for {}", track.name), + ); + if field.changed() { let _ = app.engine.execute_merged("track.comments", &json!({"track": track.id.0, "comments": c}), &format!("comments:{}", track.id.0)); } @@ -1410,17 +1437,23 @@ fn io_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { let ui = column_ui(ui, "io", r); let row = |y: f32, h: f32| (r.height() >= y + h + 1.0).then_some(Rect::from_min_size(r.min + vec2(7.0, y), vec2(r.width() - 14.0, h))); if let Some(input) = row(4.0, 15.0) { - let resp = header_selector(&ui, input, ui.id().with(("in", id.0)), &crate::mix_window::input_name(app, track), t.text, true); + let input_name = crate::mix_window::input_name(app, track); + let resp = header_selector(&ui, input, ui.scope_id().with(("in", id.0)), &input_name, t.text, true, &format!("Input for {}", track.name)); crate::mix_window::route_popup(app, &resp.on_hover_text("Input"), id, true); } if let Some(out) = row(21.0, 17.0) { let (sel, fader) = (out.with_max_x(out.max.x - 11.0), out.with_min_x(out.max.x - 11.0)); - let resp = ui.interact(sel, ui.id().with(("out", id.0)), Sense::click()); - let fresp = ui.interact(fader, ui.id().with(("out_fader", id.0)), Sense::click()); + let resp = ui.interact(sel, ui.scope_id().with(("out", id.0)), Sense::click()); + let output_name = crate::mix_window::route_name(app, &track.mixer.output); + resp.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::accesskit::Role::ComboBox, ui.is_enabled(), format!("Output for {}", track.name)); + info.current_text_value = Some(output_name.clone()); + info + }); + let fresp = ui.interact(fader, ui.scope_id().with(("out_fader", id.0)), Sense::click()); raised_key(ui.painter(), out, t.slot_face, resp.hovered() || fresp.hovered()); groove(ui.painter(), fader.min.x - 0.5, out.min.y + 1.0..=out.max.y - 1.0, t.slot_face[0]); - let name = crate::mix_window::route_name(app, &track.mixer.output); - ui.painter().with_clip_rect(sel.shrink(1.0)).text(sel.center(), Align2::CENTER_CENTER, name, bold(10.0), t.text); + ui.painter().with_clip_rect(sel.shrink(1.0)).text(sel.center(), Align2::CENTER_CENTER, &output_name, bold(10.0), t.text); let c = fader.center() + vec2(0.5, 0.0); ui.painter().vline(c.x, c.y - 5.0..=c.y + 5.0, Stroke::new(1.0, t.text)); ui.painter().rect_filled(Rect::from_center_size(c + vec2(0.0, -1.5), vec2(5.0, 2.5)), 0.5, t.text); @@ -2076,9 +2109,10 @@ pub fn clip_gain_icon(s: &Session, tl: Rect, lane: Rect, clip: &Clip) -> Option< (shown && x1 - x0 >= 48.0 && lane.height() >= 30.0).then(|| Rect::from_min_size(pos2(x0 + 4.0, lane.max.y - 16.0), vec2(9.0, 13.0))) } +/// Draw the read-only clip-gain fader tooltip at `pos`. fn clip_gain_popup(ctx: &egui::Context, fader_bottom: Pos2, pos: f32) { let t = Tokens::current(); - let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("clip_gain_fader"))); + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("clip_gain_fader"))); let track = Rect::from_min_max(pos2(fader_bottom.x - 2.0, fader_bottom.y - CLIP_FADER_TRAVEL), pos2(fader_bottom.x + 2.0, fader_bottom.y)); let panel = Rect::from_min_max(pos2(track.min.x - 22.0, track.min.y - 26.0), pos2(track.max.x + 22.0, track.max.y + 8.0)); p.rect(panel, 3.0, Color32::from_rgb(38, 38, 40), Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside); @@ -2091,8 +2125,10 @@ fn clip_gain_popup(ctx: &egui::Context, fader_bottom: Pos2, pos: f32) { p.text(pos2(panel.center().x, panel.min.y + 12.0), Align2::CENTER_CENTER, format!("{db:+.1}"), regular(10.5), t.counter_text); } +/// Handle one track lane: clip context menus, drag-and-drop sources, sampler drops, and the active tool's selection, +/// trim, fade, automation and clip editing. fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) { - let id = ui.id().with(("lane", track.id.0)); + let id = ui.scope_id().with(("lane", track.id.0)); let resp = ui.interact(lane, id, Sense::click_and_drag()); // The clip menu is attached before the early return below: once the menu is open the pointer // is over the popup, the lane has no hover position, and the rest of this function bails out. @@ -2631,7 +2667,7 @@ struct DragPlan { /// until the document, the Edit mode or the target changes. fn drag_plan(ctx: &egui::Context, s: &Session, revision: u64, clips: &[ClipId], (from, to): (TrackId, TrackId), grab_at: Samples) -> Arc { type Key = (u64, EditMode, Vec, TrackId, TrackId, Samples); - let id = egui::Id::new("clip_drag_plan"); + let id = egui::Id::unique("clip_drag_plan"); let key: Key = (revision, s.edit.edit_mode, clips.to_vec(), from, to, grab_at); if let Some((k, plan)) = ctx.data(|d| d.get_temp::<(Key, Arc)>(id)) && k == key @@ -2827,7 +2863,7 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { } else if !app.is_playing() { let x = x_of(s, tl, sel.start); let now = ui.input(|i| i.time); - let (id, key) = (ui.id().with("insertion_blink"), (sel.start, s.edit.selected_tracks.clone())); + let (id, key) = (ui.scope_id().with("insertion_blink"), (sel.start, s.edit.selected_tracks.clone())); let since = ui.ctx().data_mut(|d| { let (last, since) = d.get_temp_mut_or_insert_with(id, || (key.clone(), now)); if *last != key { @@ -2967,7 +3003,84 @@ mod universe_tests { #[cfg(test)] mod tests { use super::*; - use egui_kittest::Harness; + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + + /// The painted header selectors are interactive controls; their accessible name is the field + /// they edit, not the short value text ("Read", "Waveform") painted inside them. + #[test] + fn track_header_selectors_have_accessible_names() { + let mut h = Harness::builder().with_context_setup(crate::fonts::install).build_ui(|ui| { + let view = Rect::from_min_size(Pos2::ZERO, vec2(60.0, 17.0)); + super::header_selector(ui, view, egui::Id::unique("test-view"), "Waveform", Color32::WHITE, true, "Track view"); + let mode = Rect::from_min_size(pos2(0.0, 20.0), vec2(60.0, 17.0)); + super::header_selector(ui, mode, egui::Id::unique("test-mode"), "Read", Color32::WHITE, true, "Automation mode"); + }); + h.run(); + assert!(h.query_by_role_and_label(Role::ComboBox, "Track view").is_some()); + assert!(h.query_by_role_and_label(Role::ComboBox, "Automation mode").is_some()); + } + + /// Repeated per-track selectors and toggles must name their track and publish their current + /// value; a screen reader otherwise sees several identical "Solo" or "Track view" controls. + #[test] + fn repeated_track_controls_are_named_and_valued_per_track() { + let mut h = harness_with(soundcraft_engine::demo::demo_engine()); + for name in ["Kick", "Snare"] { + let view_label = format!("Track view for {name}"); + let view = h.query_by_role_and_label(Role::ComboBox, &view_label).unwrap_or_else(|| panic!("missing track view for {name}")); + assert_eq!(view.value().as_deref(), Some("waveform"), "track view value for {name}"); + let voice_label = format!("Voice for {name}"); + let voice = h.query_by_role_and_label(Role::ComboBox, &voice_label).unwrap_or_else(|| panic!("missing voice for {name}")); + assert_eq!(voice.value().as_deref(), Some("dyn"), "voice value for {name}"); + let mode_label = format!("Automation mode for {name}"); + let mode = h.query_by_role_and_label(Role::ComboBox, &mode_label).unwrap_or_else(|| panic!("missing automation mode for {name}")); + assert_eq!(mode.value().as_deref(), Some("read"), "automation value for {name}"); + for label in ["Record enable", "Input monitoring", "Solo", "Mute"] { + assert!(h.query_by_label(&format!("{label} for {name}")).is_some(), "missing {label} for {name}"); + } + } + assert!(h.query_all_by_label_contains("Track view for").count() >= 2, "both visible tracks expose their own control"); + + // The Mix window repeats the same controls per strip; those names must be track-qualified + // too, and the automation selector must publish its value rather than only a tooltip. + h.state_mut().ui.window = crate::MainWindow::Mix; + h.run_steps(5); + for name in ["Kick", "Snare"] { + let mode_label = format!("Automation mode for {name}"); + let mode = h.query_by_role_and_label(Role::ComboBox, &mode_label).unwrap_or_else(|| panic!("missing mix automation mode for {name}")); + assert_eq!(mode.value().as_deref(), Some("auto read"), "mix automation value for {name}"); + for label in ["Input", "Output", "Group", "Record enable", "Input monitoring", "Solo", "Mute", "Insert A", "Send A"] { + assert!(h.query_by_label(&format!("{label} for {name}")).is_some(), "missing mix {label} for {name}"); + } + } + // A used send's level is another per-track control; it must name its track too. + assert!(h.query_by_label("Send A level for Snare").is_some(), "missing mix send A level for Snare"); + } + + /// Every track's comments editor carries its own name, so label queries and screen readers can + /// tell two tracks apart instead of seeing several identical "Comments" fields. + #[test] + fn every_track_comments_field_is_named_for_its_track() { + let engine = soundcraft_engine::demo::demo_engine(); + let tracks: Vec = engine.session().tracks.iter().take(2).cloned().collect(); + assert_eq!(tracks.len(), 2); + let names: Vec = tracks.iter().map(|t| format!("Comments for {}", t.name)).collect(); + let app = SoundApp::new(engine, None, crate::Services::default()); + let mut h = Harness::new_ui_state( + move |ui, app: &mut SoundApp| { + for (i, track) in tracks.iter().enumerate() { + let r = Rect::from_min_size(pos2(i as f32 * 200.0, 0.0), vec2(180.0, 80.0)); + header_column(app, ui, track, "comments", r, Color32::BLACK); + } + }, + app, + ); + h.run(); + for name in &names { + assert!(h.query_by_label(name).is_some(), "missing comments field {name}"); + } + } #[test] fn automation_id_round_trips_for_high_slots() { @@ -4134,13 +4247,14 @@ mod tests { assert!(s.edit.selection.end > pointed, "the selection did not follow the scroll"); } + /// A pass redrawn by egui must still contribute one frame-time sample, measured from the host clock rather than + /// UI input time. #[test] fn a_redrawn_frame_counts_once_in_the_frame_time() { let mut h = harness(4); - let mut t = 1.0; - let mut frame = |h: &mut Harness| { - t += 1.0 / 60.0; - h.input_mut().time = Some(t); + // The monotonic host clock, not the UI input time, drives frame statistics. + let frame = |h: &mut Harness| { + h.state_mut().advance_test_clock(std::time::Duration::from_secs_f64(1.0 / 60.0)); h.step(); }; for _ in 0..120 { diff --git a/crates/ui-egui/src/extra_windows.rs b/crates/ui-egui/src/extra_windows.rs index df43024b..76c9278c 100644 --- a/crates/ui-egui/src/extra_windows.rs +++ b/crates/ui-egui/src/extra_windows.rs @@ -145,6 +145,7 @@ fn renderer(app: &mut SoundApp, ctx: &egui::Context) { app.extra.show_renderer = open; } +/// Appearance window: interface scale, reduce-motion, theme, auto-update configuration and Reset. fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.extra.show_ui_customization; if !open { @@ -153,30 +154,29 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) { egui::Window::new(egui::RichText::new("Appearance").font(bold(13.0))).open(&mut open).default_size(vec2(320.0, 180.0)).resizable(false).show( ctx, |ui| { - egui::Grid::new("ui_custom").num_columns(2).spacing(vec2(12.0, 8.0)).show(ui, |ui| { - ui.label("Interface scale"); - ui.add(egui::Slider::new(&mut app.extra.ui_scale, 0.75..=1.5).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0))); - ui.end_row(); - ui.label("Reduce motion"); - crate::widgets::checkbox(ui, &mut app.extra.reduce_motion, ""); - ui.end_row(); - ui.label("Theme"); - let mut chosen = None; - egui::ComboBox::from_id_salt("theme_mode").selected_text(app.ui.theme.label()).show_ui(ui, |ui| { - for mode in crate::theme::ThemeMode::ALL { - if ui.selectable_label(app.ui.theme == mode, mode.label()).clicked() { - chosen = Some(mode); - } + // Each control carries its own visible label, which is also its accessible name. A + // label in a neighbouring grid cell is only painted text: a screen reader (and the + // kittest accessibility check) would see an unnamed slider, checkbox or combo box. + ui.add( + egui::Slider::new(&mut app.extra.ui_scale, 0.75..=1.5) + .step_by(0.05) + .custom_formatter(|v, _| format!("{:.0} %", v * 100.0)) + .text("Interface scale"), + ); + crate::widgets::checkbox(ui, &mut app.extra.reduce_motion, "Reduce motion"); + let mut chosen = None; + egui::ComboBox::from_label("Theme").selected_text(app.ui.theme.label()).show_ui(ui, |ui| { + for mode in crate::theme::ThemeMode::ALL { + if ui.selectable_label(app.ui.theme == mode, mode.label()).clicked() { + chosen = Some(mode); } - }); - if let Some(mode) = chosen { - let _ = app.run("ui.theme", json!({"mode": mode.id()})); } - ui.end_row(); - ui.label("Auto-update configuration"); - crate::widgets::checkbox(ui, &mut app.extra.auto_update_config, ""); - ui.end_row(); }); + if let Some(mode) = chosen { + let _ = app.run("ui.theme", json!({"mode": mode.id()})); + } + crate::widgets::checkbox(ui, &mut app.extra.auto_update_config, "Auto-update configuration"); + ui.add_space(4.0); if ui.button("Reset").clicked() { app.extra.ui_scale = 1.0; app.extra.reduce_motion = false; diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index ae185834..3f9ce63a 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -151,6 +151,29 @@ mod tests { assert!(HANGUL.iter().filter(|(p, _)| p.ends_with("NotoSansCJK-Regular.ttc")).all(|(_, i)| *i == 1)); } + /// Every literal glyph the UI fixtures paint is covered by the bundled faces in every family, + /// without relying on an installed system font. This is the 0.37 font-feature regression test. + #[test] + fn bundled_faces_cover_the_ui_glyphs_in_every_family() { + let mut defs = FontDefinitions::default(); + let proportional = defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + defs.families.insert(FontFamily::Name("bold".into()), proportional); + let ctx = egui::Context::default(); + ctx.set_fonts(defs); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + // Status/menu text, transport/meter symbols, file/audio/search icons, music symbols and + // platform key hints used by the UI. Close/delete controls paint a cross in `icons.rs` + // instead of typesetting U+2715, which no bundled face covers in every family. + let glyphs = ["›", "v", "♩", "𝄞", "⌘", "📁", "🔊", "🔍", "⬆", "−", "∞"]; + for family in [FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("bold".into())] { + let font = egui::FontId::new(13.0, family.clone()); + for glyph in glyphs { + assert!(ctx.fonts_mut(|f| f.has_glyphs(&font, glyph)), "{family:?} has no glyph for {glyph:?}"); + } + } + } + /// Korean names draw in every family, not as boxes; skipped when the system has no Korean font. #[test] fn every_family_draws_hangul_with_a_system_korean_font() { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 55953ff8..1571e8f8 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -143,6 +143,11 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) { painter.line_segment([c0, c1], Stroke::new(s.width * 1.4, c)); } } + "close" => { + // A code-drawn close cross: no bundled UI font has U+2715 in every family. + painter.line_segment([p(r, 0.3, 0.3), p(r, 0.7, 0.7)], s); + painter.line_segment([p(r, 0.7, 0.3), p(r, 0.3, 0.7)], s); + } "lock" => { painter.rect_filled(Rect::from_min_max(p(r, 0.22, 0.46), p(r, 0.78, 0.88)), r.width() * 0.08, c); let (x, y, w) = (r.center().x, r.min.y + r.height() * 0.46, r.width() * 0.19); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 44f1c201..6a578811 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -137,7 +137,7 @@ fn run_now(app: &mut SoundApp, params: Value) -> bool { /// A small, non-blocking note while files import: the rest of the app stays usable. pub fn show(app: &SoundApp, ctx: &egui::Context) { let Some((name, waiting)) = app.imports.label() else { return }; - egui::Area::new(egui::Id::new("audio_import_progress")).anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -40.0)).interactable(false).show( + egui::Area::new(egui::Id::unique("audio_import_progress")).anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -40.0)).interactable(false).show( ctx, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0d2dea2c..58a7bc1c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -41,7 +41,7 @@ use soundcraft_model::TrackId; use soundcraft_playback::record::Trouble; use soundcraft_playback::{OutputTrouble, Player}; use soundcraft_time::{Range, Samples}; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::sync::mpsc::Receiver; pub use control::ControlRequest; @@ -302,12 +302,71 @@ pub struct SoundApp { pub(crate) edit_scroll_into_view: Option, pub(crate) mix_scroll_into_view: Option, pub synthetic: Vec, + /// The one synthetic batch handed to egui and not yet applied by a settled UI pass. Hidden + /// logic-only ticks neither apply nor drop it. + synthetic_batch: Option>, + /// A scripted press was injected and its release has not been applied by a UI pass yet. + synthetic_pointer_active: bool, + /// Cancel that press before the next egui input pass, without a release click/drop. + cancel_synthetic_pointer: bool, + /// A key/text batch crossed its deadline after [`Self::raw_input_hook`] had already appended it + /// to the pass. Do not dispatch that stale copy through shortcuts or widgets. + cancel_synthetic_keys: bool, + /// A scroll/zoom batch crossed its deadline after [`Self::raw_input_hook`] appended it to the + /// pass. Removing the event is not enough: egui has already folded it into + /// `InputState.smooth_scroll_delta`/`zoom_delta` and its frame-to-frame wheel smoothing. + /// [`Self::ui`] rebuilds the pass input from [`Self::input_before_scroll`]. + cancel_synthetic_scroll: bool, + /// The input state and the host's own raw input from before the hook appended a scroll/zoom + /// batch. Replaying that raw from here drops the batch's wheel/zoom contribution without + /// losing real input. + input_before_scroll: Option<(egui::InputState, egui::RawInput)>, + /// The exact events (the batch plus the hover `PointerMoved`s added for it) [`Self::raw_input_hook`] + /// appended to the retained raw input. A host such as eframe hands the hook-mutated input back on + /// the next tick, so that copy is removed before the batch is appended again: one pending batch + /// must never grow into two copies per hidden tick. + injected_events: Vec, + /// Where [`Self::injected_events`] starts in that retained input, when that is known. Shortcut + /// handling and batch acknowledgement use this position to identify the injected events by + /// provenance instead of by their (host-collidable) key values. + injected_at: usize, + /// The host's physical `keys_down` after the key events in this frame's raw input, before the + /// synthetic batch was appended. `ui` restores this after egui processed the scripted release, + /// so a scripted press while the user holds the same key does not report the key as up. + host_keys_down: Option>, + /// The pointer/keyboard state of the last known UI phase. A hidden logic-only tick must not act + /// on the previous pass's stale input. + pointer_down: bool, + wants_keyboard: bool, /// Where a dropped audio file loads into a track's sampler (last frame's rects). pub sample_drop_targets: Vec<(egui::Rect, TrackId)>, + /// Files dropped on the window, captured at the start of the UI pass before the font gate. + pending_drops: Vec<(egui::DroppedFileHandle, Option)>, + /// Dropped files the side panels did not claim, for the native/browser shell to open or import. + host_drops: Vec, sample_reads: Vec, synthetic_grace: u32, control_rx: Option>, + /// Gestures whose reply waits for their events to be applied by a UI pass, in request order. + gesture_acks: VecDeque, pending_shots: Vec, + /// Session identity for screenshot/request binding; changes when the open session is replaced. + doc_epoch: u64, + /// Fresh viewport geometry from the host's raw input, including hidden logic-only ticks. + host_geometry: Option, + /// Monotonic host clock for transport simulation and frame pacing (never the UI input time). + last_tick: Option, + /// Test-only offset added to the monotonic clock. + clock_offset: std::time::Duration, + /// Test-only frozen clock. Once a test advances time, `clock_now` returns this instead of the + /// wall clock, so frame pacing and transport assertions do not depend on how long the test + /// host took to lay the frame out. + test_clock: Option, + /// Monotonic seconds since the app started, accumulated from [`Self::last_tick`]. + uptime: f64, + /// When the last settled UI pass (a rendered frame) ran. Frame statistics describe rendered + /// frames, not layout passes or hidden logic ticks. + last_rendered_tick: Option, /// An insert just picked from its menu, opened once the audio engine has its instance. pub(crate) pending_open: Option<(TrackId, usize)>, last_rev: u64, @@ -330,6 +389,12 @@ pub struct SoundApp { last_write_at: Samples, /// Plugins changed in their editors whose state the session hasn't taken yet (see `editor_states_due`). editor_states: Vec<(TrackId, usize)>, + /// One editor poll per host raw-input iteration, including a hidden logic-only iteration. + /// A discarded layout pass must not mistake its already-drained queue for a gesture end. + editor_poll_due: bool, + /// Tests supply drained editor reports at the native polling boundary without an audio device. + #[cfg(test)] + editor_poll_fixture: Option, pub frame_ms: f32, last_frame: Option, last_meter_poll: f64, @@ -343,6 +408,9 @@ pub struct SoundApp { /// and colour assertions in one test can never straddle another test's palette change. #[cfg(test)] _palette_lock: theme::TestPaletteGuard, + /// Test-only count of synthetic batches acknowledged by a pass that received their events. + #[cfg(test)] + synthetic_applied_batches: u32, } /// The plugins whose state the session takes this frame, out of those changed in their editors @@ -368,6 +436,23 @@ fn editor_states_due( due } +/// Collect one admitted editor poll. Both native hosting and the lifecycle test fixture use +/// this same gesture scheduler/state-capture route. +fn editor_changes( + pending: &mut Vec<(TrackId, usize)>, + edits: Vec, + changed: Vec<(TrackId, usize)>, + closed: impl Fn((TrackId, usize)) -> bool, + capture: impl FnOnce(&[(TrackId, usize)]) -> soundcraft_playback::PluginStates, +) -> (Vec, soundcraft_playback::PluginStates) { + let due = editor_states_due(pending, &edits, changed, closed); + let states = if due.is_empty() { Vec::new() } else { capture(&due) }; + (edits, states) +} + +#[cfg(test)] +type EditorPollFixture = Box) -> (Vec, soundcraft_playback::PluginStates)>; + /// Whether another automation-write pass is due: the transport moved at least /// `step` since `last` in either direction. `last == i64::MIN` marks "never /// written", so the first pass after (re)start is always due. Saturating @@ -379,6 +464,8 @@ fn automation_step_due(pos: Samples, last: Samples, step: Samples) -> bool { } impl SoundApp { + /// Build an app around an engine and optional player, initialising UI, recording, autosave, control and + /// synthetic-input state. pub fn new(engine: Engine, player: Option, services: Services) -> Self { SoundApp { recent_seen: engine.path.clone(), @@ -417,11 +504,32 @@ impl SoundApp { edit_scroll_into_view: None, mix_scroll_into_view: None, synthetic: Vec::new(), + synthetic_batch: None, + synthetic_pointer_active: false, + cancel_synthetic_pointer: false, + cancel_synthetic_keys: false, + cancel_synthetic_scroll: false, + input_before_scroll: None, + injected_events: Vec::new(), + injected_at: 0, + host_keys_down: None, + pointer_down: false, + wants_keyboard: false, sample_drop_targets: Vec::new(), + pending_drops: Vec::new(), + host_drops: Vec::new(), sample_reads: Vec::new(), synthetic_grace: 0, control_rx: None, + gesture_acks: VecDeque::new(), pending_shots: Vec::new(), + doc_epoch: 0, + host_geometry: None, + last_tick: None, + clock_offset: std::time::Duration::ZERO, + test_clock: None, + uptime: 0.0, + last_rendered_tick: None, pending_open: None, last_rev: 0, fonts_ready: false, @@ -435,6 +543,9 @@ impl SoundApp { touched: std::collections::HashSet::new(), last_write_at: i64::MIN, editor_states: Vec::new(), + editor_poll_due: true, + #[cfg(test)] + editor_poll_fixture: None, frame_ms: 0.0, last_frame: None, last_meter_poll: f64::NEG_INFINITY, @@ -443,6 +554,8 @@ impl SoundApp { imports: import::Imports::default(), #[cfg(test)] _palette_lock: theme::lock_test_palette(), + #[cfg(test)] + synthetic_applied_batches: 0, } } @@ -467,8 +580,8 @@ impl SoundApp { match self.engine.execute(id, ¶ms) { Ok(v) => { if replacing { - // Note indices and gestures belong to the old session's clips. - self.midi = midi_editor::MidiEditorState::default(); + // Note indices and gestures belong to the old session's clips (#443). + self.document_replaced(); } if replacing || (id.starts_with("session.save") && !self.engine.is_dirty()) { self.release_autosave(false); @@ -493,6 +606,16 @@ impl SoundApp { } } + /// The open document was replaced outside [`Self::run`] (Recover, a session the browser read + /// as bytes). Screenshot requests admitted for the old document must never receive the new + /// one's pixels, so every replacement path bumps the epoch. + pub(crate) fn document_replaced(&mut self) { + self.doc_epoch = self.doc_epoch.wrapping_add(1); + // Note indices and gestures belong to the old session's clips (#443); every document + // replacement (run, Recover, a session the browser read) goes through here. + self.midi = midi_editor::MidiEditorState::default(); + } + pub fn is_playing(&self) -> bool { self.engine.transport.playing } @@ -707,13 +830,26 @@ impl SoundApp { /// modified, so it is autosaved and asks to be saved. The state is read once per gesture (see /// `editor_states_due`). fn apply_editor_edits(&mut self) { + if !std::mem::take(&mut self.editor_poll_due) { + return; + } + #[cfg(test)] + if let Some(poll) = self.editor_poll_fixture.as_mut() { + let (edits, states) = poll(&mut self.editor_states); + self.store_editor_changes(edits, states); + return; + } let Some(p) = &self.player else { self.editor_states.clear(); return; }; - let edits = p.idle(); - let due = editor_states_due(&mut self.editor_states, &edits, p.changed_states(), |(t, slot)| !p.editor_open(t, slot)); - let states = if due.is_empty() { Vec::new() } else { p.capture_states_of(&due) }; + let (edits, states) = editor_changes( + &mut self.editor_states, + p.idle(), + p.changed_states(), + |(t, slot)| !p.editor_open(t, slot), + |due| p.capture_states_of(due), + ); self.store_editor_changes(edits, states); } @@ -837,6 +973,7 @@ impl SoundApp { self.close_autosave(); match soundcraft_engine::autosave::recover(&mut self.engine, left) { Ok(missing) => { + self.document_replaced(); if self.write_autosave() { soundcraft_engine::autosave::discard(left); } @@ -925,12 +1062,12 @@ impl SoundApp { let (cause, then) = match trouble { OutputTrouble::Bug => ( "the audio engine hit an internal error (see the log)".to_string(), - "Use Reconnect audio device in Setup ▸ Playback Engine, or restart SoundCraft.", + "Use Reconnect audio device in Setup › Playback Engine, or restart SoundCraft.", ), OutputTrouble::Gone => { - (format!("“{name}” was disconnected"), "Plug it back in, then use Reconnect audio device in Setup ▸ Playback Engine.") + (format!("“{name}” is unavailable"), "Check the audio device, then use Reconnect audio device in Setup › Playback Engine.") } - OutputTrouble::Stalled => (format!("“{name}” stopped asking for audio"), "Use Reconnect audio device in Setup ▸ Playback Engine."), + OutputTrouble::Stalled => (format!("“{name}” stopped asking for audio"), "Use Reconnect audio device in Setup › Playback Engine."), }; let (recording, playing) = (self.engine.transport.recording, self.engine.transport.playing); if recording || playing { @@ -965,7 +1102,7 @@ impl SoundApp { Trouble::Device(what) => { self.recorder = None; self.recorder_failed = true; - (what, "Reconnect it, then use Reconnect audio device in Setup ▸ Playback Engine.") + (what, "Reconnect it, then use Reconnect audio device in Setup › Playback Engine.") } Trouble::Disk(what) => (what, "Check the disk, then record again."), }; @@ -1021,7 +1158,7 @@ impl SoundApp { /// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. 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. - fn write_automation(&mut self, ctx: &egui::Context) { + fn write_automation(&mut self) { use soundcraft_model::AutomationMode as M; const PASS: &str = "automation_pass"; let playing = self.is_playing(); @@ -1038,7 +1175,8 @@ impl SoundApp { return; } let from = std::mem::replace(&mut self.last_write_at, if playing { pos } else { i64::MIN }); - let dragging = ctx.input(|i| i.pointer.any_down()); + // The gesture state cached from the known UI phase, never hidden-tick stale input. + let dragging = self.pointer_down; let fader_track = match &self.gesture { Some(Gesture::Fader { track, .. }) => Some(*track), _ => None, @@ -1089,6 +1227,8 @@ impl SoundApp { } } + /// Apply engine transport requests to the player and recorder, read back the position and refresh meters. A reset + /// request also clears per-document capture, edit-gesture and meter state. fn handle_transport(&mut self, dt: f32) { let reqs: Vec = std::mem::take(&mut self.engine.transport_requests); for r in reqs { @@ -1168,7 +1308,7 @@ impl SoundApp { if self.engine.transport.playing { self.engine.transport.position = p.position(); } - let now = self.last_frame.unwrap_or_default(); + let now = self.uptime; let meter_interval = if self.ui.window == MainWindow::Mix { 1.0 / 60.0 } else { 1.0 / 20.0 }; if now - self.last_meter_poll >= meter_interval { p.copy_meters(&mut self.meter_snapshot); @@ -1213,10 +1353,13 @@ impl SoundApp { } /// Per-frame logic: control channel, transport, document sync. + /// + /// eframe calls this once per layout pass and also on logic-only ticks while hidden. Consumed + /// queues are one-shot, autosave is revision/instant based, and the monotonic clock decides + /// timed work. Bounce and import polling run only on pass zero, including hidden logic ticks. + /// Each host iteration starts with `raw_input_hook`; its editor-poll admission prevents an + /// empty queue on a discarded pass from ending a still-active plugin gesture. pub fn logic(&mut self, ctx: &egui::Context) { - if ctx.current_pass_index() > 0 { - return; - } // Fonts set now take effect next frame, so draw only from the frame after. if !self.fonts_ready { if self.fonts_installed { @@ -1232,20 +1375,38 @@ impl SoundApp { theme::apply(ctx, light); self.theme_applied = Some(light); } - let now = ctx.input(|i| i.time); - let dt = self.last_frame.replace(now).map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25); - self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1; + // A logic-only tick while hidden gets no fresh events: `ctx.input` still says what the last + // completed pass saw. Do not let that stale pointer/keyboard state keep writing automation + // or hold a transient fader gesture (loss of visibility cancels a Touch drag by design). + // `ui` (the known UI phase) caches this pass's real state for the next `logic` call. + let hidden = !ctx.input(|i| i.viewport().visible()).unwrap_or(true); + if hidden { + self.pointer_down = false; + self.wants_keyboard = false; + if matches!(self.gesture, Some(Gesture::Fader { .. })) { + self.gesture = None; + } + } + // UI input time is stale on logic-only ticks and identical across passes of one frame. + // Frame pacing and the transport simulation use a monotonic host clock instead. + let dt = self.tick_dt(); + self.last_frame = Some(self.uptime); + control::expire_gestures(self); control::drain(self, ctx); self.note_recent_session(); self.apply_editor_edits(); - bounce::step(self, ctx); - import::step(self, ctx); - if !ctx.input(|i| i.pointer.any_down()) { + // A multi-pass frame runs this logic once per pass; bounce slices and background-import + // polling are per host frame. + if ctx.current_pass_index() == 0 { + bounce::step(self, ctx); + import::step(self, ctx); + } + if !self.pointer_down { if matches!(self.gesture, Some(Gesture::Fader { .. })) { self.gesture = None; } // Keep a running automation pass merged until playback stops. - if !ctx.egui_wants_keyboard_input() && self.touched.is_empty() { + if !self.wants_keyboard && self.touched.is_empty() { self.engine.end_merge(); } } @@ -1261,7 +1422,7 @@ impl SoundApp { } self.open_inserted(); self.autosave_changes(); - self.write_automation(ctx); + self.write_automation(); self.autosave(); if self.recorder.is_none() && !self.recorder_failed @@ -1270,18 +1431,98 @@ impl SoundApp { self.ensure_input(); } // Autosave and the macOS menu bar only run on frames, and plugin editors are native windows egui never hears from. - // egui takes its predicted frame time off every delay, so add it back. + // egui 0.37 schedules the delay as given (it no longer subtracts the predicted frame time), + // so ask for the real interval instead of adding that time back. let editors = self.player.as_ref().is_some_and(Player::any_editor_open); - ctx.request_repaint_after_secs(if editors { 0.03 } else { 1.0 } + ctx.input(|i| i.predicted_dt)); - control::collect_screenshots(self, ctx); + ctx.request_repaint_after_secs(if editors { 0.03 } else { 1.0 }); + control::drain_screenshots(self, ctx); + if control::screenshots_need_paint(self) { + // An admitted capture that has not reached a UI pass needs an explicit hidden paint; + // the screenshot command itself only paints once it has been issued. + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + ctx.request_repaint(); + } + if self.synthetic_batch.is_some() { + // A batch waiting for a settled pass (or the UI pass that would apply it) needs a real + // UI paint, not just logic ticks: a hidden window runs no layout otherwise. + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + if self.synthetic_batch.is_some() || !self.synthetic.is_empty() { + ctx.request_repaint(); + } + } + + /// Monotonic seconds since the previous tick, clamped to a sane frame step. + fn tick_dt(&mut self) -> f32 { + let now = self.clock_now(); + let elapsed = self.last_tick.replace(now).map_or(1.0 / 60.0, |prev| now.duration_since(prev).as_secs_f64()); + self.uptime += elapsed; + (elapsed as f32).clamp(0.0, 0.25) + } + + /// The app clock: a frozen test clock when set, otherwise host time shifted by the test offset. + pub(crate) fn clock_now(&self) -> web_time::Instant { + self.test_clock.unwrap_or_else(|| web_time::Instant::now().checked_add(self.clock_offset).unwrap_or_else(web_time::Instant::now)) + } + + /// Test hook: advance the monotonic clock without sleeping. + /// + /// The first call freezes the clock at the current host time; later calls move it by exactly + /// `by`. Tests that assert frame or transport timing therefore see only the time they asked + /// for, not how long the test machine spent rendering. + #[doc(hidden)] + pub fn advance_test_clock(&mut self, by: std::time::Duration) { + let base = self.test_clock.unwrap_or_else(|| self.clock_now()); + self.test_clock = Some(base.checked_add(by).unwrap_or(base)); + } + + /// The viewport geometry new captures are bound to. Raw-input geometry wins because it is + /// refreshed on hidden logic-only ticks; the UI input is a fallback for harnesses without a + /// raw-input hook. + pub(crate) fn capture_geometry(&self, ctx: &egui::Context) -> control::CaptureGeometry { + self.host_geometry.unwrap_or_else(|| control::CaptureGeometry::from_context(ctx)) + } + + /// Remember the latest valid host viewport geometry for screenshot admission. + fn note_host_geometry(&mut self, ctx: &egui::Context, raw: &egui::RawInput) { + if let Some(g) = control::CaptureGeometry::from_raw(ctx, raw) { + self.host_geometry = Some(g); + } } /// Feed queued synthetic input (from the control channel) into egui: one pointer event per /// frame (so drags span frames like real ones), keys as press+release pairs, text at once. - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + self.editor_poll_due = true; + self.note_host_geometry(ctx, raw); + // Expire before injecting: a gesture that can no longer reach a UI pass must not put one + // last copy of its events into this pass after its request was already canceled. + control::expire_gestures(self); + // A host that could not run a UI pass retains the input this hook mutated (eframe's + // `pending_raw_input`, the web runner's `input.raw`) and hands it back next tick. Strip the + // copy appended last time before appending the still-pending batch again: otherwise a + // single queued key press would arrive once per hidden tick and run N times on restore. + self.remove_injected(raw); + self.host_keys_down = None; + // An expired key/scroll batch was already stripped from the retained input above. A UI + // pass that received it before the deadline crossed has consumed these flags; a hidden + // tick cannot. + self.cancel_synthetic_keys = false; + self.cancel_synthetic_scroll = false; + self.input_before_scroll = None; + if std::mem::take(&mut self.cancel_synthetic_pointer) { + // Do this before egui begins its next input/interaction pass. stop_dragging marks a + // drag_stopped in the current snapshot; the next pass replaces it without a release. + // Calling it from the UI instead could turn cancellation into a track reorder/drop. + ctx.input_mut(|i| i.pointer = Default::default()); + ctx.stop_dragging(); + egui::DragAndDrop::clear_payload(ctx); + self.pointer_down = false; + } // While a synthetic gesture plays (and briefly after), the real pointer is ignored so it - // cannot interleave with the scripted one. - if !self.synthetic.is_empty() { + // cannot interleave with the scripted one. A batch still waiting for a UI pass counts as + // "playing": the grace must not run out while that gesture is pending. + if !self.synthetic.is_empty() || self.synthetic_batch.is_some() { self.synthetic_grace = 6; } if self.synthetic_grace > 0 { @@ -1290,28 +1531,202 @@ impl SoundApp { !matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::PointerGone | egui::Event::MouseMoved(_)) }); } - let Some(first) = self.synthetic.first().cloned() else { return }; - let take = match first { - egui::Event::Key { pressed: true, .. } => { - // Press and the matching release together. - if matches!(self.synthetic.get(1), Some(egui::Event::Key { pressed: false, .. })) { 2 } else { 1 } + // Exactly one batch is in flight. It is only taken from the queue when no earlier batch is + // still waiting for a settled UI pass, so hidden logic ticks neither apply nor discard it. + if self.synthetic_batch.is_none() + && let Some(first) = self.synthetic.first().cloned() + { + let take = match first { + egui::Event::Key { pressed: true, .. } => { + // Press and the matching release together. + if matches!(self.synthetic.get(1), Some(egui::Event::Key { pressed: false, .. })) { 2 } else { 1 } + } + _ => 1, + }; + let n = take.min(self.synthetic.len()); + self.synthetic_batch = Some(self.synthetic.drain(..n).collect()); + } + let Some(batch) = self.synthetic_batch.clone() else { return }; + if batch.iter().any(|e| matches!(e, egui::Event::Key { .. })) { + // egui derives a press's `repeat` flag and the final `keys_down` from every event in the + // batch. A scripted press+release while the host physically holds the same key would + // therefore be marked as a repeat and would leave `keys_down` reporting the key as up. + // Remember the host-only state; `ui` puts it back after egui processed the batch. The + // hook itself never mutates egui's input, so a hidden logic-only tick cannot corrupt it. + let previous = ctx.input(|i| i.keys_down.clone()); + self.host_keys_down = Some(Self::host_keys_after(previous, &raw.events)); + } + if batch.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. } | egui::Event::Zoom(_))) { + // Remember the pass input and the host's own raw input from before egui folds this + // batch into its scroll/zoom aggregates, so an expiry in `logic` can replay the host + // input without it. + self.input_before_scroll = Some((ctx.input(|i| i.clone()), raw.clone())); + } + let mut injected = Vec::with_capacity(batch.len() * 2); + for e in &batch { + if matches!(e, egui::Event::PointerButton { pressed: true, .. }) { + self.synthetic_pointer_active = true; } - _ => 1, - }; - let n = take.min(self.synthetic.len()); - let evs: Vec = self.synthetic.drain(..n).collect(); - for e in &evs { if let egui::Event::PointerButton { pos, .. } | egui::Event::PointerMoved(pos) = e { // Keep the hover position current for widgets that check it. - raw.events.push(egui::Event::PointerMoved(*pos)); + injected.push(egui::Event::PointerMoved(*pos)); } } - raw.events.extend(evs); + injected.extend(batch); + self.injected_at = raw.events.len(); + raw.events.extend(injected.iter().cloned()); + self.injected_events = injected; + } + + /// The physical key state after the host's own events, ignoring any synthetic batch appended + /// later. egui starts from the previous pass's `keys_down`, then applies key events in order. + fn host_keys_after(mut down: std::collections::HashSet, events: &[egui::Event]) -> std::collections::HashSet { + for event in events { + match event { + egui::Event::Key { key, pressed: true, .. } => { + down.insert(*key); + } + egui::Event::Key { key, pressed: false, .. } => { + down.remove(key); + } + // egui clears all held keys when the window loses focus. + egui::Event::WindowFocused(false) => down.clear(), + _ => {} + } + } + down + } + + /// Remove the events [`Self::raw_input_hook`] appended to an input the host retained, so the + /// pending batch appears exactly once no matter how many logic-only ticks reuse that input. + fn remove_injected(&mut self, raw: &mut egui::RawInput) { + let injected = std::mem::take(&mut self.injected_events); + let n = injected.len(); + if n == 0 || raw.events.len() < n { + return; + } + let here = raw.events.get(self.injected_at..self.injected_at.saturating_add(n)).is_some_and(|run| run == injected.as_slice()); + // The host appends newer events after the retained copy, so it is usually still at its + // recorded index; fall back to the last identical run for a host that rewraps its input. + let found = if here { + Some(self.injected_at) + } else { + (0..=raw.events.len() - n).rev().find(|&i| raw.events.get(i..i + n).is_some_and(|run| run == injected.as_slice())) + }; + if let Some(i) = found { + raw.events.drain(i..i + n); + } + } + + /// Drop the injected copy from this pass's `InputState.events` after its gesture expired in + /// `logic`. Returns whether the copy is known to be gone: an empty batch, or an exact run that + /// was drained. On a mismatch the caller must not draw, because the stale events cannot be + /// identified safely. + fn remove_received_injected(&self, ctx: &egui::Context) -> bool { + let n = self.injected_events.len(); + if n == 0 { + return true; + } + ctx.input_mut(|i| { + let Some(run) = i.events.get(self.injected_at..self.injected_at.saturating_add(n)) else { return false }; + // egui rewrites a press's `repeat` flag before the app sees it; compare like the ack. + if run.iter().zip(&self.injected_events).all(|(received, sent)| Self::synthetic_event_received(sent, received)) { + i.events.drain(self.injected_at..self.injected_at.saturating_add(n)); + true + } else { + false + } + }) + } + + /// Rebuild this pass's input without the expired scroll/zoom batch. egui has already folded + /// the batch into `InputState.smooth_scroll_delta`, `zoom_delta` and the persistent wheel + /// smoothing state, none of which can be undone by draining `events`. Replaying the host's own + /// raw input from the pre-injection state removes that contribution while keeping simultaneous + /// host events, held keys and pointer/touch state. + fn rebuild_input_without_expired_scroll(&mut self, ctx: &egui::Context) { + let Some((before, mut host_raw)) = self.input_before_scroll.take() else { return }; + // `begin_pass` derives `stable_dt` from its own `requested_immediate_repaint_prev_frame` + // argument. Keep the real pass's value; layout reads it in this pass. + let stable_dt = ctx.input(|i| i.stable_dt); + // `ui` already moved any dropped files from this pass's raw input into `pending_drops`. + // Do not replay them into the raw as well, or the next pass would route them twice. + host_raw.dropped_files.clear(); + let options = ctx.options(|o| o.input_options); + let mut fresh = before.begin_pass(host_raw, false, ctx.pixels_per_point(), options); + fresh.stable_dt = stable_dt; + ctx.input_mut(|i| *i = fresh); + // This real pass consumed the hook-mutated raw, so there is no retained copy left to strip. + // Keeping the old run would let the next hook's last-identical-run fallback eat a genuine + // host event that happens to match it, such as the next identical physical wheel notch. + self.injected_events.clear(); } /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); + // egui just processed the scripted key events. A scripted release while the host still + // physically holds the key would report it up; put the host-only state back before any + // shortcut or widget reads it. + if let Some(host_down) = self.host_keys_down.take() { + ctx.input_mut(|i| i.keys_down = host_down); + } + // Dropped files arrive in a real UI pass. Capture and clear them before the font gate, so a + // first frame that returns before layout keeps them in a one-shot queue for the next + // laid-out pass, and a forced hidden paint handles them like any other. + let dropped: Vec<(egui::DroppedFileHandle, Option)> = ctx.input_mut(|i| { + let pos = i.pointer.latest_pos(); + i.raw.dropped_files.drain(..).map(|f| (f, pos)).collect() + }); + self.pending_drops.extend(dropped); + if self.cancel_synthetic_pointer { + // The deadline may have crossed in logic after this frame's raw-input hook. Do not + // dispatch its stale press/release to widgets; the next hook cancels it safely before + // egui computes interactions. Captured drops remain queued for that drawn pass. + ctx.request_repaint(); + return; + } + // A discard rerun gets a fresh `RawInput`, so it never contains the injected run. Only + // replay/remove when this pass really received it; otherwise a later pass would dispatch + // the earlier pass's host key/text events twice and roll its time back. + let received_injected = self.pass_received_injected(&ctx); + if std::mem::take(&mut self.cancel_synthetic_keys) { + // A key/text batch crossed its deadline in logic, after the hook had already handed it + // to this pass. Drop that copy before widgets, shortcuts and egui's end-of-pass + // quit/zoom shortcuts can execute it. A discard rerun did not receive the batch, so it + // has nothing to remove and must keep its own input. + if received_injected && !self.remove_received_injected(&ctx) { + // The stale copy could not be identified. Do not lay out a pass that might execute + // it; the next hook drops the retained raw copy. + ctx.request_repaint(); + return; + } + // This real pass consumed the hook-mutated raw. Keep drawing when the stale copy was + // removed so unrelated host key/text events in the same pass still reach shortcuts and + // widgets; clearing the recorded run also stops the next hook stripping a genuine + // identical host event. + self.injected_events.clear(); + ctx.request_repaint(); + } + if std::mem::take(&mut self.cancel_synthetic_scroll) { + if received_injected { + // A scroll/zoom batch expired after the hook injected it. Rebuild this pass's input + // without it before widgets read scroll aggregates, then keep drawing so a + // simultaneous real host key, wheel or pointer event is still dispatched. + self.rebuild_input_without_expired_scroll(&ctx); + } else { + // The batch was not injected into this pass (for example a discarded rerun after a + // font-gated pass). Replaying the earlier pass's host raw would dispatch its key + // and text events twice and roll back this pass's time. + self.input_before_scroll = None; + self.injected_events.clear(); + ctx.request_repaint(); + } + } + // Input preparation: remember this pass's real pointer/keyboard state, which `logic` (run + // before `ui` in each pass and alone on hidden ticks) uses for gestures and automation. + self.pointer_down = ctx.input(|i| i.pointer.any_down()); + self.wants_keyboard = ctx.egui_wants_keyboard_input(); // The integration creates this Ui before logic resolves the current frame's palette. ui.set_style(ctx.global_style()); if !self.fonts_ready { @@ -1322,6 +1737,11 @@ impl SoundApp { self.arrange_request = false; ctx.memory_mut(|m| m.reset_areas()); } + // Capture whether this pass received the batch *before* menus and widgets run. egui widgets + // legitimately consume matching events from `InputState.events` (`consume_key` in docking, + // `DragValue` and text completion), so checking afterwards would see them missing, leave + // the batch pending, and inject the same edit/navigation/cut again next frame. + let received_synthetic = self.pass_received_synthetic(&ctx); shortcuts::handle(self, &ctx); self.sample_drop_targets.clear(); if !self.native_menu_bar { @@ -1344,6 +1764,48 @@ impl SoundApp { score_editor::show(self, &ctx); palette::show(self, &ctx); dialogs::show(self, &ctx); + // The sampler's drop rects are current now, so route any captured drop in this pass. + self.route_dropped_files(); + // The pass that actually received the injected events has already dispatched their widget + // side effects, even when it then asks for another layout pass. `received_synthetic` was + // captured before menus and widgets, so a widget that consumed the event from InputState + // still counts as received. Acknowledge the batch here, before the discard check: leaving + // it pending would make the next host frame inject the same click/key again and run the + // command twice. A font-gated pass returns above, and a pass without the events never + // acknowledges, so those batches stay pending. + if self.synthetic_batch.is_some() && received_synthetic { + let delivered = self.synthetic_batch.as_ref().map_or(0, Vec::len); + if self.synthetic_batch.as_ref().is_some_and(|batch| batch.iter().any(|e| matches!(e, egui::Event::PointerButton { pressed: false, .. }))) + { + self.synthetic_pointer_active = false; + } + self.synthetic_batch = None; + // The pass consumed this copy; nothing is left for a later host tick to strip. + self.injected_events.clear(); + // A scripted gesture is not a user clipboard action. Do not let a synthetic copy or + // PRIMARY selection reach the OS clipboard through the host's platform output. + ctx.output_mut(|o| { + o.commands.retain(|c| { + !matches!(c, egui::OutputCommand::CopyText(_) | egui::OutputCommand::CopyImage(_) | egui::OutputCommand::TextSelectionSettled(_)) + }); + }); + control::synthetic_delivered(self, delivered); + // The next control request queued behind this gesture (inspect, engine command or + // screenshot) needs a logic tick to be drained now that the barrier is gone. + ctx.request_repaint(); + #[cfg(test)] + { + self.synthetic_applied_batches += 1; + } + } + if !ctx.will_discard() { + // Frame statistics describe rendered frames: one sample per settled host frame, not one + // per layout pass and never on a hidden logic-only tick. + let now = self.clock_now(); + let dt = self.last_rendered_tick.replace(now).map_or(1.0 / 60.0, |prev| now.duration_since(prev).as_secs_f64()) as f32; + self.frame_ms = self.frame_ms.mul_add(0.9, dt.clamp(0.0, 0.25) * 1000.0 * 0.1); + control::issue_screenshots(self, &ctx); + } // Here, not in logic(): a covered or minimized window runs only logic(), with no vsync to pace it. if self.meters_live() { let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 }; @@ -1355,6 +1817,85 @@ impl SoundApp { } } + /// Whether `received` is the event `wanted` after egui's input pass. egui rewrites the `repeat` + /// flag of `egui::Event::Key` from the keys held in the previous pass, so a scripted press can + /// arrive marked as a repeat; the other fields still identify the event. All other events must + /// match exactly. + pub(crate) fn synthetic_event_received(wanted: &egui::Event, received: &egui::Event) -> bool { + match (wanted, received) { + ( + egui::Event::Key { key, physical_key, pressed, modifiers, .. }, + egui::Event::Key { key: got_key, physical_key: got_physical, pressed: got_pressed, modifiers: got_modifiers, .. }, + ) => key == got_key && physical_key == got_physical && pressed == got_pressed && modifiers == got_modifiers, + _ => wanted == received, + } + } + + /// The exact positions in this pass's events of the pending batch's key presses. Shortcut + /// handling uses provenance rather than matching key/modifier values: a fresh host press with + /// the same chord is a separate action, and egui's rewritten `repeat` flag only describes the + /// physical key state, not which event was injected. + pub(crate) fn scripted_key_press_indices(&self) -> Vec { + if self.synthetic_batch.is_none() { + return Vec::new(); + } + self.injected_events + .iter() + .enumerate() + .filter(|(_, e)| matches!(e, egui::Event::Key { pressed: true, .. })) + .map(|(offset, _)| self.injected_at.saturating_add(offset)) + .collect() + } + + /// Whether this pass's `InputState.events` still starts with the exact injected run. This is + /// also valid after `expire_gestures` cleared `synthetic_batch`, when a cancel needs to know + /// whether replaying would address this pass or an earlier one. + fn pass_received_injected(&self, ctx: &egui::Context) -> bool { + if self.injected_events.is_empty() { + return false; + } + let Some(end) = self.injected_at.checked_add(self.injected_events.len()) else { return false }; + ctx.input(|i| { + i.events.len() >= end + && self.injected_events.iter().enumerate().all(|(offset, sent)| { + i.events.get(self.injected_at + offset).is_some_and(|received| Self::synthetic_event_received(sent, received)) + }) + }) + } + + /// Whether this UI pass actually received the pending synthetic batch's events. Call this + /// before menus and widgets: egui's `consume_key`/`consume_shortcut` remove matching events + /// from `InputState.events`, so a late check would miss a batch that really was dispatched. + fn pass_received_synthetic(&self, ctx: &egui::Context) -> bool { + self.synthetic_batch.is_some() && self.pass_received_injected(ctx) + } + + /// Load dropped audio into the sampler track under the drop point; leave everything else for + /// the native/browser shell to open or import. + fn route_dropped_files(&mut self) { + if self.pending_drops.is_empty() { + return; + } + let mut claimed = false; + for (file, pos) in std::mem::take(&mut self.pending_drops) { + let path = file.path().to_string_lossy().into_owned(); + // As before, one dropped audio file goes to the sampler under the pointer; the rest + // are left to the shell. + let claim = !claimed && sampler_window::is_audio_name(&path); + let Some(track) = self.drop_track(pos).filter(|_| claim) else { + self.host_drops.push(file); + continue; + }; + claimed = true; + if file.path().is_absolute() { + let r = self.run("mix.sampler_load", json!({"track": track.0, "path": path})); + self.sample_loaded(track, &path, r); + } else { + self.sample_reads.push(sampler_window::SampleRead { track, file }); + } + } + } + /// UI state as JSON (control channel `ui.inspect`). pub fn inspect(&self, ctx: &egui::Context) -> Value { let size = ctx.content_rect().size(); @@ -1593,6 +2134,8 @@ mod tests { assert!(automation_step_due(0, 1_000_000, 960)); } + /// Write-mode passes replace automation only along the stretches they play; skipped jumps keep the old ride, and + /// stop writes the last point. #[test] fn a_write_pass_replaces_the_automation_it_plays_over() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); @@ -1606,10 +2149,9 @@ mod tests { app.run("transport.locate", json!({"at": 0})).unwrap(); app.run("transport.play", json!({})).unwrap(); // Passes a quarter second apart, sparse like a covered window's. - let ctx = egui::Context::default(); let pass = |app: &mut SoundApp| { app.handle_transport(0.25); - app.write_automation(&ctx); + app.write_automation(); }; pass(&mut app); pass(&mut app); @@ -1622,7 +2164,7 @@ mod tests { app.handle_transport(0.25); app.run("transport.stop", json!({})).unwrap(); app.handle_transport(0.0); - app.write_automation(&ctx); + app.write_automation(); let lane = app.engine.session().track_by_name("Kick").unwrap().lane(&soundcraft_model::AutoParam::Volume).unwrap(); let values = |from, to| lane.points.iter().filter(|p| (from..=to).contains(&p.at)).map(|p| p.value).collect::>(); assert_eq!(values(q, 2 * q), [fader; 2]); @@ -1633,6 +2175,7 @@ mod tests { assert_eq!(lane.value_at(10 * q, 0.0), -30.0); } + /// Automation pass writes and the fader ride driving them collapse into one undo step. #[test] fn a_pass_and_the_fader_ride_driving_it_are_one_undo_step() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); @@ -1640,17 +2183,16 @@ mod tests { app.run("mix.automation_mode", json!({"tracks": ["Kick"], "mode": "write"})).unwrap(); app.run("transport.play", json!({})).unwrap(); let steps = app.engine.undo_history().len(); - let ctx = egui::Context::default(); for frame in 0..60 { app.handle_transport(1.0 / 60.0); - app.write_automation(&ctx); + app.write_automation(); // What the Mix window's fader sends while it is dragged. let db = -10.0 + frame as f32 / 10.0; app.engine.execute_merged("mix.volume", &json!({"track": kick.0, "db": db}), &format!("fader:{}", kick.0)).unwrap(); } app.run("transport.stop", json!({})).unwrap(); app.handle_transport(0.0); - app.write_automation(&ctx); + app.write_automation(); assert_eq!(app.engine.undo_history().len(), steps + 1); } @@ -1841,4 +2383,1071 @@ mod tests { assert!(!app.is_playing()); assert!((speed(&app) - 1.0).abs() < 1e-3, "a half-speed pass that ends on its own restores full speed"); } + /// A synthetic batch survives hidden logic-only ticks and is applied exactly once by a real + /// UI pass; hidden ticks never re-inject or discard it. + #[test] + fn one_synthetic_batch_survives_hidden_ticks_and_applies_once() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + let raw = |w: f32| { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, 900.0)); + egui::RawInput { screen_rect: Some(rect), ..Default::default() } + }; + for _ in 0..4 { + let mut r = raw(1400.0); + app.raw_input_hook(&ctx, &mut r); + let mut out = ctx.run_ui(r, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + // Synthetic pointer input is delivered one event per host frame, like a real drag. + let pos = egui::pos2(40.0, 40.0); + app.synthetic.push(egui::Event::PointerMoved(pos)); + for _ in 0..3 { + let mut r = raw(1400.0); + app.raw_input_hook(&ctx, &mut r); + let _ = ctx.run_logic(&r, |ctx| app.logic(ctx)); + assert_eq!(app.synthetic.len(), 0, "the batch was taken from the queue once"); + assert_eq!(app.synthetic_batch.as_ref().map(Vec::len), Some(1), "the batch stays pending across hidden ticks"); + assert_eq!(app.synthetic_applied_batches, 0, "hidden ticks do not acknowledge the batch"); + } + let mut r = raw(1400.0); + app.raw_input_hook(&ctx, &mut r); + let mut out = ctx.run_ui(r, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(app.synthetic_applied_batches, 1); + assert!(app.synthetic_batch.is_none()); + } + + #[derive(Debug)] + struct TestDrop(std::path::PathBuf); + + impl egui::DroppedFile for TestDrop { + /// The test drop's file path; its bytes are empty because these tests only need a name. + fn path(&self) -> &std::path::Path { + &self.0 + } + /// Test drops carry no bytes; native imports only need the path in these tests. + #[cfg(not(target_arch = "wasm32"))] + fn bytes(&self) -> Result, String> { + Ok(Vec::new()) + } + /// Test drops carry no bytes; the browser path only needs a name in these tests. + #[cfg(target_arch = "wasm32")] + fn bytes_async(&self) -> std::pin::Pin, String>> + '_>> { + Box::pin(async { Ok(Vec::new()) }) + } + } + + /// A drop captured on the font-gated first frame survives to the first laid-out pass and is + /// handed to the shell exactly once. + #[test] + fn a_drop_before_fonts_are_ready_is_imported_once_after_layout() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::Engine::default(), None, super::Services::default()); + let mut first = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + first.dropped_files.push(std::sync::Arc::new(TestDrop(std::path::PathBuf::from("/tmp/soundcraft-drop.scraft")))); + let mut out = ctx.run_ui(first, |ui| app.ui(ui)); + out.textures_delta.clear(); + assert!(app.take_dropped_files().is_empty(), "the drop is queued, not dropped on the font gate"); + for _ in 0..3 { + let mut r = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut r); + let mut out = ctx.run_ui(r, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + let dropped = app.take_dropped_files(); + assert_eq!(dropped.len(), 1, "the session file reached the shell once"); + assert!(dropped.first().is_some_and(|f| f.path().ends_with("soundcraft-drop.scraft"))); + assert!(app.take_dropped_files().is_empty(), "later ticks do not replay the drop"); + } + + /// eframe keeps the hook-mutated `RawInput` for the next hidden tick and appends new host + /// events to it. Re-injecting the batch must replace that copy, not add another: a restored + /// click or key must run once, not once per hidden tick. + #[test] + fn a_retained_host_input_holds_one_copy_of_a_pending_batch() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let hidden = egui::ViewportInfo { minimized: Some(true), ..Default::default() }; + let host_input = || egui::RawInput { + screen_rect: Some(rect), + viewports: [(egui::ViewportId::ROOT, hidden.clone())].into_iter().collect(), + ..Default::default() + }; + // Let fonts install and the palette settle first: the first pass draws nothing. + for _ in 0..3 { + let mut r = host_input(); + app.raw_input_hook(&ctx, &mut r); + let mut out = ctx.run_ui(r, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + // A click is three queued pointer events; each becomes one batch on its own host frame. + let pos = egui::pos2(40.0, 40.0); + app.synthetic.push(egui::Event::PointerMoved(pos)); + for pressed in [true, false] { + app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + let mut retained = host_input(); + for tick in 0..5 { + // `prepare_raw_input`: newer host input appended to the retained, hook-mutated input. + let mut newer = host_input(); + newer.events.push(egui::Event::PointerMoved(egui::pos2(tick as f32, 0.0))); + retained.append(newer); + app.raw_input_hook(&ctx, &mut retained); + let batch = app.synthetic_batch.clone().expect("the first pointer event is pending"); + assert_eq!(batch.len(), 1, "one pointer event per host frame"); + let copies = retained.events.iter().filter(|e| batch.contains(*e)).count(); + assert_eq!(copies, 2, "the pending event and its hover copy appear exactly once, not once per tick"); + let _ = ctx.run_logic(&retained, |ctx| app.logic(ctx)); + assert!(app.synthetic_batch.is_some(), "hidden ticks never apply the batch"); + assert_eq!(app.synthetic_applied_batches, 0); + } + // A settled UI pass applies each queued pointer event exactly once. + for applied in 1..=3 { + let mut raw = host_input(); + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(app.synthetic_applied_batches, applied); + } + assert!(app.synthetic_batch.is_none(), "every queued event was delivered once"); + assert!(app.synthetic.is_empty()); + } + + /// A batch waiting for a UI pass under a hidden window asks the host for a real paint, so it is + /// delivered instead of spinning forever on logic ticks. + #[test] + fn a_pending_synthetic_batch_asks_a_hidden_window_for_a_paint() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + app.synthetic.push(egui::Event::Text("x".into())); + let hidden = egui::ViewportInfo { minimized: Some(true), ..Default::default() }; + let mut raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + viewports: [(egui::ViewportId::ROOT, hidden)].into_iter().collect(), + ..Default::default() + }; + app.raw_input_hook(&ctx, &mut raw); + assert!(app.synthetic_batch.is_some()); + let out = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + let commands = out.viewport_commands.get(&egui::ViewportId::ROOT).cloned().unwrap_or_default(); + assert!(commands.contains(&egui::ViewportCommand::RequestPaintWhileHidden), "{commands:?}"); + assert_eq!(app.synthetic_applied_batches, 0, "logic alone does not apply the gesture"); + } + + /// A frame that discards and lays out a second pass is still one rendered frame: frame + /// statistics must not sample the extra pass's near-zero delta. + #[test] + fn frame_time_counts_a_multi_pass_frame_once() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + for _ in 0..4 { + let mut out = ctx.run_ui(raw(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + for _ in 0..90 { + app.advance_test_clock(std::time::Duration::from_secs_f64(1.0 / 60.0)); + let mut discard = true; + let mut out = ctx.run_ui(raw(), |ui| { + app.logic(ui.ctx()); + if discard { + // A widget that needs another layout pass (Grid, a timeline measurement) asks + // for the discard during this pass, before `ui` settles. + discard = false; + ui.ctx().request_discard("two-pass frame test"); + } + app.ui(ui); + }); + out.textures_delta.clear(); + } + let ms = app.frame_ms; + assert!((ms - 1000.0 / 60.0).abs() < 0.5, "{ms} ms/frame across two-pass frames"); + } + + /// Scripted input is automation, not a user clipboard action: a synthetic copy/selection in a + /// text widget must not reach the OS clipboard or PRIMARY selection through platform output. + #[test] + fn a_synthetic_pass_does_not_publish_os_clipboard_commands() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + for _ in 0..4 { + let mut out = ctx.run_ui(raw(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + // Control: the same command from a pass without synthetic input still reaches the host. + let mut out = ctx.run_ui(raw(), |ui| { + app.logic(ui.ctx()); + ui.ctx().copy_text("real user copy".into()); + app.ui(ui); + }); + assert!( + out.platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(text) if text.as_str() == "real user copy")), + "the host still receives real copy commands" + ); + out.textures_delta.clear(); + + app.synthetic.push(egui::Event::Text("x".into())); + let mut input = raw(); + app.raw_input_hook(&ctx, &mut input); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + // Stand in for a TextEdit/widget that emits clipboard commands during the pass. + ui.ctx().copy_text("scripted copy".into()); + ui.ctx().output_mut(|o| o.commands.push(egui::OutputCommand::TextSelectionSettled("scripted selection".into()))); + app.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(app.synthetic_applied_batches, 1, "the pass delivered the synthetic event"); + assert!( + !out.platform_output.commands.iter().any(|c| matches!( + c, + egui::OutputCommand::CopyText(_) | egui::OutputCommand::CopyImage(_) | egui::OutputCommand::TextSelectionSettled(_) + )), + "scripted input must not publish clipboard or PRIMARY commands: {:?}", + out.platform_output.commands + ); + } + + /// egui rewrites a key press's `repeat` flag from the keys held in the previous pass. A scripted + /// press must still run its shortcut even while the same physical key is held, a same-frame host + /// auto-repeat with the same chord must not make it run twice, and the host's held state must + /// survive the scripted press and release. + #[test] + fn a_held_key_does_not_turn_a_scripted_press_into_a_repeat() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + for _ in 0..4 { + let mut out = ctx.run_ui(raw(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + for _ in 0..2 { + app.run("track.new", json!({"count": 1, "format": "stereo"})).unwrap(); + } + let depth = app.engine.undo_history().len(); + assert!(depth >= 2, "the test needs two undoable edits"); + let modifiers = egui::Modifiers { command: true, ..egui::Modifiers::NONE }; + // The user presses and holds Cmd+Z. That real press is one undo and leaves Z physically + // down; a later same-chord press is rewritten to a repeat. + let mut held = raw(); + held.events.push(egui::Event::Key { key: egui::Key::Z, physical_key: Some(egui::Key::Z), pressed: true, repeat: false, modifiers }); + let mut out = ctx.run_ui(held, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(ctx.input(|i| i.keys_down.contains(&egui::Key::Z)), "the host is still holding Z"); + assert_eq!(app.engine.undo_history().len(), depth - 1, "the real host press ran one undo"); + let held_depth = app.engine.undo_history().len(); + // Cmd+Z from the control channel while that chord is held, with the host's same-frame + // auto-repeat already in the raw input ahead of the appended synthetic batch. Both events + // have the same key, physical key and modifiers; only the host one is a repeat. + for pressed in [true, false] { + app.synthetic.push(egui::Event::Key { key: egui::Key::Z, physical_key: Some(egui::Key::Z), pressed, repeat: false, modifiers }); + } + let mut input = raw(); + input.events.push(egui::Event::Key { key: egui::Key::Z, physical_key: Some(egui::Key::Z), pressed: true, repeat: true, modifiers }); + app.raw_input_hook(&ctx, &mut input); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(app.synthetic_batch.is_none(), "the pass that received the rewritten press acknowledged it"); + assert!(ctx.input(|i| i.key_down(egui::Key::Z)), "the scripted release did not report the physically held key as up"); + assert_eq!(app.engine.undo_history().len(), held_depth - 1, "the scripted Cmd+Z ran exactly once despite the identical host repeat"); + // The following host frame must not replay the same key. + let mut input = raw(); + input.events.push(egui::Event::Key { key: egui::Key::Z, physical_key: Some(egui::Key::Z), pressed: true, repeat: true, modifiers }); + app.raw_input_hook(&ctx, &mut input); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(app.engine.undo_history().len(), held_depth - 1, "a later host auto-repeat must not undo a second time"); + assert!(ctx.input(|i| i.key_down(egui::Key::Z)), "the host still holds Z after the repeat"); + } + + /// A fresh physical Cmd+Z and an identical scripted Cmd+Z in one frame are distinct actions. + /// Matching them by value would swallow the scripted press; provenance keeps both. + #[test] + fn a_fresh_host_press_and_a_scripted_press_both_run() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + for _ in 0..4 { + let mut out = ctx.run_ui(raw(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + for _ in 0..2 { + app.run("track.new", json!({"count": 1, "format": "stereo"})).unwrap(); + } + let depth = app.engine.undo_history().len(); + assert!(depth >= 2, "the test needs two undoable edits"); + let modifiers = egui::Modifiers { command: true, ..egui::Modifiers::NONE }; + for pressed in [true, false] { + app.synthetic.push(egui::Event::Key { key: egui::Key::Z, physical_key: Some(egui::Key::Z), pressed, repeat: false, modifiers }); + } + let mut input = raw(); + input.events.push(egui::Event::Key { key: egui::Key::Z, physical_key: Some(egui::Key::Z), pressed: true, repeat: false, modifiers }); + app.raw_input_hook(&ctx, &mut input); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(app.synthetic_batch.is_none(), "the pass acknowledged the injected batch"); + assert_eq!(app.engine.undo_history().len(), depth - 2, "the physical press and the injected press are separate actions"); + assert!(ctx.input(|i| i.key_down(egui::Key::Z)), "the physical press still holds Z after the injected release"); + } + + /// A hidden logic-only tick after the hook runs no egui input pass. Repeated hidden ticks that + /// re-hook the retained host input must leave the physical key state and one pending batch + /// untouched. + #[test] + fn a_hidden_logic_tick_after_the_hook_keeps_the_held_key_and_pending_batch() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let visible = egui::ViewportInfo { inner_rect: Some(rect), ..Default::default() }; + let hidden = egui::ViewportInfo { minimized: Some(true), ..Default::default() }; + let raw = |viewport: egui::ViewportInfo| egui::RawInput { + screen_rect: Some(rect), + viewports: [(egui::ViewportId::ROOT, viewport)].into_iter().collect(), + ..Default::default() + }; + for _ in 0..4 { + let mut out = ctx.run_ui(raw(visible.clone()), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + app.run("track.new", json!({"count": 1, "format": "stereo"})).unwrap(); + let depth = app.engine.undo_history().len(); + assert!(depth > 0, "the test needs an undoable edit"); + // Alt+Z keeps Z physically down without firing the plain-Z Commands Focus undo. + let mut held = raw(visible.clone()); + held.events.push(egui::Event::Key { + key: egui::Key::Z, + physical_key: Some(egui::Key::Z), + pressed: true, + repeat: false, + modifiers: egui::Modifiers { alt: true, ..egui::Modifiers::NONE }, + }); + let mut out = ctx.run_ui(held, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(ctx.input(|i| i.key_down(egui::Key::Z))); + assert_eq!(app.engine.undo_history().len(), depth, "holding Alt+Z is not a command"); + let modifiers = egui::Modifiers { command: true, ..egui::Modifiers::NONE }; + for pressed in [true, false] { + app.synthetic.push(egui::Event::Key { key: egui::Key::Z, physical_key: Some(egui::Key::Z), pressed, repeat: false, modifiers }); + } + // eframe retains a hook-mutated input across hidden ticks and hands it back to the hook. + let mut retained = raw(hidden); + for tick in 0..3 { + app.raw_input_hook(&ctx, &mut retained); + assert!(ctx.input(|i| i.key_down(egui::Key::Z)), "hook tick {tick} mutated the held state"); + assert!(app.synthetic_batch.is_some(), "hook tick {tick} dropped the batch"); + assert_eq!(app.engine.undo_history().len(), depth, "hook tick {tick} ran a shortcut"); + let _ = ctx.run_logic(&retained, |ctx| app.logic(ctx)); + assert!(ctx.input(|i| i.key_down(egui::Key::Z)), "hidden tick {tick} erased the physically held key"); + assert!(app.synthetic_batch.is_some(), "hidden tick {tick} applied or dropped the batch"); + assert_eq!(app.engine.undo_history().len(), depth, "hidden tick {tick} ran a shortcut"); + } + // The deferred batch still reaches the next UI pass exactly once. + let mut out = ctx.run_ui(retained, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(app.synthetic_batch.is_none()); + assert_eq!(app.engine.undo_history().len(), depth - 1, "the deferred scripted Cmd+Z ran exactly once"); + assert!(ctx.input(|i| i.key_down(egui::Key::Z)), "the held key survives the deferred pass too"); + } + + /// A frame that asks for a second layout pass receives the injected key and dispatches it in + /// the first pass. The batch must be acknowledged once, and the physically held key must still + /// be down in the settled pass rather than being cleared by the scripted release. + #[test] + fn a_multi_pass_frame_keeps_the_held_key_and_applies_the_scripted_press_once() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + for _ in 0..4 { + let mut out = ctx.run_ui(raw(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + app.run("track.new", json!({"count": 1, "format": "stereo"})).unwrap(); + let depth = app.engine.undo_history().len(); + assert!(depth > 0, "the test needs an undoable edit"); + let mut held = raw(); + held.events.push(egui::Event::Key { + key: egui::Key::Z, + physical_key: Some(egui::Key::Z), + pressed: true, + repeat: false, + modifiers: egui::Modifiers { alt: true, ..egui::Modifiers::NONE }, + }); + let mut out = ctx.run_ui(held, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(ctx.input(|i| i.key_down(egui::Key::Z))); + let modifiers = egui::Modifiers { command: true, ..egui::Modifiers::NONE }; + for pressed in [true, false] { + app.synthetic.push(egui::Event::Key { key: egui::Key::Z, physical_key: Some(egui::Key::Z), pressed, repeat: false, modifiers }); + } + let mut input = raw(); + app.raw_input_hook(&ctx, &mut input); + let mut first_pass = true; + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + if first_pass { + first_pass = false; + // A measurement or grid asks for another layout pass after the widgets ran. + ui.ctx().request_discard("discard the pass that received the synthetic key"); + } + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(out.platform_output.num_completed_passes >= 2, "the frame really ran more than one pass"); + assert!(app.synthetic_batch.is_none(), "the pass that received the batch consumed it"); + assert_eq!(app.engine.undo_history().len(), depth - 1, "one scripted Cmd+Z undid exactly one edit"); + assert_eq!(app.synthetic_applied_batches, 1); + assert!(ctx.input(|i| i.key_down(egui::Key::Z)), "the held key survives the settled multi-pass frame"); + } + + /// Losing focus clears the physical key state even when a scripted key batch is in flight. The + /// injected press still runs once, and restoring the host-only state must not bring the stale + /// held key back. + #[test] + fn a_focus_loss_clears_a_held_key_with_a_pending_scripted_press() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + for _ in 0..4 { + let mut out = ctx.run_ui(raw(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + app.run("track.new", json!({"count": 1, "format": "stereo"})).unwrap(); + let depth = app.engine.undo_history().len(); + assert!(depth > 0, "the test needs an undoable edit"); + let mut held = raw(); + held.events.push(egui::Event::Key { + key: egui::Key::Z, + physical_key: Some(egui::Key::Z), + pressed: true, + repeat: false, + modifiers: egui::Modifiers { alt: true, ..egui::Modifiers::NONE }, + }); + let mut out = ctx.run_ui(held, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(ctx.input(|i| i.key_down(egui::Key::Z))); + let modifiers = egui::Modifiers { command: true, ..egui::Modifiers::NONE }; + for pressed in [true, false] { + app.synthetic.push(egui::Event::Key { key: egui::Key::Z, physical_key: Some(egui::Key::Z), pressed, repeat: false, modifiers }); + } + let mut input = raw(); + input.events.push(egui::Event::WindowFocused(false)); + app.raw_input_hook(&ctx, &mut input); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(app.synthetic_batch.is_none(), "the injected batch still ran once after focus loss"); + assert_eq!(app.engine.undo_history().len(), depth - 1); + assert!(!ctx.input(|i| i.key_down(egui::Key::Z)), "focus loss cleared the held key and the injected release did not resurrect it"); + // The next host frame must not bring the stale key back either. + let mut out = ctx.run_ui(raw(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(!ctx.input(|i| i.key_down(egui::Key::Z))); + assert_eq!(app.engine.undo_history().len(), depth - 1); + } + + /// A host key with the same signature as the pending injection does not acknowledge a batch + /// that was never actually appended. Otherwise a lookalike event could hide a lost scripted + /// action. + #[test] + fn a_host_key_with_the_same_signature_does_not_acknowledge_the_batch() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + for _ in 0..4 { + let mut out = ctx.run_ui(raw(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + for _ in 0..2 { + app.run("track.new", json!({"count": 1, "format": "stereo"})).unwrap(); + } + let depth = app.engine.undo_history().len(); + assert!(depth >= 2, "the test needs two undoable edits"); + let modifiers = egui::Modifiers { command: true, ..egui::Modifiers::NONE }; + // The batch is pending but this pass was not prepared by the hook, so it carries no injected + // copy. The identical host press is a real action, not the injected one. + app.synthetic_batch = + Some(vec![egui::Event::Key { key: egui::Key::Z, physical_key: Some(egui::Key::Z), pressed: true, repeat: false, modifiers }]); + let mut input = raw(); + input.events.push(egui::Event::Key { key: egui::Key::Z, physical_key: Some(egui::Key::Z), pressed: true, repeat: false, modifiers }); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(app.synthetic_batch.is_some(), "a lookalike host event must not acknowledge the batch"); + assert_eq!(app.synthetic_applied_batches, 0); + assert_eq!(app.engine.undo_history().len(), depth - 1, "only the real host press ran"); + // The hook then appends the actual injection; that pass acknowledges and runs it once. + let mut input = raw(); + app.raw_input_hook(&ctx, &mut input); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(app.synthetic_batch.is_none()); + assert_eq!(app.synthetic_applied_batches, 1); + assert_eq!(app.engine.undo_history().len(), depth - 2, "the actual injected press ran in addition to the host press"); + } + + /// The acknowledgement compares key events without egui's rewritten `repeat` flag. Every other + /// identifying field — the logical key, physical key, press/release state and modifiers — still + /// has to match. + #[test] + fn synthetic_key_acknowledgement_ignores_only_the_rewritten_repeat_flag() { + let modifiers = egui::Modifiers { command: true, ..egui::Modifiers::NONE }; + let sent = egui::Event::Key { key: egui::Key::Z, physical_key: Some(egui::Key::Z), pressed: true, repeat: false, modifiers }; + let mut received = sent.clone(); + if let egui::Event::Key { repeat, .. } = &mut received { + *repeat = true; + } + assert!(super::SoundApp::synthetic_event_received(&sent, &received), "egui's repeat rewrite does not hide the press"); + let mut wrong = received.clone(); + if let egui::Event::Key { key, .. } = &mut wrong { + *key = egui::Key::X; + } + assert!(!super::SoundApp::synthetic_event_received(&sent, &wrong), "a different logical key is not the scripted press"); + let mut wrong = received.clone(); + if let egui::Event::Key { physical_key, .. } = &mut wrong { + *physical_key = None; + } + assert!(!super::SoundApp::synthetic_event_received(&sent, &wrong), "a different physical key is not the scripted press"); + let mut wrong = received.clone(); + if let egui::Event::Key { pressed, .. } = &mut wrong { + *pressed = false; + } + assert!(!super::SoundApp::synthetic_event_received(&sent, &wrong), "a release is not the scripted press"); + let mut wrong = received; + if let egui::Event::Key { modifiers: got, .. } = &mut wrong { + *got = egui::Modifiers::NONE; + } + assert!(!super::SoundApp::synthetic_event_received(&sent, &wrong), "different modifiers are not the scripted press"); + assert!(!super::SoundApp::synthetic_event_received(&sent, &egui::Event::Text("z".into())), "other variants match exactly"); + } + + /// A pass that receives the synthetic events and then asks for another layout pass has already + /// dispatched their widget side effects. It must acknowledge the batch: leaving it pending + /// makes the next host frame inject the same click/key again (a double undo, a toggled command, + /// a dialog action run twice). + #[test] + fn a_discarded_pass_acknowledges_the_synthetic_batch_it_received() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + for _ in 0..4 { + let mut out = ctx.run_ui(raw(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + app.run("track.new", json!({"count": 1, "format": "stereo"})).unwrap(); + let depth = app.engine.undo_history().len(); + assert!(depth > 0, "the test needs an undoable edit"); + // Cmd+Z, queued as egui delivers a real key: press and release together. + let modifiers = egui::Modifiers { command: true, ..egui::Modifiers::NONE }; + for pressed in [true, false] { + app.synthetic.push(egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed, repeat: false, modifiers }); + } + let mut first_pass = true; + let mut input = raw(); + app.raw_input_hook(&ctx, &mut input); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + if first_pass { + first_pass = false; + // A measurement or grid asks for another layout pass after the widgets ran. + ui.ctx().request_discard("discard the pass that received the synthetic key"); + } + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(app.synthetic_batch.is_none(), "the pass that received the batch consumed it"); + assert_eq!(app.engine.undo_history().len(), depth - 1, "one Cmd+Z undid exactly one edit"); + assert_eq!(app.synthetic_applied_batches, 1); + // The following host frame must not replay the same key. + let mut input = raw(); + app.raw_input_hook(&ctx, &mut input); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(app.engine.undo_history().len(), depth - 1, "a later frame must not undo a second time"); + assert_eq!(app.synthetic_applied_batches, 1); + } + + /// The real toolbar tempo `DragValue`/`TextEdit` consumes scripted keys from + /// `InputState.events` (arrow navigation), yet each scripted edit, navigation step and cut is + /// dispatched exactly once. A post-widget acknowledgement would miss the consumed press, keep + /// the batch pending and apply it again on the next frame. + #[test] + fn real_widgets_apply_a_scripted_edit_navigation_and_cut_once_despite_consuming_the_events() { + struct Rig { + ctx: egui::Context, + app: SoundApp, + shapes: Vec, + } + + impl Rig { + /// A 1600x1000 rig over a one-track session, warmed for four frames. + fn new() -> Self { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({})).unwrap(); + let mut rig = Self { ctx: egui::Context::default(), app: SoundApp::new(engine, None, Services::default()), shapes: Vec::new() }; + rig.frames(4); + rig + } + + /// A blank 1600x1000 host frame. + fn raw(&self) -> egui::RawInput { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); + egui::RawInput { screen_rect: Some(rect), ..Default::default() } + } + + /// Run one UI pass with `events`, keeping the emitted shapes for position lookups. + fn frame(&mut self, events: Vec) { + let mut input = self.raw(); + input.events = events; + self.app.raw_input_hook(&self.ctx, &mut input); + let mut out = self.ctx.run_ui(input, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }); + out.textures_delta.clear(); + self.shapes = out.shapes; + } + + /// Advance `n` blank frames. + fn frames(&mut self, n: usize) { + for _ in 0..n { + self.frame(Vec::new()); + } + } + + /// Queue events as scripted input and run one frame. + fn synthetic(&mut self, events: Vec) { + self.app.synthetic.extend(events); + self.frame(Vec::new()); + } + + /// The session tempo at tick 0. + fn bpm(&self) -> f64 { + self.app.engine.session().tempo.tempo_at_tick(0) + } + + /// Centre of the toolbar tempo, which shows `bpm` with four decimals. + fn field(&self, bpm: f64) -> egui::Pos2 { + let text = format!("{bpm:.4}"); + self.shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == text => Some(t.galley.rect.translate(t.pos.to_vec2()).center()), + _ => None, + }) + .expect("tempo not in the toolbar") + } + + /// Send a primary press or release at `at` in its own frame. + fn button(&mut self, at: egui::Pos2, pressed: bool) { + self.frame(vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ]); + } + + /// Press and release at `at`, then let the UI settle for two frames. + fn click(&mut self, at: egui::Pos2) { + self.button(at, true); + self.button(at, false); + self.frames(2); + } + } + + let key = |key: egui::Key, modifiers: egui::Modifiers| { + [ + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + let mut rig = Rig::new(); + let mut applied = rig.app.synthetic_applied_batches; + + // Edit: typing replaces the selected tempo, and Enter commits it once. + let at = rig.field(120.0); + rig.click(at); + assert!(rig.ctx.egui_wants_keyboard_input(), "the real tempo TextEdit has focus"); + rig.synthetic(vec![egui::Event::Text("98.5".into())]); + rig.synthetic(key(egui::Key::Enter, egui::Modifiers::NONE).to_vec()); + rig.frames(2); + assert!((rig.bpm() - 98.5).abs() < 1e-9, "the scripted edit applied once: {}", rig.bpm()); + assert!(rig.app.synthetic_batch.is_none()); + assert_eq!(rig.app.synthetic_applied_batches, applied + 2); + applied = rig.app.synthetic_applied_batches; + + // Navigation: the focused DragValue consumes ArrowUp out of InputState.events. + rig.frames(6); // The scripted gesture's pointer grace must end before a real click. + rig.click(rig.field(98.5)); + assert!(rig.ctx.egui_wants_keyboard_input(), "the real tempo DragValue is editing"); + rig.synthetic(key(egui::Key::ArrowUp, egui::Modifiers::NONE).to_vec()); + assert!( + !rig.ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::ArrowUp, pressed: true, .. }))), + "the real DragValue consumed the injected ArrowUp press" + ); + assert!(rig.app.synthetic_batch.is_none(), "the pass that let DragValue consume ArrowUp still acknowledged it"); + let stepped = rig.bpm(); + assert!((stepped - 98.6).abs() < 1e-9, "one scripted navigation step: {stepped}"); + rig.frames(2); + assert!((rig.bpm() - stepped).abs() < 1e-9, "a later frame must not step again"); + assert_eq!(rig.app.synthetic_applied_batches, applied + 1); + applied = rig.app.synthetic_applied_batches; + + // Cut: select the tempo text and cut it once. The value only changes on commit, so cancel + // with Escape and keep the previous tempo. + rig.frames(6); + rig.click(rig.field(stepped)); + assert!(rig.ctx.egui_wants_keyboard_input(), "the real tempo TextEdit is editing again"); + rig.synthetic(key(egui::Key::A, egui::Modifiers { command: true, ..egui::Modifiers::NONE }).to_vec()); + rig.synthetic(vec![egui::Event::Cut]); + assert!(rig.app.synthetic_batch.is_none(), "the scripted cut was acknowledged"); + assert!( + !rig.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == "98.6000")), + "the scripted cut removed the selected tempo text" + ); + assert_eq!(rig.app.synthetic_applied_batches, applied + 2); + rig.synthetic(key(egui::Key::Escape, egui::Modifiers::NONE).to_vec()); + rig.frames(2); + assert!((rig.bpm() - stepped).abs() < 1e-9, "cutting the edit text and cancelling leaves the tempo unchanged"); + } + + /// A hidden logic-only tick has no fresh input: the last pass's pointer must not write Touch + /// automation or leave the fader gesture stuck. + #[test] + fn a_hidden_tick_does_not_write_automation_from_the_last_pass_pointer() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + app.run("mix.automation_mode", json!({"tracks": ["Kick"], "mode": "touch"})).unwrap(); + app.run("transport.locate", json!({"at": 0})).unwrap(); + app.run("transport.play", json!({})).unwrap(); + app.handle_transport(0.0); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let pos = egui::pos2(700.0, 450.0); + let press = egui::RawInput { + screen_rect: Some(rect), + events: vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + ], + ..Default::default() + }; + let mut out = ctx.run_ui(press, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(ctx.input(|i| i.pointer.any_down()), "the visible pass left the pointer down"); + let start_db = app.engine.session().track(kick).unwrap().mixer.volume_db; + app.gesture = Some(super::Gesture::Fader { track: kick, start_db }); + app.last_write_at = i64::MIN; + let points = |app: &super::SoundApp| { + app.engine.session().track(kick).and_then(|t| t.lane(&soundcraft_model::AutoParam::Volume)).map_or(0, |l| l.points.len()) + }; + let before = points(&app); + let hidden = egui::ViewportInfo { minimized: Some(true), ..Default::default() }; + let raw = + egui::RawInput { screen_rect: Some(rect), viewports: [(egui::ViewportId::ROOT, hidden)].into_iter().collect(), ..Default::default() }; + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + assert!(app.gesture.is_none(), "visibility loss drops the transient fader gesture"); + assert_eq!(points(&app), before, "a hidden tick wrote automation from the stale pointer"); + app.run("transport.stop", json!({})).unwrap(); + } + + /// The font-gated first pass returns before layout, so it cannot have dispatched the batch + /// and must leave it pending for the first laid-out pass. + #[test] + fn a_font_gated_pass_does_not_ack_the_pending_synthetic_batch() { + let ctx = egui::Context::default(); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + app.synthetic.push(egui::Event::Text("x".into())); + let mut input = raw(); + app.raw_input_hook(&ctx, &mut input); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(!app.fonts_ready, "the first pass installs fonts but does not lay out"); + assert!(app.synthetic_batch.is_some(), "the font gate returns before the acknowledgement"); + assert_eq!(app.synthetic_applied_batches, 0); + // The next hook offers the same batch to the first laid-out pass, which applies it once. + let mut input = raw(); + app.raw_input_hook(&ctx, &mut input); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(app.fonts_ready); + assert!(app.synthetic_batch.is_none()); + assert_eq!(app.synthetic_applied_batches, 1); + // And the following frame must not replay it. + let mut input = raw(); + app.raw_input_hook(&ctx, &mut input); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(app.synthetic_applied_batches, 1); + } + + /// A settled pass that never received the injected events (for example a forced second pass + /// after a font-gated first pass) must not consume the pending batch. + #[test] + fn a_settled_pass_that_did_not_receive_the_batch_does_not_ack_it() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + for _ in 0..4 { + let mut out = ctx.run_ui(raw(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + app.synthetic_batch = Some(vec![egui::Event::Text("x".into())]); + let mut out = ctx.run_ui(raw(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(app.synthetic_batch.is_some(), "a pass without the events keeps the batch pending"); + assert_eq!(app.synthetic_applied_batches, 0); + let mut injected = raw(); + app.raw_input_hook(&ctx, &mut injected); + let mut out = ctx.run_ui(injected, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(app.synthetic_applied_batches, 1); + assert!(app.synthetic_batch.is_none()); + } + + /// The transport simulation advances by real elapsed monotonic time, not by a full frame step + /// per call and not by the stale UI input clock. + #[test] + fn transport_simulation_advances_by_monotonic_elapsed_time_only() { + let ctx = egui::Context::default(); + let mut app = super::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, super::Services::default()); + app.sim = Some((0, None, None)); + app.engine.transport.playing = true; + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + // Two passes of one host frame must not each advance a full predicted frame. + let before = app.position(); + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + let same_frame = app.position() - before; + assert!(same_frame.abs() < 480, "a repeated pass advanced {same_frame} samples"); + app.advance_test_clock(std::time::Duration::from_millis(200)); + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + let elapsed = app.position() - before; + let expected = (0.2 * app.engine.session().sample_rate.as_f64()) as i64; + assert!((elapsed - expected).abs() < expected / 20, "200 ms advanced {elapsed} samples, expected about {expected}"); + app.engine.transport.playing = false; + app.sim = None; + } + /// Plugin queues are drained once per host iteration, rather than once per discarded pass. + /// The fixture replaces only native polling; the real raw hook, logic/UI, scheduler and + /// engine state/undo commands run normally. + #[test] + fn editor_gesture_polling_survives_discarded_and_hidden_host_iterations() { + use std::cell::{Cell, RefCell}; + use std::rc::Rc; + + let ctx = egui::Context::default(); + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + for _ in 0..4 { + let mut input = raw(); + app.raw_input_hook(&ctx, &mut input); + let mut output = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + output.textures_delta.clear(); + } + let keys = app.engine.session().track_by_name("Keys").unwrap().id; + let original = instrument_state(&app, keys); + let revision = app.engine.revision; + let undo = app.engine.undo_history().len(); + let history = app.engine.undo_history(); + let journal = app.engine.journal.clone(); + let state_command = soundcraft_engine::find_command("mix.insert_state").unwrap(); + assert!(!state_command.undoable && !state_command.journal, "this fixture exercises state-only instrument persistence"); + assert!(!app.engine.is_dirty()); + let reports = Rc::new(RefCell::new(Vec::::new())); + let latest = Rc::new(Cell::new(1u8)); + let polls = Rc::new(Cell::new(0)); + let captures = Rc::new(RefCell::new(Vec::new())); + let (poll_reports, poll_latest, poll_count, poll_captures) = (reports.clone(), latest.clone(), polls.clone(), captures.clone()); + app.editor_poll_fixture = Some(Box::new(move |pending| { + poll_count.set(poll_count.get() + 1); + let edits = std::mem::take(&mut *poll_reports.borrow_mut()); + super::editor_changes( + pending, + edits, + Vec::new(), + |_| false, + |due| { + poll_captures.borrow_mut().extend_from_slice(due); + due.iter().map(|&(track, slot)| (track, slot, vec![poll_latest.get(), 1])).collect() + }, + ) + })); + reports.borrow_mut().push((keys, soundcraft_mix::INSTRUMENT_SLOT, "cutoff".into(), 1.0)); + let mut input = raw(); + app.raw_input_hook(&ctx, &mut input); + let mut passes = 0; + let mut output = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + passes += 1; + if passes == 1 { + ui.ctx().request_discard("plugin gesture must survive a second layout pass"); + } + app.ui(ui); + }); + output.textures_delta.clear(); + assert!(passes >= 2, "the regression must actually force a discarded pass"); + assert_eq!(polls.get(), 1, "the rerun cannot drain an empty editor queue"); + assert!(captures.borrow().is_empty(), "the ongoing gesture has not ended"); + assert_eq!(app.editor_states, [(keys, soundcraft_mix::INSTRUMENT_SLOT)]); + assert_eq!(instrument_state(&app, keys), original); + assert_eq!(app.engine.revision, revision); + assert_eq!(app.engine.undo_history().len(), undo); + assert!(app.has_unsaved_changes(), "pending editor work still needs autosave/recovery"); + + // The next host hook is hidden: it still polls the continuing gesture despite a stale + // discarded-pass index and without drawing any UI. + latest.set(2); + reports.borrow_mut().push((keys, soundcraft_mix::INSTRUMENT_SLOT, "cutoff".into(), 2.0)); + let mut hidden = raw(); + hidden.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().minimized = Some(true); + app.raw_input_hook(&ctx, &mut hidden); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + assert_eq!(polls.get(), 2, "hidden hook rearms exactly one poll, not one per logic callback"); + assert!(captures.borrow().is_empty()); + assert_eq!(app.editor_states, [(keys, soundcraft_mix::INSTRUMENT_SLOT)]); + assert_eq!(instrument_state(&app, keys), original); + assert_eq!(app.engine.revision, revision); + + // A genuine next-iteration empty poll ends the gesture and persists its latest state. + app.raw_input_hook(&ctx, &mut hidden); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + assert_eq!(polls.get(), 3); + assert_eq!(*captures.borrow(), [(keys, soundcraft_mix::INSTRUMENT_SLOT)]); + assert!(app.editor_states.is_empty()); + assert_eq!(instrument_state(&app, keys), Some(vec![2, 1])); + assert_eq!(app.engine.revision, revision.wrapping_add(1), "the completed gesture persists exactly one document revision"); + assert!(app.engine.is_dirty(), "captured instrument state is unsaved document work"); + assert_eq!(app.engine.undo_history().len(), undo, "state-only instrument persistence does not create an undo step"); + assert_eq!(app.engine.undo_history(), history, "state capture does not replace another history entry"); + assert_eq!(app.engine.journal, journal, "plugin-state blobs are not journaled"); + let settled_revision = app.engine.revision; + app.raw_input_hook(&ctx, &mut hidden); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + assert_eq!(polls.get(), 4, "a fresh empty host iteration is still live"); + assert_eq!(captures.borrow().len(), 1, "the completed gesture is captured once"); + assert_eq!(app.engine.revision, settled_revision); + assert_eq!(app.engine.undo_history().len(), undo); + assert_eq!(app.engine.undo_history(), history); + assert_eq!(app.engine.journal, journal); + } } diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index dbd6f6d2..2515dbbb 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -68,6 +68,7 @@ fn is_black(p: i32) -> bool { matches!(p.rem_euclid(12), 1 | 3 | 6 | 8 | 10) } +/// Draw the MIDI editor for the target clip, with an empty-state prompt that adds a clip on the selected MIDI track. pub fn show(app: &mut SoundApp, ui: &mut Ui) { // Painter-only contents do not advance the layout. Claim the resized // space, including the empty state, so the dock keeps its chosen height. @@ -90,7 +91,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { ui.painter().text(full.center(), Align2::CENTER_CENTER, msg, regular(13.0), t.text_dim); let body = Rect::from_min_max(pos2(full.min.x, header.max.y), full.max); if let Some((id, _)) = track - && ui.interact(body, ui.id().with("new_clip"), Sense::click()).clicked() + && ui.interact(body, ui.scope_id().with("new_clip"), Sense::click()).clicked() { let sel = app.engine.session().edit.selection; if let Ok(v) = app.run("midi.new_clip", json!({"track": id.0, "start": sel.start, "end": sel.end})) { @@ -234,7 +235,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { painter.rect(r, 2.0, fill, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); } // Interaction. - let resp = ui.interact(roll, ui.id().with(("roll", cid.0)), Sense::click_and_drag()); + let resp = ui.interact(roll, ui.scope_id().with(("roll", cid.0)), Sense::click_and_drag()); let hit = |pos: egui::Pos2| -> Option<(usize, bool)> { sequence.notes.iter().enumerate().rev().find_map(|(i, n)| { let r = Rect::from_min_max( @@ -375,7 +376,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { // Velocity lane. ui.painter().rect_filled(vel, 0.0, Color32::from_rgb(28, 28, 30)); ui.painter().text(pos2(full.min.x + 6.0, vel.center().y), Align2::LEFT_CENTER, "Velocity", regular(10.0), t.text_dim); - let vresp = ui.interact(vel, ui.id().with(("vel", cid.0)), Sense::click_and_drag()); + let vresp = ui.interact(vel, ui.scope_id().with(("vel", cid.0)), Sense::click_and_drag()); for (i, n) in sequence.notes.iter().enumerate() { let x = x_of(n.start); let h = (vel.height() - 4.0) * f32::from(n.velocity) / 127.0; diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 1540bcfb..cacf4699 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -3,8 +3,8 @@ use crate::SoundApp; use crate::theme::{Tokens, bold, regular, rgb}; use crate::widgets::{ - PannerEdit, PannerSpeaker, db_text, fader, fader_slot, groove, inset, meter, meter_scale, multi_meter, pan_knob, pan_text, raised_key, - rec_toggle, selector_box, selector_box_with_tooltip, shade, surround_panner, text_toggle, vgradient, + PannerEdit, PannerSpeaker, SelectorLabels, db_text, fader, fader_slot, groove, inset, meter, meter_scale, multi_meter, pan_knob, pan_text, + raised_key, rec_toggle, selector_box_named, shade, surround_panner, text_toggle, vgradient, }; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; @@ -43,6 +43,8 @@ pub fn separate_closed(app: &mut SoundApp) { app.ui.window = crate::MainWindow::Mix; } +/// Draw the horizontal Mix window strips for visible non-video tracks, scroll one requested strip into view and open +/// New Tracks when the space past the last strip is double-clicked. pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) { let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect(); // Per-channel peaks for multichannel meters (read once per frame). @@ -57,7 +59,7 @@ pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) { strip(app, ui, id, snap.as_ref(), scroll_to == Some(id)); } let rest = Rect::from_min_max(pos2(ui.cursor().min.x, ui.clip_rect().min.y), ui.clip_rect().max); - if rest.width() > 4.0 && ui.interact(rest, ui.id().with("past_strips"), Sense::click()).double_clicked() { + if rest.width() > 4.0 && ui.interact(rest, ui.scope_id().with("past_strips"), Sense::click()).double_clicked() { let _ = app.dialogs.open_for_command(&app.engine, "track.new"); } }); @@ -90,7 +92,7 @@ fn panner_speakers(fmt: ChannelFormat) -> Vec { /// Ballistic per-channel meter levels for multichannel strips, kept in egui memory. fn channel_levels(ui: &Ui, key: (&str, u64), peaks: &[f32], n: usize) -> Vec { - let id = egui::Id::new(key); + let id = egui::Id::unique(key); let dt = ui.input(|i| i.stable_dt).clamp(0.0, 0.25); let fall = 10f32.powf(-26.0 * dt / 20.0); let mut lv: Vec = ui.data(|d| d.get_temp(id)).unwrap_or_default(); @@ -152,6 +154,8 @@ pub(crate) fn route_popup(app: &mut SoundApp, resp: &egui::Response, id: TrackId egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, input)); } +/// Draw one mixer channel strip: preamp, instrument, object, insert and send sections, EQ curve, routing, fader and +/// meters, with its interactions. fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>, scroll_here: bool) { let t = Tokens::current(); let session = app.engine.session_arc(); @@ -188,7 +192,9 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let kr = Rect::from_min_size(pos2(x0 + 6.0, sec.min.y + 15.0), vec2(inner_w - 12.0, 16.0)); let mut db = track.mixer.trim_db; let mut c = ui.new_child(egui::UiBuilder::new().max_rect(kr)); - let resp = c.add_sized(kr.size(), egui::DragValue::new(&mut db).range(-24.0..=24.0).speed(0.1).suffix(" dB").max_decimals(1)); + let resp = c + .add_sized(kr.size(), egui::DragValue::new(&mut db).range(-24.0..=24.0).speed(0.1).suffix(" dB").max_decimals(1)) + .accessible_name("Input trim (dB)"); if resp.changed() { let _ = app.engine.execute_merged("mix.trim", &json!({"tracks": [id.0], "db": db}), &format!("trim:{}", id.0)); } @@ -282,7 +288,11 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let mut c = track.comments.clone(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sec)); let hint = if narrow { "" } else { "comments" }; - if child.add(egui::TextEdit::multiline(&mut c).desired_width(inner_w).desired_rows(2).hint_text(hint).font(regular(10.0))).changed() { + let field = crate::widgets::named_text_field( + child.add(egui::TextEdit::multiline(&mut c).desired_width(inner_w).desired_rows(2).hint_text(hint).font(regular(10.0))), + &format!("Comments for {}", track.name), + ); + if field.changed() { let _ = app.engine.execute_merged("track.comments", &json!({"track": id.0, "comments": c}), &format!("comments:{}", id.0)); } y = sec.max.y + 4.0; @@ -294,12 +304,26 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let in_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0), vec2(inner_w - 4.0, 16.0)); let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0)); let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r)); - let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input_name(app, track), t.text); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Input")); + let input = input_name(app, track); + let resp = selector_box_named( + &mut c, + in_r.width(), + in_r.height(), + &input, + t.text, + SelectorLabels { name: &format!("Input for {}", track.name), value: &input, tooltip: "Input" }, + ); route_popup(app, &resp, id, true); let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-output", id.0)).max_rect(out_r)); - let resp = selector_box(&mut c, out_r.width(), out_r.height(), &route_name(app, &track.mixer.output), t.text); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Output")); + let output = route_name(app, &track.mixer.output); + let resp = selector_box_named( + &mut c, + out_r.width(), + out_r.height(), + &output, + t.text, + SelectorLabels { name: &format!("Output for {}", track.name), value: &output, tooltip: "Output" }, + ); route_popup(app, &resp, id, false); // Automation mode. let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0)); @@ -321,7 +345,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } else { &full_label }; - let resp = selector_box_with_tooltip(&mut c, am_r.width(), am_r.height(), label, col, &full_label); + let resp = selector_box_named( + &mut c, + am_r.width(), + am_r.height(), + label, + col, + SelectorLabels { name: &format!("Automation mode for {}", track.name), value: &full_label, tooltip: &full_label }, + ); egui::Popup::menu(&resp).show(|ui| { for m in soundcraft_model::AutomationMode::ALL { if ui.selectable_label(m == mode, m.label()).clicked() { @@ -336,7 +367,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let gname = app.engine.session().groups.iter().find(|g| g.members.contains(&id)).map_or(no_group.to_string(), |g| g.name.clone()); let gr = Rect::from_min_size(pos2(x0 + 2.0, y), vec2(inner_w - 4.0, 16.0)); let mut c = ui.new_child(egui::UiBuilder::new().max_rect(gr)); - let _ = selector_box(&mut c, gr.width(), gr.height(), &gname, t.text_dim); + let _ = selector_box_named( + &mut c, + gr.width(), + gr.height(), + &gname, + t.text_dim, + SelectorLabels { name: &format!("Group for {}", track.name), value: &gname, tooltip: "Group" }, + ); y += 22.0; // Pan: a surround panner when the output is multichannel, else the stereo knobs. let out_fmt = output_format(app, track); @@ -378,8 +416,18 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let _ = resp; let pr = Rect::from_center_size(pos2(cx, y + 42.0), vec2(ks + 8.0, 13.0)); inset(ui.painter(), pr, t.counter_bg); - let (_, entered) = - editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan, 0.0); + let (_, entered) = editable_readout( + ui, + pr, + ui.scope_id().with(("pan-value", id.0, i)), + Readout { + name: &format!("{} pan {}", track.name, i + 1), + display: &pan_text(v), + edit_initial: &format!("{:.0}", v * 100.0), + parse: parse_pan, + reset: 0.0, + }, + ); if let Some(v) = entered { let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0)); } @@ -393,10 +441,12 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh ); row.spacing_mut().item_spacing.x = 6.0; if track.kind.has_playlist() { - if text_toggle(&mut row, vec2(bw, 18.0), "I", track.mixer.input_monitor, t.input, "Input monitoring").clicked() { + let input_label = format!("Input monitoring for {}", track.name); + if text_toggle(&mut row, vec2(bw, 18.0), "I", track.mixer.input_monitor, t.input, &input_label).clicked() { let _ = app.run("mix.input_monitor", json!({"track": id.0})); } - if rec_toggle(&mut row, vec2(bw, 18.0), track.mixer.record_arm, "Record enable").clicked() { + let record_label = format!("Record enable for {}", track.name); + if rec_toggle(&mut row, vec2(bw, 18.0), track.mixer.record_arm, &record_label).clicked() { let _ = app.run("mix.record_arm", json!({"track": id.0})); } } @@ -406,10 +456,12 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh ); row.spacing_mut().item_spacing.x = 6.0; if track.kind != TrackKind::Master { - if text_toggle(&mut row, vec2(bw, 18.0), "S", track.mixer.solo, t.solo, "Solo").clicked() { + let solo_label = format!("Solo for {}", track.name); + if text_toggle(&mut row, vec2(bw, 18.0), "S", track.mixer.solo, t.solo, &solo_label).clicked() { let _ = app.run("mix.solo", json!({"track": id.0})); } - if text_toggle(&mut row, vec2(bw, 18.0), "M", track.mixer.mute, t.mute, "Mute").clicked() { + let mute_label = format!("Mute for {}", track.name); + if text_toggle(&mut row, vec2(bw, 18.0), "M", track.mixer.mute, t.mute, &mute_label).clicked() { let _ = app.run("mix.mute", json!({"track": id.0})); } } @@ -419,7 +471,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let fh = (r.max.y - bottom_h - y).max(120.0); let wide_meter = (if track.kind == TrackKind::Master { app.engine.session().main_format() } else { track.format }).channels() > 2; let fr = Rect::from_min_size(pos2(x0, y), vec2(inner_w * if wide_meter { 0.45 } else { 0.6 }, fh)); - if let Some(db) = fader(ui, fr, track.mixer.volume_db, ui.id().with(("fader", id.0))) { + if let Some(db) = fader(ui, fr, track.mixer.volume_db, ui.scope_id().with(("fader", id.0)), &format!("{} level", track.name)) { app.gesture = Some(crate::Gesture::Fader { track: id, start_db: track.mixer.volume_db }); let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0)); } @@ -454,11 +506,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let (_, entered) = editable_readout( ui, vr, - ui.id().with(("volume-value", id.0)), - &db_text(track.mixer.volume_db), - &format!("{:.1}", track.mixer.volume_db), - parse_db, - 0.0, + ui.scope_id().with(("volume-value", id.0)), + Readout { + name: &format!("{} volume", track.name), + display: &db_text(track.mixer.volume_db), + edit_initial: &format!("{:.1}", track.mixer.volume_db), + parse: parse_db, + reset: 0.0, + }, ); if let Some(db) = entered { let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0)); @@ -483,7 +538,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh StrokeKind::Inside, ); ui.painter().with_clip_rect(nr).text(nr.center(), Align2::CENTER_CENTER, &track.name, bold(12.0), t.text_dark); - let nresp = ui.interact(nr, ui.id().with(("strip_name", id.0)), Sense::click()); + let nresp = ui.interact(nr, ui.scope_id().with(("strip_name", id.0)), Sense::click()); if nresp.clicked() { crate::edit_window::click_track(app, id, ui.input(|i| i.modifiers), |_, t| !t.hidden && t.kind != TrackKind::Video); } @@ -505,8 +560,8 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: let (id, slot) = (track.id, soundcraft_mix::INSTRUMENT_SLOT); let ins = track.instrument.as_ref(); let info = ins.and_then(|i| plugin_info(&i.plugin)); - let resp = ui.interact(r, ui.id().with(("instrument", id.0)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Instrument")); + let resp = ui.interact(r, ui.scope_id().with(("instrument", id.0)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Instrument for {}", track.name))); match info { Some(info) => used_slot(ui.painter(), r, t.slot_face, Mark::Dot, info.short_name, t.text, None), None => empty_slot(ui.painter(), r, resp.hovered(), Mark::Dot), @@ -592,12 +647,13 @@ fn level_icon(r: Rect) -> Rect { Rect::from_min_max(pos2(outer.max.x - 9.0, outer.min.y + 2.0), pos2(outer.max.x - 4.0, outer.max.y - 2.0)) } +/// Draw one insert slot. Clicking a filled slot opens its plugin window; Cmd/Ctrl-click toggles bypass. pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { let t = Tokens::current(); let id = track.id; let ins = track.mixer.inserts.get(slot).cloned().flatten(); - let resp = ui.interact(r, ui.id().with(("ins", id.0, slot)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Insert {}", (b'A' + slot as u8) as char))); + let resp = ui.interact(r, ui.scope_id().with(("ins", id.0, slot)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Insert {} for {}", (b'A' + slot as u8) as char, track.name))); let info = ins.as_ref().and_then(|i| plugin_info(&i.plugin)); let name = ins.as_ref().map(|i| info.map_or(i.plugin.as_str(), |p| p.short_name)).unwrap_or_default(); match &ins { @@ -626,7 +682,7 @@ pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: /// An insert's menu: a search field that takes typing at once, then (until something is typed) /// Bypass and removal and the plugins by category and format. pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, occupied: bool) { - let search = MenuSearch::show(ui, egui::Id::new(("plugin-search", id.0, slot)), "Search plugins…"); + let search = MenuSearch::show(ui, egui::Id::unique(("plugin-search", id.0, slot)), "Search plugins…"); if search.typing() { if let Some(plugin) = plugin_search(ui, &search, false) { pick_insert(app, ui, id, slot, &plugin); @@ -667,7 +723,7 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc /// instruments. pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let slot = soundcraft_mix::INSTRUMENT_SLOT; - let search = MenuSearch::show(ui, egui::Id::new(("instrument-search", id.0)), "Search instruments…"); + let search = MenuSearch::show(ui, egui::Id::unique(("instrument-search", id.0)), "Search instruments…"); if search.typing() { if let Some(plugin) = plugin_search(ui, &search, true) { run_pick(app, ui, "mix.instrument", json!({"track": id.0, "plugin": plugin})); @@ -925,6 +981,7 @@ pub fn plugin_info(id: &str) -> Option<&'static soundcraft_dsp::PluginInfo> { .or_else(|| soundcraft_au_host::plugin_info(id)) } +/// Draw one send slot: the route area picks a destination, while the level area opens or edits the send level. pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { let t = Tokens::current(); let id = track.id; @@ -934,8 +991,9 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us // destination. A short track's square has no icon, so all of it is the level. let level_r = if compact { r } else { level_icon(r).expand2(vec2(3.0, 0.0)).with_min_y(r.min.y).with_max_y(r.max.y) }; let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y)); - let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click()); - route_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {}", (b'A' + slot as u8) as char))); + let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.scope_id().with(("snd-route", id.0, slot)), Sense::click()); + route_resp + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Send {} for {}", (b'A' + slot as u8) as char, track.name))); let mark = if compact { Mark::Arrow } else { Mark::Letter((b'a' + slot as u8) as char) }; match &snd { Some(s) => { @@ -945,8 +1003,8 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us used_slot(ui.painter(), r, t.slot_face, mark, &name, color, Some(level)); let which = (b'A' + slot as u8) as char; let shown = app.ui.send_window == Some((id, slot)); - let level_resp = ui.interact(level_r, ui.id().with(("send-value", id.0, slot)), Sense::click_and_drag()); - level_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {which} level"))); + let level_resp = ui.interact(level_r, ui.scope_id().with(("send-value", id.0, slot)), Sense::click_and_drag()); + level_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Send {which} level for {}", track.name))); if shown { ui.painter().rect_stroke(level_r.shrink(0.5), 1.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } @@ -1004,7 +1062,7 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us pub(crate) fn send_window(app: &mut SoundApp, ctx: &egui::Context) { // egui closes menus and drops text focus on Escape before this runs, so whether this Escape // was theirs comes from the last frame. A dialog or the search on top takes it too. - let busy_id = egui::Id::new("send_window_escape"); + let busy_id = egui::Id::unique("send_window_escape"); let busy = ctx.data(|d| d.get_temp(busy_id)).unwrap_or(false) || app.dialogs.open.is_some() || app.ui.show_search; if !busy && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.send_window = None; @@ -1014,6 +1072,7 @@ pub(crate) fn send_window(app: &mut SoundApp, ctx: &egui::Context) { ctx.data_mut(|d| d.insert_temp(busy_id, busy)); } +/// Draw the floating send window for the remembered track and slot, or close it when that send no longer exists. fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) { let Some((id, slot)) = app.ui.send_window else { return }; let Some((name, send)) = app.engine.session().track(id).and_then(|t| Some((t.name.clone(), t.mixer.sends.get(slot).cloned().flatten()?))) else { @@ -1023,7 +1082,7 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) { let t = Tokens::current(); let mut open = true; egui::Window::new(format!("{name} · Send {}", (b'A' + slot as u8) as char)) - .id(egui::Id::new("send_window")) + .id(egui::Id::unique("send_window")) .open(&mut open) .auto_sized() .collapsible(false) @@ -1053,8 +1112,10 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) { } ui.label(egui::RichText::new(pan_text(send.pan)).color(t.counter_text).small()); let (fr, fresp) = ui.allocate_exact_size(vec2(56.0, 220.0), Sense::hover()); - fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Send level")); - if let Some(db) = fader(ui, fr, send.level_db, ui.id().with("send_fader")) { + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, true, "Send level")); + // Distinct from the strip's "Send A level for " 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") { let _ = app.engine.execute_merged( "mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), @@ -1064,7 +1125,18 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) { let (rr, _) = ui.allocate_exact_size(vec2(64.0, 15.0), Sense::hover()); ui.painter().rect_filled(rr, 1.0, t.counter_bg); let shown = db_text(send.level_db); - let (_, typed) = editable_readout(ui, rr, ui.id().with("send_level_value"), &shown, &format!("{:.1}", send.level_db), parse_db, 0.0); + let (_, typed) = editable_readout( + ui, + rr, + ui.scope_id().with("send_level_value"), + Readout { + name: &format!("{name} send level"), + display: &shown, + edit_initial: &format!("{:.1}", send.level_db), + parse: parse_db, + reset: 0.0, + }, + ); if let Some(db) = typed { let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db})); } @@ -1129,18 +1201,24 @@ fn dragged_send_db(db: f32, dy: f32, fine: bool) -> f32 { soundcraft_model::fader_pos_to_db(soundcraft_model::fader_db_to_pos(db) - dy * per_point) } +/// What an [`editable_readout`] shows and how a typed value is parsed. +struct Readout<'a> { + /// Accessible name of the text field shown while editing. + name: &'a str, + /// The value painted while not editing. + display: &'a str, + /// The editor's initial text. + edit_initial: &'a str, + parse: fn(&str) -> Option, + /// The value an Alt-click sets. + reset: f32, +} + /// Paint a value until clicked, then replace it with a focused text field. Enter or clicking away /// commits a valid value; Escape cancels, and so does the field losing focus while hidden. /// Alt-click returns `reset`. The interaction response remains available for dragging. -fn editable_readout( - ui: &mut Ui, - r: Rect, - id: egui::Id, - display: &str, - edit_initial: &str, - parse: fn(&str) -> Option, - reset: f32, -) -> (Option, Option) { +fn editable_readout(ui: &mut Ui, r: Rect, id: egui::Id, readout: Readout<'_>) -> (Option, Option) { + let Readout { name, display, edit_initial, parse, reset } = readout; let t = Tokens::current(); let active_id = id.with("active"); let text_id = id.with("text"); @@ -1170,10 +1248,10 @@ fn editable_readout( let edit = egui::TextEdit::singleline(&mut text) .id(id.with("editor")) .frame(egui::Frame::NONE) - .horizontal_align(egui::Align::Center) + .align(egui::Align2::CENTER_TOP) .font(regular(9.0)) .desired_width(r.width()); - let edit_resp = child.add_sized(r.size(), edit); + let edit_resp = crate::widgets::named_text_field(child.add_sized(r.size(), edit), name); if started { edit_resp.request_focus(); } @@ -1241,9 +1319,10 @@ fn send_level_scale(ui: &mut Ui, db: f32) { ui.painter().line_segment([pos2(x, r.min.y), pos2(x, line_y + 3.0)], Stroke::new(2.0, t.counter_text)); } +/// Searchable bus destination list with a New Bus action for a send. fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, current: Option<&Route>) { let busses = app.engine.session().busses.clone(); - let search = MenuSearch::show(ui, egui::Id::new(("send-bus-search", id.0, slot)), "Search buses…"); + let search = MenuSearch::show(ui, egui::Id::unique(("send-bus-search", id.0, slot)), "Search buses…"); let visible: Vec<_> = busses.iter().filter(|bus| search.matches(&bus.name)).collect(); let picked = search.rows(ui, visible.len(), 260.0, |ui, i, highlighted| { let current = matches!(current, Some(Route::Bus(bus_id)) if *bus_id == visible[i].id); @@ -1268,6 +1347,7 @@ fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, cu } } +/// Input or output menu: no route and buses on both, plus the main output (outputs) or inputs 1-8 (inputs). fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { let key = if input { "input" } else { "output" }; let cmd = if input { "track.input" } else { "track.output" }; @@ -1291,7 +1371,7 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { ui.separator(); let busses: Vec = app.engine.session().busses.iter().map(|b| b.name.clone()).collect(); ui.strong("Bus"); - let search = MenuSearch::show(ui, egui::Id::new(("route-bus-search", id.0, input)), "Search buses…"); + let search = MenuSearch::show(ui, egui::Id::unique(("route-bus-search", id.0, input)), "Search buses…"); let visible: Vec<&String> = busses.iter().filter(|b| search.matches(b)).collect(); if let Some(i) = search.rows(ui, visible.len(), 420.0, |ui, i, highlighted| menu_row(ui, highlighted, visible[i])) { let _ = app.run(cmd, json!({"track": id.0, key: visible[i]})); @@ -1309,7 +1389,7 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { #[cfg(test)] mod tests { - use super::{dragged_send_db, editable_readout, parse_db, parse_pan}; + use super::{Readout, dragged_send_db, editable_readout, parse_db, parse_pan}; use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2}; #[test] @@ -1341,6 +1421,8 @@ mod tests { assert_eq!(dragged_send_db(12.0, -50.0, false), 12.0); } + /// Alt-click resets a readout; a click opens its text field, and hiding the widget drops focus so the formatted + /// display returns. #[test] fn readouts_reset_on_alt_click_and_drop_a_field_that_lost_focus() { let ctx = egui::Context::default(); @@ -1355,7 +1437,12 @@ mod tests { let mut out = (false, None); let mut full = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| { if shown { - let (resp, value) = editable_readout(ui, r, egui::Id::new("readout"), "-3.0", "-3.0", parse_db, 0.0); + let (resp, value) = editable_readout( + ui, + r, + egui::Id::unique("readout"), + Readout { name: "Readout", display: "-3.0", edit_initial: "-3.0", parse: parse_db, reset: 0.0 }, + ); out = (resp.is_some(), value); } }); diff --git a/crates/ui-egui/src/ops_windows.rs b/crates/ui-egui/src/ops_windows.rs index c4a492ca..ec0f3f7d 100644 --- a/crates/ui-egui/src/ops_windows.rs +++ b/crates/ui-egui/src/ops_windows.rs @@ -88,6 +88,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { rtp(app, ctx); } +/// Beat Detective window: sensitivity, separate and set-tempo options, Analyze and Apply results, and Identify Beat. fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_beat_detective; if !open { @@ -114,7 +115,7 @@ fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) { ui.separator(); ui.horizontal(|ui| { ui.label("Identify Beat: selection is"); - ui.add(egui::DragValue::new(&mut app.ops.identify_bars).range(1..=64)); + ui.add(egui::DragValue::new(&mut app.ops.identify_bars).range(1..=64)).accessible_name("Identify beat bars"); ui.label("bar(s)"); if ui.button("Set Tempo").clicked() { let r = app.run("event.identify_beat", json!({"bars": app.ops.identify_bars})); @@ -144,6 +145,8 @@ pub(crate) fn tempo_ops_apply(curve: &str, start_bpm: f64, end_bpm: f64) -> (&'s } } +/// Tempo Operations window: choose a curve and start/end tempo, then dispatch its command using the edit selection. +/// Constant sends only the start BPM; without a nonempty range, its command defaults to sample zero. fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_tempo_ops; if !open { @@ -155,8 +158,8 @@ fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) { ui.selectable_value(&mut app.ops.tempo_curve, c.to_string(), c); } }); - ui.add(egui::DragValue::new(&mut app.ops.tempo_start).range(5.0..=999.0).prefix("start bpm ")); - ui.add(egui::DragValue::new(&mut app.ops.tempo_end).range(5.0..=999.0).prefix("end bpm ")); + ui.add(egui::DragValue::new(&mut app.ops.tempo_start).range(5.0..=999.0).prefix("start bpm ")).accessible_name("Start tempo (BPM)"); + ui.add(egui::DragValue::new(&mut app.ops.tempo_end).range(5.0..=999.0).prefix("end bpm ")).accessible_name("End tempo (BPM)"); ui.label( egui::RichText::new( "Applies across the edit selection (constant: at the insertion point; scale: factor = end / start; stretch: factor = start / end).", @@ -172,6 +175,8 @@ fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_tempo_ops = open; } +/// Time Operations window: insert a duration in seconds at the selection start, cut the edit selection, change the +/// meter, or move the song start to the selection start. fn time_ops(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_time_ops; if !open { @@ -186,17 +191,18 @@ fn time_ops(app: &mut SoundApp, ctx: &egui::Context) { match app.ops.time_op.as_str() { "change meter" => { ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut app.ops.meter_num).range(1..=64)); + ui.add(egui::DragValue::new(&mut app.ops.meter_num).range(1..=64)).accessible_name("Meter numerator"); ui.label("/"); - egui::ComboBox::from_id_salt("den").selected_text(app.ops.meter_den.to_string()).show_ui(ui, |ui| { + let den = egui::ComboBox::from_id_salt("den").selected_text(app.ops.meter_den.to_string()).show_ui(ui, |ui| { for d in [2u32, 4, 8, 16] { ui.selectable_value(&mut app.ops.meter_den, d, d.to_string()); } }); + den.response.accessible_name("Meter denominator"); }); } "insert" => { - ui.add(egui::DragValue::new(&mut app.ops.time_seconds).range(0.0..=3600.0).suffix(" s")); + ui.add(egui::DragValue::new(&mut app.ops.time_seconds).range(0.0..=3600.0).suffix(" s")).accessible_name("Time (seconds)"); } _ => { ui.label(egui::RichText::new("Uses the edit selection.").small()); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d7a2c06b..c0317958 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -23,7 +23,7 @@ fn drag_track(app: &mut SoundApp, ui: &Ui, row: Rect, id: u64, dropped: bool) { let y = row.min.y + (gap - from as f32) * pitch - spacing / 2.0; ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); ui.ctx() - .layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("track_list_reorder"))) + .layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("track_list_reorder"))) .line_segment([pos2(row.min.x, y), pos2(row.max.x, y)], Stroke::new(2.0, Tokens::current().accent)); } } @@ -40,6 +40,7 @@ fn panel_header(ui: &mut Ui, title: &str) { /// the panel and the window beside it. const GUTTER_W: f32 = 10.0; +/// Tracks and Groups panels: headers, track and group lists, and their scrollbars in the panel gutter. pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let full = ui.max_rect(); @@ -61,17 +62,19 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { s.tracks.iter().map(|x| (x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id))).collect() }; let hidden = egui::scroll_area::ScrollBarVisibility::AlwaysHidden; - let lists = - egui::ScrollArea::vertical().id_salt("tracks_scroll").max_height(total * 0.62).auto_shrink([false, false]).scroll_bar_visibility(hidden); + let lists = with_pending_offset(egui::ScrollArea::vertical().id_salt("tracks_scroll"), ui, "tracks_scroll") + .max_height(total * 0.62) + .auto_shrink([false, false]) + .scroll_bar_visibility(hidden); let out = lists.show(ui, |ui| { for (id, name, shown, color, sel) in tracks { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, &name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, &name)); if sel { ui.painter().rect_filled(r, 0.0, t.row_selected); } let dot = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(8.0, 8.0)); - let dresp = ui.interact(dot.expand(3.0), ui.id().with(("vis", id)), Sense::click()); + let dresp = ui.interact(dot.expand(3.0), ui.scope_id().with(("vis", id)), Sense::click()); ui.painter().circle(dot.center(), 3.5, if shown { t.visible_dot } else { Color32::TRANSPARENT }, Stroke::new(1.0, t.text_dim)); if dresp.clicked() { let _ = app.run("track.hide", json!({"tracks": [id], "hidden": shown})); @@ -97,11 +100,12 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { panel_header(ui, "GROUPS"); let groups: Vec<(u64, char, String, bool, [u8; 3])> = app.engine.session().groups.iter().map(|g| (g.id.0, g.letter, g.name.clone(), g.active, g.color)).collect(); - let out = egui::ScrollArea::vertical().id_salt("groups_scroll").auto_shrink([false, false]).scroll_bar_visibility(hidden).show(ui, |ui| { + let groups_area = with_pending_offset(egui::ScrollArea::vertical().id_salt("groups_scroll"), ui, "groups_scroll"); + let out = groups_area.auto_shrink([false, false]).scroll_bar_visibility(hidden).show(ui, |ui| { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::hover()); ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "", regular(11.5).clone(), t.text); - let all = ui.interact(r, ui.id().with("group_all"), Sense::click()); - all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "")); + let all = ui.interact(r, ui.scope_id().with("group_all"), Sense::click()); + all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "")); if all.double_clicked() { let _ = app.run("edit.select_all", json!({})); } @@ -113,7 +117,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { }); for (id, letter, name, active, color) in groups { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, active, &name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, active, &name)); if active { ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 2.0), vec2(14.0, 13.0)), 1.0, rgb(color)); } @@ -156,8 +160,21 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { gutter_scroll(ui, gutter, &out); } +/// Apply a pending gutter page/drag target to the next frame's `ScrollArea`. `State::offset` is +/// private in egui 0.37, so the target cannot be written directly; it is applied here before layout. +fn with_pending_offset(area: egui::ScrollArea, ui: &Ui, salt: &str) -> egui::ScrollArea { + // Match `ScrollArea` exactly: it hashes an `IdSalt` into the persistent id. + let id = ui.make_persistent_id(egui::IdSalt::new(salt)); + let pending = ui.ctx().memory_mut(|m| m.data.remove_temp::(id.with("gutter_target"))); + match pending { + Some(y) => area.vertical_scroll_offset(y), + None => area, + } +} + /// A list's scroll thumb in the gutter beside it, when the list is longer than its space: drag it, -/// or click above or below it to page. +/// or click above or below it to page. The target applies on the next frame, because the public +/// `ScrollArea` API has no way to write the current pass's offset. fn gutter_scroll(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOutput) { let t = Tokens::current(); let (view, content) = (out.inner_rect.height(), out.content_size.y); @@ -166,12 +183,12 @@ fn gutter_scroll(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOu } let bar = Rect::from_x_y_ranges(gutter.x_range(), out.inner_rect.y_range()); let (max, h) = (content - view, (bar.height() * view / content).max(20.0)); - let mut state = out.state; - let thumb = Rect::from_min_size(pos2(bar.min.x + 2.0, bar.min.y + (bar.height() - h) * state.offset.y / max), vec2(bar.width() - 4.0, h)); + let offset = out.state.clamped_offset().y; + let thumb = Rect::from_min_size(pos2(bar.min.x + 2.0, bar.min.y + (bar.height() - h) * offset / max), vec2(bar.width() - 4.0, h)); let resp = ui.interact(bar, out.id.with("gutter"), Sense::click_and_drag()); ui.painter().rect_filled(thumb, 3.0, if resp.hovered() || resp.dragged() { t.button_hi.lerp_to_gamma(Color32::WHITE, 0.1) } else { t.button_hi }); let to = if resp.dragged() { - Some(state.offset.y + resp.drag_delta().y * max / (bar.height() - h).max(1.0)) + Some(offset + resp.drag_delta().y * max / (bar.height() - h).max(1.0)) } else if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.clicked()) { let page = if p.y < thumb.min.y { -view @@ -180,13 +197,13 @@ fn gutter_scroll(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOu } else { 0.0 }; - Some(state.offset.y + page) + Some(offset + page) } else { None }; if let Some(y) = to { - state.offset.y = y.clamp(0.0, max); - state.store(ui.ctx(), out.id); + ui.ctx().memory_mut(|m| m.data.insert_temp(out.id.with("gutter_target"), y.clamp(0.0, max))); + ui.ctx().request_repaint(); } } @@ -218,15 +235,16 @@ fn clip_rows(s: &soundcraft_model::Session, needle: &str) -> Vec { sources.chain(clips).collect() } +/// Clips panel with a filter and a revision-cached list of audio sources and clips. pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); panel_header(ui, "CLIPS"); - let fkey = egui::Id::new("clip_filter"); + let fkey = egui::Id::unique("clip_filter"); let mut filter: String = ui.ctx().memory(|m| m.data.get_temp(fkey)).unwrap_or_default(); ui.add(egui::TextEdit::singleline(&mut filter).hint_text("🔍 Find clips").desired_width(f32::INFINITY)); ui.ctx().memory_mut(|m| m.data.insert_temp(fkey, filter.clone())); let needle = filter.to_lowercase(); - let rkey = egui::Id::new("clip_rows"); + let rkey = egui::Id::unique("clip_rows"); let revision = app.engine.revision; let cached = ui.ctx().memory(|m| m.data.get_temp::(rkey)).filter(|c| c.revision == revision && c.filter == needle); let rows = match cached { @@ -238,7 +256,8 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { } }; let row_h = 17.0; - egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, shown| { + let clips_area = with_pending_offset(egui::ScrollArea::vertical().id_salt("clips_scroll"), ui, "clips_scroll"); + clips_area.auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, shown| { // Only the rows in view: what they show is read now, before any of them acts on the session. let items: Vec<(u64, String, bool, [u8; 3], bool)> = { let s = app.engine.session(); @@ -426,13 +445,14 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_big_counter = open; } +/// One recallable memory-location row: number button, editable name, time, length, kind and delete. fn memory_location_row(app: &mut SoundApp, ui: &mut Ui, n: u32, name: &str, loc: &str, len: &str, kind: &str) { if ui.button(n.to_string()).on_hover_text("Recall").clicked() { let _ = app.run("markers.recall", json!({"number": n})); } - let key = egui::Id::new(("mem_name", n)); + let key = egui::Id::unique(("mem_name", n)); let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.to_string()); - let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0)); + let r = crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0)), "Name"); if r.changed() { ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone())); } @@ -443,7 +463,7 @@ fn memory_location_row(app: &mut SoundApp, ui: &mut Ui, n: u32, name: &str, loc: ui.label(egui::RichText::new(loc).font(mono(11.0))); ui.label(egui::RichText::new(len).font(mono(11.0))); ui.label(kind); - if ui.small_button("✕").on_hover_text("Delete").clicked() { + if crate::widgets::close_button(ui, "Delete marker").clicked() { let _ = app.run("markers.delete", json!({"number": n})); } ui.end_row(); @@ -504,6 +524,7 @@ pub(crate) fn undo_history(app: &mut SoundApp, ui: &mut Ui) { }); } +/// Draw plugin windows for instrument and insert slots that still exist, removing closed ones from the list. fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { let wins = app.ui.plugin_windows.clone(); let mut keep = Vec::new(); @@ -520,7 +541,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { let place = if instrument { "instrument".to_string() } else { char::from(b'a'.saturating_add(slot as u8)).to_string() }; let mut open = true; let shown = egui::Window::new(format!("{tname} · {place} · {}", info.name)) - .id(egui::Id::new(("plugin", tid.0, slot))) + .id(egui::Id::unique(("plugin", tid.0, slot))) .open(&mut open) .default_width(340.0) .default_pos(egui::pos2(ctx.content_rect().width() - 380.0, 120.0)) @@ -533,7 +554,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { if ui.button(label).clicked() { let _ = app.run("mix.insert_bypass", json!({"track": tid.0, "slot": slot})); } - ui.menu_button("Presets ▾", |ui| preset_menu(app, ui, tid, slot, info, &ins)); + ui.menu_button("Presets v", |ui| preset_menu(app, ui, tid, slot, info, &ins)); } if soundcraft_mix::is_third_party(&ins.plugin) { let open = app.player.as_ref().is_some_and(|p| p.editor_open(tid, slot)); @@ -569,7 +590,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| { for p in info.params { let v = ins.params.get(p.id).copied().unwrap_or(p.default); - ui.label(p.name); + let label = ui.label(p.name); let mut x = v; let resp = if !p.choices.is_empty() { let mut idx = x.round().max(0.0) as usize; @@ -592,6 +613,9 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { } ui.add(sl) }; + // The grid cell's painted label is not an accesskit name; tie the + // combo box or slider to it so screen readers and label queries find it. + let resp = resp.labelled_by(label.id); if (x - v).abs() > f32::EPSILON && instrument { let _ = app.engine.execute_merged( "mix.instrument_param", @@ -658,12 +682,13 @@ fn eq_curve(ui: &mut Ui, id: &str, ins: &soundcraft_model::Insert) { ui.painter().add(egui::Shape::line(pts, Stroke::new(2.0, t.counter_text))); } +/// About window tabs for version information, contributors and models. fn about(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_about; if !open { return; } - let tab_id = egui::Id::new("about_tab"); + let tab_id = egui::Id::unique("about_tab"); egui::Window::new("About SoundCraft").open(&mut open).default_size(vec2(640.0, 420.0)).collapsible(false).show(ctx, |ui| { let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); ui.horizontal(|ui| { @@ -710,6 +735,7 @@ fn preset_dir(app: &SoundApp, plugin: &str) -> Option { app.preset_dir.as_ref().map(|d| d.join(crate::preset_folder_name(plugin))) } +/// Factory default plus user presets for a plugin insert; user presets need a desktop preset directory. fn preset_menu( app: &mut SoundApp, ui: &mut Ui, @@ -751,10 +777,10 @@ fn preset_menu( } } ui.separator(); - let key = egui::Id::new(("preset_name", tid.0, slot)); + let key = egui::Id::unique(("preset_name", tid.0, slot)); let mut name: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| format!("{} preset", info.name)); ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut name).desired_width(140.0)); + crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(&mut name).desired_width(140.0)), "Preset name"); if ui.button("Save").clicked() { let clean = soundcraft_engine::io::sanitize_name(&name); let body = json!({"format": "soundcraft-plugin-preset", "plugin": info.id, "params": ins.params}); diff --git a/crates/ui-egui/src/sampler_window.rs b/crates/ui-egui/src/sampler_window.rs index 45f9697b..a16fee9e 100644 --- a/crates/ui-egui/src/sampler_window.rs +++ b/crates/ui-egui/src/sampler_window.rs @@ -56,7 +56,7 @@ pub fn drop_target(app: &mut SoundApp, ctx: &egui::Context, r: Rect, t: TrackId) app.sample_drop_targets.push((r, t)); let over = ctx.input(|i| !i.raw.hovered_files.is_empty() && i.pointer.latest_pos().is_some_and(|p| r.contains(p))); if over { - ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new(("sample_drop", t.0)))).rect_stroke( + ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique(("sample_drop", t.0)))).rect_stroke( r, 2.0, Stroke::new(2.0, Tokens::current().accent), @@ -82,38 +82,16 @@ impl SampleRead { } impl SoundApp { - /// Takes this frame's dropped files: audio dropped on an instrument track or a Sampler window - /// loads into that track's sampler (the sampler becomes its instrument). A file with a full - /// path loads straight away; one without (the browser) waits in [`SoundApp::take_sample_reads`] - /// for the shell to read. Everything else is returned for the shell to open or import. - pub fn take_dropped_files(&mut self, ctx: &egui::Context) -> Vec { - let (files, pos) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.latest_pos())); - if files.is_empty() { - return files; - } - let Some(track) = self.drop_track(pos) else { return files }; - let mut rest = Vec::new(); - let mut claimed = false; - for f in files { - if claimed || !is_audio_name(&f.path().to_string_lossy()) { - rest.push(f); - continue; - } - claimed = true; - if f.path().is_absolute() { - let path = f.path().to_string_lossy().into_owned(); - let r = self.run("mix.sampler_load", json!({"track": track.0, "path": path})); - self.sample_loaded(track, &path, r); - } else { - self.sample_reads.push(SampleRead { track, file: f }); - } - } - rest + /// Dropped files this frame's UI pass did not load into a sampler, for the shell to open or + /// import. The capture happened at the start of the UI pass, before the font gate; this only + /// takes the queued result, so it is safe (and needed) on a logic-only tick after a paint. + pub fn take_dropped_files(&mut self) -> Vec { + std::mem::take(&mut self.host_drops) } /// The track a drop at `pos` loads into. Without a position (some platforms report none /// during a drag), a single open Sampler window takes it. - fn drop_track(&self, pos: Option) -> Option { + pub(crate) fn drop_track(&self, pos: Option) -> Option { if let Some(p) = pos { return self.sample_drop_targets.iter().rev().find(|(r, _)| r.contains(p)).map(|(_, t)| *t); } @@ -140,7 +118,8 @@ impl SoundApp { self.sample_loaded(read.track, &name, r); } - fn sample_loaded(&mut self, track: TrackId, what: &str, r: Result) { + /// Report a sampler load and open its instrument window on success, or show the failure status. + pub(crate) fn sample_loaded(&mut self, track: TrackId, what: &str, r: Result) { match r { Ok(v) => { if !self.ui.plugin_windows.contains(&(track, soundcraft_mix::INSTRUMENT_SLOT)) { @@ -246,7 +225,7 @@ pub fn sample_strip(app: &mut SoundApp, ui: &mut Ui, tid: TrackId, ins: &Insert) p.text(flag.center(), Align2::CENTER_CENTER, *tag, regular(9.0), t.text_dark); } // Dragging the nearest marker sets its parameter. - let grab_id = ui.id().with(("sample_marker", tid.0)); + let grab_id = ui.scope_id().with(("sample_marker", tid.0)); let nearest = |x: f32| { shown .iter() diff --git a/crates/ui-egui/src/score_editor.rs b/crates/ui-egui/src/score_editor.rs index 470d6ef5..043b1ee4 100644 --- a/crates/ui-egui/src/score_editor.rs +++ b/crates/ui-egui/src/score_editor.rs @@ -33,6 +33,8 @@ fn diatonic(pitch: u8) -> (i32, bool) { (oct * 7 + step, sharp) } +/// Read-only score view for the target MIDI clip, drawing its notes as notation; notes are edited in the MIDI Editor +/// or Event List. pub fn show(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_score; if !open { @@ -119,7 +121,14 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { p.add(Shape::ellipse_filled(head.center(), head.size() * 0.5, ink)); } if sharp { - p.text(pos2(x - 12.0, y), Align2::CENTER_CENTER, "♯", regular(12.0), ink); + // An original drawn sharp sign: no bundled family covers U+266F. + let cx = x - 12.0; + for dx in [-2.6, 1.2] { + p.line_segment([pos2(cx + dx - 1.0, y - 5.0), pos2(cx + dx + 1.0, y + 5.0)], Stroke::new(1.1, ink)); + } + for dy in [-1.8, 1.8] { + p.line_segment([pos2(cx - 5.0, y + dy), pos2(cx + 5.0, y + dy - 1.0)], Stroke::new(1.1, ink)); + } } if q < 3.5 { // Stems up below the middle line of each staff, down above it. diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4aa47a9e..35319f1c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -54,17 +54,32 @@ fn search_chord(m: Modifiers, mac: bool) -> bool { mac && m.command && m.ctrl } +/// Dispatch global keyboard shortcuts once no text field or dialog wants input, ignoring non-scripted auto-repeats +/// while still running a host press alongside a scripted one. pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { // Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom. ctx.options_mut(|o| o.zoom_with_keyboard = false); if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() { return; } + // A scripted press is identified by its exact position in this pass's events, not by matching + // key/modifier values. A fresh host press with the same chord must still run its own action in + // addition to the scripted one, while a host auto-repeat stays ignored: egui rewrites an + // event's `repeat` flag from the physical key state, which says nothing about whether that + // particular event was injected. Every non-scripted repeat is ignored as before. + let scripted = app.scripted_key_press_indices(); let events: Vec<(Key, Modifiers)> = ctx.input(|i| { i.events .iter() - .filter_map(|e| match e { - egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } => Some((*key, *modifiers)), + .enumerate() + .filter_map(|(index, e)| match e { + egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => { + if scripted.contains(&index) { + Some((*key, *modifiers)) + } else { + (!*repeat).then_some((*key, *modifiers)) + } + } _ => None, }) .collect() @@ -241,7 +256,7 @@ fn edit_mode_key(app: &mut SoundApp, pressed: Key) { let ed = &app.engine.session().edit; let keep_grid = ed.edit_mode.is_grid() && ed.flag("options.edit_tool_mode_keyboard_lock"); // F4 pressed first in an F4+F1–F3 chord switched the Grid mode on its own; put that back. - let flipped = egui::Id::new("edit_mode_key_grid_before_f4"); + let flipped = egui::Id::unique("edit_mode_key_grid_before_f4"); let before = (ed.edit_mode.is_grid() && !keep_grid).then(|| if ed.flag(soundcraft_engine::edit::RELATIVE_GRID) { "relative_grid" } else { "grid" }); let restore = match &ctx { diff --git a/crates/ui-egui/src/spot.rs b/crates/ui-egui/src/spot.rs index e92798fe..cd927baa 100644 --- a/crates/ui-egui/src/spot.rs +++ b/crates/ui-egui/src/spot.rs @@ -113,15 +113,17 @@ fn recorded_at(s: &Session, time_reference: u64) -> Samples { i64::try_from(time_reference).unwrap_or(0).saturating_sub(s.timecode_start) } +/// One editable Spot field: accept the entered position when it parses and lies in `-MAX_POSITION..=MAX_POSITION`, +/// or show the formatted value when read-only. fn spot_field_row(ui: &mut Ui, s: &Session, d: &mut Spot, i: usize, label: &str) { let show = |at: Samples, f: TimeFormat| s.format_position(at, f); - ui.label(label); + let lab = ui.label(label); if d.editable(i) { - let id = Id::new(("spot_field", i)); + let id = Id::unique(("spot_field", i)); if !ui.memory(|m| m.has_focus(id)) { d.text[i] = show(d.value(i), d.format); } - let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0)); + let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0)).labelled_by(lab.id); if r.changed() && let Ok(v) = s.parse_position(&d.text[i], d.format) && (-MAX_POSITION..=MAX_POSITION).contains(&v) @@ -152,12 +154,13 @@ fn original_time_stamp_row(ui: &mut Ui, s: &Session, d: &mut Spot, at: Samples) /// The dialog's fields; the caller adds OK and Cancel. pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) { egui::Grid::new("spot").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label("Time Scale"); - egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| { + let scale = ui.label("Time Scale"); + let time_scale = egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| { for f in TimeFormat::ALL { ui.selectable_value(&mut d.format, f, f.label()); } }); + time_scale.response.labelled_by(scale.id); ui.end_row(); for (i, label) in FIELDS.into_iter().enumerate() { if i == 1 && matches!(d.target, Target::Trim { .. }) { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 078fdbfb..cc063c68 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -73,6 +73,7 @@ fn draw_mode_cell(p: &egui::Painter, cell: Rect, resp: &egui::Response, t: &Toke } } +/// The Shuffle, Spot, Slip and Grid mode selector with its keyboard hints and relative-grid state. fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let ed = &app.engine.session().edit; @@ -92,10 +93,10 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { let ch = inner.height() / 2.0; for (m, cx, cy, label, fkey) in modes { let cell = Rect::from_min_size(pos2(inner.min.x + cw * cx as f32, inner.min.y + ch * cy as f32), vec2(cw, ch)).shrink(1.0); - let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click()); + let resp = ui.interact(cell, ui.scope_id().with(("mode", label)), Sense::click()); draw_mode_cell(ui.painter(), cell, &resp, &t, m, &state, label); let on = state.cur == m || (m == EditMode::Grid && state.cur == EditMode::GridRelative); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, on, label)); if resp.clicked() { mode_click(app, m, ui.input(|i| i.modifiers)); } @@ -164,6 +165,7 @@ fn zoom_group(app: &mut SoundApp, ui: &mut Ui) { }); } +/// The tool selector, including Smart-tool membership and Zoom Toggle. fn tools(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let cur = app.engine.session().edit.tool; @@ -195,7 +197,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { let bx0 = rr.min.x + 76.0; let bx1 = bx0 + 38.0 * 3.0 - 2.0; let bracket = Rect::from_min_max(pos2(bx0 - 2.0, r.min.y + 1.0), pos2(bx1 + 2.0, r.min.y + 5.0)); - let resp = ui.interact(bracket.expand2(vec2(0.0, 2.0)), ui.id().with("smart"), Sense::click()); + let resp = ui.interact(bracket.expand2(vec2(0.0, 2.0)), ui.scope_id().with("smart"), Sense::click()); ui.painter().rect_filled(bracket, 1.0, if cur == Tool::Smart { t.accent } else { t.tool_bracket_off }); if resp.on_hover_text("Smart Tool (F6+F7 / F7+F8)").clicked() { let _ = app.run("edit.tool", json!({"tool": "smart"})); @@ -230,6 +232,7 @@ fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String { s.format_position(at, f) } +/// Main and sub position counters, a main-counter format menu, selection readouts and dirty/device status. fn counters(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let (main_fmt, sub_fmt, sel) = { @@ -242,7 +245,7 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) { let main_r = Rect::from_min_max(r.min, pos2(r.min.x + 180.0, r.min.y + 44.0)); let txt = fmt_pos(app, pos, main_fmt); ui.painter().text(pos2(main_r.max.x - 18.0, main_r.center().y), Align2::RIGHT_CENTER, txt, mono(26.0), t.counter_text); - let resp = ui.interact(main_r, ui.id().with("main_counter"), Sense::click()); + let resp = ui.interact(main_r, ui.scope_id().with("main_counter"), Sense::click()); icons::draw(ui.painter(), Rect::from_center_size(pos2(main_r.max.x - 9.0, main_r.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim); egui::Popup::menu(&resp).show(|ui| { for f in TimeFormat::ALL { @@ -273,6 +276,7 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) { ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, format!("{st} · {dev}"), regular(10.0), t.text_dim); } +/// Grid-line toggle, grid value menu and nudge value menu. fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let (grid, nudge, lines) = { @@ -282,7 +286,7 @@ fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::hover()); ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside); let gl = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 8.0), vec2(48.0, 16.0)); - let resp = ui.interact(gl, ui.id().with("grid_lines"), Sense::click()); + let resp = ui.interact(gl, ui.scope_id().with("grid_lines"), Sense::click()); key(ui.painter(), gl, &resp, if lines { t.mode_on } else { t.counter_off }); ui.painter().text(gl.center(), Align2::CENTER_CENTER, "Grid", bold(11.0), if lines { t.mode_on_text } else { t.mode_off_text }); if resp.on_hover_text("Show grid lines").clicked() { @@ -290,12 +294,12 @@ fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) { } let gr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 6.0), pos2(r.max.x - 4.0, r.min.y + 26.0)); ui.painter().text(pos2(gr.max.x - 14.0, gr.center().y), Align2::RIGHT_CENTER, grid.label(), mono(11.0), t.counter_text); - let gresp = ui.interact(gr, ui.id().with("grid_value"), Sense::click()); + let gresp = ui.interact(gr, ui.scope_id().with("grid_value"), Sense::click()); value_menu(app, &gresp, "view.grid"); ui.painter().text(pos2(r.min.x + 10.0, r.min.y + 44.0), Align2::LEFT_CENTER, "Nudge", regular(11.0), t.counter_label); let nr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 34.0), pos2(r.max.x - 4.0, r.min.y + 54.0)); ui.painter().text(pos2(nr.max.x - 14.0, nr.center().y), Align2::RIGHT_CENTER, nudge.label(), mono(11.0), t.counter_text); - let nresp = ui.interact(nr, ui.id().with("nudge_value"), Sense::click()); + let nresp = ui.interact(nr, ui.scope_id().with("nudge_value"), Sense::click()); value_menu(app, &nresp, "view.nudge"); for rr in [gr, nr] { icons::draw(ui.painter(), Rect::from_center_size(pos2(rr.max.x - 6.0, rr.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim); @@ -381,6 +385,8 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) { }); } +/// Count-off, meter and tempo readouts; the tempo value is editable in place for the tempo event at or before the +/// playhead. fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let (bpm, meter, countoff, click, bars) = { @@ -392,7 +398,7 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(150.0, 64.0), Sense::hover()); ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside); let co = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 6.0), vec2(64.0, 15.0)); - let resp = ui.interact(co, ui.id().with("countoff"), Sense::click()); + let resp = ui.interact(co, ui.scope_id().with("countoff"), Sense::click()); key(ui.painter(), co, &resp, if countoff { t.mode_on } else { t.counter_off }); ui.painter().text(co.center(), Align2::CENTER_CENTER, "Count Off", bold(10.0), if countoff { t.mode_on_text } else { t.mode_off_text }); if resp.clicked() { @@ -406,7 +412,7 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) { tempo_value(app, ui, r.max.x - 8.0, r.min.y + 48.0, bpm); let clk = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 40.0), vec2(18.0, 18.0)); icons::draw(ui.painter(), clk, "click", if click { t.counter_text } else { t.text_dim }); - if ui.interact(clk, ui.id().with("click"), Sense::click()).on_hover_text("Click").clicked() { + if ui.interact(clk, ui.scope_id().with("click"), Sense::click()).on_hover_text("Click").clicked() { let _ = app.run("options.click", json!({})); } } @@ -427,7 +433,8 @@ fn tempo_value(app: &mut SoundApp, ui: &mut Ui, right: f32, y: f32, bpm: f64) { } v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.counter_border); let mut value = bpm; - let resp = c.add_sized(rect.size(), egui::DragValue::new(&mut value).speed(0.1).range(5.0..=1000.0).fixed_decimals(4)); + let resp = + c.add_sized(rect.size(), egui::DragValue::new(&mut value).speed(0.1).range(5.0..=1000.0).fixed_decimals(4)).accessible_name("Tempo (BPM)"); if resp.changed() { let s = app.engine.session(); let tick = s.tempo.samples_to_ticks(app.position(), s.sample_rate); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d785c995..1a23ef63 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -101,6 +101,16 @@ fn button_style(selected_fill: Color32, normal_icon: Color32, radius: u8) -> Ico } } +/// Give a bare text field an accessible name without changing its visible layout. +/// +/// `TextEdit` alone has a text-input node but no label; kittest 0.37 panics on a visible unnamed +/// input after a settled step. A hint or a real adjacent label is preferred where the design has +/// one; this covers fields whose name lives in the dialog title or a painted heading. +pub(crate) fn named_text_field(resp: egui::Response, name: &str) -> egui::Response { + let _ = resp.ctx.accesskit_node_builder(resp.id, |node| node.set_label(name)); + resp +} + /// A toolbar button with an icon; `on` draws the blue selected state. pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &str) -> Response { let t = Tokens::current(); @@ -113,10 +123,32 @@ pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &st resp.on_hover_text(tip) } +/// A compact close/delete button whose cross is painted, not typeset. +/// +/// U+2715 is missing from egui 0.37's bundled proportional face (and from the shared fallback set +/// until the symbol font lands), so this control must not depend on a text glyph. `label` is both +/// the accessible name and the tooltip. +pub fn close_button(ui: &mut Ui, label: &str) -> Response { + let t = Tokens::current(); + let size = Vec2::splat(ui.spacing().interact_size.y.max(14.0)); + let style = IconButtonStyle { + normal: ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon: t.text_dim }, + hovered: ButtonVisuals { fill: t.button_hi, stroke: Stroke::new(1.0, t.button_border), icon: t.text }, + pressed: Some(ButtonVisuals { fill: shade(t.button_hi, -0.1), stroke: Stroke::new(1.0, t.button_border), icon: t.text }), + selected: ButtonVisuals { fill: t.button, stroke: Stroke::new(1.0, t.button_border), icon: t.text }, + corner_radius: CornerRadius::same(3), + focus_stroke: Stroke::new(1.0, t.accent), + }; + IconButton::new(label, size, &style) + .show(ui, |ui, r, tint| icons::draw(ui.painter(), r.shrink(size.x * 0.28), "close", tint)) + .on_hover_text(label) +} + /// Square text toggle used for S / M / I / record etc. pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color: Color32, tip: &str) -> Response { let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, tip)); key(ui.painter(), r, &resp, if on { on_color } else { key_fill(&resp) }); let col = if on { t.text_dark } else { t.text }; ui.painter().text(r.center(), Align2::CENTER_CENTER, text, bold((size.y * 0.62).clamp(8.0, 13.0)), col); @@ -153,8 +185,29 @@ pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Col /// A selector with a compact visible label and the complete meaning on hover. pub(crate) fn selector_box_with_tooltip(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32, tooltip: &str) -> Response { + selector_box_named(ui, width, height, text, color, SelectorLabels { name: tooltip, value: text, tooltip }) +} + +/// The accessibility strings for a custom selector: its unique name, current value and hover text. +#[derive(Clone, Copy)] +pub(crate) struct SelectorLabels<'a> { + pub name: &'a str, + pub value: &'a str, + pub tooltip: &'a str, +} + +/// A selector whose accessible name is unique (for example per track), with its current value +/// published separately in the accessibility tree. +pub(crate) fn selector_box_named(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32, labels: SelectorLabels<'_>) -> Response { let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); + // The compact visible text can be an abbreviation ("t/l"); the full meaning on hover is the + // accessible name, not the tooltip. `current_text_value` tells a screen reader the selection. + resp.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), labels.name); + info.current_text_value = Some(labels.value.to_string()); + info + }); key(ui.painter(), r, &resp, key_fill(&resp)); let tr = Rect::from_min_max(pos2(r.max.x - height * 0.8, r.min.y), r.max); icons::draw(ui.painter(), tr.shrink(height * 0.15), "triangle_down", t.text_dim); @@ -166,13 +219,14 @@ pub(crate) fn selector_box_with_tooltip(ui: &mut Ui, width: f32, height: f32, te let galley = ui.painter().layout_job(job); let pos = text_rect.center() - galley.size() * 0.5; ui.painter().with_clip_rect(r.shrink(1.0)).galley(pos, galley, color); - resp.on_hover_text(tooltip) + resp.on_hover_text(labels.tooltip) } /// Rotary knob for pan / send pan (-1..1). Drag vertically; double-click resets. pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response { let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(*value), tip)); if resp.dragged() { let d = resp.drag_delta(); *value = (*value + (d.x - d.y) * 0.01).clamp(-1.0, 1.0); @@ -318,10 +372,12 @@ pub fn fader_slot(r: Rect) -> Rect { Rect::from_center_size(r.center(), vec2(4.0, r.height() - FADER_CAP_H)) } -/// A fader: returns the new dB value when dragged. `db` in -144..12. -pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option { +/// A fader: returns the new dB value when dragged. `db` in -144..12. `name` is its accessible +/// name; it must be unique enough that a label query picks one strip's control. +pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id, name: &str) -> Option { let t = Tokens::current(); let resp = ui.interact(r, id, Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(db), name)); let p = ui.painter(); let slot = fader_slot(r); let cap_w = (r.width() * 0.4).clamp(16.0, 18.0); @@ -515,7 +571,7 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: impl Into) /// released. Returns whether `on` changed. fn drag_toggle(ui: &Ui, resp: &Response, on: &mut bool) -> bool { let ctx = ui.ctx(); - let key = egui::Id::new("soundcraft-checkbox-drag-target"); + let key = egui::Id::unique("soundcraft-checkbox-drag-target"); if !ctx.input(|i| i.pointer.primary_down()) { ctx.data_mut(|d| d.remove::(key)); } @@ -564,6 +620,38 @@ mod tests { (h, at) } + /// Close/delete controls are painted crosses with an accessible name, not a glyph that depends + /// on a font family covering U+2715. + #[test] + fn a_close_button_is_a_named_clickable_icon() { + let mut h = Harness::new_ui_state( + |ui, clicked: &mut bool| { + if super::close_button(ui, "Delete marker").clicked() { + *clicked = true; + } + }, + false, + ); + h.run(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete marker").click(); + h.run(); + assert!(*h.state(), "the painted close control dispatches its command"); + } + + /// The compact visible label of a selector is an abbreviation; the full meaning it shows on + /// hover is what a screen reader must hear. + #[test] + fn a_compact_selector_is_named_by_its_full_meaning() { + let mut h = Harness::new_ui(|ui| { + super::selector_box_with_tooltip(ui, 80.0, 16.0, "t/l", egui::Color32::WHITE, "auto touch latch"); + }); + h.run(); + let selector = + h.query_by_role_and_label(egui::accesskit::Role::ComboBox, "auto touch latch").expect("the full automation mode names the selector"); + assert_eq!(selector.value().as_deref(), Some("t/l"), "the visible value stays available to a screen reader"); + assert!(h.query_by_role_and_label(egui::accesskit::Role::ComboBox, "t/l").is_none(), "the abbreviation is not the name"); + } + /// #399: pressing a box and dragging over its neighbours sets them all to the state the first /// one switched to; the box past the drag stays as it was. #[test] @@ -753,6 +841,8 @@ mod selector_visual_regressions { } } + /// Write real rendered mixer fixtures when `CRAFT_UI_VISUAL_FIXTURES` is set, covering automation modes, narrow + /// widths and display scales. #[test] fn capture_actual_mixer_visual_fixtures() { let _palette = crate::theme::lock_test_palette(); @@ -783,14 +873,12 @@ mod selector_visual_regressions { .output() .shapes .iter() + // The strip background is a gradient mesh, not a RectShape. .filter_map(|shape| match &shape.shape { - egui::Shape::Rect(rect) - if (rect.rect.width() - crate::mix_window::strip_width(narrow)).abs() < 0.1 && rect.rect.height() >= 560.0 => - { - Some(rect.rect) - } + egui::Shape::Mesh(mesh) => Some(mesh.calc_bounds()), _ => None, }) + .filter(|rect| (rect.width() - crate::mix_window::strip_width(narrow)).abs() < 0.1 && rect.height() >= 560.0) .fold(Rect::NOTHING, Rect::union); assert!(strips.is_finite()); std::fs::write( diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 7cd3467f..c11edf91 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -230,6 +230,8 @@ fn metadata(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_metadata = open; } +/// MIDI Event List for the target clip: velocity is editable, start/pitch/length are read-only, and notes can be +/// deleted. fn event_list(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_event_list; win(ctx, &mut open, "MIDI Event List", vec2(420.0, 360.0), |ui| { @@ -251,11 +253,11 @@ fn event_list(app: &mut SoundApp, ctx: &egui::Context) { ui.label(egui::RichText::new(n.start.to_string()).font(mono(11.0))); ui.label(soundcraft_midi::note_name(n.pitch)); let mut v = i64::from(n.velocity); - if ui.add(egui::DragValue::new(&mut v).range(1..=127)).changed() { + if ui.add(egui::DragValue::new(&mut v).range(1..=127)).accessible_name("Note velocity").changed() { let _ = app.engine.execute("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v})); } ui.label(n.length.to_string()); - if ui.small_button("✕").clicked() { + if crate::widgets::close_button(ui, "Delete note").clicked() { let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": [i]})); } ui.end_row(); @@ -373,6 +375,8 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_workspace = open; } +/// Window Configurations window: capture the current UI state and restore a saved configuration after validating both +/// dock layouts. fn configurations(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_configurations; win(ctx, &mut open, "Window Configurations", vec2(320.0, 240.0), |ui| { @@ -401,7 +405,7 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) { app.ui = st; } } - if ui.small_button("✕").clicked() { + if crate::widgets::close_button(ui, "Remove configuration").clicked() { app.ui.configurations.remove(i); } }); @@ -413,7 +417,7 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) { /// The audio systems as last enumerated (enumerating probes devices: done when the window opens /// and on Refresh, not every frame). fn audio_systems(ctx: &egui::Context, refresh: bool) -> Arc> { - let id = egui::Id::new("audio_systems"); + let id = egui::Id::unique("audio_systems"); if !refresh && let Some(s) = ctx.data(|d| d.get_temp::>>(id)) { return s; } @@ -436,25 +440,29 @@ fn chosen_device<'a>(devices: &'a [DeviceInfo], name: Option<&str>) -> Option<&' name.and_then(|n| devices.iter().find(|d| d.name == n)).or_else(|| devices.iter().find(|d| d.is_default)) } -fn device_combo(ui: &mut egui::Ui, id: &str, value: &mut Option, devices: &[DeviceInfo]) { +/// Device picker with a Default entry and channel counts, labelled for accessibility. +fn device_combo(ui: &mut egui::Ui, id: &str, name: &str, value: &mut Option, devices: &[DeviceInfo]) { let default = devices.iter().find(|d| d.is_default).map_or("none".to_string(), |d| d.name.clone()); let shown = value.clone().unwrap_or_else(|| format!("Default ({default})")); - egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| { ui.selectable_value(value, None, format!("Default ({default})")); for d in devices { ui.selectable_value(value, Some(d.name.clone()), format!("{} ({} ch)", d.name, d.max_channels)); } }); + combo.response.accessible_name(name); } -fn number_combo(ui: &mut egui::Ui, id: &str, value: &mut Option, options: &[u32], unit: &str) { +/// Optional-number picker with a Device default entry and a unit, labelled for accessibility. +fn number_combo(ui: &mut egui::Ui, id: &str, name: &str, value: &mut Option, options: &[u32], unit: &str) { let shown = value.map_or("Device default".to_string(), |v| format!("{v} {unit}")); - egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| { ui.selectable_value(value, None, "Device default"); for v in options { ui.selectable_value(value, Some(*v), format!("{v} {unit}")); } }); + combo.response.accessible_name(name); } /// Setup › Playback Engine / Hardware: choose the audio system, devices, rate and buffer size. @@ -463,7 +471,7 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) { if !open { return; } - let draft_id = egui::Id::new("audio_draft"); + let draft_id = egui::Id::unique("audio_draft"); let mut draft = ctx.data(|d| d.get_temp::(draft_id)).unwrap_or_else(|| app.ui.audio.clone()); let mut refresh = false; let mut apply = false; @@ -471,10 +479,10 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) { win(ctx, &mut open, "Playback Engine", vec2(460.0, 360.0), |ui| { let t = Tokens::current(); egui::Grid::new("pe").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { - ui.label("Audio system"); + let system_label = ui.label("Audio system"); let def = systems.iter().find(|s| s.is_default).map_or("none".to_string(), |s| s.name.clone()); let shown = draft.system.clone().unwrap_or_else(|| format!("Default ({def})")); - egui::ComboBox::from_id_salt("pe-system").width(300.0).selected_text(shown).show_ui(ui, |ui| { + let system = egui::ComboBox::from_id_salt("pe-system").width(300.0).selected_text(shown).show_ui(ui, |ui| { let before = draft.system.clone(); ui.selectable_value(&mut draft.system, None, format!("Default ({def})")); for s in systems.iter() { @@ -486,21 +494,22 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) { draft.input = None; } }); + system.response.labelled_by(system_label.id); ui.end_row(); let sys = chosen_system(&systems, &draft); ui.label("Output device"); - device_combo(ui, "pe-out", &mut draft.output, sys.map_or(&[][..], |s| &s.outputs)); + device_combo(ui, "pe-out", "Output device", &mut draft.output, sys.map_or(&[][..], |s| &s.outputs)); ui.end_row(); ui.label("Input device"); - device_combo(ui, "pe-in", &mut draft.input, sys.map_or(&[][..], |s| &s.inputs)); + device_combo(ui, "pe-in", "Input device", &mut draft.input, sys.map_or(&[][..], |s| &s.inputs)); ui.end_row(); let out = sys.and_then(|s| chosen_device(&s.outputs, draft.output.as_deref())); ui.label("Sample rate"); - number_combo(ui, "pe-rate", &mut draft.sample_rate, out.map_or(&[][..], |d| &d.sample_rates), "Hz"); + number_combo(ui, "pe-rate", "Sample rate", &mut draft.sample_rate, out.map_or(&[][..], |d| &d.sample_rates), "Hz"); ui.end_row(); ui.label("Buffer size"); let buffers = out.map(DeviceInfo::buffer_sizes).unwrap_or_else(|| soundcraft_playback::devices::STANDARD_BUFFERS.to_vec()); - number_combo(ui, "pe-buffer", &mut draft.buffer_size, &buffers, "samples"); + number_combo(ui, "pe-buffer", "Buffer size", &mut draft.buffer_size, &buffers, "samples"); ui.end_row(); }); ui.add_space(4.0); @@ -564,7 +573,7 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) { // Closing discards unapplied edits and the device list (re-read on the next open). ctx.data_mut(|d| { d.remove_temp::(draft_id); - d.remove_temp::>>(egui::Id::new("audio_systems")); + d.remove_temp::>>(egui::Id::unique("audio_systems")); }); } app.ui.show_playback_engine = open; @@ -613,9 +622,9 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) { let busses: Vec<(String, String)> = app.engine.session().busses.iter().map(|b| (b.name.clone(), b.format.label().to_string())).collect(); egui::Grid::new("busses").num_columns(3).striped(true).show(ui, |ui| { for (name, fmt) in busses { - let key = egui::Id::new(("bus_name", name.clone())); + let key = egui::Id::unique(("bus_name", name.clone())); let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone()); - let r = ui.text_edit_singleline(&mut edit); + let r = crate::widgets::named_text_field(ui.text_edit_singleline(&mut edit), &format!("Bus name: {name}")); ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone())); if r.lost_focus() && edit != name { let _ = app.run("setup.io", json!({"action": "rename_bus", "name": name, "new_name": edit})); @@ -636,14 +645,15 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) { // Main output format: stereo, or a surround format (the mix and master faders follow). let main = app.engine.session().main_format(); ui.horizontal(|ui| { - ui.label("Main output"); - egui::ComboBox::from_id_salt("main_output_format").selected_text(main.label()).show_ui(ui, |ui| { + let main_label = ui.label("Main output"); + let format = egui::ComboBox::from_id_salt("main_output_format").selected_text(main.label()).show_ui(ui, |ui| { for f in soundcraft_model::ChannelFormat::ALL.into_iter().filter(|f| (2..=16).contains(&f.channels())) { if ui.selectable_label(f == main, f.label()).clicked() && f != main { let _ = app.run("setup.main_format", json!({"format": f.label()})); } } }); + format.response.labelled_by(main_label.id); ui.label(egui::RichText::new(format!("{} ch", main.channels())).small()); }); for o in app.engine.session().outputs.clone() { diff --git a/crates/ui-egui/tests/it/clip_menu.rs b/crates/ui-egui/tests/it/clip_menu.rs index 71684a92..8da61fd7 100644 --- a/crates/ui-egui/tests/it/clip_menu.rs +++ b/crates/ui-egui/tests/it/clip_menu.rs @@ -4,12 +4,14 @@ use egui::{Event, Modifiers, PointerButton, pos2, vec2}; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use soundcraft_ui_egui::{Services, SoundApp}; /// One UI frame, with queued synthetic pointer input applied first (as the control channel does). fn step(h: &mut Harness<'_, SoundApp>) { let mut raw = std::mem::take(h.input_mut()); - h.state_mut().raw_input_hook(&mut raw); + let ctx = h.ctx.clone(); + h.state_mut().raw_input_hook(&ctx, &mut raw); *h.input_mut() = raw; h.step(); } @@ -27,6 +29,8 @@ fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize { s.tracks.iter().find(|t| t.name == "Kick").map_or(0, |t| t.clips().len()) } +/// Moving the pointer from a right-clicked clip onto the menu's Delete item keeps the menu open; clicking Delete +/// removes one Kick clip. #[test] fn clip_menu_stays_open_while_moving_onto_delete() { let engine = soundcraft_engine::demo::demo_engine(); @@ -50,14 +54,15 @@ fn clip_menu_stays_open_while_moving_onto_delete() { for _ in 0..10 { step(&mut h); } - // Move (no click) into the menu, as a real pointer would, so the lane loses its hover. - for (x, y) in [(900.0, 300.0), (907.0, 442.0)] { - h.state_mut().synthetic.push(Event::PointerMoved(pos2(x, y))); - for _ in 0..10 { - step(&mut h); - } + // Move (no click) onto the menu's Delete item, as a real pointer would, so the lane loses + // its hover. Ask the newly laid-out menu where the item is instead of pinning coordinates + // that change with egui's item metrics. + let delete = h.get_by_label("Delete").rect().center(); + h.state_mut().synthetic.push(Event::PointerMoved(delete)); + for _ in 0..10 { + step(&mut h); } - press(&mut h, 907.0, 442.0, PointerButton::Primary); + press(&mut h, delete.x, delete.y, PointerButton::Primary); for _ in 0..15 { step(&mut h); } diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index d0013494..7018e38e 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -34,6 +34,9 @@ fn drag(h: &mut Harness<'_, SoundApp>, from: egui::Pos2, to: egui::Pos2) { h.run_steps(4); } +/// A real panel drag, float, close and reopen keeps the recorded floating rectangle through a UI-state reload; +/// the Trim tool and record-arm controls still work, and a floating edit panel does not cover the mix strip used +/// after switching windows. #[test] fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() { let mut h = harness(None, 1400.0, 1.0); @@ -60,10 +63,12 @@ fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() { assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected); reloaded.state_mut().run("ui.dock", json!({"operation":"move","panel":"clips","target":"tracks","zone":"center"})).unwrap(); reloaded.run_steps(4); - // The narrow Tracks frame shows the active Clips tab and lists Tracks under "…". - reloaded.get_by_label("Panels").click(); - reloaded.run_steps(3); - reloaded.get_by_label("Tracks and Groups").click(); + // craft-ui 650b squeezes both tabs into the narrow Tracks frame, so the inactive Tracks tab + // is visibly shown (only shown tabs get AccessKit nodes) instead of moving under a "Panels" + // overflow. Activate it through that visible tab with a real pointer click. + let tracks_tab = reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").rect(); + assert!(tracks_tab.width() > 0.0 && tracks_tab.height() > 0.0, "the Tracks tab is visibly laid out: {tracks_tab:?}"); + reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").click(); reloaded.run_steps(3); assert_eq!(reloaded.state().ui.docking.layout.panel_state(&Panel::Tracks), craft_ui::docking::PanelState::Front); reloaded.get_by_label("Trim (F6)").click(); @@ -77,7 +82,7 @@ fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() { reloaded.run_steps(4); assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), edit); assert!(!reloaded.state().ui.mix_docking.layout.contains(&Panel::Clips)); - let record = reloaded.get_by_label("Record enable"); + let record = reloaded.get_by_label_contains("Record enable"); // A real pointer click must remain outside the floating panel's occluding rectangle. assert!(!egui::Rect::from_min_size(egui::pos2(900.0, 70.0), vec2(220.0, 420.0)).contains(record.rect().center())); record.click(); @@ -283,3 +288,67 @@ fn a_panel_without_a_recorded_place_opens_beside_the_session_view() { assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Left))); assert!(h.state().ui.show_tracks_list); } + +/// A hidden panel whose saved anchor has disappeared reopens beside the session view instead of failing to restore. +#[test] +fn a_recorded_place_whose_anchor_is_gone_opens_beside_the_session_view() { + use craft_ui::docking::{Action, Permissions, Placement, Zone}; + let allow = |_: &Panel| Permissions::default(); + let mut state = UiState::default(); + // A stale saved location: its recorded anchor closed without leaving a place to follow. + state.docking.apply(Action::Close { panel: Panel::Clips }, allow).unwrap(); + state.docking.apply(Action::Close { panel: Panel::Tracks }, allow).unwrap(); + state.docking.hidden.retain(|(panel, _)| *panel != Panel::Tracks); + let location = &mut state.docking.hidden.iter_mut().find(|(panel, _)| *panel == Panel::Clips).unwrap().1; + location.anchor = Some(Panel::Tracks); + location.placement = Placement::Split(Zone::Left); + location.sibling.clear(); + location.floating = None; + soundcraft_ui_egui::docking::validate(&state.docking).unwrap(); + state.show_clip_list = false; + state.show_tracks_list = false; + assert!(!state.docking.layout.contains(&Panel::Tracks)); + let mut h = harness(Some(state), 1400.0, 1.0); + assert!(!h.state().ui.docking.layout.contains(&Panel::Tracks), "app sync keeps the stale anchor absent"); + h.state_mut().run("ui.dock", json!({"operation":"open","panel":"clips"})).unwrap(); + h.run_steps(4); + let location = h.state().ui.docking.layout.location(&Panel::Clips).unwrap(); + assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Right))); + assert!(h.state().ui.show_clip_list); +} + +/// Panels hidden one by one reopen into their original floating window, rebuilding the hidden-anchor chain in the +/// process. +#[test] +fn panels_hidden_one_by_one_reopen_into_their_recorded_window() { + use craft_ui::docking::{Action, Node, Permissions}; + let allow = |_: &Panel| Permissions::default(); + let mut state = UiState::default(); + state.docking.layout.floating.retain(|window| !window.contains(&Panel::History)); + let window = state.docking.layout.floating.iter_mut().find(|window| window.contains(&Panel::Memory)).unwrap(); + let rect = window.rect; + window.root = Node::Tabs { panels: vec![Panel::Memory, Panel::History], active: 0 }; + state.docking.apply(Action::Close { panel: Panel::Memory }, allow).unwrap(); + state.docking.apply(Action::Close { panel: Panel::History }, allow).unwrap(); + let own = &mut state.docking.hidden.iter_mut().find(|(panel, _)| *panel == Panel::Memory).unwrap().1; + assert_eq!(own.anchor, Some(Panel::History)); + // Only the outer hidden anchor retains a floating fallback, so the chain walk is required. + own.floating = None; + let own = own.clone(); + assert_eq!(state.docking.layout.clone().restore(Panel::Memory, &own), Err(craft_ui::docking::DockError::MissingTarget)); + state.show_memory_locations = false; + state.show_undo_history = false; + let mut h = harness(Some(state), 1400.0, 1.0); + h.state_mut().run("ui.dock", json!({"operation":"open","panel":"memory"})).unwrap(); + h.run_steps(4); + let hidden_history = &h.state().ui.docking.hidden.iter().find(|(panel, _)| *panel == Panel::History).unwrap().1; + assert_eq!(hidden_history.anchor, Some(Panel::Memory), "the restored chain reverses its hidden neighbour's anchor"); + h.state_mut().run("ui.dock", json!({"operation":"open","panel":"history"})).unwrap(); + h.run_steps(4); + let layout = &h.state().ui.docking.layout; + let window = layout.floating.iter().find(|window| window.contains(&Panel::Memory)).expect("Memory reopens in its window"); + assert!(window.contains(&Panel::History), "the hidden chain reopens History in Memory's window: {window:?}"); + assert_eq!(window.rect, rect); + assert!(h.state().ui.show_memory_locations && h.state().ui.show_undo_history); + soundcraft_ui_egui::docking::validate(&h.state().ui.docking).unwrap(); +} diff --git a/crates/ui-egui/tests/it/edit_window_headers.rs b/crates/ui-egui/tests/it/edit_window_headers.rs index a9a2052c..fa5ae057 100644 --- a/crates/ui-egui/tests/it/edit_window_headers.rs +++ b/crates/ui-egui/tests/it/edit_window_headers.rs @@ -8,9 +8,11 @@ use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; const CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; +/// Feed one harness frame through the raw-input hook before stepping, so scripted input reaches the app. fn step(h: &mut Harness<'_, SoundApp>) { let mut raw = std::mem::take(h.input_mut()); - h.state_mut().raw_input_hook(&mut raw); + let ctx = h.ctx.clone(); + h.state_mut().raw_input_hook(&ctx, &mut raw); *h.input_mut() = raw; h.step(); } @@ -84,6 +86,8 @@ fn header_keys_freeze_silence_and_show_automation() { assert_eq!(h.state().ui.window, MainWindow::Mix); } +/// From the Edit window's insert column, clicking a plugin opens it, Cmd-click bypasses it, right-click changes or +/// removes it through the menu and an empty slot adds a plugin. #[test] fn edit_window_insert_slots_open_change_and_add_plugins() { let (mut h, kick, row) = harness("Kick", "medium", "view.edit_inserts_ae"); @@ -104,7 +108,7 @@ fn edit_window_insert_slots_open_change_and_add_plugins() { let plugin = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap(); click(&mut h, slot(2)); - let category = h.get_by_label(&format!("{:?} ⏵", plugin.category)).rect().center(); + let category = h.get_by_label(&format!("{:?}", plugin.category)).rect().center(); h.state_mut().synthetic.push(Event::PointerMoved(category)); for _ in 0..10 { step(&mut h); @@ -114,6 +118,7 @@ fn edit_window_insert_slots_open_change_and_add_plugins() { assert_eq!(insert(&h, 2).as_deref(), Some(plugin.id)); } +/// Short tracks draw insert slots as squares that still open, bypass and open the add-plugin menu. #[test] fn short_tracks_show_insert_slots_as_squares() { let (mut h, kick, row) = harness("Kick", "small", "view.edit_inserts_ae"); @@ -127,7 +132,7 @@ fn short_tracks_show_insert_slots_as_squares() { click(&mut h, square(4)); let plugin = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap(); - assert!(h.query_by_label(&format!("{:?} ⏵", plugin.category)).is_some()); + assert!(h.query_by_label(&format!("{:?}", plugin.category)).is_some()); } #[test] diff --git a/crates/ui-egui/tests/it/menu_search.rs b/crates/ui-egui/tests/it/menu_search.rs index 21b99a6c..c193ad18 100644 --- a/crates/ui-egui/tests/it/menu_search.rs +++ b/crates/ui-egui/tests/it/menu_search.rs @@ -52,12 +52,14 @@ fn vox() -> soundcraft_engine::Engine { engine } +/// Typing filters the insert menu immediately, a click in the search keeps it open, and arrow keys plus Enter choose +/// the highlighted plugin. #[test] fn an_insert_menu_searches_as_soon_as_it_opens() { let reverbs = built_in(false, "reverb"); assert!(reverbs.len() >= 2, "the test needs two built-in reverbs"); let mut h = harness(vox()); - open(&mut h, "Insert A"); + open(&mut h, "Insert A for Vox"); type_text(&mut h, "reverb"); assert!(h.query_by_label(reverbs[0].name).is_some(), "typing went to the search"); assert!(h.query_by_label_contains("Dynamics").is_none(), "the matches replace the categories"); @@ -74,15 +76,16 @@ fn an_insert_menu_searches_as_soon_as_it_opens() { assert!(h.query_by_label(reverbs[0].name).is_none(), "picking closes the menu"); } +/// Reopening a menu clears its old search, Escape closes it, and Enter with no matches picks nothing. #[test] fn a_menu_opens_with_an_empty_search() { let mut h = harness(vox()); - open(&mut h, "Insert B"); + open(&mut h, "Insert B for Vox"); type_text(&mut h, "reverb"); press(&mut h, Key::Escape); assert!(h.query_by_label(built_in(false, "reverb")[0].name).is_none(), "Escape closes the menu"); - open(&mut h, "Insert B"); + open(&mut h, "Insert B for Vox"); let category = format!("{:?}", soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap().category); assert!(h.query_by_label_contains(&category).is_some(), "the old search is gone, so the categories show"); type_text(&mut h, "zzzz"); @@ -91,14 +94,16 @@ fn a_menu_opens_with_an_empty_search() { assert!(h.state().engine.session().tracks[0].mixer.inserts.iter().all(Option::is_none), "Enter without a match picks nothing"); } +/// Choosing a plugin from a category closes the menu, whether it was opened by a primary click on an empty slot or +/// a secondary click on the now-filled slot. #[test] fn picks_without_the_search_still_close_the_menu() { let first = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap(); let other = soundcraft_dsp::plugins().iter().find(|p| p.category == first.category && p.id != first.id).unwrap(); - let category = format!("{:?} ⏵", first.category); + let category = format!("{:?}", first.category); let mut h = harness(vox()); for (plugin, click) in [(first, egui::PointerButton::Primary), (other, egui::PointerButton::Secondary)] { - h.get_by_label("Insert C").click_button(click); + h.get_by_label("Insert C for Vox").click_button(click); h.run_steps(3); h.get_by_label(&category).hover(); h.run_steps(3); @@ -112,7 +117,7 @@ fn picks_without_the_search_still_close_the_menu() { h.run_steps(2); for (item, bypassed) in [("Bypass", Some(true)), ("no insert", None)] { - h.get_by_label("Insert C").click_secondary(); + h.get_by_label("Insert C for Vox").click_secondary(); h.run_steps(3); h.get_by_label(item).click(); h.run_steps(3); @@ -121,6 +126,7 @@ fn picks_without_the_search_still_close_the_menu() { } } +/// Typing filters an instrument menu immediately and Enter inserts the highlighted built-in instrument. #[test] fn an_instrument_menu_searches_as_soon_as_it_opens() { let drums = built_in(true, "drum"); @@ -130,7 +136,7 @@ fn an_instrument_menu_searches_as_soon_as_it_opens() { let mut h = harness(engine); h.state_mut().ui.mix_views.push("instruments".into()); h.run_steps(2); - open(&mut h, "Instrument"); + open(&mut h, "Instrument for Keys"); type_text(&mut h, "drum"); press(&mut h, Key::Enter); let instrument = h.state().engine.session().tracks[0].instrument.clone().unwrap(); @@ -138,6 +144,7 @@ fn an_instrument_menu_searches_as_soon_as_it_opens() { assert!(h.query_by_label(drums[0].name).is_none(), "picking closes the menu"); } +/// Send and output menus filter buses as soon as they open, and Enter routes to the highlighted bus. #[test] fn send_and_output_menus_search_buses_as_soon_as_they_open() { let mut engine = vox(); @@ -149,25 +156,26 @@ fn send_and_output_menus_search_buses_as_soon_as_they_open() { let mut h = harness(engine); let bus = |h: &Harness, name: &str| h.state().engine.session().busses.iter().find(|b| b.name == name).unwrap().id; - open(&mut h, "Send C"); + open(&mut h, "Send C for Vox"); type_text(&mut h, "del"); assert!(h.query_by_label("Verb").is_none()); press(&mut h, Key::Enter); let send = h.state().engine.session().tracks[0].mixer.sends[2].clone().expect("Enter picks the bus"); assert_eq!(send.target, Route::Bus(bus(&h, "Delay"))); - open(&mut h, "Output"); + open(&mut h, "Output for Vox"); type_text(&mut h, "ver"); press(&mut h, Key::Enter); assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::Bus(bus(&h, "Verb"))); } +/// Scripted typing and Enter reach a menu search even when the host window is unfocused. #[test] fn scripted_keys_reach_the_search_with_the_window_in_the_background() { let plates = built_in(false, "plate"); let mut h = harness(vox()); h.input_mut().focused = false; - open(&mut h, "Insert A"); + open(&mut h, "Insert A for Vox"); type_text(&mut h, "plate"); press(&mut h, Key::Enter); let insert = h.state().engine.session().tracks[0].mixer.inserts[0].clone().expect("Enter works without window focus, as typing does"); diff --git a/crates/ui-egui/tests/it/midi_panel.rs b/crates/ui-egui/tests/it/midi_panel.rs index 9ab3091e..7fbb61d8 100644 --- a/crates/ui-egui/tests/it/midi_panel.rs +++ b/crates/ui-egui/tests/it/midi_panel.rs @@ -12,8 +12,9 @@ fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec) { .clear(); } +/// The MIDI editor's last laid-out body rectangle, stored by the dock. fn panel(ctx: &egui::Context) -> Rect { - ctx.data(|data| data.get_temp::(Id::new("soundcraft-midi-body"))).unwrap() + ctx.data(|data| data.get_temp::(Id::unique("soundcraft-midi-body"))).unwrap() } fn button(pos: Pos2, pressed: bool) -> Event { diff --git a/crates/ui-egui/tests/it/new_midi_clip.rs b/crates/ui-egui/tests/it/new_midi_clip.rs index 46b54616..dae4bdbf 100644 --- a/crates/ui-egui/tests/it/new_midi_clip.rs +++ b/crates/ui-egui/tests/it/new_midi_clip.rs @@ -122,6 +122,7 @@ fn the_pencil_makes_no_clips_on_audio_tracks() { assert!(r.clips().is_empty()); } +/// Clicking the empty MIDI editor adds a clip spanning the selected bars at the insertion point and selects it. #[test] fn a_click_in_the_empty_midi_editor_adds_a_clip_at_the_insertion_point() { let mut r = Rig::new("instrument"); @@ -130,7 +131,7 @@ fn a_click_in_the_empty_midi_editor_adds_a_clip_at_the_insertion_point() { r.app.run("edit.select", json!({"tracks": [track], "start": (r.bar(3) + r.bar(4)) / 2})).unwrap(); r.frames(4); assert_eq!(midi_editor::target_clip(&r.app), None); - let body = r.ctx.data(|d| d.get_temp::(Id::new("soundcraft-midi-body"))).unwrap(); + let body = r.ctx.data(|d| d.get_temp::(Id::unique("soundcraft-midi-body"))).unwrap(); r.click_at(body.center()); assert_eq!(r.clips(), vec![(r.bar(3), r.bar(4))]); assert_eq!(midi_editor::target_clip(&r.app), Some(r.app.engine.session().tracks[0].clips()[0].id)); diff --git a/crates/ui-egui/tests/it/new_tracks.rs b/crates/ui-egui/tests/it/new_tracks.rs index ff4c97ce..53cdffaf 100644 --- a/crates/ui-egui/tests/it/new_tracks.rs +++ b/crates/ui-egui/tests/it/new_tracks.rs @@ -55,12 +55,13 @@ fn double_click_below_the_last_track_opens_new_tracks() { } } +/// Double-clicking to the right of the last Mix strip opens the New Tracks dialog. #[test] fn double_click_right_of_the_last_strip_opens_new_tracks() { let mut h = harness(1); h.state_mut().ui.window = MainWindow::Mix; h.run_steps(4); - let strip = h.get_by_label("Record enable").rect(); + let strip = h.get_by_label_contains("Record enable").rect(); let at = pos2(strip.max.x + 150.0, strip.center().y); click(&mut h, at); click(&mut h, at); diff --git a/crates/ui-egui/tests/it/open_on_insert.rs b/crates/ui-egui/tests/it/open_on_insert.rs index c21b75cf..ec62835e 100644 --- a/crates/ui-egui/tests/it/open_on_insert.rs +++ b/crates/ui-egui/tests/it/open_on_insert.rs @@ -38,7 +38,7 @@ fn vox_id(h: &Harness) -> TrackId { fn two_effects() -> (String, &'static soundcraft_dsp::PluginInfo, &'static soundcraft_dsp::PluginInfo) { let first = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap(); let other = soundcraft_dsp::plugins().iter().find(|p| p.category == first.category && p.id != first.id).unwrap(); - (format!("{:?} ⏵", first.category), first, other) + (format!("{:?}", first.category), first, other) } /// Opens `slot`'s menu (a click on an empty slot, a right-click on a filled one) and picks @@ -61,20 +61,22 @@ fn inserted(h: &Harness) -> Option { h.state().engine.session().tracks[0].mixer.inserts[0].as_ref().map(|i| i.plugin.clone()) } +/// Choosing a plugin for an empty insert inserts it and opens its window. #[test] fn picking_a_plugin_for_an_empty_insert_opens_it() { let (category, plugin, _) = two_effects(); let mut h = harness(vox(), MainWindow::Mix); - pick(&mut h, "Insert A", false, &category, plugin.name); + pick(&mut h, "Insert A for Vox", false, &category, plugin.name); assert_eq!(inserted(&h).as_deref(), Some(plugin.id)); let id = vox_id(&h); assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)], "the new insert's window opened"); } +/// A plugin chosen by typing a search and pressing Enter also opens its window. #[test] fn picking_from_the_search_opens_it_too() { let mut h = harness(vox(), MainWindow::Mix); - h.get_by_label("Insert A").click(); + h.get_by_label("Insert A for Vox").click(); h.run_steps(3); h.event(egui::Event::Text("reverb".into())); h.run_steps(2); @@ -85,6 +87,7 @@ fn picking_from_the_search_opens_it_too() { assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)]); } +/// Replacing a filled insert inserts the new plugin and opens that plugin's window. #[test] fn replacing_a_filled_insert_opens_the_new_plugin() { let (category, first, second) = two_effects(); @@ -93,16 +96,17 @@ fn replacing_a_filled_insert_opens_the_new_plugin() { engine.execute("mix.insert", &json!({"track": track.0, "slot": 0, "plugin": first.id})).unwrap(); let mut h = harness(engine, MainWindow::Mix); assert!(h.state().ui.plugin_windows.is_empty(), "nothing opens before a pick"); - pick(&mut h, "Insert A", true, &category, second.name); + pick(&mut h, "Insert A for Vox", true, &category, second.name); assert_eq!(inserted(&h).as_deref(), Some(second.id)); assert_eq!(h.state().ui.plugin_windows, vec![(track, 0)]); } +/// Choosing a plugin from the Edit window's insert column inserts it and opens its window. #[test] fn the_edit_windows_inserts_column_opens_it_too() { let (category, plugin, _) = two_effects(); let mut h = harness(vox(), MainWindow::Edit); - pick(&mut h, "Insert A", false, &category, plugin.name); + pick(&mut h, "Insert A for Vox", false, &category, plugin.name); assert_eq!(inserted(&h).as_deref(), Some(plugin.id)); let id = vox_id(&h); assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)]); diff --git a/crates/ui-egui/tests/it/plugin_window.rs b/crates/ui-egui/tests/it/plugin_window.rs index 23870333..553b6a8e 100644 --- a/crates/ui-egui/tests/it/plugin_window.rs +++ b/crates/ui-egui/tests/it/plugin_window.rs @@ -1,6 +1,7 @@ use egui_kittest::Harness; use soundcraft_ui_egui::{Services, SoundApp}; +/// A plugin window taller than a short screen is clamped so its bottom remains visible. #[test] fn a_long_plugin_window_stays_on_a_short_screen() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); @@ -14,6 +15,6 @@ fn a_long_plugin_window_stays_on_a_short_screen() { app, ); h.run_steps(4); - let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("plugin", kick.0, 0usize)))).unwrap(); + let window = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("plugin", kick.0, 0usize)))).unwrap(); assert!(window.bottom() <= 500.0, "{window:?}"); } diff --git a/crates/ui-egui/tests/it/pro_tools_keys.rs b/crates/ui-egui/tests/it/pro_tools_keys.rs index 97952c1e..183e458d 100644 --- a/crates/ui-egui/tests/it/pro_tools_keys.rs +++ b/crates/ui-egui/tests/it/pro_tools_keys.rs @@ -195,6 +195,7 @@ fn window_keys_open_their_windows() { } } +/// Modified arrow keys stay with the global shortcuts, while an unmodified arrow still moves the selected note. #[test] fn the_piano_roll_leaves_modified_arrows_to_the_shortcuts() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); @@ -213,7 +214,7 @@ fn the_piano_roll_leaves_modified_arrows_to_the_shortcuts() { for _ in 0..6 { frame(&mut app, vec![]); } - let body = ctx.data(|d| d.get_temp::(egui::Id::new("soundcraft-midi-body"))).unwrap(); + let body = ctx.data(|d| d.get_temp::(egui::Id::unique("soundcraft-midi-body"))).unwrap(); frame(&mut app, vec![Event::PointerMoved(body.center())]); app.midi.selected = vec![0]; let pitch = |app: &SoundApp| { @@ -233,6 +234,7 @@ fn the_piano_roll_leaves_modified_arrows_to_the_shortcuts() { assert_eq!(pitch(&app), before + 1); } +/// Shift+Up moves the selection to the same note an octave above, past intervening notes in the chord. #[test] fn the_piano_roll_selection_follows_notes_the_arrows_move_past_others() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); @@ -251,7 +253,7 @@ fn the_piano_roll_selection_follows_notes_the_arrows_move_past_others() { for _ in 0..6 { frame(&mut app, vec![]); } - let body = ctx.data(|d| d.get_temp::(egui::Id::new("soundcraft-midi-body"))).unwrap(); + let body = ctx.data(|d| d.get_temp::(egui::Id::unique("soundcraft-midi-body"))).unwrap(); frame(&mut app, vec![Event::PointerMoved(body.center())]); let notes = |app: &SoundApp| { let clip = soundcraft_ui_egui::midi_editor::target_clip(app).unwrap(); diff --git a/crates/ui-egui/tests/it/sampler_window.rs b/crates/ui-egui/tests/it/sampler_window.rs index b322fdc9..a2e3e3c7 100644 --- a/crates/ui-egui/tests/it/sampler_window.rs +++ b/crates/ui-egui/tests/it/sampler_window.rs @@ -33,13 +33,17 @@ fn wav(secs: f32) -> Vec { soundcraft_audio_io::encode(&buf, &opts).unwrap() } +/// Feed one harness frame through the raw-input hook before stepping, so dropped files and scripted input are +/// handled. fn step(h: &mut Harness<'_, SoundApp>) { let mut raw = std::mem::take(h.input_mut()); - h.state_mut().raw_input_hook(&mut raw); + let ctx = h.ctx.clone(); + h.state_mut().raw_input_hook(&ctx, &mut raw); *h.input_mut() = raw; h.step(); } +/// A demo app with the Keys sampler window open and six settled frames, plus that track's id. fn harness() -> (Harness<'static, SoundApp>, TrackId) { let engine = soundcraft_engine::demo::demo_engine(); let keys = engine.session().tracks.iter().find(|t| t.name == "Keys").unwrap().id; @@ -49,7 +53,6 @@ fn harness() -> (Harness<'static, SoundApp>, TrackId) { |ui, app: &mut SoundApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); - let _ = app.take_dropped_files(&ctx); app.ui(ui); }, app, @@ -130,12 +133,17 @@ fn dropping_audio_without_a_path_reads_its_bytes() { assert_eq!(h.state().engine.session().sources.len(), before); } +/// A non-audio drop goes to the shell exactly once and does not change the instrument or queue a sample read. #[test] fn non_audio_drops_are_left_to_the_shell() { let (mut h, keys) = harness(); drop_on_window(&mut h, keys, Dropped { path: PathBuf::from("/tmp/song.scraft"), bytes: Vec::new() }); assert_eq!(instrument(&h, keys).plugin, "subtractive_synth"); assert!(h.state_mut().take_sample_reads().is_empty()); + let host = h.state_mut().take_dropped_files(); + assert_eq!(host.len(), 1, "a session drop is handed to the shell once"); + assert!(host.first().is_some_and(|f| f.path().ends_with("song.scraft"))); + assert!(h.state_mut().take_dropped_files().is_empty(), "later steps do not replay it"); } #[test] diff --git a/crates/ui-egui/tests/it/send_window.rs b/crates/ui-egui/tests/it/send_window.rs index dcdef662..ac3a6dad 100644 --- a/crates/ui-egui/tests/it/send_window.rs +++ b/crates/ui-egui/tests/it/send_window.rs @@ -38,10 +38,12 @@ fn press(h: &mut Harness, at: Pos2, pressed: bool, modifiers: Modifier h.step(); } +/// A send's level opens its window; Alt-click resets the fader to 0 dB, dragging reaches −∞, and clicking the +/// same send again closes the window. #[test] fn a_sends_level_opens_its_send_window() { let (mut h, vox) = harness(); - h.get_by_label("Send A level").click(); + h.get_by_label("Send A level for Vox").click(); h.run_steps(3); assert_eq!(h.state().ui.send_window, Some((vox, 0))); assert!(h.query_by_label_contains("Vox · Send A").is_some()); @@ -63,33 +65,35 @@ fn a_sends_level_opens_its_send_window() { assert_eq!(level(&h, 0), -144.0, "the fader goes down to −∞"); assert_eq!(level(&h, 1), -10.0); - h.get_by_label("Send B level").click(); + h.get_by_label("Send B level for Vox").click(); h.run_steps(3); assert_eq!(h.state().ui.send_window, Some((vox, 1)), "another send's level moves the window there"); assert!(h.query_by_label_contains("Vox · Send B").is_some()); - h.get_by_label("Send B level").click(); + h.get_by_label("Send B level for Vox").click(); h.run_steps(3); assert_eq!(h.state().ui.send_window, None, "clicking the same send again closes it"); assert!(h.query_by_label_contains("Vox · Send").is_none()); } +/// Escape first closes a send menu or cancels level typing, then a further Escape closes the send window. #[test] fn escape_closes_the_send_window_after_menus_and_typing() { let (mut h, vox) = harness(); - h.get_by_label("Send A level").click(); + h.get_by_label("Send A level for Vox").click(); h.run_steps(3); // A right-click menu on top takes the first Escape. - let at = h.get_by_label("Send A level").rect().center(); + let at = h.get_by_label("Send A level for Vox").rect().center(); h.hover_at(at); for pressed in [true, false] { h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); } h.run_steps(3); - assert!(h.query_by_label("Pre-fader").is_some(), "the send's options menu is open"); + // The window's own PRE toggle is a button labelled "Pre-fader"; the menu's is a checkbox. + assert!(h.query_by_role_and_label(egui::accesskit::Role::CheckBox, "Pre-fader").is_some(), "the send's options menu is open"); h.key_press(egui::Key::Escape); h.run_steps(3); - assert!(h.query_by_label("Pre-fader").is_none()); + assert!(h.query_by_role_and_label(egui::accesskit::Role::CheckBox, "Pre-fader").is_none()); assert_eq!(h.state().ui.send_window, Some((vox, 0)), "that Escape only closed the menu"); // So does typing a level in the window. diff --git a/crates/ui-egui/tests/it/shared_controls.rs b/crates/ui-egui/tests/it/shared_controls.rs index 71ae5de7..acee071c 100644 --- a/crates/ui-egui/tests/it/shared_controls.rs +++ b/crates/ui-egui/tests/it/shared_controls.rs @@ -132,6 +132,8 @@ fn toolbar_keyboard_activation_changes_tools_without_transport_or_marker_actions } } +/// Clicking record-enable arms the track; Enter and Space on the focused button toggle arming without starting +/// transport or recording. #[test] fn track_record_button_arms_the_track_without_starting_transport() { let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); @@ -144,11 +146,11 @@ fn track_record_button_arms_the_track_without_starting_transport() { app, ); h.run_steps(5); - h.get_by_label("Record enable").click(); + h.get_by_label_contains("Record enable").click(); h.run_steps(3); assert!(h.state().engine.session().tracks[0].mixer.record_arm); for (key, armed) in [(Key::Enter, false), (Key::Space, true)] { - h.get_by_label("Record enable").focus(); + h.get_by_label_contains("Record enable").focus(); h.run_steps(2); h.key_press(key); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/toolbar_scroll.rs b/crates/ui-egui/tests/it/toolbar_scroll.rs index 4a6b7c52..e71f5f14 100644 --- a/crates/ui-egui/tests/it/toolbar_scroll.rs +++ b/crates/ui-egui/tests/it/toolbar_scroll.rs @@ -14,6 +14,7 @@ fn tempo(h: &egui_kittest::Harness<'_, SoundApp>) -> Option<(egui::Rect, egui::R }) } +/// In a narrow window the toolbar scrolls horizontally far enough to reach the tempo readout. #[test] fn a_narrow_window_scrolls_the_toolbar_to_reach_the_tempo() { let mut engine = soundcraft_engine::Engine::default(); @@ -41,6 +42,7 @@ fn a_narrow_window_scrolls_the_toolbar_to_reach_the_tempo() { delta: vec2(-300.0, 0.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, }); h.step(); } diff --git a/crates/ui-egui/tests/it/track_name_menu.rs b/crates/ui-egui/tests/it/track_name_menu.rs index e11ed788..4a695ee2 100644 --- a/crates/ui-egui/tests/it/track_name_menu.rs +++ b/crates/ui-egui/tests/it/track_name_menu.rs @@ -13,6 +13,7 @@ fn right_click(h: &mut Harness, at: Pos2) { h.run_steps(3); } +/// Right-clicking a track name opens the track menu in both the Edit window and the Mix strip. #[test] fn right_click_on_a_track_name_opens_the_track_menu() { let mut engine = soundcraft_engine::Engine::default(); @@ -34,7 +35,7 @@ fn right_click_on_a_track_name_opens_the_track_menu() { h.state_mut().ui.window = MainWindow::Mix; h.run_steps(4); assert!(h.query_by_label("Make Inactive").is_none()); - let strip = h.get_by_label("Record enable").rect(); + let strip = h.get_by_label_contains("Record enable").rect(); right_click(&mut h, pos2(strip.center().x, 900.0 - 17.0)); assert!(h.query_by_label("Make Inactive").is_some(), "the Mix strip's name plate"); } diff --git a/crates/ui-egui/tests/it/trackpad.rs b/crates/ui-egui/tests/it/trackpad.rs index 83a4180a..fcbea19f 100644 --- a/crates/ui-egui/tests/it/trackpad.rs +++ b/crates/ui-egui/tests/it/trackpad.rs @@ -12,18 +12,28 @@ struct Rig { } impl Rig { + /// A demo app with a control channel, a chosen window width and deterministic non-kinetic scrolling for exact + /// offset assertions. fn new(width: f32) -> Rig { let (tx, rx) = channel(); let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()).with_control(rx); let mut r = Rig { ctx: egui::Context::default(), app, tx, width }; + // egui 0.37 scroll areas treat point wheel events as a trackpad: they rubber-band at an + // edge and can coast after the gesture. These tests assert exact offsets, so the rig uses + // deterministic scrolling (production keeps the kinetic feel). + r.ctx.all_styles_mut(|s| { + s.spacing.scroll.kinetic.decay_time = 0.0; + s.spacing.scroll.kinetic.rubber_band = false; + }); r.frames(6); r } + /// Run `n` frames through the raw-input hook and the app's logic and UI. fn frames(&mut self, n: usize) { for _ in 0..n { let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 900.0))), ..Default::default() }; - self.app.raw_input_hook(&mut raw); + self.app.raw_input_hook(&self.ctx, &mut raw); self.ctx .run_ui(raw, |ui| { self.app.logic(ui.ctx()); diff --git a/crates/ui-egui/tests/it/tracks_scroll_bar.rs b/crates/ui-egui/tests/it/tracks_scroll_bar.rs index d596cde2..523fb63a 100644 --- a/crates/ui-egui/tests/it/tracks_scroll_bar.rs +++ b/crates/ui-egui/tests/it/tracks_scroll_bar.rs @@ -3,9 +3,11 @@ use egui_kittest::Harness; use serde_json::json; use soundcraft_ui_egui::{Services, SoundApp, theme::Tokens}; +/// Feed one harness frame through the raw-input hook before stepping, so scripted input reaches the app. fn step(h: &mut Harness<'_, SoundApp>) { let mut raw = std::mem::take(h.input_mut()); - h.state_mut().raw_input_hook(&mut raw); + let ctx = h.ctx.clone(); + h.state_mut().raw_input_hook(&ctx, &mut raw); *h.input_mut() = raw; h.step(); } diff --git a/crates/ui-egui/tests/mix_instrument_contrast.rs b/crates/ui-egui/tests/mix_instrument_contrast.rs index 82ad099a..bcdc5c28 100644 --- a/crates/ui-egui/tests/mix_instrument_contrast.rs +++ b/crates/ui-egui/tests/mix_instrument_contrast.rs @@ -72,6 +72,8 @@ fn contrast(a: Color32, b: Color32) -> f64 { (a.max(b) + 0.05) / (a.min(b) + 0.05) } +/// In real frames, populated and empty instrument and insert slots use the active theme's readable palette across +/// light, dark and system modes, and a plugin change leaves the rest of the session and audio pool intact. #[test] fn populated_mix_instrument_uses_readable_palette_in_real_frames() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); @@ -177,7 +179,7 @@ fn populated_mix_instrument_uses_readable_palette_in_real_frames() { harness.step(); } harness.step(); // Let the popup finish its initial sizing pass. - harness.get_by_label("Built-in ⏵").hover(); + harness.get_by_label("Built-in").hover(); harness.step(); harness.step(); harness.get_by_label("Drum Synth").click(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a8428179..605b4f4e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -47,16 +47,18 @@ Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no tr | `ui.set` | any `UiState` fields | e.g. `{"window": "Mix", "narrow_mix": true}`. | | `ui.menu.list` | `{}` | Every catalog menu path and the command that implements it (or null). | | `ui.menu.invoke` | `{path}` or `{id}` | Like clicking the menu item: may open its dialog (e.g. `"Track > New..."`). | -| `ui.click` | `{x, y, button?, count?, cmd?, shift?, alt?, ctrl?}` | Synthetic click (window points). | +| `ui.click` | `{x, y, button?, count?, cmd?, shift?, alt?, ctrl?}` | Synthetic click (window points). The reply is sent only after a UI pass has applied the press/release. | | `ui.drag` | `{x, y, to_x, to_y, steps?}` | Synthetic drag with the primary button. | -| `ui.scroll` | `{x, y, dx?, dy?, unit?: "point"\|"line", steps?, cmd?, shift?, alt?, ctrl?}` | A two-finger trackpad swipe over `(x, y)`: `dx`/`dy` points spread over `steps` frames (default 8). Positive values move the content right/down (natural scrolling). `unit: "line"` sends mouse-wheel notches instead. With `cmd` it zooms; with `shift` it scrolls sideways. | +| `ui.scroll` | `{x, y, dx?, dy?, unit?: "point"\|"line", steps?, cmd?, shift?, alt?, ctrl?}` | A two-finger trackpad swipe over `(x, y)`: `dx`/`dy` points spread over `steps` frames (default 8). Positive values move the content right/down (natural scrolling). `unit: "line"` sends mouse-wheel notches instead. With `cmd` it zooms; with `shift` it scrolls sideways. Point scrolling is trackpad-like: egui 0.37 scroll areas may rubber-band at an edge and spring back after the gesture, so a script that needs an exact final offset should use `unit: "line"` or drive the target with a command. | | `ui.zoom` | `{x, y, factor, steps?}` | A trackpad pinch around `(x, y)`: `factor` > 1 spreads the fingers (zooms in). | | `ui.move` | `{x, y}` | Move the pointer. | | `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press (`Space`, `Enter`, `A`, `F7`, `=`…). | -| `ui.text` | `{text}` | Type text into the focused field. | -| `ui.screenshot` | `{path?}` | PNG of the window (base64 in `png_base64` when no path). | +| `ui.text` | `{text}` | Type text into the focused field. At most 64 KiB of UTF-8 text per request. | +| `ui.screenshot` | `{path?}` | PNG of the window (base64 in `png_base64` when no path). Exactly one reply per request. A request is bound to the window geometry, focus state and open session it was admitted for. A geometry or DPI change is retried within its 8 s deadline, then fails `screenshot stale`; a change of focused window or of the open document cancels it (`screenshot canceled: focus changed` / `... the session changed`) because those pixels must not be reported as a capture of the new state. It can also fail `screenshot queue full` (8 pending, 4 in flight), `screenshot too large` (64 MiB per image, 192 MiB aggregate **encoding peak**), `screenshot path longer than 4096 bytes`, or `screenshot unavailable: the window has no capturable size yet` (a first request before any window geometry is known, e.g. minimised before the first paint). Each request reserves its own worst-case encode peak — the captured RGBA image plus the raw copy, PNG buffer and base64 text built during encoding — so several small captures cannot retain or encode full-size images past the aggregate. The callback never writes a file: encoding and writing happen after collection, and a result arriving after the deadline returns `screenshot timed out` without creating the file. A native hidden/minimised window is asked for an explicit paint; browser hidden tabs cannot paint on request and time out. | | `app.quit` | `{}` | Quit. | +Scripted input (`ui.click`, `ui.drag`, `ui.scroll`, `ui.zoom`, `ui.key`, `ui.text`, `ui.move`) queues at most 4096 waiting events; a request that would exceed that bound fails with `synthetic input queue full` instead of growing the queue. A gesture's reply is delivered only after a UI pass has applied all of its events. Requests that follow it on the same connection (inspect, engine commands, screenshots) are therefore ordered after the gesture; requests already queued behind an undelivered gesture wait as well. If no UI pass can deliver the gesture within 15 s, it is canceled with `synthetic input was not delivered` and the request chain continues. Scripted input is automation, not a clipboard action: copy/selection commands it triggers are not published to the OS clipboard or PRIMARY selection. + ## Positions and references - Tracks: id (number) or name (`"Kick"`). @@ -99,7 +101,7 @@ Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use mo Panel IDs: `tracks`, `clips`, `midi`, `memory`, `history`. The protected center is `canvas`. -`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: tab|center|left|right|top|bottom|above|below`; PascalCase names such as `Left` also parse). `open` and `activate` show a hidden panel at its saved location, or beside the session view when it has none, and bring a visible panel to the front. Center and `tab` group with the target; side zones split beside it; `above` and `below` stack the panel in a new group above or below a floating target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. +`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: tab|center|left|right|top|bottom|above|below`; PascalCase names such as `Left` also parse). `open` and `activate` show a hidden panel at its saved location, or beside the session view when it has no saved location or its saved anchor cannot be restored, and bring a visible panel to the front. Center and `tab` group with the target; side zones split beside it; `above` and `below` stack the panel in a new group above or below a floating target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tracks","rect":[100,100,320,440]}}`. diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 00000000..4aa45323 --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1424 @@ +# SoundCraft — egui 0.37 migration plan + +## Current qualification — 2026-10-25 + +The local implementation incorporates main through +`541096d2cb5f96a13d5b58935e45711530471480`. Managed job +`egui037-sound-main541-ci-20261025-0857` passed all seven stages: formatting, +strict workspace lint, tests, doctests, assets, layering and WASM. Across 52 test +summaries it recorded 1,420 passing executions, zero failures and one ignored. + +The manifest and lock use canonical Git URLs and immutable revisions: +egui `6d518fc799681571963842f95b55a6b68431b15a`, +winit `a517b3846ee8457d84ee4975110bfda6797c44dc`, +craft-ui `650b1c0b058017c4eb3af06d5b39586d6732e07b`, and +craft-docpath/craft-media `16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3`. +The craft-ui commit is available on GitHub; it includes the newer docking +Workspace/DockCommand APIs required by this app. Local validation used the +retained task-scoped Git mirror. Remote CI must independently qualify fetching +the dependency graph. + +The docking port preserves SoundCraft's default side beside the session canvas +when a saved panel anchor has disappeared. It probes recorded locations and +hidden-anchor chains before selecting that fallback. Regression coverage includes +a deleted anchor and a hidden floating chain. Earlier drag/float/close/reopen/reload +GUI evidence qualifies the prior candidate, not this final main reconciliation. + +Final-source native/browser GUI, X11/Wayland, Windows/macOS, audio/plugin hardware, +packaging/licence coverage and paired app performance remain acceptance gates. +Human review and human merge are required. Historical pins, counts and qualification +claims below describe earlier states and are superseded by this section. + +## Historical planning record + +Status: **implementation in progress (local worktree only)**. The planning sections below remain +the contract; the local implementation amendments at the end record the current source and +dependency state. Nothing here changes a published branch, tag, PR or release, and the current +path-only lockfile/local integration configuration is **not** a publishable dependency state. +Every push or new PR requires fresh, explicit human approval after the exact contents and +validation results are shown; nothing here grants that approval. No agent/model/tool signature is +to be added when this text or its derived PR text is published. + +Scope: the assigned checkout +`/home/codex/work/CraftApps/egui037-migration-20261022/soundcraft` only. The shared +`storytold/egui`, `storytold/winit`, `storytold/craft-libs`, `craft-ai-integrations` and sibling +applications are dependency/hand-off points, not repositories changed by this plan. + +The plan is written against source, not against a generic migration template. Every claim names +the file and symbol it comes from. Historical compiler output and old audit logs are labelled as +clues, never as current acceptance. + +## 1. Decision in one page + +SoundCraft is not starting from a 0.36 `update`-only application. It already uses the root-`Ui` +lifecycle: `apps/soundcraft/src/main.rs:37-94` implements `eframe::App::logic`/`App::ui`, and +`crates/ui-egui/src/lib.rs:1136` / `:1232` already take `&egui::Context` for logic and +`&mut egui::Ui` for layout. Upstream 0.36.2 has the same `App::logic`/`App::ui` shape, so this +migration must **not** rewrite the app into `update` or introduce `Context::root_ui` bridges. + +The committed dependency state is nevertheless inconsistent: `Cargo.toml:49-52` asks for +`egui`, `eframe` and `egui_kittest` 0.37 while `craft-ui` is pinned at +`5c874d51…`, whose `egui` dependency is 0.36.2. `Cargo.lock` therefore contains two egui families +(`egui`/`ecolor`/`emath`/`epaint` 0.36.2 and 0.37.0). The first deliverable is one coherent graph and +one coherent lifecycle, not another version bump. + +Target release state: + +1. One immutable egui family from the complete reviewed/published **storytold egui P8** + hand-off and one immutable **completed storytold winit**, each at a storytold-owned full + SHA. P8 is the E1–E21 base plus **P2b** (`E4` regular-clipboard/PRIMARY routing over the + async W15 transfer path), **P7** held-key release pairing and `E21`, **P7b/E21b** outgoing + surrounding text, and **P7c**'s selected `#8763` selection-action and `#8748` + hidden-parent/deferred-child fixes, plus only justified performance fixes. Winit must + contain **W15P**, the **W27 lifecycle revision** and the **bounded W15 async transfer + API** (§5.1). `aiongg/craft/0.37` = + `a4f9a17f64a6bae61350fb85804c9df0d9865916` and `aiongg/winit + craft/0.30.13-lang` = `8b35388bd719e8cc86a95971ba0bc026c9a8f058` are **research inputs** + at inspection, not publishable pins. If those organization-owned revisions are not + reachable when implementation starts, wait for approved publication; never fill the gap + with a movable branch, an invented SHA or an incomplete stage. Stock 0.37 is only a + measurement control, not an intermediate production stage. +2. `craft-ui` and `craft-docpath` from one reviewed `storytold/craft-libs` revision on the + reconciled merge base `3832c47ef68a0e6214f2c1e85f3526b6adcb686e`, which carries the intended + egui-API port. The historical PR #199 hunks (`8614e4a`, README `1764bca`) and the earlier + `4919088` main inspection describe how that base was assembled; they are no longer the base + to apply. The stale PR head `b68de8c5f4f1c060904d211a7b1320aa0f7bce78` and audit merge + `ec8840af8c5fda695cf195cdd05e5ae078415c07` must not be merged as trees: they omit current + `craft-platform` and revert security/settings/docking/printer fixes. The exact reachable + immutable publication SHA remains a central-publication prerequisite; it is not guessed here. +3. Source changes limited to: the real compile-error classes (`UserData`/`Event::Screenshot`, + `MouseWheel.source`, `WidgetType`→`Role`, private `ScrollArea::State.offset`), deprecations that + `clippy -D warnings` will reject (`Id::new`, `Ui::id`), the native `clipboard` feature that + 0.36 supplied implicitly, font/emoji feature and glyph-coverage fixes, accessibility names, and + the lifecycle/input fixes in §6. No wholesale UI or command-layer rewrite. +4. A staged implementation: atomic fork/shared-library/API compatibility including callback and + lifecycle correctness, then deeper accessibility/input/fonts and platform/performance evidence. Each stage is + independently buildable, testable and revertible. See §14. + +The largest behavioural risk is not the Cargo update. It is the interaction between egui 0.37's +multi-pass `Context::run_ui`, logic-only ticks (`Context::run_logic` / `App::logic` while hidden), +and SoundCraft's current per-frame side effects (control queue, autosave, transport, dropped files, +screenshots). That work is specified in §6. + +## 2. Baselines, exact revisions and evidence + +### 2.1 Checkouts and baseline revisions + +| Role | Revision / value | Notes | +| --- | --- | --- | +| Assigned SoundCraft checkout (planning) | `52471b37e957feaea56dfd48932ef16d314cc74a` | Historical planning `HEAD`; superseded by the local implementation update below | +| Historical coherent 0.36.2 SoundCraft baseline | `e85a4801e9029c9a1f5ce536b11be65aea832ed8` (`Release 0.12.1`) | Direct `egui = "0.36"`, `eframe = "0.36"`, `egui_kittest = "0.36"`, `craft-ui` 0.36; one coherent family; the required performance baseline | +| Upstream egui 0.37.0 | `841a328ba2dd8f6996beb7189ebb7fc00e668a2a` | Peeled release commit; annotated tag object differs | +| Suite fork research input | `a4f9a17f64a6bae61350fb85804c9df0d9865916` | `aiongg/craft/0.37`, movable branch; E1–E21; not a production pin | +| Suite winit research input | `8b35388bd719e8cc86a95971ba0bc026c9a8f058` | `aiongg/winit craft/0.30.13-lang`; W17–W27; movable branch; no W15P | +| storytold winit base | `ffae5198770d7730e718043f035a772b7dbc3b1b` | `craft/0.30.13`, W1–W15 | +| Published storytold egui 0.36.2 | `767b1cad294120689a514bb4bd68c077e46679b9` | Existing `craft/0.36.2`; not the 0.37 target | +| craft-libs reconciliation base | `3832c47ef68a0e6214f2c1e85f3526b6adcb686e` | Current merged `craft-ui`/`craft-docpath` base for this migration; the exact publish pin is still a central-publication gate | +| craft-libs historical inspection | `49190884278f16c9a9f5823d7698a8ffb5deb062` / PR #199 `8614e4a` + README `1764bca` | Earlier base and hunks; retained as history only. Branch head `b68de8c…`/audit `ec8840a…` are stale trees and must not be merged | +| craft-libs audit merge | `ec8840af8c5fda695cf195cdd05e5ae078415c07` | Evidence only; not a target | + +The exact `storytold/egui`, `storytold/winit` and reviewed `craft-libs` revision after merge are +**blocking unknowns** (§16). They must be supplied as full immutable SHAs before P1 manifests +are changed. + +### 2.2 Historical compiler clues (not acceptance) + +`/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/soundcraft.errors` +reports nine error classes from a trial that patched 11 crates.io packages and both craft-libs +crates by local path: + +- `control.rs:192`: `Event::MouseWheel` initializer lacks `source`. +- `control.rs:253`: `Event::Screenshot` no longer exists. +- `control.rs:268`: `egui::UserData` no longer exists. +- `panels.rs:69`: no `Role::SelectableLabel` (the current source actually uses the deprecated + `WidgetType` alias at this line). +- `panels.rs:110`: no `Role::Checkbox` (current source `WidgetType::Checkbox`). +- `panels.rs:164,168,177,182`: `egui::scroll_area::State.offset` is private. + +The audit's path patches and source state are historical. They prove the API delta is small and +show where to look; they do not prove the current tree compiles, and they do not cover tests or +warnings that `clippy -D warnings` will reject. + +### 2.3 Dependency facts in the committed lockfile + +`Cargo.lock` currently contains: + +- `egui` 0.36.2 and 0.37.0; likewise `ecolor`, `emath`, `epaint` (0.36.2 and 0.37.0). +- `accesskit` 0.24.1 and 0.25.1; `accesskit_winit` 0.34.1; `kittest` 0.5.0. +- `craft-ui` at `git+…?rev=5c874d51…#5c874d51…`, depending on `egui 0.36.2`. +- `craft-docpath` at `git+…?rev=82a24b00…#82a24b00…`; no egui dependency. +- `eframe`, `egui-winit`, `egui-wgpu`, `egui_kittest` 0.37.0 from crates.io; `wgpu` 30.0.1; + `winit` 0.30.13 from crates.io. +- No `[patch.crates-io]`, no `vendor/` directory, and no machine-local source replacement. + `.cargo/config.toml` contains only the `xtask` alias. + +A read-only offline `cargo tree` was attempted for planning and could not resolve the +workspace because the local registry index lacks `symphonia-common` (required by +`symphonia-bundle-flac 0.6.1`). The feature graph below therefore comes from manifests and the +lockfile, and must be rechecked with network/cache in a managed job. This is not a build result. + +### 2.4 Non-UI audio prerequisite follow-up + +The `symphonia` 0.6.1 and `cpal` 0.18.2 prerequisite port was reviewed against the registries. Two +audio-io defects and one playback semantic gap are repaired in source (no runtime claim here): + +- **Audio probe candidate selection.** `crates/audio-io/src/symph.rs` no longer treats any + `0xFFEx` pair as an MPEG/ADTS marker. It asks the registered readers for their exact + `probe_data()` markers and scores the candidate with `Scoreable::score` over a + `ScopedStream`, mirroring the private probe's rules: `Supported` selects, EOF/other scoring + errors continue, and a non-EOF I/O error aborts. The scan remains the first actual candidate + within the 1 MiB probe depth, so a false sync cannot suppress a later FLAC/CAF/MP4 check. +- **Regression coverage.** `crates/audio-io/tests/malformed.rs` now prefixes empty FLAC and the + existing malformed CAF/MP4 fixtures with a false `0xFFE0` sync and checks both public `probe` + and `decode`; a valid MP3 and a valid ADTS stream each carry incidental `caff`/`kuki` magic. + The earlier-malformed-CAF-before-valid-CAF rejection stays covered. +- **ID3 footer.** `id3v2_len` applies the 10-byte footer only to ID3v2.4; v2.2/v2.3 bit `0x10` + is reserved. An ID3-tag-only file remains `Malformed` (the truncated/oversized class), noted + rather than called a restoration of every prior ID3 classification. +- **RF64/BW64 alignment.** `first_marker` documents that its WAV marker includes RF64/BW64 for + `wav::check_info`, while symphonia 0.6's probe registers only `RIFF`. Native PCM RF64/BW64 is + parsed by `wav::parse` before this path; foreign RF64/BW64 stays unsupported rather than + silently misdetected. +- **Playback stream invalidation.** `crates/playback/src/devices.rs` maps `StreamInvalidated` to + the existing lost-stream/`OutputTrouble::Gone` path (which offers Reconnect) instead of leaving + a dead stream logging silently. `DeviceChanged` remains live and `Xrun` transient; + `HostUnavailable`, `DeviceBusy` and `BackendError` remain log-only. This is a conservative + policy mapping, not a rebuilt stream and not a hardware result. + +No build, test, formatter, clippy, device, VM or hardware run is implied by this subsection. + +## 3. Current implementation inventory + +### 3.1 Workspace, packages and feature targets + +Workspace root: `Cargo.toml:1-11` (`resolver = "3"`, members `crates/*`, `apps/*`, `xtask`); +`workspace.package.rust-version = "1.90"`; `crates/ui-egui/Cargo.toml:7` already declares +`rust-version = "1.95"` because the egui 0.37 family requires it. `docs/architecture.md:13-14` +records that the UI may require 1.95 without raising the core crates' 1.90 declaration. + +Relevant package targets: + +| Target | egui use | Current features / notes | +| --- | --- | --- | +| `crates/ui-egui` (library) | `egui`; `craft-ui` docking/buttons | `Cargo.toml:24`; dev `egui_kittest` with `wgpu,snapshot` at `:31`; `build.rs` bakes contributor credits (no asset change) | +| `apps/soundcraft` (desktop binary) | `eframe`, `egui` | `Cargo.toml:21` features `default_fonts,wgpu,accesskit,wayland,x11,links`; **missing `clipboard`** compared with 0.36's implicit native clipboard; Windows `winresource`; macOS `muda`/`objc2` | +| `apps/soundcraft-web` (wasm binary) | `eframe`, `egui` | `Cargo.toml:27` features `default_fonts,wgpu,accesskit`; `rfd` browser picker; `WebRunner` in `src/web.rs` | +| `apps/soundcraft-cli` | none | Headless engine/control/MCP; no egui dependency | +| `crates/{time,audio-io,midi,dsp,model,mix,engine,playback,automation}` | none | Must stay UI-free; `cargo xtask layers` enforces this | +| `xtask` | none | `ci`, `layers`, `assets`, `parity`, `wasm` gates | + +`rustc 1.99.0` / `cargo 1.99.0` were present at inspection; the plan does not assume that this +toolchain is used by every CI lane. The 1.95 UI MSRV must be checked against each lane. + +Nested workspaces `crates/clap-host/tests/fixture` and `crates/vst3-host/tests/fixture` are +independent roots with their own lockfiles and no egui/winit dependencies. They do not inherit the +root patch and need no dependency change; they still need to build as part of the plugin tests. + +### 3.2 Entry points and actual lifecycle paths + +| Path | Current shape | Migration relevance | +| --- | --- | --- | +| Native shell | `apps/soundcraft/src/main.rs:37-105` `impl eframe::App`: `logic`, `raw_input_hook`, `ui`, `on_exit`; `run_native` at `:266` | `App::logic` and `App::ui` both run inside `Context::run_ui`, once per layout pass; `run_logic` also calls `logic` while hidden. Do not model native logic as a once-per-frame hook outside `run_ui` | +| Native dropped files | `main.rs:52-58` calls `SoundApp::take_dropped_files(ctx)` from `logic`; `sampler_window.rs:88-100` clones `ctx.input().raw.dropped_files` and does not clear it | At the start of `App::ui` — before the `!fonts_ready` early return at `lib.rs:1232-1239` — capture and clear `raw.dropped_files` through `input_mut`, together with the drop-time pointer position, into a one-shot pending queue. Process that queue after layout and sampler drop targets exist, for every UI pass including a forced hidden paint, and once fonts are ready. A first-frame or first forced-hidden-paint drop must not be lost; `run_logic` leaves the last pass's input in place, so later hidden ticks must not replay it | +| Native control server | `apps/soundcraft/src/control_server.rs:35-140`; `crates/ui-egui/src/control.rs:49-65` drain | Queue/reply must execute once per consumed request, including hidden ticks and multiple passes | +| Native screenshots | `control.rs:221-234`, `:244-279`; `docs/control-protocol.md:25` | Old `Event::Screenshot`/`UserData` path must become callback based | +| Native separate Mix window | `mix_window.rs:25-38` `show_viewport_immediate`; `separate_closed` at `:41` | Child root `Ui`, focus/close, hidden-parent behaviour; never call `root_ui` from the child | +| Native macOS menu | `native_menu.rs`; `main.rs:43-50,286-301` | `muda` events processed in `logic`; no egui pass required | +| Native Windows renderer | `apps/soundcraft/src/graphics.rs:11-30`; `main.rs:263-265` | DX12/FXC defaults are deliberate app fixes; preserve and measure the 0.37 shader cost | +| Web shell | `apps/soundcraft-web/src/web.rs:194-218` `WebShell` `logic`/`ui`/`raw_input_hook`; `start` `:18-50`; `take_drops` at `:136-160` already clears the raw list | Same `SoundApp` lifecycle; route drop/picker completion from the known UI phase too, so both hosts share one rule; browser file picker/drop/download; no control channel | +| Headless render example | `crates/ui-egui/examples/ui_shot.rs` uses `Harness::builder().build_ui_state` and calls `app.logic` inside the UI closure at `:65-70` | Keep logic then UI inside the harness closure, matching both native and web; assert effects across discarded passes | +| UI tests | 54 integration files; 108 `#[test]` in `crates/ui-egui/src` plus 156 in `crates/ui-egui/tests` (264 total); 1,159 workspace `#[test]` occurrences at HEAD inspection | Existing geometry/roles/command tests are the regression base; counts are the comparison point for later selected-test evidence; no committed kittest snapshot files exist | + +### 3.3 Platform cfgs + +- `cfg(target_arch = "wasm32")` / `not`: `fonts.rs:46,82,106` (system font loading is native-only), + `video_track.rs:93-105,217` (thread vs inline decoder), `bounce.rs:49-63` (time slice vs 32 blocks), + `dialogs.rs:296-300` (native picker), `sampler_window.rs` `DroppedFileHandle` web bytes. +- `cfg(target_os = "macos")`: `main.rs:19,43-50,199-203,286-301`, `native_menu.rs`, icon include; + `muda`, `objc2`, `objc2-app-kit`. +- `cfg(target_os = "windows")`: `main.rs:264`, `graphics.rs` (DX12/FXC), `build.rs` VERSIONINFO. +- `cfg(unix)`: `config.rs:299` (settings directory/permissions). +- `cfg(target_os = "linux")` / FreeBSD: `fonts.rs` fontconfig/`fc-match`, packaging; Linux CI is + X11+Wayland. +- `cfg(not(target_arch = "wasm32"))` in `apps/soundcraft/Cargo.toml:26-27` gives native `rfd`. + +These cfgs are part of acceptance. A green Linux check is not a macOS native-menu, Windows IME, +Wayland clipboard, or browser workflow result. + +### 3.4 Existing tests and CI + +`cargo xtask ci` (`xtask/src/main.rs:174-200`) runs `cargo fmt --all -- --check`, +`cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, +`cargo xtask assets`, `cargo xtask layers`, and `cargo xtask wasm`. It does not add `--locked` +to every step; a lockfile/graph check must be a separate accepted command. + +Repository workflows at inspection: `freebsd.yml` (nightly/release, common action), +`windows-arm64.yml` (nightly/release, common action), `packaging-lint.yml`, `nix.yml` (common +action; there is no root flake in this checkout), `sonarcloud.yml`, `release.yml` (release branch +only). The primary PR test matrix is supplied by `storytold/craft-common-actions`, not by a +`ci.yml` in this repository. + +Existing UI tests already exercise real frames and shapes rather than only state: + +- `tests/docking_workflows.rs` (drag float/close/reopen, reload, command ownership), +- `tests/trackpad.rs` (`smooth_scroll_delta`, pinch zoom, shift pan), +- `tests/tracks_scroll_bar.rs` (custom gutter paging), +- `tests/side_lists_access.rs`, `tests/shared_controls.rs`, `tests/appearance.rs`, + `tests/theme_docking.rs`, `tests/mix_instrument_contrast.rs` (roles, colors, scale, theme), +- `tests/save_changes.rs`, `tests/save_as_replace.rs`, `tests/recovery.rs`, + `tests/bounce_dialog.rs`, `tests/edit_window_headers.rs`, `tests/midi_*`. +- `crates/ui-egui/src/lib.rs:1400-1545` contains lifecycle/automation/autosave tests, + including `a_hidden_window_still_autosaves` using `Context::run_logic`. + +Optional evidence hooks already exist: `CRAFT_UI_THEME_SHOTS`, `SOUNDCRAFT_MIX_PROOF_DIR`, +`ui_shot`, and the control channel's `ui.screenshot`. They are not a substitute for native +window evidence. + +## 4. Actual egui 0.37 incompatibilities and required adaptations + +These are released upstream changes, not fork features. The fork may contain more changes (E1–E21); +those are handled in §5 and §12. + +| ID | Source anchor | Change | Required SoundCraft adaptation | +| --- | --- | --- | --- | +| A1 | `crates/ui-egui/src/control.rs:192` | `Event::MouseWheel` now has `source: MouseWheelSource` (`egui/src/data/input/event.rs` 0.37.0) | Set `MouseWheelSource::Wheel` for `unit:"line"`, `Trackpad` for point/trackpad; assert source and phase in `trackpad.rs` | +| A2 | `control.rs:253`, `:268`; `PendingShot` at `:32-39` | `Event::Screenshot` and `egui::UserData` removed; `ViewportCommand::Screenshot(ScreenshotCallback)` | Replace event polling with `ctx.request_screenshot`, a bounded weak completion slot per request, and callback correlation (§6.4) | +| A3 | `panels.rs:69`, `:98`, `:110`; all `WidgetType::*` occurrences | `WidgetType` is a deprecated alias for `accesskit::Role`; `SelectableLabel`/`Checkbox` are not accesskit roles | `SelectableLabel`→`Role::Button`, `Checkbox`→`Role::CheckBox`, `ComboBox`→`Role::ComboBox`, `Slider`→`Role::Slider`, `RadioButton`→`Role::RadioButton`; use `egui::Role` or `egui::accesskit::Role` consistently | +| A4 | `panels.rs:155-185` | `scroll_area::State.offset` is private; public reads are `clamped_offset()`/`unclamped_offset()` | Read `out.state.clamped_offset().y`; apply a pending target through `ScrollArea::vertical_scroll_offset` next frame (no private writes) | +| A5 | `Cargo.toml:50-52`; all `Id::new`/`ui.id()` uses | `Id::new` deprecated for `Id::unique`; `Ui::id`/`UiBuilder::id` renamed to `scope_id`; `AsId` is deprecated (`egui/src/id.rs:8-12`) but still present | Mechanical `Id::new`→`Id::unique`, `ui.id()`→`ui.scope_id()`, `UiBuilder::id`→`scope_id`; same hash, no state change. `clippy -D warnings` makes this blocking | +| A6 | `crates/ui-egui/Cargo.toml:24,31`; `apps/soundcraft/Cargo.toml:21`; `egui_kittest-0.37.0/Cargo.toml` (`[dependencies.egui]`, `[dev-dependencies.egui]`) | 0.37 `egui` declares `default = []`; `default_fonts`/`monochrome_emoji_fonts` are opt-in. eframe's selected `default_fonts` enables ordinary bundled faces for the binary; the new `egui/monochrome_emoji_fonts` must be selected explicitly. kittest's normal `egui` dependency is `default-features = false`; only kittest's OWN `[dev-dependencies]` enable the font features, and dev-dependencies do not propagate. An isolated `cargo test -p soundcraft-ui-egui` graph is therefore predicted to have no bundled fonts | Enable `default_fonts` and `monochrome_emoji_fonts` on SoundCraft's egui dependency for the UI/test root and prove unification with `cargo tree -e features`; treat the no-fonts prediction as a resolution claim until that command runs. See §9 | +| A7 | `control.rs` and `Cargo.toml:21` | 0.36 eframe forced `egui-winit/clipboard` in its native dependency; 0.37 gates it behind `eframe/clipboard` | Add `"clipboard"` to native `eframe` features. Without it, copy/paste silently uses egui's simulated clipboard instead of the OS clipboard | +| A8 | `crates/ui-egui/src/lib.rs:1136-1139` | After a completed `run_ui`, `current_pass_index()` is zero (`end_pass` takes the output), so the `>0` guard does not by itself skip an ordinary hidden tick. The real hazards are stale input/time in `run_logic` and duplicated non-idempotent work | Keep public host ordering; separate fresh input from background queues and monotonic deadlines (§6.2); test hidden and repeated passes | +| A9 | `main.rs:52-58`; `sampler_window.rs:88-100`; `lib.rs:1232-1239`; web `web.rs:136-160` | `ctx.input()` in `run_logic` is the last UI pass's input; native `dropped_files` is not consumed. Visibility does not identify a logic-only callback: `RequestPaintWhileHidden` can run `App::ui` while minimized/occluded. `ui` returns before layout while `!fonts_ready`, so a drop read only after layout is lost on the first frame | Capture and clear `raw.dropped_files` through `input_mut` at the top of `ui`, before the `!fonts_ready` return, into a one-shot queue; process it after layout for every UI pass including a forced hidden paint. Never gate on `visible()` | +| A10 | `Cargo.toml:50-52`; `crates/ui-egui/src/theme.rs:431-475` | 0.37 style defaults are roomier/larger corner radius; panel/menu geometry can shift | Keep explicit SoundCraft spacing/font/size overrides; assert geometry and exact custom faces; do not refresh screenshots first | +| A11 | `bounce.rs:68`; `shortcuts.rs:64-101`; `edit_window.rs:341` | 0.37 adds `MouseWheelSource`, kinetic scrolling and rubber-banding; smooth scroll values can change | Test wheel vs trackpad source, phase and momentum; make deterministic test styles (disable kinetic/rubber-band where exact offsets are asserted) | +| A12 | `egui_kittest` 0.37 `accessibility.rs` | Every visible input widget must have an accessible name; otherwise the harness panics after a settled step | Add names to all custom-painted controls and text fields; audit every window/dialog; do not disable the check globally | +| A13 | `egui_kittest` 0.37 `builder.rs` | Missing glyphs panic by default | Enable bundled fonts/emoji and fix the app's uncovered glyphs (§9); use `allow_missing_glyphs` only in a deliberately labelled tofu test, never for the suite | +| A14 | `apps/soundcraft-web/src/web.rs:194-218` | Same App lifecycle; browser has no control channel. `eframe`'s web runner also calls `logic` then `ui` inside `run_ui` per pass | Apply the same known-phase rules, not a visibility split; do not make browser pickers or downloads part of a multi-pass paint path | + +### 4.1 Not incompatibilities + +- `egui::Panel::top/bottom` at `edit_window.rs:109-113`, `menus.rs:224` already use the unified + `Panel` API; 0.37 only changes the salt constraint to `impl AsIdSalt` and `show_inside` is + deprecated but not used. Keep panel order and ids. +- `egui::Frame::NONE`, `CornerRadius`, `Modal`, `Popup::menu`, `Context::content_rect`, + `Context::global_style`, `Context::set_theme(ThemePreference::System)`, and + `ViewportBuilder`/`ViewportCommand` calls are already on the 0.37-compatible API surface. +- `TextEdit::load_state`/`store`, `CCursorRange`, `has_glyphs`, and `TextureHandle::set` remain + available; their semantics need tests, not mechanical rewrites. +- `Window::new`'s title-derived id is not a 0.37 change: 0.36.2 already uses `IntoAtoms` and + `Id::new(title.text())` (`egui 0.36.2 crates/egui/src/containers/window.rs:102-105`), and 0.37 + uses `Id::unique`. Keep the advice to pass a stable id for persistent windows, but do not list + it as a new 0.37 incompatibility. +- No `update`→`ui` rewrite and no generic `Context` compatibility bridge is proposed. + +## 5. Dependency target, fork provenance and publication rules + +### 5.1 Coherent production target + +P1 adopts the reviewed storytold egui family, companion winit and compatible +craft-libs revision atomically with all required source changes. The aiongg SHAs +are research inputs and patch provenance, not final production pins. Stock 0.37 may +be built as an isolated measurement control; it is not an intermediate shipping +stage. Wait for approved, reachable dependency publication rather than inventing a +SHA before implementation or publication. + +The final production pin is the whole prerequisite set, not the E1–E21 base or an +intermediate stage: + +- The complete **storytold egui P8** hand-off: E1–E21, **E4/P2b** regular-clipboard + and PRIMARY routing (`get_primary_text`/`set_primary_text`, per-seat + owner/offer/serial handling, no second regular `wl_data_device`, password + exclusion, no unchanged-selection reclaim) over the bounded async transfer path, + **P7** held-key release pairing plus `E21`, **P7b/E21b** outgoing surrounding + text, and **P7c**'s selected `#8763`/`#8748` fixes, plus justified performance + fixes. Incoming E21 deletion/commit alone is not surrounding-text support. +- The **completed storytold winit**: **W15P** PRIMARY on the existing wayland + connection/per-seat lifecycle, the **bounded W15 async transfer API** (total + deadline from admission, cumulative byte cap, bounded read/writer workers and + queue depth, handle-owned result/cancel slot that cancels on drop, owner-specific + completion waker; no unmanaged thread per request) and the **W27 lifecycle + revision** with owner/focus epochs and pending preedit/commit/delete clearing. + W15 is the regular clipboard path; it is not PRIMARY. +- The reviewed craft-libs **CL-1** revision for `craft-ui`/`craft-docpath` and the + frozen shared capture helper (**CL-2F**). The eleven craft-ui consumers need CL-1 + and CL-2F at this stage; the DesignCraft/GridCraft pilot instead uses the + provisional CL-2 API, and its adapters must precede CL-2F. Any incompatible + existing craft-ui bump lands in this same atomic dependency stage. Reuse the API + port hunks that now sit on the reconciled `3832c47` base (the earlier `8614e4a`/`1764bca` + hunks on `4919088` are history); never merge PR #199's stale `b68de8c` or `ec8840a` trees. + +P1 is blocked in full until the complete reviewed/published dependency set and +CL-2F exist: no root pin, lockfile change, partial manifest change or per-row P1 +claim lands earlier, and there is no stock/intermediate production stage. The fork +SHAs cannot be filled in from a movable branch, an aiongg ref or this document. + +### 5.2 Root patch shape (only after exact SHAs exist) + +Cargo `[patch]` sections do not propagate from a dependency's workspace into a consumer root. A +consumer that patches only `egui` will still get registry `egui-winit`, `egui-wgpu` or winit. The +root `Cargo.toml` must name every used family member at the same full SHA (illustrative, not a +manifest to land with placeholders): + +```toml +[patch.crates-io] +egui = { git = "https://github.com/storytold/egui", rev = "" } +ecolor = { git = "https://github.com/storytold/egui", rev = "" } +emath = { git = "https://github.com/storytold/egui", rev = "" } +epaint = { git = "https://github.com/storytold/egui", rev = "" } +epaint_default_fonts = { git = "https://github.com/storytold/egui", rev = "" } +eframe = { git = "https://github.com/storytold/egui", rev = "" } +egui-winit = { git = "https://github.com/storytold/egui", rev = "" } +egui-wgpu = { git = "https://github.com/storytold/egui", rev = "" } +egui_kittest = { git = "https://github.com/storytold/egui", rev = "" } +winit = { git = "https://github.com/storytold/winit", rev = "" } +``` + +The table is illustrative, not an exhaustive resolved-family list. Include `egui_glow`, +`egui_system_font_provider` and any other egui workspace crate selected by native/web/dev +features after metadata inspection. `egui_extras` is not directly used by SoundCraft; inspect +transitive test/optional graphs before deciding whether its patch is needed. `egui_plot` is not part of +the 0.37 workspace and is not a SoundCraft dependency. `wgpu` stays at 30.0.1 unless a separately +reviewed change is needed; winit stays 0.30.13-compatible. + +Workflow pins: + +```toml +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "" } +``` + +Use one craft-libs revision for both if the reviewed diff only changes the UI crate plus unrelated +main changes; otherwise retain the current `craft-docpath` rev and document why. Do not use two +craft-libs revisions merely to avoid reviewing the merged commit. Review PR #199's actual edits +line by line: its `WidgetType`→`Role` mappings, `UiBuilder::id`→`scope_id`, and the fixture +ellipsis change are evidence of the API port; its lockfile and stale base are not to be copied. +No machine-local path patch is allowed in the committed manifest or in `.cargo/config.toml`. + +### 5.3 Lockfile and compatibility checks + +Before accepting P1, in a managed build job with a complete registry index: + +1. `cargo metadata --locked --format-version 1` and assert every egui family package has exactly + one package id/source from the selected git SHA, and `craft-ui` resolves to the reviewed + craft-libs SHA. +2. `cargo tree -d --locked` shows no 0.36.2 `egui`/`ecolor`/`emath`/`epaint`, no duplicate + `winit`, and no second `accesskit` family. +3. `cargo tree -e features -p soundcraft --locked` shows `eframe/clipboard`, + `egui/default_fonts`, `egui/monochrome_emoji_fonts`, and the intended winit/accesskit features. + Repeat for `-p soundcraft-web` under the wasm target and check `eframe` web clipboard/file + features. Also inspect the SoundCraft UI test graph with + `cargo tree -e features -p soundcraft-ui-egui --locked` (dev-dependencies included) and the + production-only graph with `cargo tree -e features,no-dev -p soundcraft-ui-egui --locked`: + `egui/default_fonts`, `egui/monochrome_emoji_fonts` and `epaint_default_fonts` must appear in + the test graph. Keep the three roots distinct: the production binary, the SoundCraft UI test + target and kittest's own test root. kittest's normal `[dependencies] egui` is `default-features = false`; only its own + `[dev-dependencies]` enable fonts, and a dependency's dev-dependencies do not propagate. +4. `cargo check --locked --target wasm32-unknown-unknown -p soundcraft-ui-egui -p soundcraft-web`. +5. `cargo check --locked` from a clean cache; no source replacement, no local path, no branch + dependency, and all git revisions reachable by a clean clone. +6. After the pin, add Dependabot `ignore` entries for `egui`, `ecolor`, `emath`, `epaint`, + `epaint_default_fonts`, `eframe`, `egui-winit`, `egui-wgpu`, `egui_kittest` and `winit` (with a + comment pointing here), or require the dependency PR to update all pins together. A daily + single-package bump would otherwise recreate the split this plan removes. +7. Record `git rev-parse HEAD`, full `cargo tree` output, `Cargo.lock` hash and selected feature + list in the task evidence. + +### 5.4 Vendors and existing fixes + +SoundCraft has no `vendor/` directory and no `[patch]` section today. It must not add a vendored +copy of egui to solve compile errors. The relevant external fixes are: + +- E2+E3 in the suite fork (`egui-wgpu` surface fit and error-scoped configure/renderer creation), + relevant to `graphics.rs` and resize/GPU failure paths. +- E4 + winit W15 (regular Wayland clipboard) over the bounded async transfer API, + the separate **W15P + E4/P2b** PRIMARY path, E19/E21 + W25/W26/W27 (non-Latin + shortcuts, IME delete-surrounding), the **E21b/P7b** outgoing surrounding-text + state and **P7c**'s selected `#8763`/`#8748` fixes, as one accepted **P8** fork + set. A pin without the bounded W15 transfer, W15P/P2b, E21b/P7b or the P7c fixes + supports none of the complete clipboard, PRIMARY, surrounding-text or + selection/deferred-child acceptance rows. +- The app-local Windows DX12/FXC choice in `apps/soundcraft/src/graphics.rs:11-30` is a + SoundCraft fix and must be preserved. Do not delete it because the dependency changed. +- `muda` 0.21.2, `rfd` 0.17.2 and `image` 0.25.10 are non-egui dependencies; keep them unless a + separate, reviewed reason exists. `build.rs` Windows resources and `ATTRIBUTION.md` assets are + unaffected. + +No blanket vendor deletion is authorized. If a future consumer of SoundCraft carries a vendor, +its disposition is tracked in that consumer's plan, not here. + +### 5.5 Language work (#198) integration decision + +SoundCraft has no `craft-i18n` dependency and its menus/messages are English literals. The +language work tracked by craft-libs issue #198 is not a merged UI-localization contract. This +migration therefore consumes the fork's text/input mechanisms where they affect existing fields +(E5/E12–E16 bidi shaping and grapheme editing, E19/W25 keyboard layouts, E21/W27 IME +incoming deletion, E21b/P7b outgoing surrounding text), +but does **not** add a locale switch, a `craft-i18n` catalog, an RTL application layout, or a +second bidi pass. It also does not use E17 `Context::set_language` / E18 +`Frame::set_ui_language` until a separate localization plan defines the UI language and its +AccessKit root language. Existing logical strings remain the save/clipboard/accessibility source +of truth; never feed already-reordered visual text back into a `TextEdit` or the document. + +## 6. Root `Ui`, multi-pass and logic-only semantics + +### 6.1 Actual host contract + +Both native `epi_integration.rs::update` and web `app_runner.rs::logic` call +`App::logic` followed by `App::ui` inside the `Context::run_ui` closure, once per +layout pass. Both also call `App::logic` through `run_logic` while hidden. Native +logic is not a once-per-frame hook outside `run_ui`. Keep the existing public +`SoundApp::logic`/`ui` entry points and pass the existing root Ui; no recursive +`Context::root_ui` bridge is needed. + +`run_logic` refreshes viewport/focus information but leaves events, time and UI +state from the last pass. After a completed `run_ui`, `current_pass_index()` is +zero because `end_pass` takes the platform output. The existing first-pass guard +does not, by itself, skip an ordinary hidden tick after a completed multipass frame. +Do not create a test that expects that false failure. Visibility also does not +identify the callback phase: `RequestPaintWhileHidden` can run UI while the window +remains minimized/occluded. + +### 6.2 Background work and current input + +Extract small private methods only where they clarify the existing responsibilities: + +- `logic` polls consumed control/plugin/result queues, revision-based autosave, + transport/device state and capture deadlines. Multiple calls are safe: one queued + command is consumed once, one pending open is taken once, revision changes are + applied once and monotonic deadlines determine timed work. A first-pass guard may + remain around genuinely per-layout-frame diagnostics, not the entire async inbox. +- `ui` calls an input-preparation method before layout for pointer/keyboard + release and shortcuts. Raw dropped files are captured and cleared at the top of + `ui` (before the `!fonts_ready` early return) into a one-shot queue, then routed + after the content has laid out the current sampler drop targets. Both are the + known UI phase and run for every UI pass, including a forced hidden paint. + Drain each input once; keep sampler hit routing and browser async read behavior. + Native/web host `ui` methods perform platform-specific open/import routing from + the resulting one-shot actions. + Synthetic input requests while hidden request a UI paint or return the documented + error; they never report an unperformed action. +- `App::raw_input_hook` runs once per host frame in `prepare_raw_input`, before + `run_ui`, and also on logic-only ticks (`eframe/src/native/epi_integration.rs:315-340`; + web `app_runner.rs` hidden branch), so it is not a UI-pass hook. A synthetic step + drained there on a logic-only tick has not been applied by a UI pass. Keep exactly + one unapplied gesture step pending across repeated hidden logic ticks until a full + UI pass consumes it; later hidden ticks must neither apply nor discard it, and a + press/release pair must stay together rather than collapsing into one event. Test + repeated `run_logic` ticks with a queued click and key, then a full UI pass that + applies each exactly once and in order. +- Replace transport simulation's `ctx.input().time` delta with a monotonic clock + whose last instant is updated after each advance. A second pass observes only + additional elapsed time, never another full frame delta. Autosave and screenshot + deadlines use the same injectable clock concept. Audio position remains owned + by the audio player; do not move real-time work onto the UI callback. +- Automation writes remain keyed by transport sample position and `last_write_at`. + Split `write_automation(ctx)` so background work uses an explicit gesture state, + not stale pointer input. Focus/visibility loss cancels the transient Touch drag; + Latch/TouchLatch state continues according to its existing engine semantics. + A current UI phase refreshes gesture state before processing new edits. +- Fonts/theme installation remains idempotent. Frame statistics count rendered + frames rather than hidden ticks or layout passes. Bounce processing uses an + elapsed-time work budget and cannot get an extra full budget for every discard. + +Native/web hosts use the same logic/UI ordering. Test helpers must reproduce it: +call `logic` then `ui` inside the UI closure; call only `logic` from `run_logic`. +Do not simulate a fictional native host by calling background work once outside +`run_ui`. Existing tests may keep their public entry points. Add a small helper +for new phase-sensitive tests, without rewriting every test for style alone. + +### 6.3 Side-effect placement + +| Work | Current anchor | Class | Required change / regression | +| --- | --- | --- | --- | +| Font install and theme application | `lib.rs:1136-1154`; `fonts::install`; `theme::apply` (`theme.rs:453-480`) | `logic` host-once pre-pass, before any layout | Keep both in `logic` (or a named host-once pre-pass called before layout) and idempotent by `fonts_installed`/`theme_applied`. `ui` currently returns before layout while fonts are not ready (`lib.rs:1232-1239`), and `theme::apply` mutates global style branches before the root `ui.set_style(ctx.global_style())` call. Do not move this to `ui`: a hidden first tick must install fonts and a direct `logic` call must switch theme. Test hidden first frame, a multi-pass frame, and repeated direct `logic` calls. | +| Control queue drain | `lib.rs:1158`, `control.rs:49-65` | `logic` | Consume each queued request once; hidden ticks still answer requests. Test `ui.inspect`/`engine.execute` while minimized; two-pass frame executes command once. | +| Recent-session/prefs notification | `lib.rs:1159`, host `main.rs:71-74` | `logic` | Host save remains idempotent; hidden tick must not lose the pending recent change. Test after a hidden tick. | +| Plugin editor edits | `lib.rs:1160`, `apply_editor_edits` | `logic` | Native plugin editors idle between paints; run once per tick. Test with a fake editor queue if possible; otherwise document native-only. | +| Bounce rendering | `lib.rs:1161`, `bounce.rs:22-38` | `logic` | 30 ms slice per tick; hidden window must continue. `bounce::show`'s Escape check stays in `ui` (pass) or consumes current input. Test bounce progress across `run_logic`. | +| Pointer/gesture cleanup and automation merge | `lib.rs:1162-1170` | `logic` for writing/merge; `ui` input preparation for pointer release | Do not read stale `ctx.input` for pointer/keyboard in hidden ticks. Capture `pointer_down`/`keyboard_wanted` in `ui` input preparation, and clear a transient `Gesture::Fader`/pointer-down on entry to hidden logic so it cannot stick; `write_automation` uses the cached gesture/touched state, not stale input. Test a hidden tick does not write a spurious point. | +| Output/input device trouble | `lib.rs:1171-1172` | `logic` | Device errors arrive off-frame; one handling per tick. | +| Transport/player position | `lib.rs:1173-1180` | `logic` | Player position is read from the player; sim position only advances with real time. Hidden audio playback must continue. Test `transport.play` then `run_logic` with a fake/no-player path. | +| Open inserted plugin | `lib.rs:1181` | `logic` | Pending selection must open once, not per pass. Test a two-pass frame with `pending_open`. | +| Autosave revision/time | `lib.rs:1182-1184`, `672-708` | `logic` | `autosave_changes` is revision-idempotent; `autosave` is wall-clock based. Hidden window must still autosave. Existing `a_hidden_window_still_autosaves` must be extended to hidden-after-multipass. | +| Input recorder ensure | `lib.rs:1185-1189` | `logic` | One open attempt per tick; test no duplicate recorder on two passes. | +| Repaint deadlines | `lib.rs:1191-1195` | `logic` | Keep `request_repaint_after` for autosave/plugin editors. Add a short deadline while screenshots are pending. | +| Screenshot collection/issue | `control.rs:244-279` | `logic` | Callback + weak slot; explicit count/byte admission and in-flight budget; one reply. See §6.4. | +| Shortcuts | `ui` `lib.rs:1244`, `shortcuts.rs` | `ui` input preparation | Input-derived; hidden `run_logic` must not consume stale key events. | +| Dropped files | native `main.rs:52-58`, `sampler_window.rs:88-100`; web `web.rs:112-160` | known UI phase (`ui`); never `logic` and never gated on `visible()` | Capture and clear `raw.dropped_files` at the top of `ui`, before the `!fonts_ready` early return (`lib.rs:1232-1239`), together with the drop-time pointer position, into a one-shot pending queue; process it after layout once fonts are ready, on every UI pass including `RequestPaintWhileHidden`. Preserve sampler placement and web byte reads. Test a fonts-not-ready first frame (ordinary and forced-hidden), an ordinary paint and a forced hidden paint, each followed by hidden ticks, with exactly one import. | +| Native macOS menu | `main.rs:43-50` | `logic` | `muda` events are not egui input; sync is time-throttled. Hidden menu actions still apply. | +| Host title/window size/prefs | `main.rs:64-87` | `logic` or a host-once helper | Viewport info is current in `run_logic`; title/size updates are idempotent. Do not save prefs from every pass. | +| `ui_shot` test driver | `examples/ui_shot.rs:65-70` | test helper (optional) | The compatibility `logic` driver makes the current call safe; a helper that calls `logic`/`ui` input preparation explicitly is clearer and should be used for new scenarios. | +| `Harness` tests | all `build_ui_state` closures | test helper (recommended) | Add a helper matching native/web order: raw input hook, then logic/UI inside each pass; hidden tests call logic only. Assert side-effect outcomes rather than one hook call; no broad test rewrite is required. | + +### 6.4 Screenshot callback correlation + +Use the shared craft-ui design selected after review: a weak callback to a +cancelable, request-owned completion slot. Each admitted request owns its token, +reply/path, monotonic deadline, settle state and slot. The callback holds only a +weak slot reference; timeout/cancel/close drops the live slot so a late callback +releases its image without a file write or second reply. The live slot owns a +viewport wakeup handle; release its lock before waking the context/viewport. Never +strongly capture the Context in a callback queued inside that Context. + +The eleven craft-ui consumers need CL-1 and the frozen CL-2F at this stage; the +DesignCraft/GridCraft pilot instead uses the provisional CL-2 API, and its adapters +must precede CL-2F. DeckCraft, FigCraft and ArtCraft Native use small +contract-compatible local adapters, and `craft-ai-integrations/ai-egui` has no +capture path, so the earlier "thirteen craft-ui consumers" shorthand is +superseded. SoundCraft is one of the eleven and keeps its own request IDs, paths, +settle delays, deadlines and replies outside the helper. + +Admission is explicit and checked before a capture is issued. The shared helper's +default is **four outstanding captures, 128 MiB per image and 256 MiB aggregate**; +an application may choose stricter caps. SoundCraft keeps its own **8-request +pending queue**, **4 in-flight captures**, **64 MiB per captured image** and **192 MiB +aggregate encoding peak** (the image plus the raw/PNG/base64 copies built while encoding); +a further request is rejected with `"screenshot queue full"` and +writes no file. Before a request is issued, reserve a checked +`width_px * height_px * 4` from the capture-time surface/geometry estimate clamped +by `max_texture_side`, with overflow and aggregate checks; it is not a +post-completion guess. A request that cannot be reserved is rejected with +`"screenshot too large"`. + +Bind the known issuance geometry and viewport/document generation (epoch) to the +request: viewport points, `pixels_per_point`, zoom and the surface-size estimate. +Revalidate that identity at issuance and completion rather than comparing current +settings. Any geometry/DPI/zoom change before the image is saved cancels and +retries within the deadline — even if the values change back before the save, +because the pixels were rendered for the earlier geometry. A second mismatch fails +with `"screenshot stale"` and never associates old pixels with new state. A request +that misses its deadline returns `"screenshot timed out"`. These errors travel +through the existing control-protocol error field; success still produces exactly +one reply and at most one file. + +E2 `surface_fit` can render a smaller surface than the window +(`egui-wgpu/src/winit.rs:45,446`) and its `render_scale` is private (`:25`), so +returned image dimensions need not equal viewport points × `pixels_per_point`. +Derive the effective scale from the returned image dimensions plus the +capture-time logical viewport metadata, test a surface-reduced window, and accept +that capture instead of rejecting it as stale. + +The callback delivers only pixels. The application owns the strong completion slot +and waker; the callback holds a `Weak` reference, releases the lock before waking, +and does not wake itself. `request_screenshot` sends the viewport command that +requests the paint; the callback itself requests no frame, and a post-completion +wake is only for collection/reply. A screenshot that is queued but not yet issued +— including a hidden request that is settling — needs an explicit +`RequestPaintWhileHidden` from `logic` so the request can reach the UI phase. +Encoding, file IO and replies happen after collection without holding the slot +lock. Keep a monotonic deadline if no frame is presented. No unbounded mpsc queue +is the default design; a bounded nonblocking channel is a measured fallback only if +the shared slot design fails a demonstrated requirement. + +kittest completes every screenshot callback from **one** rendered image +(`egui_kittest/src/lib.rs:914-942` collects all viewport callbacks and completes +each with the same image), so it can test single-viewport request/cancel/lifetime +behavior only. Cross-viewport pixel correlation needs native two-window evidence +or a custom per-viewport renderer that renders each viewport separately. + +Test 0, 1, N and N+1 requests; two large concurrent images; a resize race; forced +discard; cancellation; timeout; out-of-order completion; shutdown; hidden wakeup; +and retention returning to zero after drain. Each succeeds once or returns one of +the defined errors. A late callback cannot create a file or send a second reply. +Synthetic hidden input must visibly take effect on the requested UI paint or return +an explicit failure. + +### 6.5 Lifecycle regressions and deliberate failure probes + +- Complete a two-pass frame, then run multiple hidden ticks through the same public + `logic` method. Verify autosave, control replies, bounce and transport continue; + assert the actual pass index behavior rather than inventing a stale nonzero index. +- Drop one audio/session file through a real UI phase, then run hidden ticks and a + restore. It imports exactly once. Repeat with a forced hidden UI paint + (`RequestPaintWhileHidden`): the drop must be handled on that paint and not + replayed afterwards. Repeat again on the very first frame, while fonts are not + ready and `ui` returns before layout; the drop must survive in the one-shot queue + and import once on the first laid-out pass. Gating on `visible()` must make the + forced-hidden case fail; leaving the raw list uncleared must make the repeated-tick + case fail; reading drops only after layout without pre-layout capture must make + the first-frame case fail. +- Queue a command and force a discard. Both native-shaped and web-shaped drivers + produce one mutation, one reply and one undo transaction. Deliberately replay the + same consumed command to prove detection; merely moving a drained queue to UI is + not guaranteed to duplicate anything and is not a valid mutation by itself. +- Advance an injected monotonic clock while egui input time remains unchanged. + Hidden simulation advances by that elapsed interval; an extra layout pass cannot + apply the same interval twice. Restoring the stale input-time clock must fail. +- Exercise Touch, Latch and TouchLatch across pointer release, focus loss, minimize, + hidden playback and restore. Check exact automation points and merge boundaries. +- Request a hidden UI paint with synthetic input and capture. Current input executes + once even though `visible()` is false; ordinary logic-only ticks cannot replay it. +- Complete a capture after timeout/cancel/shutdown: no file, no second reply and no + retained image. Use a deliberately misrouted request slot to prove the pixel and + reply assertions detect cross-request delivery. + +## 7. Panels, docking, IDs, viewports and geometry + +- Keep panel order: `menus::menu_bar` (`menus.rs:224`) before the Edit/Mix content; Edit's + toolbar/status/lower-tabs/universe panels at `edit_window.rs:109-113`; the `DockArea` child at + `docking.rs:410-434`; all floating windows/areas after the main content (`lib.rs:1254-1265`). + `Panel` constructors accept `impl AsIdSalt` in 0.37; keep stable string salts and do not rename + them during the mechanical migration. +- `DockArea::new(egui::Id::new(("soundcraft-docking", edit)))` and + `cancel_drag::(ctx, egui::Id::new(("soundcraft-docking", edit)))` at `docking.rs:332,410` + must change together to `Id::unique`. A mismatch leaves a drag active after a workspace + replacement; `tests/docking_workflows.rs` and craft-ui's cancellation tests are the regression. +- The custom Tracks/Groups scrollbar (`panels.rs:155-185`) must use + `out.state.clamped_offset().y` for drawing and `ScrollArea::vertical_scroll_offset` for a + pending target. Do not write the private `State.offset`. Because the target is applied next + frame, `tests/tracks_scroll_bar.rs` needs a one-step settle after a page click; assert the + offset is not overwritten by a wheel event in the intervening frame. +- `soundcraft-midi-body` temporary data (`docking.rs:429`, tests `pro_tools_keys.rs:216`, + `midi_panel.rs:16`, `new_midi_clip.rs:133`) uses `Id::new`; convert all producers and readers to + `Id::unique` with the same string. Do not leave a stale reader. +- Viewports: `mix_window::show_separate` gives the child its own `&mut Ui`; do not call + `root_ui` there. Preserve `focus_mix`, close-request handling, and `separate_closed`. In headless + tests `Context::embed_viewports` is true by default; native eframe sets it false. Headless tests + can assert state transitions and no panic; native X11/Wayland/Windows evidence is required for + real popout focus/close, mixed DPI, and parent-hidden behaviour. Upstream #8748 is + **closed and unmerged**, not 0.37 behavior. The current Mix window uses immediate + viewports (`mix_window.rs:25-38`), so it does not inherit the deferred-child fix. + Keep the immediate-viewport limitation explicit. If the Mix window later moves to + `show_viewport_deferred`, require the fork's tested hidden-parent/deferred-child + fix plus open/update/close under an overdue or occluded parent before claiming it. +- Geometry/hit assertions: at 1600x1000 and 1400x900, with 1x/1.5x/2x, assert the menu bar height, + timeline rect, track row heights, clips panel width, mix strip/narrow strip widths, dock tab + rect, and the `ui.inspect` `edit_layout` values. Existing tests already derive many of these + from painted shapes; retain that style. +- Clip/nesting assertions: docking bodies must clip to their panel rect intersected with the + parent clip (`craft-ui` `surface.rs` uses `set_clip_rect`), and `minimum_body` must not paint + outside its scroll viewport. Add a shape-visibility assertion for a floating panel overlapping + the canvas and for the MIDI body at its minimum size. +- Persistence: `UiState`/`Workspace` remain the source of truth for layout, not egui ids. Test + float/dock/close/reopen, reload, hidden panels and narrow Mix after the craft-ui update. + +## 8. Input, IME, clipboard, PRIMARY and scrolling + +- Enable `eframe/clipboard` on native. This is a real behavioural restoration, not a cosmetic + feature: 0.36's eframe target dependency forced `egui-winit/clipboard` + (`eframe 0.36.2 Cargo.toml` native dependency), while 0.37 exposes it as an optional + `clipboard` feature. Without it, copy/paste of session names and text fields uses egui's + in-app simulated clipboard. +- Test copy/paste from every text surface: session name, track/bus/marker/clip rename, Spot + fields, search fields and plugin/bus search. Native external paste and middle-click PRIMARY + require a real X11/Wayland session; headless tests only establish event routing. +- egui 0.37 has `Event::MiddleClickPaste` and `MouseWheelSource`. E4/W15 replaces smithay with + winit's **regular** Wayland clipboard over the **bounded async W15 transfer API**: a total + deadline from admission (2 s default, never reset per poll), a cumulative 16 MiB byte cap, at + most 4 concurrent reads, 2 writer threads and queue depth 8, cancellation, stale-result + discard and a live owner-specific completion waker. The transfer handle owns result/cancel/wake + state and cancels on drop; no unmanaged thread is spawned per request. A bounded synchronous + compatibility read may remain but must not run on the egui event path. PRIMARY is + separate: it requires winit W15P plus egui E4/P2b, on the existing connection/per-seat + lifecycle, with one regular + `wl_data_device` per seat; a selection claim validates the triggering serial and seat + synchronously while fresh, and only serving is asynchronous. A build with only W15/E4 has no + native PRIMARY path (the candidate falls back to arboard/smithay). PRIMARY acceptance runs on + wlroots/Hyprland, KWin and Mutter plus X11, and covers **keyboard- and pointer-triggered** + selection serials and freshness per seat, owner/source/seat changes, no arbitrary first-seat + fallback, empty/absent offers, self-paste, and slow/canceled/trickling/oversize transfers. + SoundCraft has no password field today, so password exclusion is a fork-level requirement + rather than an app workflow; the unchanged-selection non-reclaim rule still applies to every + app selection. Record each compositor's capability rather than assuming parity. +- `ui.scroll` (`control.rs:187-197`) must set `source` (`Wheel` for line units, `Trackpad` for + point units). Preserve `TouchPhase::Start/Move/End` and test that 0.37 kinetic/rubber-band + behaviour does not turn a synthetic paging action into an unbounded scroll. For exact offset + tests, set the test style's `spacing.scroll.kinetic.decay_time` to 0 (or assert direction and + bounds instead of exact pixels). +- TextEdit click-away: upstream #8763 reports that a click outside a focused field collapses its + selection before the clicked button runs; proposed PR #8764 is **open/unmerged** (head + `6eb460a2a6d537d312a80579cb801895509deaed`), not released behavior. The suite fork owns the + selected P7c fix with pointer and keyboard regressions. SoundCraft has inline rename fields + (`panels.rs:411`, `dialogs.rs` path/name fields, `spot.rs`) and buttons beside them. Require + the fork fix; permit an app-local workaround only with preserved selected range and undo + semantics plus an explicit removal condition. Test select-then-click on a neighboring button + through both pointer and keyboard paths. +- IME/E21/W27: test preedit, commit, replacement, cancellation and click-away in every rename path. + Incoming deletion/commit is only half the contract. The accepted pin must satisfy the complete + shared egui/winit **E21b/P7b** plus W27 lifecycle contract, not a weaker subset: an + owner/context generation identity that is present even when surrounding text is + **Unavailable**; a distinct **Available-empty** state sent explicitly as + `set_surrounding_text("", 0, 0)` plus commit, while Unavailable omits the request after a + committed clear boundary; a bounded <=4000-byte payload with UTF-8 byte cursor/anchor, + preedit excluded and no full-document allocation for this new payload (the existing TextEdit + change-detection/undo clone is recorded separately); clearing cached state and committing the + disable/enable boundary on owner or available/unavailable transitions, since changing purpose + or sending empty text is not a clear; cancelling pending preedit/commit/delete on Leave, + disable, reset and seat removal; the final completed pass's `PlatformOutput` as authoritative + **including `None`**, not `ime.or(self.ime)` accumulation; and a current-epoch `Done` with a + mismatched serial applying incoming edits **once** while deferring outgoing state until a + matching serial, with obsolete owner/epoch batches invalidated. SoundCraft's fields are + built-in `TextEdit`s and inherit the producer; it has no custom document editor to adapt. + Test outgoing text immediately after delete+commit and deletion-only batches, preedit + exclusion, supplementary/combining/RTL text, field and two-window switches, focus + loss/reset/late serial, current-epoch serial mismatch versus stale epoch, + password/no-context clearing, and initially-empty/available-empty/unavailable transitions. + Without this complete contract the "updated surrounding text" row is an unresolved gate, not + a pass. The locked fork references `unicode-bidi 0.3.18` (Unicode 16) and + `unicode-segmentation 1.13.3` (Unicode 17); conformance vectors must be pinned to those data + versions. A similar Mozilla report (bug 2080478) is motivation, not proof of a SoundCraft + defect. +- RTL/CJK names: the fork's E5/E12–E16 shape and reorder RTL text; SoundCraft's own layout remains + LTR. Test Arabic/Hebrew session names and Korean/Japanese names using the pinned fixtures from + §9.2 (Noto Sans CJK SC covers Hangul; BIZ UDPGothic covers Japanese), not a nondeterministic + system fallback. Include combining marks and emoji clusters. Do not add an app-wide + `right_to_left` style in this migration; the product UI is LTR and the fork's Style mirroring + is not enabled. +- Non-Latin keyboard layouts: E19/W25. Test a Russian/Arabic/Hebrew layout on + Windows and Linux: letter shortcuts must not fire from a non-Latin character; Ctrl/Cmd shortcuts + still work. If P1 only, record this as an unresolved gap, not a pass. + +## 9. Fonts, glyphs, accessibility and theme + +### 9.1 Font feature decision + +0.36's default fonts included emoji faces; 0.37 makes `default_fonts` opt-in and moves emoji +fonts behind `monochrome_emoji_fonts` (while eframe can add a system color provider). SoundCraft's +loader at `fonts.rs:109-139` starts from `FontDefinitions::default()`, prepends a system regular/ +bold and Hangul fallback, and assumes bundled defaults exist. Therefore: + +- Enable `default_fonts` and `monochrome_emoji_fonts` for the `egui` package used by `ui-egui` + and the native app. `eframe/default_fonts` covers the binary, but not an isolated + `cargo test -p soundcraft-ui-egui` graph. `egui` 0.37 declares `default = []`; + `egui_kittest`'s normal `[dependencies] egui` is `default-features = false`, and only + kittest's OWN `[dev-dependencies]` enable `default_fonts`/`monochrome_emoji_fonts`. A + dependency's dev-dependencies do not propagate, so kittest supplies no fonts to SoundCraft's + tests. Prove this per root with `cargo tree -e features` for the production binary, the + SoundCraft UI test target and (separately, out of scope) kittest's own test root; treat the + no-bundled-fonts prediction as a resolution claim until the command runs. +- Keep `eframe/system_fonts` **off** for this migration. That avoids eframe's new provider, not + all font-discovery cost: SoundCraft's own loader still runs up to three `fc-match` calls + (regular, bold, Hangul) and reads the returned files on Linux/FreeBSD (`fonts.rs:53-79`, + `:109-139`). Measure `fc-match`, file reads and font install separately from egui work, cold + and warm cache, and do not attribute the retained cost to egui. Re-evaluate a system provider + only with startup measurements and coverage tests. +- Keep the runtime `SOUNDCRAFT_NO_SYSTEM_FONTS` escape hatch. It is useful for reproducible UI + tests and for minimal systems. +- Keep the app's `FontId` contract (`theme.rs:439-451`: proportional, monospace and the explicit + `"bold"` family) and its sizes; do not inherit a new 0.37 default text style for track headers, + mixer labels or counters. The bold family must retain the same fallback order after the + `FontDefinitions` feature change. +- `ATTRIBUTION.md` currently names Ubuntu-Light, Hack, Noto Emoji and emoji-icon-font as fonts + shipped inside egui. `monochrome_emoji_fonts` keeps that statement true; if the feature is not + enabled, the statement must be corrected. No new repository asset row is needed because these + fonts remain inside the `epaint_default_fonts` dependency; its license texts travel with it. + +### 9.2 Glyph coverage findings from the bundled font cmaps + +The following is source/asset inspection of +`egui/crates/epaint_default_fonts/fonts/*.ttf` (cmap scan), not a rendered test. It identifies +specific risks for `egui_kittest`'s new `MissingGlyphPolicy::Panic`: + +| Glyph / use | Anchor | Bundled coverage | Required action | +| --- | --- | --- | --- | +| `▸` U+25B8 in status text | `lib.rs:848,851,853,888`; docs text | Only Hack (monospace); proportional Ubuntu lacks it | Replace with `›`/`>` or draw it; add to a glyph test | +| `▾` U+25BE in menu labels | `panels.rs:477` (`Presets ▾`) | Only Hack (monospace); proportional Ubuntu lacks it | Replace with `›`/`v`/none, or draw the menu arrow; add to a glyph test | +| `✕` U+2715 close/delete buttons | `windows.rs:258,404`; `panels.rs:422` | emoji-icon-font, only with `monochrome_emoji_fonts` | Enable the feature; otherwise use `×` U+00D7 (Ubuntu has it) or a code-drawn close icon | +| `♯` U+266F sharp | `score_editor.rs:122` | None of the bundled fonts | Draw original sharp strokes in `icons.rs`/`score_editor.rs`, or bundle an OFL music font with an ATTRIBUTION row | +| `𝄞` U+1D11E treble clef | `score_editor.rs:67` | `emoji-icon-font` (present in its cmap) | Coverage exists when `monochrome_emoji_fonts` is enabled. Replacing it with a drawn original clef is a visual-quality choice, not a missing-glyph fix; assert whichever face is chosen | +| `⌘` U+2318 | `mix_window.rs:609` (mac tooltip) | `emoji-icon-font` (present in its cmap); mac system font also supplies it | Bundled coverage exists with `monochrome_emoji_fonts`. Keeping the mac-native glyph or drawing an original key is a visual-quality choice; in deterministic tests assert the bundled face. Do not globally allow missing glyphs | +| `📁`/`🔊`/`🔍`/`⬆` | `windows.rs:327,355`; `panels.rs:206` | Noto Emoji with `monochrome_emoji_fonts` | Enable the feature and test in every family | +| `♩` U+2669 | `panels.rs:352` | emoji-icon-font | Covered by `monochrome_emoji_fonts`; assert | +| `−`/`∞` | `mix_window.rs:1079,1088` | Ubuntu-Light | Covered; assert | + +Add a `fonts::tests` (or a kittest-only test) that checks every literal glyph used by UI fixtures +in `FontFamily::Proportional`, `FontFamily::Monospace` and `FontFamily::Name("bold")`, with +`MissingGlyphPolicy::Panic` and a bundled-font-only font set. Use a dedicated test binary or a +`context_setup` that installs `FontDefinitions::default()` so system fonts cannot make the test +pass by accident. Do not call `HarnessBuilder::allow_missing_glyphs()` for the main suite; use it +only in one explicitly named test that demonstrates the fallback is intentional. + +Separate the **bundled UI-glyph test** (`▸`, `▾`, `✕`, `♯`, `♩`, `𝄞`, `⌘`, `📁`/`🔊`/`🔍`/`⬆`) +from **document-text multilingual tests**. The latter use the pinned fixture set from the egui +plan §10.6; do not infer multilingual coverage from the bundled faces: + +- craft-fonts at `2481a8a324b986a45954e90045ed1f8f2efaaf3c`, each OFL-1.1 with its + adjacent `OFL.txt`: + - Noto Sans Arabic `63111b5b2e074dd48cc67692e0a2726d86ee94c1c37fe8598257b7b4e87e869e` + - Noto Sans Thai `5a1c559bb539583c8a1fd99d1c5b9491e5e14478c9cd2bd0970d5c3096cc9ef8` + - Noto Sans CJK SC `2c76254f6fc379fddfce0a7e84fb5385bb135d3e399294f6eeb6680d0365b74b` + - BIZ UDPGothic Japanese `258d7156c165f2ff774b6efee637c22c3b950de0d8a10e501137061bc8085d01` +- Noto Sans Hebrew variable font from google/fonts at + `8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5`, SHA256 + `7ef36a2c3593758cdb622e1bdef4f84523e92fbc3ccc667438dd80ff54c2de88`, weight 400 / + width 100, with its `OFL.txt`. +- Noto Color Emoji v2.047 from googlefonts/noto-emoji at + `22e564626297b4df0a40570ad81d6c05cc7c38bd`, SHA256 + `39ee3c587e10e89669b9ff32703261d10d5f9c4dd5ad147b6b5a1c5200591817`, OFL-1.1. + +Fetch them into the task's explicit fixture directory, keep them out of app source unless the +asset policy adds an `ATTRIBUTION.md` row, and record file hashes and licenses in the evidence +manifest. A missing fixture or unsupported glyph must fail the row, never skip it silently. + +### 9.3 Accessibility + +`egui_kittest` now checks every visible input for a name after a settled step +(`accessibility.rs:35-70`); the check treats a label or non-empty placeholder as a name. The app +has many custom-painted controls and bare fields: + +- `text_toggle` (`widgets.rs:117-125`) draws a key and only calls `on_hover_text`; a tooltip is not + an AccessKit name. Add `resp.widget_info(|| WidgetInfo::selected(Role::Button, enabled, on, tip))`. +- `fader`/`pan_knob`/`surround_panner`/`multi_meter` (`widgets.rs`) and custom track/timeline + controls need `WidgetInfo` with `Role::Slider`/`Button`/`CheckBox`, label and value; output-only + meters should not be advertised as inputs. +- `TextEdit`s with only an adjacent `ui.label` need `Response::labelled_by` or + `Ui::label_inputs_by`; hint text is acceptable only when it is a real field name (search). +- `DragValue`/`Slider` instances without `.text(...)` in `ops_windows.rs`, `windows.rs`, + `dialogs.rs`, `toolbar.rs` need a label. +- Group rows use `Role::CheckBox`; track rows use `Role::Button` with toggled state, matching + `tests/side_lists_access.rs`. The role migration must keep those exact roles. +- Hidden controls must disappear from the tree; 0.37's #8621 makes invisible widgets hidden. Do not + add placeholder nodes. +- Top-level `Window`/`Area`/`Popup` nodes need a stable accessible name where egui does not derive + a useful one (0.37 added `Area::role`/`accessible_name` and improved popup/menu trees). Keep + modal/dialog focus inside the modal and restore it to the invoking control on close. + +`Harness::unnamed_widgets()` is a useful supplementary assertion, but it is not sufficient: +it walks only nodes that exist (`egui_kittest/src/accessibility.rs:14-21`), filters by +`is_input`, and a control that never calls `Response::widget_info` has no node at all. Roles +excluded from `is_input`, including `Role::Unknown`, also pass. A sweep can therefore be green +while custom buttons and faders are unreachable. + +For every top-level window (Edit, Mix, MIDI Editor, Score, Video, Workspace, Appearance, About, +Session Info, Audio Health, Bounce modal, Save Changes, Spot and each popup), define an +**explicit expected node inventory**: role, accessible name, selected/checked, disabled and +value where applicable, plus absence where a control must not exist. Look nodes up with +`get_by_role`/`get_by_role_and_label` and assert counts so an absent node fails. Cover every +custom-painted control, not just the ones a workflow clicks. `unnamed_widgets().is_empty()` runs +as an additional check. + +Mutation controls: (a) remove a `widget_info` call entirely; (b) set a role to `Role::Unknown`; +(c) remove an accessible name; (d) hide a control and assert it disappears. Each must fail its +inventory test; remove the deliberate defect afterwards. Add a test that opens a popup and +checks the popup tree separately from the window tree. + +### 9.4 Theme and style + +`theme::apply` (`theme.rs:458-476`) overwrites `Visuals`, item spacing, button padding and +tooltip delay for both dark/light style branches and pins `ThemePreference::System` so later OS +appearance changes still arrive. Preserve this contract. The 0.37 default style is roomier +(buttons/corner radius); if `theme::apply` does not override every changed field, explicit +geometry tests will catch it. Do not accept a global visual change because the upstream default +changed. Re-run light/dark/system at 1x/1.5x/2x and both `system_theme: Some` / `None`; keep the +existing `theme_docking.rs` and `appearance.rs` assertions. + +## 10. Commands, undo, save/reopen and persistence + +These contracts are below the UI and must not change: + +- `SoundApp::run` (`lib.rs:418-453`) dispatches UI-layer commands via + `menus::run_ui_command`, then `Engine::execute`; `app.quit` sets `quit_requested`. +- `Engine::execute` pushes one undo snapshot per changed document; continuous gestures use + `execute_merged` (`mix.volume`, `mix.send_level`, automation pass). Every additional logic run + or UI pass must not create a second undo step. +- `session.save*`/`session.open*` remain engine/io operations; UI changes must not intercept them + or require a paint. +- `UiState` prefs (`window_size`, `window_maximized`, `ui_scale`, docking, recent sessions) are + saved by the host; hidden logic must still update the recent flag. + +Regression assertions: + +1. A two-pass frame triggered by a popup/menu executes one command and one undo step. +2. Fader/send drag still produces one merged undo step; a pass with no pointer input does not + close the merge prematurely. +3. Save → close → reopen preserves tracks, clips, fades, mixer levels, sends, automation, markers, + groups, busses and selection; `save_changes.rs`, `save_as_replace.rs` and `recovery.rs` stay + green and gain a richer fixture. +4. Close/reopen a floating dock panel and restore its workspace; `docking_workflows.rs` keeps + command ownership and transport commands. +5. Recovery after a crash/exit without Quit still offers the autosave; hidden logic must not + stop the autosaver. +6. `ui.screenshot`/`ui.inspect` control replies remain one request → one reply, including hidden + ticks and timeouts. +7. A mutation probe that makes `run` execute twice must fail the undo assertion; a probe that + moves autosave into `ui` must fail the hidden-autosave assertion. + +## 11. GPU, video, textures and rendering + +- Preserve `apps/soundcraft/src/graphics.rs:11-30`: Windows initializes only DX12 and defaults to + FXC because GL initialization and missing DXC/DXIL break real machines. The 0.37 family keeps + wgpu 30.0.1 and winit 0.30.13; do not change these defaults as part of the egui bump. +- E2+E3 (surface fit and error-scoped configure/renderer creation) should be required in the + fork/release pin if SoundCraft keeps wgpu; verify resize, maximize, DPI changes, and device/ + surface failure paths return errors instead of panicking. +- `VideoPool` (`video_track.rs:281-430`) uses `load_texture("video-frame", ...)`, + per-thumbnail names, and `TextureHandle::set` for the current frame. Tests set + `max_texture_side = Some(8192)`; keep that and add a test for an oversized frame being rejected/ + clamped without a debug assertion. +- Screenshot callback images are CPU `Arc`; do not upload them as textures. Encode PNG + and write files only in `collect_screenshots` on the UI thread. +- `egui_kittest` default `RendererOptions::PREDICTABLE` gives deterministic test images; native + eframe uses its own defaults. Record the renderer/backend/options for every screenshot and + performance run. Software-rendered Windows and a real GPU are separate evidence rows. +- 0.37 upstream reports that need reproduction, not automatic acceptance: + - #8731: plain-text tessellation regression from the color-glyph check. SoundCraft is text-heavy + (menus, track headers, mixer labels). Measure text tessellation in the frame benchmark; if + confirmed, adopt a row-level fast path in the fork with mixed/color-row tests. Do not use a + clamp-only or output-changing shortcut. + - #8736: DX12/FXC shader compile cost in `egui-wgpu` 0.37. SoundCraft explicitly selects FXC. + Measure renderer construction and first frame separately from the rest of startup. A + float/`select` wrap implementation or a pipeline-overridable constant is acceptable only with + Repeat/MirroredRepeat/clamp pixel tests; a clamp-only change is not equivalent. + - #8512: system-font discovery/startup cost. Keeping `system_fonts` off avoids eframe's provider + only; SoundCraft's own loader still performs up to three `fc-match` calls and file reads. + Measure those, and font install, separately from egui startup, cold and warm cache. + - #8763/#8764: TextEdit click-away selection. #8764 is open/unmerged; the fork owns the fix. + Test the inline rename and Spot paths through pointer and keyboard (§8). + - #8748: closed and unmerged, not 0.37 behavior. Current SoundCraft uses immediate viewports; + keep the limitation explicit and revisit only if the Mix window moves to deferred viewports. + - #8657 and #8577: Wayland clipboard/PRIMARY and bidi. PRIMARY means W15P + E4/P2b, not W15 + alone; test native compositor workflows, not just compilation. + +## 12. Native, web and platform workflows + +### 12.1 Native (Linux, Windows, macOS, FreeBSD) + +- Linux X11 and Wayland with a named compositor: launch with `--demo --control 0 --no-audio`, + inspect and screenshot via the control channel, exercise track rename, clip import, mix + fader/send, docking drag/float/close/reopen, MIDI/Score, save/reopen, and copy/paste. + The planning server has no `DISPLAY`/`WAYLAND_DISPLAY` session; this can only be run in a + managed native environment, and must not be reported as passed from headless renders. + Read-only device inspection found an i915 render node (`/dev/dri/renderD128`) but no display + session; that is availability evidence only, not a qualified Vulkan/OpenGL compositor result. +- Wayland PRIMARY matrix: wlroots/Hyprland, KWin and Mutter, plus X11 regular clipboard. With + W15P + E4/P2b installed, verify keyboard- and pointer-triggered selection serials and freshness + per seat, owner/source/seat changes, no arbitrary first-seat fallback, empty/absent offers, + self-paste, unchanged-selection non-reclaim, and slow/canceled/trickling/oversize transfers. + Record each compositor's capability/version and `WAYLAND_DEBUG=1` protocol trace showing one + regular `wl_data_device` per seat. A wlroots-only pass is not the matrix. +- Two-viewport screenshot correlation: run native two-window evidence or a custom per-viewport + renderer. kittest renders one image for all callbacks and cannot prove this. +- Windows: use a uniquely owned clone from `/home/codex/work/VMs/craft-windows` and its + `ACCESS.txt`/`READY.json`; build with the reviewed `Build-DesignCraft`-style equivalent for + SoundCraft or `cargo build --release --locked -p soundcraft`; verify FXC/DX12, mixed DPI, + native file picker, save/reopen and screenshots. The baseline uses the software renderer, so + physical-GPU/audio claims are separate and unresolved. + Capacity gate before allocation: the default 128 GiB full clone does not fit the recorded + allocator snapshot (about 23.6 GiB and 37.7 GiB allocatable on the two drives, not combinable). + Inspect `task-storage.py status` first. Use a checksum-verified overlay clone with a reservation + covering expected guest growth (at least the documented 10 GiB minimum) if it fits, or record + the Windows row as blocked by capacity. Do not lower the reservation to pass the guard. +- macOS: native menu (`muda`), traffic lights, ⌘ shortcuts, Metal/wgpu, IME commit/deletion, + file picker and termination need real macOS evidence. Cross-compilation is not acceptance. + If unavailable, this is an explicit rollout blocker, not a pass. +- FreeBSD: existing nightly workflow; compile/packaging can be checked, but native UI/IME/audio + acceptance needs an actual FreeBSD environment (not provided here). +- Native control channel: use `--control 0` and `SOUNDCRAFT_NO_PREFS`/a scratch + `SOUNDCRAFT_CONFIG_DIR` for isolation; never arm/monitor/record in an automated UI run + (`docs/control-protocol.md:22-25`). + +### 12.2 Web (wasm) + +- `cargo check --target wasm32-unknown-unknown -p soundcraft-ui-egui -p soundcraft-web` + (part of `cargo xtask wasm`) is a compile gate only. +- `cd apps/soundcraft-web && trunk build --release` produces `dist/web`; use + `packaging/web/package.sh` for the zip and relative-path checks. Record raw and compressed wasm + size, first-interactive time, WebGPU and `?webgl` fallback, `?empty` and demo sessions. +- Browser workflows: open demo, use the file picker and drag/drop, import audio/MIDI, save a + `.scraftz` download, play after a user gesture, resize, and inspect the page. Browser screenshots + and console logs are evidence; a native screenshot is not browser evidence. +- `rfd` browser picker and `DroppedFileHandle::bytes_async` are asynchronous. The raw drop list + is consumed in the known `ui` phase; completed byte reads are a separate consumed queue and may + be handled in `logic`. Test that a picker/drop is handled once and not repeated on subsequent + frames, including a forced hidden UI paint. The browser has no control channel, so browser UI + testing needs a page driver (Playwright/Chromium or equivalent), not the TCP protocol. + +## 13. Test and performance plan + +### 13.1 Required gates + +Run every new build/test through the managed server workflow (`maintenance/task.py run` / +`status` / `finish`) with a unique task, source and evidence path. Do not run directly or reuse +another worker's target. Proposed commands (implementation stage): + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo test --workspace --locked +cargo test -p soundcraft-ui-egui --locked +cargo test -p soundcraft-engine --locked +cargo test --workspace --all-targets --locked +cargo xtask assets +cargo xtask layers +cargo xtask wasm +cargo check --locked --target wasm32-unknown-unknown -p soundcraft-ui-egui -p soundcraft-web +cargo metadata --locked --format-version 1 +cargo tree -d --locked +cargo tree -e features -p soundcraft --locked +``` + +On Windows add `cargo build --release --locked -p soundcraft -p soundcraft-cli`, the VM GUI +workflow, and the FXC startup capture. On Linux add X11 and Wayland native runs. Add +`trunk build --release` and a browser workflow. Selected optional features to check explicitly: +native `eframe/clipboard`, `accesskit`, `wgpu`, `x11`, `wayland`; web `wgpu` with WebGL2 fallback; +`egui_kittest/wgpu,snapshot`; fork-only `E17/E18` only if the app starts using a UI language. + +`cargo xtask ci` is the repository gate but does not add `--locked` to each step; the implementation +PR must add or separately run the lock/graph checks above and record actual selected test counts, +not just exit codes. + +### 13.2 Regression assertions + +| Risk | Observable assertion | +| --- | --- | +| Root `Ui`/multi-pass | One command, one undo step in a forced two-pass frame; no `root_ui` recursion panic | +| Hidden logic | Autosave, control reply, player position and bounce progress continue; ordinary and forced-hidden paints each import a dropped file once and later hidden ticks do not replay it; paste does not repeat; screenshot timeout is clean | +| Docking | Float/dock/close/reopen/reload keeps panel identity, permissions, floating rect and command ownership | +| Panels/geometry | Timeline, track rows, strips, dock tabs and menu bar rects match the baseline within the stated tolerance | +| Accessibility | Explicit expected node inventory per window/popup with role/name/selected/disabled/value and count assertions; `unnamed_widgets().is_empty()` only as an extra check; missing `widget_info` and `Role::Unknown` mutations fail; hidden controls absent | +| Fonts/glyphs | Bundled UI glyphs have coverage in every family; pinned craft-fonts/Hebrew/Noto Color Emoji fixtures render document text; no tofu; no global `allow_missing_glyphs` | +| Text/IME | Preedit/commit/cancel/replacement, click-away selection, grapheme/UTF-8 boundaries, outgoing surrounding text after delete+commit via E21b/P7b with owner present when unavailable, available-empty versus unavailable, transition clear/disable-enable, final-pass `None`, serial-mismatch apply-once/defer, stale-epoch invalidation; focus/password/reset clearing | +| Scroll/input | Wheel vs trackpad source/phase, momentum/rubber-band policy, zoom anchor, shift-pan, gutter page | +| Clipboard/PRIMARY | Bounded regular clipboard transfer (deadline/byte cap/workers/cancel/stale discard), keyboard- and pointer-triggered PRIMARY serials, owner/seat changes, no first-seat fallback, self-paste | +| Screenshot | Two-viewport/request correlation in native/custom per-viewport evidence, exactly one reply/file, timeout/late callback inert, checked pre-issue reservation, geometry/epoch binding with cancel-on-any-change, surface-fit scale derivation, shared 4/128 MiB/256 MiB defaults with SoundCraft's stricter 4/64 MiB per image and 192 MiB encoding-peak caps, drain-to-zero | +| Commands/undo | Fader/send drag one merged step; command via menu/control/CLI same id and one mutation | +| Save/reopen | Round-trip document meaning, selection/workspace and autosave recovery; no dirty flag loss | +| GPU/video | Video texture update/resize, oversize rejection, surface errors return errors, no panic | +| Native/web | Native focus/IME/picker/clipboard/PRIMARY and browser picker/download/playback are actually exercised | + +### 13.3 UI screenshot and pixel policy + +- Capture baseline images from `e85a4801e9029c9a1f5ce536b11be65aea832ed8` and candidate images at the same fixture, window size + (1600x1000 and 1400x900), DPI (1x/1.5x/2x), theme (dark/light/system), fonts (bundled-only + test set) and renderer (`egui_kittest` wgpu with `PREDICTABLE`, or the named native backend). + Store them under the task evidence directory, not as unreviewed committed goldens. +- Prefer semantics/geometry over images. Exact RGBA equality is required for flat palette fills, + custom control faces, text colors and shape rects, as existing `mix_instrument_contrast.rs` and + `shared_controls.rs` do. +- For full-window raster comparisons, use a documented mask: allow per-channel delta ≤3 in at + most 0.5% of pixels, no delta >32 outside reviewed regions, and zero missing-glyph/tofu pixels. + Any difference in panel/window geometry, focus ring, scroll thumb, or text position is a + failure until reviewed. The only expected style changes are the reviewed 0.37 default + button/corner differences; restore SoundCraft's explicit values where the product should not + change. +- Never bulk-refresh a golden after seeing a failure. A changed image is accepted only with a + written reason and a corresponding geometry/semantic assertion. +- Mutation controls: replace a `Role` with `Unknown`, remove the whole `widget_info` call, remove + an accessible name, shift a panel rect, drop a screenshot callback token, and replay a consumed + command; each must fail its intended test. Remove the defect after proving detection. +- kittest renders one image for every viewport screenshot callback + (`egui_kittest/src/lib.rs:914-942`). Never use it to assert two-viewport pixel correlation or + swapped-slot detection; those require a native two-window run or a custom per-viewport + renderer. + +### 13.4 Performance protocol + +Record baselines before production edits. Four variants are distinct, and the report must not +collapse them into "three": + +1. **Historical coherent baseline**: + `e85a4801e9029c9a1f5ce536b11be65aea832ed8` (0.36.2 egui family, its own non-UI dependency set). +2. **Current-engine / 0.36-UI control**: current non-UI dependencies (the 11-package bump: + `symphonia` 0.5→0.6, `cpal` 0.16→0.18, `zip` 9.0.1→9.0.5, `libloading` 0.8.9→0.9.0, + `muda` 0.19.2→0.21.2 and the rest) with the egui family reverted to 0.36.2 and `craft-ui` + back at `5c874d51eeb3de70e735c8ce87a59a7dedd47675`. Build it only if it actually compiles; + if it does not, record it as an unavailable attribution control, not a pass or a fabricated + result. +3. **Equivalent stock-0.37 control**: stock `841a328ba2dd8f6996beb7189ebb7fc00e668a2a`, with the same operation expressed in a + pinned minimal adapter/harness where fork-only APIs are involved. Record any semantic + differences. +4. **Fork candidate** at the exact reviewed P8/winit lock/SHA. + +The stock-0.37 equivalent control is required, not optional: report it first in every +table and evaluate any regression against both the paired baseline and this control. +Report non-UI dependency deltas separately from egui-frame work. A `cpal`/`symphonia` startup or +audio-underrun difference must not be attributed to egui or the fork. Record one lockfile, +source SHA and feature list per variant, and note where an adapter is not bit-equivalent. + +Use identical compiler, profile, features, font set, fixture, renderer/backend, driver, DPI and +viewport. Separate software renderer and real-GPU results, and keep **native eframe** timings in +a different row from **synthetic kittest/Harness** timings: they measure different event loops, +paint scheduling and input paths and are not interchangeable. Do not mix any of these rows. + +Workloads: + +1. **Startup**: 20 fresh processes, demo session, `--no-audio`, time to first painted frame; on + Windows also time `egui_wgpu::Renderer::new`/pipeline creation separately (FXC, #8736) and + font install/`fc-match` separately (#8512). +2. **Idle**: 60 s with no input; frames/repaints, CPU%, median frame interval, idle `request_repaint` + count. Any new busy loop or audio underrun is a failure. +3. **Text/timeline**: 1,600x1,000, demo + 60 tracks, 5 clips each, 1,000 frames of horizontal pan + and vertical scroll; `smooth_scroll_delta`, waveform shapes, text tessellation, frame p50/p95/ + p99. +4. **Mix**: 64 tracks, meters live (no-audio simulation and, separately, player), 60 Hz, 1,000 + frames; strip layout and meter paint. +5. **Windows/dialogs**: open/close MIDI, Score, Video, docking float/resize, About/Preferences; + operation latency and frames to settle. +6. **Video**: one 1080p frame decode + texture update cycle, repeated; report upload bytes and + frame time. +7. **Screenshots**: request-to-callback-and-file latency, pending queue depth, peak image bytes. +8. **Web**: wasm raw/compressed size, first interactive, demo load and a fixed interaction script; + record browser/backend separately. + +Proposed measurement harness: `crates/ui-egui/examples/frame_bench.rs` (a dev example, not +a shipped binary), driven by the same demo fixture and `Harness` renderer; `ui_shot` remains the +screenshot tool. The harness must not be used as a production compatibility layer. + +Measurements: 10 warmups and at least 30 iterations for steady operations; at least 1,000 frames +for frame distributions (including p99); 20 fresh processes for startup. Report raw samples, +median/p95/p99 only where sample count supports it, peak RSS (`VmHWM` on Linux), allocation +count/bytes via a vetted dev-only profiler such as `dhat` or an external heap profiler, texture +set/upload counts and bytes from `FullOutput.textures_delta`, and idle CPU/repaint counts. Do not +add a local `#[global_allocator]`: a `GlobalAlloc` implementation needs `unsafe`, and the +workspace forbids it (`Cargo.toml:56-57`, applied to `ui-egui` via `[lints] workspace = true`). +Use paired baseline/candidate runs and alternate order; record host load and driver versions. + +Predefined stop thresholds, frozen before seeing candidate results: investigate a repeatable +>5% median, >10% p95 or >5% peak-RSS regression, >5% startup median or >25 ms, any new idle +busy-loop/audio underrun, or any loss of an existing stricter app budget; every relative change +must also exceed the measured run-to-run noise floor. For sub-millisecond operations, require the +relative increase also to exceed 0.05 ms or be corroborated by instructions/allocations. Report +p99 from ≥1,000 frames and investigate a repeatable p99 regression even though it has no separate +fixed threshold. Reject noisy comparisons as inconclusive and rerun; do not widen thresholds +after seeing a failure. A confirmed #8731/#8736 regression needs a reviewed fork fix or a +documented exception, not a changed golden. + +## 14. Reviewable implementation stages, DAG and rollback + +Dependency DAG: + +```text +P0 reviewed plans + SoundCraft baselines (e85a480..., current-engine/0.36-UI if it builds, + stock-0.37 control, fork) and preserved baseline executable/fixture + | + +-- approved published storytold egui P8 (E1-E21 + P2b + P7 + P7b/E21b + P7c) + +-- completed approved published storytold winit (W15P + bounded W15 transfer + + W27 lifecycle revision) + +-- reviewed craft-libs CL-1 (reconciled 3832c47 base) + | -> CL-2 capture prototype -> Design/Grid pilot adapters -> CL-2F API freeze + | + -> P1 atomic SoundCraft compatibility (root pins, lock, API/clipboard/fonts, + lifecycle, capture adapter against the frozen CL-2F API) + -> P1 rollback rehearsal + -> P2 deeper UI assurance (accessibility inventory, scroll, glyphs, IME) + -> P3 native/browser/performance acceptance + repeat rollback rehearsal +``` + +1. **P0 — plans and baselines.** Land all reviewed plans. Add + regression/measurement support on the historical coherent baseline + `e85a4801e9029c9a1f5ce536b11be65aea832ed8`; preserve the current mixed graph as a + separately identified pre-existing failure. Record exact full SHAs, features and + lockfiles for the four performance variants and raw baseline samples before + production changes. Preserve the baseline executable and a saved fixture for + rollback rehearsal. +2. **P1 — atomic compatibility.** Prerequisites are the complete approved, + reachable **storytold egui P8** and **completed storytold winit** revisions + (P2b over the bounded W15 transfer, P7, P7b/E21b, P7c; W15P, W15 transfer and + W27 lifecycle/serial state), the reviewed craft-libs **CL-1** revision built by + on the reconciled `3832c47` base (`8614e4a`/`1764bca` are the historical port hunks), + and the **frozen CL-2F** capture-helper API. P1 waits for CL-2F; it does not land + a provisional helper API. Then land all root family pins, lock changes, + API/clipboard/font adaptations, callback replacement, any incompatible craft-ui + bump and required lifecycle fixes together. A dependency-only or stock-first + intermediate release is not planned. P1 is blocked in full until every + prerequisite exists: a missing SHA or unpublished stage blocks all manifest + changes, not just one PRIMARY/IME/capture row, and no partial P1 is claimed. +3. **P1 rollback rehearsal.** In a separate managed allocation, restore the + previous coherent source/manifests/lockfiles/settings set at + `e85a4801e9029c9a1f5ce536b11be65aea832ed8`, run the preserved baseline + executable and reopen the saved fixture. Record the exact commands, identities + and result. Do not restore only an old lockfile and call that a rehearsal. +4. **P2 — deeper UI assurance.** Add the explicit accessibility node inventories, + remaining roles/names, scroll, glyph and **E21b outgoing-IME** tests and any + measured fixes. The P1 commit must already pass the required tests; this stage + expands cases rather than deferring broken behavior. +5. **P3 — native/browser/performance acceptance.** Qualify supported environments + (including the W15P/E4/P2b PRIMARY matrix and two-viewport native capture), + retain evidence, repeat the rollback rehearsal and perform a fresh adversarial + implementation review. Regressions require app fixes or a reviewed shared-fork + follow-up with downstream revalidation. + +The first compatibility PR is a single coherent dependency unit. Subsequent commits +are individually reviewable and buildable. Rollback restores the entire accepted +source/manifests/lock/helper set in a new revision; do not restore only an old lock, +drop the fork while keeping fork-only code, or reset published history. Preserve all +evidence and source work. No stage authorizes publication without fresh approval. + +## 15. Compatibility pin and lockfile acceptance details + +- Patch section is in the **root** `Cargo.toml`, not a dependency's manifest. +- Every selected egui family member and winit is at one immutable full SHA; no branch names, no + path dependencies, no machine-local source replacement. +- `Cargo.lock.version = 4` may remain; regenerate it through Cargo, do not hand-edit package + identities. Record old and new lockfile hashes. +- The shared libraries are consumed by reviewed git revision for this migration. A separate + crates.io publication or stock compatibility promise is not implied. SoundCraft must repeat + required root patches and never depend on machine-local path overrides. +- `cargo package`/`cargo publish` is not applicable to `soundcraft` (`publish = false`); still + inspect the generated `Cargo.lock` and metadata for accidental local paths. +- Dependabot must not silently re-split the family; add ignores or an all-family update rule. +- A clean clone with a cold cache must fetch every git revision and pass `--locked` without local + overrides. Recheck remote heads before merge; a moved branch is not a revision. + +## 16. Blocking unknowns and explicit acceptance criteria + +Blocking unknowns: + +1. **Exact reviewed `craft-libs` commit** after applying PR #199's intended hunks to current + main. The local reconciliation base is `3832c47ef68a0e6214f2c1e85f3526b6adcb686e`; + `b68de8c`/`ec8840a` are stale trees and must not be merged, and the historical + `8614e4a`/`1764bca` hunks on `4919088` are no longer the base. Until a reviewed reachable SHA + exists, P1 cannot pin it. +2. **Organization-owned egui/winit SHAs, including the required new work.** `aiongg/craft/0.37` + and `aiongg/winit craft/0.30.13-lang` are movable research inputs. P1 requires approved, + reachable `storytold` full SHAs for the complete **egui P8** set (E1–E21, **P2b**, **P7**, + **P7b/E21b**, **P7c**) and the **completed winit** (**W15P**, bounded W15 transfer, W27 + lifecycle). A missing SHA blocks all P1 manifest changes; these SHAs cannot be invented + before implementation and publication. +3. **CL-2F capture-helper API.** P1 sequences after the shared helper's approved freeze. The + inspected `4919088` tree had no `crates/ui/src/capture.rs`, and the current source imports + `craft_ui::capture::CaptureSlot` from the local craft-libs worktree. The reconciled `3832c47` + base must be confirmed to carry the agreed CL-2F API when the final pin is chosen; a + provisional API must not be pinned. +4. **Upstream #8731/#8736 disposition.** The fork does not yet contain either fix. SoundCraft's + text-heavy UI and explicit FXC choice make both likely relevant; a measured result and a + reviewed fork patch (or documented exception) are required before final performance acceptance. +5. **Native evidence environments.** No `DISPLAY`/Wayland session, no Windows clone allocated, no + macOS hardware and no physical-GPU/audio qualification exists in this planning run. The + Windows full-clone capacity gate is also unresolved. These rows remain unresolved until managed + evidence exists. +6. **Browser workflow.** Compilation to wasm is not a browser test; a Chromium/Playwright-style + driver, a running `trunk build --release` site and download/picker/audio checks must be + available or the browser row is unresolved. +7. **Exact count of accessibility failures and scroll/multi-pass failures** is unknown until the + candidate is built. The plan specifies the audit and tests; it does not claim a pass. + +Acceptance for the whole migration: + +- Exactly one immutable egui family and one winit revision in the accepted lock; no 0.36/0.37 + duplicate; reviewed craft-ui revision; no local/branch/path dependency. +- All repository gates plus the selected feature, native Windows/Linux, wasm compile and browser + workflows pass with recorded counts and evidence. +- The lifecycle tests prove no duplicate or lost control/autosave/transport/undo/screenshot work in + multi-pass and logic-only frames. +- UI geometry, screenshots, accessibility, fonts/glyphs, input/IME/clipboard, docking/viewport and + save/reopen assertions pass with mutation controls shown to detect their intended defects. +- Performance meets the predefined thresholds with paired, same-environment measurements across + the four distinct variants (historical coherent baseline, current-engine/0.36-UI control only + if it compiles, equivalent stock-0.37 control, fork), with non-UI dependency deltas and + native-versus-synthetic timings reported separately; any accepted regression is an explicit, + reviewed tradeoff. +- The complete egui P8 and completed winit (W15P, bounded W15 transfer, W27 lifecycle) are + present in the accepted pin; PRIMARY (keyboard and pointer serials), bounded clipboard + transfer, outgoing-IME lifecycle, two-viewport capture and rollback-rehearsal gates have real + evidence or remain explicit blockers. +- No publication occurs until the user has seen the exact commit range and validation and given + fresh approval. + +## 17. Evidence and limitations of this planning document + +- Source inspected: all root/nested manifests and the root lockfile; `crates/ui-egui` source and + tests; desktop/web/CLI entry points; build.rs; graphics/native-menu/config; xtask and workflow + files; selected upstream 0.37.0 and fork source (`a4f9a17f` egui and `8b35388b` winit); + `egui_kittest` accessibility/screenshot code and its normalized manifest feature split; + eframe `prepare_raw_input`/`update_logic_only` and the web hidden branch; `egui-wgpu` + `surface_fit`/`render_scale`; workspace lints; craft-libs PR #199/audit evidence; suite + rollout notes and upstream discussion snapshots (`upstream-8764-fresh.json`). +- Static checks performed for this revision: `git diff e85a480..52471b3` (manifests/lock only); + `#[test]` counts (108 src, 156 tests, 1,159 workspace); cmap scan of the + `epaint_default_fonts` files; source presence/absence checks for `capture.rs`, + `set_ime_surrounding_text`, W15P, `AsId`, 0.36.2 `Window::new`, and the kittest + single-image screenshot completion. These are inspection results, not runtime evidence. +- No build, test, render, screenshot, benchmark, VM, push, PR or message was executed. The + historical audit logs and the earlier fork/stock compiler errors are clues only. +- A read-only `cargo tree` was blocked offline by a missing `symphonia-common` registry index + entry; feature/graph claims are from manifests/lockfile and must be rechecked in a managed job. +- The plan intentionally uses placeholder names for revisions and a test helper; it does not + invent a merge SHA, an upstream API, or a benchmark result. Those placeholders must be replaced + by exact reviewed values before implementation. + + +## Local implementation update — 2026-10-23 + +The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval. + +Canonical `storytold/soundcraft` `main` resolved to `d47abe484d4f9ef0cc9f8e872e0b3880a61a4588` when the owned checkout was fast-forwarded on 2026-10-23. Earlier baseline SHAs and line anchors above are retained as historical review evidence. + +Current local implementation update (2026-10-24): the owned worktree `HEAD` is +`6b84a4f0461f2467402eafe41dcc708c369fb045` on `codex/egui-037-soundcraft-20261023`, with the +uncommitted migration/upstream-reconciliation edits on top. The craft-libs reconciliation base +for those edits is `3832c47ef68a0e6214f2c1e85f3526b6adcb686e`. The dependency graph is currently +the local integration configuration/path-only lock, not a publishable manifest: one exact, +reachable, immutable craft-libs revision plus the complete egui/winit family remains the final +publication gate and must come from central local commits, not a guessed SHA. + +No migration API redesign required by this delta; preserve the merged packaging, test and chrono changes where present. + +The complete static delta review and exact old/new object identities are retained in the task evidence: `canonical-diffs-refresh-20261023.json` and `overnight-consumer-review-20261023.md`. This amendment claims no new build, native GUI or performance acceptance. + +## Local implementation stage 1 — compatibility and lifecycle (source only) + +This stage is implemented in the owned worktree only. No build, test, formatter, clippy, lockfile, +commit, push, PR or release was produced by the implementing worker; the parent runs the managed +`task.py` jobs and regenerates `Cargo.lock` for the local-path dependency set. + +Implemented against the local pre-E22 `../egui-implementation` and the local +`../craft-libs-implementation` API: + +- `MouseWheel.source` (`Wheel` for line units, `Trackpad` for point units), no `Event::Screenshot`/ + `UserData`, `Role` instead of `WidgetType` (`SelectableLabel`→`Button`, `Checkbox`→`CheckBox`), + `Id::unique`, `Ui::scope_id`, `TextEdit::align(CENTER_TOP)` for the old horizontal center. +- Screenshot lifecycle: app-owned strong `craft_ui::capture::CaptureSlot` per request, weak renderer + callback, 8 pending / 4 in flight, 64 MiB per image, 192 MiB aggregate **encoding peak** (image, + raw copy, PNG buffer and base64 text), checked byte reservation from the capture-time viewport, + 8 s monotonic deadline checked before any write, geometry and session binding with one retry path, + cancellation on session/focus change, and exactly one reply. `ui.screenshot` success now includes + the request token; error strings are documented in `docs/control-protocol.md`. +- Lifecycle: `logic` no longer returns early on `current_pass_index`; frame pacing and the transport + simulation use a monotonic `web_time::Instant`; `raw_input_hook` keeps exactly one unapplied + synthetic batch across hidden ticks; a settled UI pass acknowledges it and issues captures after + layout; dropped files are captured before the font gate and routed after layout, once. +- Control ordering: a scripted gesture's reply waits until a UI pass applied every event, later + control requests behind it wait too, and an undeliverable gesture is canceled after 15 s. This + makes a following inspect/engine command/screenshot see the post-gesture state. Scripted input + does not publish OS clipboard or PRIMARY commands from its pass. +- Fonts/features: `egui/default_fonts` + `egui/monochrome_emoji_fonts` on the UI and app roots, + `eframe/clipboard` on native, a bundled-face glyph test, and drawn/ASCII replacements for the + uncovered `▸`, `▾` and `♯` glyphs. +- Accessibility names for the custom toggles/selectors/faders and bare text fields used by dialogs, + inline rename, Spot and comments; repeated per-track Edit/Mix controls now carry track-qualified + accessible names and their current value. This is not the full §9.3 node inventory. + +Not included: E21b outgoing surrounding text (the sibling producer is under review), the full P2 +accessibility inventory and mutation controls, native/browser/performance qualification, Windows +and macOS evidence, and any change to the shared dependency revisions. + +Known local test-helpers: new phase-sensitive tests call `raw_input_hook(ctx, raw)` and then +`logic`/`ui` inside one `run_ui` closure, mirroring native and web eframe. The historical coherent +baseline `e85a480…` keeps its 0.36 test helpers until the parent backports this helper shape for +paired measurements. + +## Post-port review repair (source only, 2026-10-23) + +This round addressed the substantive findings of +`egui037-soundcraft-postport-review-20261023-root-v1.md` in the owned worktree; no build, test, +formatter, clippy, lockfile, commit, push or PR was produced. + +- Synthetic input: `raw_input_hook` records the exact events it appended (batch plus the hover + `PointerMoved`s) and removes that run before re-appending, so a host that retains its + hook-mutated `RawInput` (eframe `pending_raw_input`, web `input.raw`) holds one copy, not one more + per hidden tick. A pending batch now sends `RequestPaintWhileHidden`, the synthetic grace keeps + being refreshed while a batch is pending, and a settled pass acknowledges the batch only if that + pass actually received its events. +- Lifecycle: frame statistics are sampled once per settled UI pass from the monotonic clock (not + per layout pass and never on hidden ticks); a bounce slice runs on the first pass of a host frame + only; `last_frame`/record-hint freshness uses monotonic uptime; hidden logic ticks clear the + cached pointer/keyboard state and a transient `Gesture::Fader`, and automation writing uses that + cached state instead of stale input. +- Capture: `recover` and `dialogs::open_pending_session` go through `SoundApp::document_replaced`, + so a pending capture is canceled for every document replacement path; the byte check accepts the + same one-pixel tolerance as the dimension check; truncated images fail as malformed instead of + succeeding; a missing `max_texture_side` falls back to the context's last known limit; a window + with no known geometry reports `screenshot unavailable` rather than `too large`. +- Accessibility: I/O Setup bus names, the Score title/composer fields, per-strip faders and the + inline pan/volume/send readouts now have accessible names; repeated controls are named per track. +- `ui.scroll` point events are trackpad-like; the deterministic control tests disable kinetic coast + and rubber-banding, and `docs/control-protocol.md` documents the behavior. + +Pending acceptance gates (not claimed by this repair): the E21b/P7b outgoing surrounding-text +producer is implemented in `../egui-ime-implementation` and is not integrated here — incoming IME +works but outgoing surrounding text remains unqualified; the local egui pin does not contain the +P7c selected `#8763` click-away/selection-action fix, so rename/Spot/path rename fields inherit the +unfixed behavior; native/browser GUI, IME and performance qualification still need the parent's +managed runs. diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml index bec9ffb5..b30c9ba2 100644 --- a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml @@ -71,6 +71,7 @@ modules: build-commands: - cargo --offline build --release --locked -p soundcraft -p soundcraft-cli - install -Dm755 target/release/soundcraft /app/bin/soundcraft + - install -Dm644 NOTICE /app/share/doc/soundcraft/NOTICE - install -Dm755 target/release/soundcraft-cli /app/bin/soundcraft-cli - install -Dm644 packaging/linux/ai.storyteller.soundcraft.desktop /app/share/applications/ai.storyteller.soundcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.soundcraft.mime.xml /app/share/mime/packages/ai.storyteller.soundcraft.xml diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index e23fed92..f27357be 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -125,7 +125,6 @@ if has appimage; then fi APPDIR="$WORK/SoundCraft.AppDir" cp -R "$STAGE" "$APPDIR" - mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" ln -s usr/bin/soundcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index dd3676d9..651bf73b 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -117,6 +117,8 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" # Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). cp "$WORK/bin/soundcraft" "$APP/Contents/MacOS/SoundCraft" cp "$ROOT/assets/app-icon/soundcraft.icns" "$APP/Contents/Resources/SoundCraft.icns" +# Required embedded-font notices are independent of optional font inputs. +cp "$ROOT/NOTICE" "$APP/Contents/Resources/NOTICE" sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ -e "s/@BUILD_SHA@/${SOUNDCRAFT_BUILD_SHA:-unknown}/g" \ "$HERE/Info.plist.in" >"$APP/Contents/Info.plist" diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index b46ba330..bc274d2b 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -90,6 +90,9 @@ Copy-Item (Join-Path $Bin 'soundcraft.exe'), (Join-Path $Bin 'soundcraft-cli.exe & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'soundcraft.exe') (Join-Path $Stage 'soundcraft-cli.exe') +# Required embedded-font notices ship independently of optional font inputs. +Copy-Item -LiteralPath (Join-Path $Root 'NOTICE') -Destination $Stage + # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "soundcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { diff --git a/packaging/windows/soundcraft.wxs b/packaging/windows/soundcraft.wxs index 77f538ee..4fd708f3 100644 --- a/packaging/windows/soundcraft.wxs +++ b/packaging/windows/soundcraft.wxs @@ -42,6 +42,9 @@ + + + Date: Sun, 25 Oct 2026 12:06:36 -0400 Subject: [PATCH 86/90] Follow-ups to #1058: Auto Pan keeps the stereo image, paste and Strip Silence fixes (#1060) * Auto Pan: balance the left and right channels at constant power instead of panning their mono sum * Strip Silence rectangles: on the web, rescan when a slider drag ends instead of on every frame of it * Paste: keep the clipboard's remapped Auto Rename keys only when the paste succeeds * Subharmonic: test that Q 5 filters after the saturator keep the output finite * AudioSuite window: drop the constant-speed varispeed controls no menu item opens * docs: gaps: Clip Looping has no loop crossfade and caps Number of Loops at 512 --- ROADMAP.md | 4 ++- crates/dsp/src/plugins/auto_pan.rs | 23 ++++++++----- crates/dsp/tests/it/auto_pan.rs | 50 +++++++++++++++++++++++++++- crates/dsp/tests/it/plugins.rs | 13 ++++++-- crates/engine/src/cmd/auto_rename.rs | 20 +++++++++++ crates/engine/src/cmd/edit.rs | 6 ++-- crates/engine/src/cmd/edit_more.rs | 1 + crates/ui-egui/src/dialogs.rs | 14 +++----- docs/gaps.md | 7 +++- docs/plugin-parity.md | 5 +-- 10 files changed, 116 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 979e35fd..cc80cc78 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-24 · **Last updated:** 2026-10-24 · **Change:** trivial (Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **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-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change. - **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change. - **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change. - **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change. @@ -174,6 +175,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged | diff --git a/crates/dsp/src/plugins/auto_pan.rs b/crates/dsp/src/plugins/auto_pan.rs index f5a2f3ad..f77ab436 100644 --- a/crates/dsp/src/plugins/auto_pan.rs +++ b/crates/dsp/src/plugins/auto_pan.rs @@ -94,9 +94,12 @@ fn duration_bars(duration: usize) -> Option { } } -/// Pans the mono sum of the first two channels between them. The pan position is -/// `manual · (1 - width) + modulator · width`, where the modulator is the LFO or the envelope -/// follower in -1..=1. A signal that is the same on both channels keeps its level at the center. +/// Moves the balance of the first two channels with a constant-power law: as the pan position +/// moves right the left channel's gain falls and the right's rises, and each channel stays on its +/// own side. The pan position is `manual · (1 - width) + modulator · width`, where the modulator is +/// the LFO or the envelope follower in -1..=1. At the center both gains are 1, so a stereo signal +/// passes unchanged; a signal that is the same on both channels pans across the field at a +/// constant level. pub struct AutoPan { p: Params, sr: f32, @@ -277,8 +280,10 @@ impl Plugin for AutoPan { let release_samples = ms_to_samples(self.p.v(13), self.sr) as u64; for n in 0..frames { let sample = |io: &[Vec], c: usize| io.get(c).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0); - let mono = if ch >= 2 { 0.5 * (sample(io, 0) + sample(io, 1)) } else { sample(io, 0) }; - let level = mono.abs(); + let left = sample(io, 0); + let right = if ch >= 2 { sample(io, 1) } else { left }; + // The louder channel drives the follower, so anti-phase material still has a level. + let level = left.abs().max(right.abs()); let k = if level > self.env { attack } else { release }; self.env = level + (self.env - level) * k; if self.env < 1.0e-12 { @@ -332,14 +337,14 @@ impl Plugin for AutoPan { let position = self.position.next(); let out = self.output.next(); if ch >= 2 { - // Constant power: sin² + cos² = 1 across the field. + // Constant power: sin² + cos² = 1 across the field, and both gains are 1 at the center. let theta = (position.clamp(-1.0, 1.0) + 1.0) * FRAC_PI_4; let (l, r) = (theta.cos().max(0.0), theta.sin().max(0.0)); if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) { - *x = mono * l * SQRT_2 * out; + *x = left * l * SQRT_2 * out; } if let Some(x) = io.get_mut(1).and_then(|b| b.get_mut(n)) { - *x = mono * r * SQRT_2 * out; + *x = right * r * SQRT_2 * out; } for c in 2..ch { if let Some(x) = io.get_mut(c).and_then(|b| b.get_mut(n)) { @@ -347,7 +352,7 @@ impl Plugin for AutoPan { } } } else if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) { - *x = mono * out; + *x = left * out; } } self.since_transport = self.since_transport.saturating_add(frames as u64); diff --git a/crates/dsp/tests/it/auto_pan.rs b/crates/dsp/tests/it/auto_pan.rs index 59fb3d12..d615301e 100644 --- a/crates/dsp/tests/it/auto_pan.rs +++ b/crates/dsp/tests/it/auto_pan.rs @@ -1,4 +1,4 @@ -use std::f32::consts::PI; +use std::f32::consts::{FRAC_PI_4, PI, TAU}; use soundcraft_dsp::{Plugin, Transport, create, db_to_gain}; @@ -294,3 +294,51 @@ fn auto_pan_output_gain_and_mono_tracks() { p.process(&mut buf, BLOCK); assert_near(buf[0][BLOCK - 1], 0.5 * db_to_gain(-6.0), 1e-4, "mono output"); } + +#[test] +fn auto_pan_balances_a_stereo_signal_without_summing_it() { + // A sine on the left, the same sine inverted plus an offset on the right: the channels differ, + // and a mono sum would cancel the sine. + let len = at(0.2); + let left: Vec = (0..len).map(|i| 0.5 * (TAU * 440.0 * i as f32 / SR).sin()).collect(); + let right: Vec = left.iter().map(|x| 0.25 - x).collect(); + let run = |p: &mut dyn Plugin| { + let mut out = vec![Vec::new(), Vec::new()]; + for start in (0..len).step_by(BLOCK) { + let end = (start + BLOCK).min(len); + let mut buf = vec![left[start..end].to_vec(), right[start..end].to_vec()]; + p.process(&mut buf, end - start); + out[0].extend_from_slice(&buf[0]); + out[1].extend_from_slice(&buf[1]); + } + out + }; + + let mut p = auto_pan(&[("width", 0.0)]); + let out = run(p.as_mut()); + for i in 0..len { + assert_near(out[0][i], left[i], 1e-5, "center passes the left channel"); + assert_near(out[1][i], right[i], 1e-5, "center passes the right channel"); + } + + // Half left: each channel keeps its own content, the left louder and the right quieter. + let mut p = auto_pan(&[("width", 0.0), ("manual", -50.0)]); + let out = run(p.as_mut()); + let theta = 0.5 * FRAC_PI_4; + let (gl, gr) = (2f32.sqrt() * theta.cos(), 2f32.sqrt() * theta.sin()); + for i in at(0.05)..len { + assert_near(out[0][i], left[i] * gl, 1e-5, "half left, left channel"); + assert_near(out[1][i], right[i] * gr, 1e-5, "half left, right channel"); + } + + // Anti-phase channels still drive the envelope follower to the right. + let mut p = auto_pan(&[("source", 1.0), ("threshold", -30.0)]); + let mut buf = vec![vec![1.0f32; BLOCK], vec![-1.0f32; BLOCK]]; + for _ in 0..at(0.5) / BLOCK { + buf[0].fill(1.0); + buf[1].fill(-1.0); + p.process(&mut buf, BLOCK); + } + assert_near(buf[0][BLOCK - 1], 0.0, 1e-3, "anti-phase at full scale pans right: left"); + assert_near(buf[1][BLOCK - 1], -(2f32.sqrt()), 1e-3, "anti-phase at full scale pans right: right"); +} diff --git a/crates/dsp/tests/it/plugins.rs b/crates/dsp/tests/it/plugins.rs index 35b41cd1..44cb6e36 100644 --- a/crates/dsp/tests/it/plugins.rs +++ b/crates/dsp/tests/it/plugins.rs @@ -597,16 +597,25 @@ fn subharmonic_solo_plays_one_band_and_mutes_the_dry_signal() { #[test] fn subharmonic_drive_keeps_every_character_bounded() { - for character in 0..6 { + let driven = |character: usize, q: f32| { let mut p = subharmonic_wet(); p.set_param("input", 18.0); p.set_param("lower_gain", 24.0); p.set_param("upper_gain", 24.0); p.set_param("drive", 18.0); p.set_param("character", character as f32); - let out = run(p.as_mut(), &[sine(60.0, 1.0, 48_000)]); + p.set_param("hp_q", q); + p.set_param("lp_q", q); + run(p.as_mut(), &[sine(60.0, 1.0, 48_000)]) + }; + for character in 0..6 { + let out = driven(character, 0.707); let top = peak(&out[0][24_000..]); assert!(top > 0.5 && top < 1.5, "character {character}: peak {top}"); + // Q 5 filters after the saturator ring well above full scale, but never to infinity. + let out = driven(character, 5.0); + assert!(out[0].iter().all(|v| v.is_finite()), "character {character} at Q 5"); + assert!(peak(&out[0][24_000..]) > 0.5, "character {character} at Q 5 is silent"); } } diff --git a/crates/engine/src/cmd/auto_rename.rs b/crates/engine/src/cmd/auto_rename.rs index bd0d69e0..5bad8597 100644 --- a/crates/engine/src/cmd/auto_rename.rs +++ b/crates/engine/src/cmd/auto_rename.rs @@ -188,6 +188,26 @@ mod tests { assert_eq!(s.auto_names[&key].number, 100, "the other scheme's count is untouched"); } + #[test] + fn a_refused_paste_leaves_the_clipboard_as_it_was() { + let mut e = crate::demo::demo_engine(); + let (t, id, start, len) = first_kick(&e); + e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap(); + e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap(); + // Without its scheme in the session, a paste puts the scheme back under a new key. + e.session_mut().auto_names.clear(); + let copied = e.clipboard.clone(); + // A time-locked clip at the paste point leaves Shuffle no room. + e.session_mut().find_clip_mut(id).unwrap().time_locked = true; + e.session_mut().edit.edit_mode = soundcraft_model::EditMode::Shuffle; + assert!(e.execute("edit.paste", &json!({"tracks": [t], "at": start})).is_err()); + assert_eq!(e.clipboard, copied); + e.session_mut().edit.edit_mode = soundcraft_model::EditMode::Slip; + let at = e.session().content_end() + 48_000; + e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap(); + assert_ne!(e.clipboard, copied, "a paste that succeeds keeps the scheme's new key"); + } + #[test] fn bad_auto_rename_params_are_refused() { let mut e = crate::demo::demo_engine(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a874b81e..c11d3cbb 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -327,12 +327,12 @@ fn cut_track(s: &mut Session, t: TrackId, r: soundcraft_time::Range, sh: bool, f } /// The clipboard for a paste, with the Auto Rename schemes of its clips put into the session first -/// ([`Clipboard::adopt_auto_names`]). +/// ([`Clipboard::adopt_auto_names`]). A paste that succeeds stores it back in `e.clipboard`, so later +/// pastes find its schemes under their session keys; a refused paste leaves the clipboard as it was. pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard { let mut cb = e.clipboard.clone(); if cb.tracks.iter().flatten().any(|c| c.auto_name.is_some()) { cb.adopt_auto_names(e.session_mut()); - e.clipboard = cb.clone(); } cb } @@ -373,6 +373,7 @@ fn paste(e: &mut Engine, p: &Value) -> Result { } } s.edit.selection = soundcraft_time::Range::new(at, at + cb.length); + e.clipboard = cb; Ok(json!({"pasted": pasted})) } @@ -462,6 +463,7 @@ fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result { at += len; n += 1; } + e.clipboard = cb; Ok(json!({"repeats": n})) } diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs index 693c0ef1..4e67608e 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -510,6 +510,7 @@ fn paste_merge_midi(e: &mut Engine, p: &Value) -> Result { } } } + e.clipboard = cb; Ok(json!({"merged": merged, "placed": placed})) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 30e5360e..a2f11adb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -907,8 +907,9 @@ fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result StripRects { let values = match &app.dialogs.open { Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) => [*threshold, *min_ms, *pre_ms, *post_ms], @@ -936,8 +937,9 @@ pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> Stri let shown = r.shown.as_ref().map(|(k, rects)| (*k == key, Arc::clone(rects))); #[cfg(not(target_arch = "wasm32"))] let busy = r.running.is_some(); + // The browser's scan blocks the frame, so it waits for the release that ends a slider drag. #[cfg(target_arch = "wasm32")] - let busy = false; + let busy = ctx.input(|i| i.pointer.any_down()); if let Some((true, rects)) = shown { return rects; } @@ -968,8 +970,6 @@ pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> Stri return shown.map(|(_, rects)| rects).unwrap_or_default(); } } - #[cfg(target_arch = "wasm32")] - let _ = ctx; // The browser has no threads, and without a thread to spare the scan runs on this one. let rects: StripRects = Arc::new(scan.run().unwrap_or_default()); app.dialogs.strip_rectangles.shown = Some((key, Arc::clone(&rects))); @@ -1106,10 +1106,6 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { let v = params.entry("semitones".into()).or_insert(0.0); ui.add(egui::Slider::new(v, -24.0..=24.0).text("Semitones")); } - "varispeed" => { - let v = params.entry("speed".into()).or_insert(1.0); - ui.add(egui::Slider::new(v, 0.25..=4.0).text("Speed")); - } "speed_ramp" => { for (id, label, default, choices) in SPEED_RAMP_CHOICES { let v = params.entry(id.to_string()).or_insert(default); diff --git a/docs/gaps.md b/docs/gaps.md index 4bc186ec..cd832000 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (G-08 and G-20: built-in plugin count, the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-25 · **Change:** trivial (G-04: Clip Looping crossfade and loop count; previously trivial: G-08 and G-20: built-in plugin count, the sampler shipped) · **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** @@ -100,6 +100,10 @@ channels, clipping order, centered mono tracks, each 5.1 channel and 1-, 37- and including surround channels, instead of repeating channel zero. Clip edits, source timestamps, Undo/Redo and save/reopen are covered by regression tests; missing or empty media fails atomically. +**Open (2026-10-25):** the Clip Looping dialog (#1030) has no option to crossfade the loops into +each other, and its Number of Loops field stops at 512, while Loop Length and Loop Until End of +Session or Next Clip make up to 1000 loops. + 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); @@ -221,6 +225,7 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | G-04: Clip Looping (#1030) lacks the loop crossfade option, and Number of Loops stops at 512 while length and fill make up to 1000 loops | | 2026-10-24 | trivial | G-08: 27 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing | | 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained | | 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged | diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index 9be6e0a7..ad819351 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-24 · **Change:** minor (AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance; previously minor: AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously 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 @@ -17,7 +17,7 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories. | Dynamics | compressor/limiter, expander/gate, de-esser, maximizer, vintage compressors, multiband | `compressor`, `expander_gate`, `de_esser`, `maximizer` | Vintage/opto models, multiband, sidechain input (no key input anywhere) | | Reverb | algorithmic room/plate/hall; convolution (paid tier) | `room_reverb`, `plate_reverb` | Hall/chamber, convolution, early-reflection editor | | Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth | -| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `auto_pan`, `chorus`, `flanger`, `phaser`, `ring_mod` (ring, frequency modulation, positive and negative resonators; LFO, envelope or sample-and-hold modulation). Auto Pan pans the mono sum of a stereo track with an LFO (free, single, beat clock, envelope or manual trigger; rate in BPM or linked to the session tempo) or an envelope follower. | Tremolo, frequency shift; Auto Pan on a mono track (inserts on a mono track have one channel), LCR, quad and 5.0 outputs with Angle, Place and Spread, circular surround paths, output meters, side-chain input | +| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `auto_pan`, `chorus`, `flanger`, `phaser`, `ring_mod` (ring, frequency modulation, positive and negative resonators; LFO, envelope or sample-and-hold modulation). Auto Pan moves the balance of a stereo track at constant power, keeping each channel on its own side and the track unchanged at the center, with an LFO (free, single, beat clock, envelope or manual trigger; rate in BPM or linked to the session tempo) or an envelope follower. | Tremolo, frequency shift; Auto Pan on a mono track (inserts on a mono track have one channel), LCR, quad and 5.0 outputs with Angle, Place and Spread, circular surround paths, output meters, side-chain input | | Harmonic | lo-fi, rectifier, amp simulation, saturation, tape, subharmonic synthesis | `lofi`, `rectifier`, `saturator`, `subharmonic` (octave-down bass from a lower and an upper band, direct bass, drive characters, HP and LP filters) | Amp and cabinet models, tape; subharmonic MIDI tuning and surround (LFE) version | | Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift`; the AudioSuite speed ramp (`speed_ramp`) slows a clip down to a stop or speeds it up from one, fitted to the clip's length or extended to 150 %, with or without a fade. | Formant control, 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 | @@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Auto Pan: constant-power balance of the left and right channels; stereo passes unchanged at the center | | 2026-10-24 | minor | AudioSuite speed ramps (`speed_ramp`): down to a stop and up from one, Fit To or Extend, fades on or off | | 2026-10-24 | minor | Auto Pan: LFO and envelope panning of stereo tracks; built-in count 27 processors + 3 instruments | | 2026-10-24 | trivial | Harmonic: Subharmonic (`subharmonic`); built-in count 26 processors + 3 instruments | From b8547712519e1334c94f7a518a09949e8bb354f4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 14:38:14 -0400 Subject: [PATCH 87/90] feat: startup update prompt modal and direct self-updating (#1073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: startup update prompt modal and direct self-updating Integrate `craft-update` pinned to storytold/craft-libs fe61243 to check for updates on startup, display the standard suite update modal with changelog notes, and support one-click in-app updating and restart. Ref: storytold/craft-libs#319 * wip * build: refresh the lock * refactor(updates): drive the flow with craft-update's Updater The check at start is opt-in for the desktop app, a successful update closes through the app's own close path, and no update starts over unsaved work. * fix(updates): register the update command and hold the install back during a recording Help ▸ Check for Updates… goes through the shared command id, and an active recording counts as work the update must not interrupt. --- Cargo.lock | 493 ++++++++++++++++++++++++++++++- Cargo.toml | 1 + apps/soundcraft/Cargo.toml | 3 + apps/soundcraft/src/main.rs | 6 + crates/ui-egui/Cargo.toml | 3 + crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/menus.rs | 8 + crates/ui-egui/src/updates_ui.rs | 122 ++++++++ 8 files changed, 631 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/updates_ui.rs diff --git a/Cargo.lock b/Cargo.lock index 1182ef9a..36d2a951 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -441,6 +441,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bindgen" version = "0.72.1" @@ -507,6 +513,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -699,7 +714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core", ] @@ -803,6 +818,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -816,7 +841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -829,7 +854,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -888,6 +913,15 @@ dependencies = [ "windows-core", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -923,6 +957,31 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc#5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -972,6 +1031,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -984,6 +1053,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "dify" version = "0.8.0" @@ -997,6 +1072,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1056,6 +1141,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ecolor" version = "0.37.0" @@ -1282,7 +1373,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1353,6 +1444,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1503,6 +1604,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1522,6 +1633,17 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1745,6 +1867,22 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "icu_collections" version = "2.3.0" @@ -2222,7 +2360,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2339,6 +2477,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -2770,6 +2914,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -2806,7 +2956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2960,6 +3110,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -3032,6 +3195,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "presser" version = "0.3.1" @@ -3128,6 +3297,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -3324,6 +3502,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3382,7 +3574,81 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -3412,6 +3678,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3424,6 +3699,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -3435,6 +3733,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "send_wrapper" @@ -3505,6 +3807,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3663,6 +3976,7 @@ name = "soundcraft" version = "0.13.1" dependencies = [ "chrono", + "craft-update", "eframe", "egui", "log", @@ -3836,6 +4150,7 @@ name = "soundcraft-ui-egui" version = "0.13.1" dependencies = [ "craft-ui", + "craft-update", "egui", "egui_kittest", "image", @@ -3933,6 +4248,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "symphonia" version = "0.6.1" @@ -4163,6 +4484,16 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4173,7 +4504,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4239,6 +4570,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -4377,6 +4738,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -4385,7 +4752,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4430,6 +4797,41 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4442,6 +4844,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4486,6 +4894,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -4520,6 +4938,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -4704,7 +5128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -4756,6 +5180,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -4951,7 +5384,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.60.2", ] [[package]] @@ -5243,7 +5676,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -5288,6 +5721,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" version = "0.1.34" @@ -5298,6 +5741,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -5568,6 +6017,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerotrie" version = "0.2.5" @@ -5608,10 +6063,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ "crc32fast", + "flate2", "indexmap", "memchr", "nix", "typed-path", + "zopfli", ] [[package]] @@ -5626,6 +6083,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 3f00bd1b..7b7e0579 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ 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 = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } +craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc", features = ["egui"] } # ui # 650b1c0b058017c4eb3af06d5b39586d6732e07b is the revision produced by the separate craft-libs current-main-0135 diff --git a/apps/soundcraft/Cargo.toml b/apps/soundcraft/Cargo.toml index 5e5b2d08..00217fd8 100644 --- a/apps/soundcraft/Cargo.toml +++ b/apps/soundcraft/Cargo.toml @@ -35,3 +35,6 @@ winresource = "0.1" muda = { version = "0.21.2", default-features = false } objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] } + +[target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index ef929e54..6dc7756f 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -210,6 +210,11 @@ fn app_icon() -> Option { } 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::>()) { + std::process::exit(i32::from(result.is_err())); + } // First, so every start-up record (and the engine's panic hook, installed with the first // Engine) is captured; see `logging`. let logger = logging::install(); @@ -276,6 +281,7 @@ fn main() -> eframe::Result { let player = if no_audio { None } else { Some(soundcraft_playback::Player::with_config(Arc::new(engine.session().clone()), &prefs.audio)) }; let mut app = SoundApp::new(engine, player, services()); + app.set_startup_update_check(true); app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave"))); app.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default(); app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets"))); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 87a64369..eb179cc1 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -33,6 +33,9 @@ image.workspace = true [target.'cfg(target_os = "linux")'.dependencies] zbus = "5" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-update = { workspace = true } + [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d95b25b3..7b564f92 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -29,6 +29,7 @@ pub mod shortcuts; pub mod spot; pub mod theme; pub mod toolbar; +pub mod updates_ui; pub mod video_track; pub mod video_window; pub mod waveform; @@ -285,6 +286,7 @@ pub struct SoundApp { pub preset_dir: Option, last_autosave: Option, pub dialogs: dialogs::Dialogs, + pub(crate) updates_rt: updates_ui::Runtime, pub meters: HashMap, pub main_meter: MeterDisplay, /// Reused meter snapshot, taken from the player at an adaptive rate by `logic`: the highest @@ -492,6 +494,7 @@ impl SoundApp { preset_dir: None, last_autosave: None, dialogs: dialogs::Dialogs::default(), + updates_rt: updates_ui::Runtime::default(), meters: HashMap::new(), main_meter: MeterDisplay::default(), meter_snapshot: soundcraft_playback::MeterSnapshot::default(), @@ -1401,6 +1404,8 @@ impl SoundApp { if ctx.current_pass_index() == 0 { bounce::step(self, ctx); import::step(self, ctx); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::tick(self, ctx); } if !self.pointer_down { if matches!(self.gesture, Some(Gesture::Fader { .. })) { @@ -1765,6 +1770,8 @@ impl SoundApp { score_editor::show(self, &ctx); palette::show(self, &ctx); dialogs::show(self, &ctx); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::show(self, &ctx); // The sampler's drop rects are current now, so route any captured drop in this pass. self.route_dropped_files(); // The pass that actually received the injected events has already dispatched their widget diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 62ef195a..8f67f2ad 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,6 +7,7 @@ use soundcraft_engine::catalog; /// UI-layer commands: (id, label, catalog path or "", shortcut). pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ + ("app.checkForUpdates", "Check for Updates…", "", None), ("ui.dock", "Arrange Panel", "", None), ("ui.dock.reset", "Reset Panel Layout", "Window > Reset Panel Layout", None), ("window.mix", "Mix", "Window > Mix", Some("Cmd+=")), @@ -232,6 +233,9 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) { if ui.button("About SoundCraft").clicked() { app.ui.show_about = true; } + if ui.button("Check for Updates…").clicked() { + let _ = app.run("app.checkForUpdates", json!({})); + } if ui.button("Session Info").clicked() { app.ui.show_session_info = true; } @@ -519,6 +523,10 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option { + crate::updates_ui::check(app); + json!({}) + } "window.mix" => { app.ui.window = MainWindow::Mix; app.focus_mix = app.ui.mix_separate; diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs new file mode 100644 index 00000000..fcc52541 --- /dev/null +++ b/crates/ui-egui/src/updates_ui.rs @@ -0,0 +1,122 @@ +//! Software updates, background check at startup, and startup prompt modal. + +use crate::SoundApp; + +#[cfg(not(target_arch = "wasm32"))] +use craft_update::updater::{Action, Status, Updater}; + +pub(crate) struct Runtime { + #[cfg(not(target_arch = "wasm32"))] + updater: Updater, + /// The desktop app turns the check at start on; tests and the web build leave it off. + #[cfg(not(target_arch = "wasm32"))] + startup_check: bool, + #[cfg(not(target_arch = "wasm32"))] + check_requested: bool, + pub(crate) manual_window_open: bool, + pub(crate) status_message: Option, +} + +impl Default for Runtime { + fn default() -> Self { + Self { + #[cfg(not(target_arch = "wasm32"))] + updater: Updater::new(&craft_update::suite::SOUNDCRAFT, env!("CARGO_PKG_VERSION")), + #[cfg(not(target_arch = "wasm32"))] + startup_check: false, + #[cfg(not(target_arch = "wasm32"))] + check_requested: false, + manual_window_open: false, + status_message: None, + } + } +} + +impl SoundApp { + /// Whether the app asks for a newer release when it starts (off until the desktop app turns it on). + #[cfg(not(target_arch = "wasm32"))] + pub fn set_startup_update_check(&mut self, on: bool) { + self.updates_rt.startup_check = on; + } +} + +pub(crate) fn check(app: &mut SoundApp) { + #[cfg(not(target_arch = "wasm32"))] + { + app.updates_rt.check_requested = true; + } + #[cfg(target_arch = "wasm32")] + { + app.updates_rt.manual_window_open = true; + app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into()); + } +} + +pub(crate) fn tick(app: &mut SoundApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + let rt = &mut app.updates_rt; + if std::mem::take(&mut rt.check_requested) { + rt.updater.check_now(ctx); + } else if rt.startup_check { + rt.updater.check_at_start(ctx); + } + rt.updater.tick(ctx); + } + #[cfg(target_arch = "wasm32")] + let _ = (app, ctx); +} + +pub(crate) fn show(app: &mut SoundApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + // The restart helper is scheduled before the app closes, so no update starts over unsaved work or a take being recorded. + let clean = !app.has_unsaved_changes() && !app.engine.transport.recording; + match app.updates_rt.updater.show_if(ctx, clean) { + Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), + Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + Action::None => {} + } + // The window answers Help ▸ Check for Updates, so it follows the updater's status. + let rt = &mut app.updates_rt; + (rt.manual_window_open, rt.status_message) = match rt.updater.status() { + Status::Idle => (false, None), + Status::Checking => (true, None), + Status::UpToDate => (true, Some(format!("SoundCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))), + Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))), + }; + } + + if app.updates_rt.manual_window_open { + let mut open = true; + let mut close = false; + egui::Window::new("Check for Updates") + .open(&mut open) + .resizable(false) + .collapsible(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + if let Some(msg) = &app.updates_rt.status_message { + ui.label(msg); + } else { + ui.label("Checking for updates…"); + } + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Close").clicked() { + close = true; + } + }); + }); + if !open || close { + app.updates_rt.manual_window_open = false; + app.updates_rt.status_message = None; + } + } + + // Closing the window dismisses the outcome it showed. + #[cfg(not(target_arch = "wasm32"))] + if !app.updates_rt.manual_window_open { + app.updates_rt.updater.dismiss_status(); + } +} From eec559f057aed910ceec0f169e7cb54b1c1473ff Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 15:34:58 -0400 Subject: [PATCH 88/90] build: pin craft-update to craft-libs main (db19c37) (#1090) Picks up the fix that keeps the close request pending while the app holds unsaved work, the executable-name check in the Windows update helper, and the zip 9 / dirs-free build. Part of tracking issue storytold/craft-libs#319 --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36d2a951..304fc7d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -960,7 +960,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc#5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc" +source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" dependencies = [ "chrono", "dunce", @@ -1373,7 +1373,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2360,7 +2360,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2956,7 +2956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3574,7 +3574,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3631,7 +3631,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4504,7 +4504,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4752,7 +4752,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5384,7 +5384,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7b7e0579..4946da5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,7 @@ 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 = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } -craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc", features = ["egui"] } +craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } # ui # 650b1c0b058017c4eb3af06d5b39586d6732e07b is the revision produced by the separate craft-libs current-main-0135 From 5bbefeae625203eb58e2355f8b90edcded66c585 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 17:37:38 -0400 Subject: [PATCH 89/90] build: pin craft-update to craft-libs main (495e597) (#1092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the update prompt fix that draws its close mark and version pill without glyphs a font may lack, so the prompt no longer takes the app down when a bundled face has no ✕ or →. Part of tracking issue storytold/craft-libs#319 🤖 Generated with Codebuff Co-authored-by: Codebuff --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 304fc7d1..ec82b3bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -960,7 +960,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index 4946da5d..6360acb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,7 @@ 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 = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } -craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } +craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } # ui # 650b1c0b058017c4eb3af06d5b39586d6732e07b is the revision produced by the separate craft-libs current-main-0135 From 0197e27fc41ead385783fde4b21090f995e5bb5b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 17:50:34 -0400 Subject: [PATCH 90/90] build: pin craft-update to craft-libs main (495e597) (#1096) Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down. Part of tracking issue storytold/craft-libs#319 --- Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec82b3bf..9e4386ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1373,7 +1373,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2360,7 +2360,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2956,7 +2956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3574,7 +3574,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3631,7 +3631,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4504,7 +4504,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4752,7 +4752,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5384,7 +5384,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.60.2", ] [[package]]