Merge track r1-t02

# Conflicts:
#	contributors/people.toml
#	docs/plugin-parity.md
This commit is contained in:
aiongg
2026-10-24 06:54:04 +00:00
36 changed files with 1837 additions and 60 deletions
+3
View File
@@ -127,10 +127,12 @@ mutated and truncated movies. Field reports from the first public week are track
- **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.
- **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-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 (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)
@@ -169,6 +171,7 @@ mutated and truncated movies. Field reports from the first public week are track
| 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 |
| 2026-10-16 | trivial | Split into Mono preserves each source channel, clip edits and saved audio; readiness estimates unchanged |
| 2026-10-14 | trivial | Progress log: oversized MCP batches rejected before execution (#460) |
| 2026-10-13 | trivial | Progress log: empty WAV data chunk followed by metadata (#466) |
| 2026-10-13 | trivial | Progress log: sampler ping-pong phase for wide steps (#463) |
+1 -1
View File
@@ -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<AuDescriptor> {
registry::scan()
+1 -1
View File
@@ -9,7 +9,7 @@ use std::sync::OnceLock;
pub fn fixture_dir() -> &'static Path {
static DIR: OnceLock<PathBuf> = 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)
+1 -1
View File
@@ -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]
+26 -1
View File
@@ -130,8 +130,19 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
n.pitch = i64::from(n.pitch).saturating_add(ds).clamp(0, 127) as u8;
}
}
let moved: Vec<Note> = 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)]
@@ -172,6 +183,20 @@ mod tests {
assert_eq!(n["notes"][0]["pitch"], 127);
}
#[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();
+52 -5
View File
@@ -5,7 +5,7 @@ use super::{
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
use soundcraft_model::{ChannelFormat, ClipContent, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -414,6 +414,8 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
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 {
@@ -429,12 +431,46 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
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;
}
@@ -704,15 +740,26 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
fn rename(e: &mut Engine, p: &Value) -> Result<Value> {
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);
}
+20 -5
View File
@@ -886,12 +886,27 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result<Option<TrackId>> {
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();
@@ -1837,7 +1852,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);
}
+160
View File
@@ -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<Vec<f32>>) {
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<Vec<f32>> = (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<f32>], expected: &[Vec<f32>]) {
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<Vec<f32>> = signal.iter().map(|ch| ch.iter().map(|sample| -sample * gain).collect()).collect();
let head: Vec<Vec<f32>> = 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());
}
}
+159
View File
@@ -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<TrackId> {
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());
}
}
@@ -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));
}
}
+28 -3
View File
@@ -204,7 +204,12 @@ pub fn read_smf(bytes: &[u8]) -> Result<Smf, MidiError> {
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<u8>, mut events: Vec<WEvent>) -> 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<Vec<u8>, 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<Vec<u8>, 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();
@@ -565,6 +577,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();
+153 -17
View File
@@ -1358,26 +1358,97 @@ fn processing_order(s: &Session) -> Vec<usize> {
(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<usize> =
(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<usize> = 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<Vec<usize>> {
#[derive(Default)]
struct BusDependencies {
remaining: usize,
depth: usize,
last_producer: Option<usize>,
last_processed: Option<usize>,
consumers: Vec<usize>,
}
let mut busses = HashMap::<BusId, BusDependencies>::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<Item = BusId> + '_ {
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<usize> = auxes_feeding(s, &inputs);
if feeders.is_empty() {
break;
}
d += 1;
cur = feeders;
}
d
}
fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {
s.tracks
.iter()
@@ -1390,6 +1461,60 @@ fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {
.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 };
@@ -2274,6 +2399,13 @@ pub fn render_track_pre_fader(s: &Session, track: TrackId, range: Range) -> Vec<
/// A built-in plugin, else a hosted CLAP plugin (`clap:<id>`).
fn create_plugin(id: &str) -> Option<Box<dyn Plugin>> {
// 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))
@@ -2326,6 +2458,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};
@@ -3049,7 +3185,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);
}
+307
View File
@@ -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<Vec<f32>> {
// 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<Vec<f32>> {
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<BusId>,
/// Logical routing order, deliberately different from the session's creation order.
tracks: Vec<TrackId>,
}
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<f32>], expected: &[Vec<f32>], 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<Vec<f32>> {
render_range(session, Range::new(0, FRAMES as i64), block)
}
fn engine_block(engine: &mut MixEngine, session: &Session) -> Vec<Vec<f32>> {
let mut out = vec![vec![0.0; 128]; 2];
engine.render(session, 0, 128, &mut out);
out
}
fn first_block(pcm: Vec<Vec<f32>>) -> Vec<Vec<f32>> {
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");
}
}
}
+13
View File
@@ -186,6 +186,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();
+15 -1
View File
@@ -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 })
}
@@ -229,6 +231,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;
+3 -1
View File
@@ -377,8 +377,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 =
+7 -2
View File
@@ -2327,8 +2327,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),
+16 -1
View File
@@ -459,13 +459,17 @@ impl SoundApp {
if let Some(r) = menus::run_ui_command(self, id, &params) {
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();
}
let replacing = matches!(id, "session.new" | "session.open" | "session.close" | "session.revert");
match self.engine.execute(id, &params) {
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);
}
@@ -1448,6 +1452,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());
+34 -2
View File
@@ -21,6 +21,19 @@ pub struct MidiEditorState {
pub band: Option<egui::Pos2>,
}
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)));
}
}
+85 -7
View File
@@ -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<soundcraft_model::TrackId>,
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<Value> {
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));
}
}
+8 -2
View File
@@ -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}));
}
+2 -1
View File
@@ -353,7 +353,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));
+1
View File
@@ -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;
+15
View File
@@ -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]));
}
+10
View File
@@ -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");
}
+38
View File
@@ -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<Event>| {
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::<Rect>(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 {
@@ -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);
}
+54
View File
@@ -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");
}
@@ -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<u8> = [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");
}
+260 -5
View File
@@ -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::<sv::IMessage>().map(|p| p.into_raw().cast::<c_void>())
} else if wants(sv::IAttributeList::IID) {
ComWrapper::new(HostAttributes::default()).to_com_ptr::<sv::IAttributeList>().map(|p| p.into_raw().cast::<c_void>())
} 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<std::collections::HashMap<Vec<u8>, 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<Vec<u8>> {
// 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<Vec<u8>, 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::<u8>(), 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::<c_void>();
*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<std::ffi::CString>,
attributes: ComWrapper<HostAttributes>,
}
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::<sv::IAttributeList>().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)]
@@ -2151,6 +2355,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<I: Interface>(host: &ComWrapper<HostContext>, cid: [u8; 16]) -> Option<ComPtr<I>> {
let app = host.to_com_ptr::<sv::IHostApplication>()?;
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::<I>()) }).flatten()
}
#[test]
fn plugins_get_messages_and_attribute_lists_from_the_host() {
let host = ComWrapper::new(HostContext::default());
let msg = create::<sv::IMessage>(&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<u16> = "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::<u8>(), n as usize), [1, 2, 3]);
assert_eq!(attrs.setInt(std::ptr::null(), 1), sb::kInvalidArgument, "a null id is refused");
}
assert!(create::<sv::IAttributeList>(&host, sv::IAttributeList::IID).is_some());
// Anything else is still not provided.
assert!(create::<sv::IMessage>(&host, sv::IComponentHandler::IID).is_none());
}
#[test]
fn the_transport_fills_the_process_context() {
+7 -1
View File
@@ -214,8 +214,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];
}
+23
View File
@@ -160,6 +160,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"));
+17
View File
@@ -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.
+41
View File
@@ -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. |
+9 -2
View File
@@ -96,6 +96,9 @@ regression tests verify rendered impulse timing, retained healthy latency and re
instead of discarding the right side. Surround mixes average both sides of the ITU stereo
fold-down. Regression tests cover right-only and left-only audio, equal and opposite-polarity
channels, clipping order, centered mono tracks, each 5.1 channel and 1-, 37- and 512-frame callbacks.
**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);
@@ -180,8 +183,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
@@ -220,6 +225,8 @@ missing. Out of scope unless the owner decides otherwise.
| 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 |
| 2026-10-16 | trivial | G-04: Split into Mono channel preservation fixed and regression-tested; estimates unchanged |
| 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 |
+2 -1
View File
@@ -1,6 +1,6 @@
# Plugin parity: built-in processors and plugin hosting
> **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
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** 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
@@ -59,6 +59,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 | minor | VST3 host: IMessage and IAttributeList for plugins whose halves talk through messages (Line 6 Helix Stadium Native verified) |
| 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 |