Compare commits

..
12 Commits
Author SHA1 Message Date
Brandon Thomas 8444edf075 Merge pull request #1248 from Silvertree2010/fix/1247-vst3-multi-out-instruments-e-g-s
Accept VST3 multi-out instruments with up to 64 audio buses
2026-11-02 00:30:05 -05:00
NetterSilverandClaude Opus 5.5 6c3a0dfc50 Accept VST3 multi-out instruments with up to 64 audio buses
Fixes #1247

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 06:19:39 +01:00
Brandon Thomas d557d18118 Merge pull request #1223 from TheGalitube/fix/h7732-aiff-parsing-rejects-valid-chann
AIFF: test that a negative channel count is rejected
2026-11-01 23:06:45 -05:00
Brandon Thomas 98cda6ecaf Merge pull request #1032 from Silvertree2010/fix/h2086-midi-export-writes-notes-that-a
Export only the MIDI notes a trimmed clip still plays
2026-11-01 23:06:40 -05:00
Brandon Thomas 920ec2c3f3 Merge pull request #1234 from Silvertree2010/fix/h5095-score-export-drops-every-note-wh
Keep score export notes when bars are shorter than a sixteenth
2026-11-01 23:06:37 -05:00
Brandon ThomasandClaude Opus 5.5 5e8898976e Test a grid of 1/16, 1/32 and 1/64 bars in a row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:06:02 -05:00
Brandon ThomasandClaude Opus 5.5 c426b889bb Test MIDI export of a clip trimmed at both ends
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:05:08 -05:00
Max MittelstädtandGPT-5.6-Luna cf40296888 fix: reject unsigned AIFF channel counts
Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
Co-Authored-By: GPT-5.6-Terra <noreply@openai.com>
2026-11-01 09:13:52 +00:00
NetterSilverandClaude Opus 5.5 b32065a109 Keep score export notes when bars are shorter than a sixteenth
Found while testing crates/engine/src/score.rs:~150 (measure_grid, `if ub <= ua { break; }`), used by layout()

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 00:34:16 +01:00
NetterSilverandClaude Sonnet 5.5 97e2603683 fix: release a held sustain at the end of a trimmed MIDI clip on export
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 16:48:51 +00:00
Max MittelstädtandGPT-6-Luna 73e3478ae3 Accept unsigned AIFF channel counts
Found while testing crates/audio-io/src/aiff.rs:84

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-31 07:06:09 +00:00
NetterSilverandClaude Haiku 5.5 4740e8d4c4 Export only the MIDI notes a trimmed clip still plays
Found while testing crates/engine/src/io.rs:350-362 (export_midi)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 05:24:39 +01:00
8 changed files with 196 additions and 21 deletions
+9
View File
@@ -110,6 +110,15 @@ fn zero_channels_is_malformed() {
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
}
#[test]
fn aiff_negative_channel_count_is_malformed() {
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
aiff[comm + 8] = 0x80;
aiff[comm + 9] = 0;
assert!(probe(&aiff, None).is_err());
}
#[test]
fn zero_sample_rate_is_malformed() {
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
+25 -3
View File
@@ -82,15 +82,37 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
for c in t.clips() {
if let ClipContent::Midi { sequence } = &c.content {
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
for n in &sequence.notes {
// Only what plays: notes starting inside the clip, cut at its end.
let clip_end = s.tempo.samples_to_ticks(c.end(), s.sample_rate);
for n in sequence.notes.iter().filter(|n| n.start.saturating_add(base) < clip_end) {
let mut n = *n;
n.start = n.start.saturating_add(base);
n.length = n.length.min(clip_end - n.start).max(1);
seq.notes.push(n);
}
for ev in &sequence.ctrls {
let mut kept: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
for ev in sequence.ctrls.iter().filter(|ev| ev.tick.saturating_add(base) < clip_end) {
let mut ev = *ev;
ev.tick = ev.tick.saturating_add(base);
seq.ctrls.push(ev);
kept.push(ev);
}
// A sustain still held at the clip end is released there.
let mut release: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
for ev in &kept {
if ev.kind != soundcraft_midi::CtrlKind::Cc(64) {
continue;
}
match release.iter_mut().find(|r| r.channel == ev.channel) {
Some(r) if ev.tick >= r.tick => *r = *ev,
Some(_) => {}
None => release.push(*ev),
}
}
seq.ctrls.append(&mut kept);
for mut r in release.into_iter().filter(|r| r.value >= 64) {
r.tick = clip_end;
r.value = 0;
seq.ctrls.push(r);
}
}
}
+63 -3
View File
@@ -146,12 +146,20 @@ fn measure_grid(s: &Session, end_unit: i64) -> Vec<(i64, i64, u32, u32)> {
let a = s.tempo.bar_start_tick(bar);
let b = s.tempo.bar_start_tick(bar + 1);
let m = s.tempo.meter_at_tick(a);
let (ua, ub) = (a / UNIT, b / UNIT);
if ub <= ua {
if b <= a {
break;
}
v.push((ua, ub - ua, m.numerator, m.denominator));
let (ua, ub) = (a / UNIT, b / UNIT);
bar += 1;
if ub <= ua {
// A bar shorter than a sixteenth that does not reach the next unit: the following
// bar carries this span, so the grid stays contiguous.
continue;
}
// Bars shorter than a sixteenth are written as one measure of the units they cover, so
// the time signature matches the measure's content.
let (num, den) = if m.ticks_per_bar() < UNIT { (u32::try_from(ub - ua).unwrap_or(1), 16) } else { (m.numerator, m.denominator) };
v.push((ua, ub - ua, num, den));
if ub >= end_unit.max(1) || v.len() >= MAX_MEASURES {
break;
}
@@ -644,6 +652,58 @@ mod tests {
assert!(s.starts_with("<svg") && s.contains("<ellipse"));
}
/// Note onsets (pitch count of every chord that is not a tie continuation).
fn note_starts(parts: &[Part]) -> usize {
parts.iter().flat_map(|p| &p.measures).flat_map(|m| &m.items).filter(|i| !i.tie_stop).map(|i| i.pitches.len()).sum()
}
#[test]
fn bars_shorter_than_a_sixteenth_keep_every_note() {
let base = crate::demo::demo_engine();
let baseline = note_starts(&layout(base.session(), &[]));
assert!(baseline > 0, "demo has notes");
for (n, d) in [(1, 32), (1, 64), (2, 64), (3, 64)] {
for mid in [false, true] {
let mut e = crate::demo::demo_engine();
let tick = if mid { e.session().tempo.bar_start_tick(5) } else { 0 };
e.session_mut().tempo.set_meter(tick, n, d).expect("valid meter");
let parts = layout(e.session(), &[]);
assert!(parts.iter().all(|p| !p.measures.is_empty()), "{n}/{d} mid={mid}: empty part");
assert_eq!(note_starts(&parts), baseline, "{n}/{d} mid={mid}: notes lost");
for p in &parts {
for m in &p.measures {
let units: u32 = m.items.iter().map(|i| i.units).sum();
// The written meter matches the measure's content (divisions are sixteenths).
assert_eq!(units * m.denominator, 16 * m.numerator, "{n}/{d} mid={mid}: meter/content mismatch");
}
}
let setup = ScoreSetup::from_session(e.session());
let x = musicxml(&parts, &setup).expect("musicxml fits");
assert!(x.contains("<pitch>"), "{n}/{d} mid={mid}: musicxml has no notes");
let s = svg(&parts, &setup).expect("svg fits");
assert!(s.contains("<ellipse"), "{n}/{d} mid={mid}: svg has no notes");
}
}
}
#[test]
fn bars_of_a_sixteenth_a_32nd_and_a_64th_in_a_row_keep_a_contiguous_grid() {
let base = crate::demo::demo_engine();
let baseline = note_starts(&layout(base.session(), &[]));
let mut e = crate::demo::demo_engine();
// Bars 5 to 8 are 1/16, 1/32, 1/64 and 3/64 long, then 4/4 resumes.
for (bar, (n, d)) in (5..).zip([(1, 16), (1, 32), (1, 64), (3, 64), (4, 4)]) {
let tick = e.session().tempo.bar_start_tick(bar);
e.session_mut().tempo.set_meter(tick, n, d).expect("valid meter");
}
let grid = measure_grid(e.session(), i64::MAX);
assert_eq!(grid.first().map(|m| m.0), Some(0));
for w in grid.windows(2) {
assert_eq!(w[0].0 + w[0].1, w[1].0, "measures leave no gap or overlap: {w:?}");
}
assert_eq!(note_starts(&layout(e.session(), &[])), baseline, "notes lost");
}
#[test]
fn output_is_bounded_by_byte_budget() {
// A part with many notes must not produce an unbounded document.
+68
View File
@@ -28,3 +28,71 @@ fn midi_track_offset_moves_the_notes_like_moving_the_clip() {
e.execute("edit.move_clips", &json!({"clip": clip, "by": 24_000})).unwrap();
assert!(render(&e) == offset, "the offset must match moving the clip by 24000 samples");
}
#[test]
fn export_midi_leaves_out_what_a_trimmed_clip_no_longer_plays() {
let mut e = Engine::default();
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Trim", "instrument": "subtractive_synth"})).unwrap();
let track = r["tracks"][0].as_u64().unwrap();
let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
let clip = r["clip"].as_u64().unwrap();
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 0, "length_ticks": 960, "velocity": 100})).unwrap();
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 62, "start_ticks": 800, "length_ticks": 960, "velocity": 100})).unwrap();
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 64, "start_ticks": 2000, "length_ticks": 960, "velocity": 100})).unwrap();
// 24000 samples is 960 ticks at 120 bpm.
let id = e.session().tracks[0].clips()[0].id;
e.session_mut().find_clip_mut(id).unwrap().trim_end_to(24_000);
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
let notes = &smf.tracks[0].sequence.notes;
assert_eq!(notes.len(), 2, "only notes starting inside the clip are exported");
let late = notes.iter().find(|n| n.pitch == 62).unwrap();
assert_eq!(late.start + late.length, 960, "a note running past the clip end is cut at it");
}
#[test]
fn export_midi_releases_a_sustain_held_past_the_trim() {
use soundcraft_midi::{CtrlEvent, CtrlKind};
let mut e = Engine::default();
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Sus", "instrument": "subtractive_synth"})).unwrap();
let track = r["tracks"][0].as_u64().unwrap();
e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
let id = e.session().tracks[0].clips()[0].id;
{
let clip = e.session_mut().find_clip_mut(id).unwrap();
if let soundcraft_model::ClipContent::Midi { sequence } = &mut clip.content {
let cc = |tick, channel, value| CtrlEvent { tick, channel, kind: CtrlKind::Cc(64), value };
sequence.ctrls = vec![cc(100, 0, 127), cc(200, 1, 127), cc(300, 1, 0), cc(2000, 0, 0)];
}
clip.trim_end_to(24_000);
}
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
let ctrls = &smf.tracks[0].sequence.ctrls;
let sus: Vec<_> = ctrls.iter().filter(|c| c.kind == CtrlKind::Cc(64)).collect();
assert!(sus.iter().any(|c| c.channel == 0 && c.tick == 960 && c.value == 0), "{sus:?}");
assert!(!sus.iter().any(|c| c.channel == 1 && c.tick == 960), "released channel gets no extra event: {sus:?}");
}
#[test]
fn export_midi_keeps_the_notes_of_a_clip_trimmed_at_both_ends_in_place() {
let mut e = Engine::default();
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Both", "instrument": "subtractive_synth"})).unwrap();
let track = r["tracks"][0].as_u64().unwrap();
let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
let clip = r["clip"].as_u64().unwrap();
for (pitch, start) in [(60, 0), (62, 1000), (64, 2000)] {
e.execute("midi.note_add", &json!({"clip": clip, "pitch": pitch, "start_ticks": start, "length_ticks": 960, "velocity": 100})).unwrap();
}
// 24000 samples is 960 ticks and 72000 samples is 2880 ticks at 120 bpm.
e.execute("edit.trim_start_to_insertion", &json!({"tracks": [track], "at": 24_000})).unwrap();
e.execute("edit.trim_end_to_insertion", &json!({"tracks": [track], "at": 72_000})).unwrap();
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
let mut notes: Vec<_> = smf.tracks[0].sequence.notes.iter().map(|n| (n.pitch, n.start, n.length)).collect();
notes.sort_unstable();
// The note cut by the start trim is gone; the others keep their place and the last stops at the end.
assert_eq!(notes, vec![(62, 1000, 960), (64, 2000, 880)]);
}
+3 -2
View File
@@ -51,8 +51,9 @@ pub(crate) use process::{EventList, MemoryStream, ParamChanges};
const MAX_CLASSES: i32 = 1024;
/// Most parameters read from one controller.
pub(crate) const MAX_PARAMS: i32 = 4096;
/// Most audio buses per direction we provide buffers for.
const MAX_BUSES: i32 = 16;
/// Most audio buses per direction we provide buffers for. Multi-out instruments declare dozens
/// of (mostly inactive) aux buses, e.g. 32 for a drum sampler; every one still gets buffers.
const MAX_BUSES: i32 = 64;
/// Most channels per audio bus.
const MAX_BUS_CHANNELS: u32 = 64;
/// Capacity of the per-block event lists (preallocated: `process` never allocates).
+1 -1
View File
@@ -35,7 +35,7 @@ pub fn fixture_dir() -> &'static Path {
let bin = dir.join("vendor/Fixture.vst3/Contents").join(folder);
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(&built, bin.join(exe)).unwrap();
assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 5);
assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 6);
dir
})
}
+12 -9
View File
@@ -1,4 +1,4 @@
//! Test-only VST3 plugin for soundcraft-vst3-host (five audio module classes):
//! Test-only VST3 plugin for soundcraft-vst3-host (six audio module classes):
//!
//! - "Fixture Gain" (`Fx`): stereo or mono in → out × gain, with a separate edit controller
//! (connected through IConnectionPoint, synced through the component state). Params: 7 "Gain"
@@ -11,9 +11,9 @@
//! its edit controller). No audio input, one stereo output, one event input; every output
//! sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and
//! MIDI CC 7 through `IMidiMapping` (hidden parameter 1), set it to their value too.
//! - "Fixture Wide 16x64", "Fixture Wide 17 buses", "Fixture Wide 65 channels" (`Fx`): effects
//! advertising wide layouts (16 buses of 64 channels, 17 buses of 2 channels, one bus of 65
//! channels per direction), through `getBusInfo` only. `process` writes 1.0 into every channel
//! - "Fixture Wide 16x64", "Fixture Wide 32 buses", "Fixture Wide 65 buses", "Fixture Wide 65
//! channels" (`Fx`): effects advertising wide layouts (16 buses of 64 channels, 32 and 65 buses
//! of 2 channels, one bus of 65 channels per direction), through `getBusInfo` only. `process` writes 1.0 into every channel
//! of output bus 0 when the host's buffers match the advertised layout, -1.0 when they do not.
//! Report 3 samples of latency.
//!
@@ -34,8 +34,9 @@ const GAIN_CID: TUID = uid(0x5C0A1D00, 0x11112222, 0x33334444, 0x55556666);
const GAIN_CTRL_CID: TUID = uid(0x5C0A1D01, 0x11112222, 0x33334444, 0x55556666);
const SYNTH_CID: TUID = uid(0x5C0A1D02, 0x11112222, 0x33334444, 0x55556666);
const WIDE16_CID: TUID = uid(0x5C0A1D03, 0x11112222, 0x33334444, 0x55556666);
const WIDE17_CID: TUID = uid(0x5C0A1D04, 0x11112222, 0x33334444, 0x55556666);
const WIDE65B_CID: TUID = uid(0x5C0A1D04, 0x11112222, 0x33334444, 0x55556666);
const WIDE65_CID: TUID = uid(0x5C0A1D05, 0x11112222, 0x33334444, 0x55556666);
const WIDE32_CID: TUID = uid(0x5C0A1D06, 0x11112222, 0x33334444, 0x55556666);
fn copy_c8(src: &str, dst: &mut [c_char]) {
for (d, s) in dst.iter_mut().zip(src.bytes().chain(std::iter::once(0))) {
@@ -736,12 +737,13 @@ impl Class for Factory {
type Interfaces = (IPluginFactory2,);
}
const CLASSES: [(TUID, &str, &str, &str); 6] = [
const CLASSES: [(TUID, &str, &str, &str); 7] = [
(GAIN_CID, "Audio Module Class", "Fixture Gain", "Fx"),
(GAIN_CTRL_CID, "Component Controller Class", "Fixture Gain", ""),
(SYNTH_CID, "Audio Module Class", "Fixture Synth", "Instrument|Synth"),
(WIDE16_CID, "Audio Module Class", "Fixture Wide 16x64", "Fx"),
(WIDE17_CID, "Audio Module Class", "Fixture Wide 17 buses", "Fx"),
(WIDE32_CID, "Audio Module Class", "Fixture Wide 32 buses", "Fx"),
(WIDE65B_CID, "Audio Module Class", "Fixture Wide 65 buses", "Fx"),
(WIDE65_CID, "Audio Module Class", "Fixture Wide 65 channels", "Fx"),
];
@@ -785,8 +787,9 @@ impl IPluginFactoryTrait for Factory {
.to_com_ptr::<FUnknown>()
} else if cid == SYNTH_CID {
ComWrapper::new(Synth { level: AtomicU32::new(0), _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
} else if let Some((buses, channels)) =
[(WIDE16_CID, (16, 64)), (WIDE17_CID, (17, 2)), (WIDE65_CID, (1, 65))].into_iter().find_map(|(c, layout)| (c == cid).then_some(layout))
} else if let Some((buses, channels)) = [(WIDE16_CID, (16, 64)), (WIDE32_CID, (32, 2)), (WIDE65B_CID, (65, 2)), (WIDE65_CID, (1, 65))]
.into_iter()
.find_map(|(c, layout)| (c == cid).then_some(layout))
{
ComWrapper::new(Wide { buses, channels, _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
} else {
+15 -3
View File
@@ -15,7 +15,7 @@ fn id_of(name: &str) -> String {
fn scan_lists_both_audio_modules_but_not_the_controller_class() {
let dir = fixture_dir();
let found = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]);
assert_eq!(found.len(), 5, "{found:?}");
assert_eq!(found.len(), 6, "{found:?}");
let g = found.iter().find(|d| d.name == "Fixture Gain").unwrap();
// `uid()` lays out the first 8 bytes differently on Windows (COM GUID order).
let want = if cfg!(windows) { "vst3:001D0A5C111122223333444455556666" } else { "vst3:5C0A1D00111122223333444455556666" };
@@ -320,8 +320,8 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == 1.0), "16x64 layout delivered as advertised: {:?}", &io[0][..4]);
// 17 buses, or a 65-channel bus: never activated, never processed with a cut-down layout.
for name in ["Fixture Wide 17 buses", "Fixture Wide 65 channels"] {
// 65 buses, or a 65-channel bus: never activated, never processed with a cut-down layout.
for name in ["Fixture Wide 65 buses", "Fixture Wide 65 channels"] {
let mut p = soundcraft_vst3_host::create(&id_of(name)).unwrap();
p.prepare(44_100.0, 64, 2);
assert_eq!(p.latency(), 0, "{name}: not activated");
@@ -331,6 +331,18 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
}
}
#[test]
fn many_buses_like_multi_out_instruments_are_activated() {
// Multi-out instruments declare dozens of buses (SSD Sampler 5: 32); only the main one is
// active, but the plugin must still load, get buffers for every bus and play (#1247).
let mut p = soundcraft_vst3_host::create(&id_of("Fixture Wide 32 buses")).unwrap();
p.prepare(44_100.0, 64, 2);
assert_eq!(p.latency(), 3, "32-bus plugin activated");
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == 1.0), "32-bus layout delivered as advertised: {:?}", &io[0][..4]);
}
#[test]
fn editor_view_is_probed_and_edits_come_back_as_plain_values() {
let id = id_of("Fixture Gain");