diff --git a/crates/vst3-host/src/ffi/instance.rs b/crates/vst3-host/src/ffi/instance.rs index 7bc23e2a..c7d783e0 100644 --- a/crates/vst3-host/src/ffi/instance.rs +++ b/crates/vst3-host/src/ffi/instance.rs @@ -35,6 +35,11 @@ pub(crate) struct Layout { pub event_input: bool, } +/// `"input"` or `"output"`, for error messages. +fn dir_name(dir: sv::BusDirections) -> &'static str { + if dir == sv::BusDirections_::kInput { "input" } else { "output" } +} + pub(super) fn apply_transport(c: &mut sv::ProcessContext, t: &Transport) { use sv::ProcessContext_::StatesAndFlags_::{ kBarPositionValid, kContTimeValid, kPlaying, kProjectTimeMusicValid, kRecording, kTempoValid, kTimeSigValid, @@ -248,7 +253,17 @@ impl Instance { fn bus_count(&self, media: sv::MediaTypes, dir: sv::BusDirections) -> i32 { // SAFETY: valid component. - unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.clamp(0, MAX_BUSES) + unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.max(0) + } + + /// The plugin's audio buses in one direction. More than `MAX_BUSES` is an error: we never + /// hand a plugin fewer buses than it advertised. + fn audio_bus_count(&self, dir: sv::BusDirections) -> Result { + let n = self.bus_count(sv::MediaTypes_::kAudio, dir); + if n > MAX_BUSES { + return Err(Vst3Error::Process(format!("{n} audio {} buses (at most {MAX_BUSES} supported)", dir_name(dir)))); + } + Ok(n) } fn arrangement(&self, dir: sv::BusDirections, index: i32) -> Option { @@ -258,30 +273,41 @@ impl Instance { ok(unsafe { p.getBusArrangement(dir as sv::BusDirection, index, &mut arr) }).then_some(arr) } - fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> u32 { - if let Some(arr) = self.arrangement(dir, index) { - return arr.count_ones().min(MAX_BUS_CHANNELS); - } - // SAFETY: plain data; all-zero is valid. - let mut info: sv::BusInfo = unsafe { std::mem::zeroed() }; - // SAFETY: valid component; index below the bus count; `info` is writable. - if ok(unsafe { self.component.getBusInfo(sv::MediaTypes_::kAudio as sv::MediaType, dir as sv::BusDirection, index, &mut info) }) { - u32::try_from(info.channelCount).unwrap_or(0).min(MAX_BUS_CHANNELS) + /// Channels of one audio bus. More than `MAX_BUS_CHANNELS` is an error, as for the bus count. + fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> Result { + let channels = if let Some(arr) = self.arrangement(dir, index) { + arr.count_ones() } else { - 0 + // SAFETY: plain data; all-zero is valid. + let mut info: sv::BusInfo = unsafe { std::mem::zeroed() }; + // SAFETY: valid component; index below the bus count; `info` is writable. + if ok(unsafe { self.component.getBusInfo(sv::MediaTypes_::kAudio as sv::MediaType, dir as sv::BusDirection, index, &mut info) }) { + u32::try_from(info.channelCount).unwrap_or(0) + } else { + 0 + } + }; + if channels > MAX_BUS_CHANNELS { + return Err(Vst3Error::Process(format!( + "audio {} bus {index} has {channels} channels (at most {MAX_BUS_CHANNELS} supported)", + dir_name(dir) + ))); } + Ok(channels) } /// Stops processing, deactivates, negotiates the bus layout (main buses mono or stereo as /// asked, falling back to stereo and then to the plugin's own choice), activates the main - /// buses, sets up 32-bit processing and activates the component. + /// buses, sets up 32-bit processing and activates the component. A layout beyond + /// `MAX_BUSES` buses or `MAX_BUS_CHANNELS` channels per bus is an error, and the component + /// stays inactive. pub fn configure(&mut self, sample_rate: f64, max_frames: usize, mono: bool) -> Result { self.shutdown(); let audio = sv::MediaTypes_::kAudio; let event = sv::MediaTypes_::kEvent; let (din, dout) = (sv::BusDirections_::kInput, sv::BusDirections_::kOutput); - let nin = self.bus_count(audio, din); - let nout = self.bus_count(audio, dout); + let nin = self.audio_bus_count(din)?; + let nout = self.audio_bus_count(dout)?; let current = |s: &Self, dir: sv::BusDirections, n: i32| -> Vec { (0..n).map(|i| s.arrangement(dir, i).unwrap_or(sv::SpeakerArr::kStereo)).collect() }; @@ -311,8 +337,8 @@ impl Instance { log::debug!("vst3: plugin refused mono/stereo bus arrangements; using its own"); } let layout = Layout { - inputs: (0..nin).map(|i| self.bus_channels(din, i)).collect(), - outputs: (0..nout).map(|i| self.bus_channels(dout, i)).collect(), + inputs: (0..nin).map(|i| self.bus_channels(din, i)).collect::>()?, + outputs: (0..nout).map(|i| self.bus_channels(dout, i)).collect::>()?, event_input: self.bus_count(event, din) > 0, }; // Main buses on, aux buses off (VST3 still gets buffers for every bus). diff --git a/crates/vst3-host/tests/common/mod.rs b/crates/vst3-host/tests/common/mod.rs index e49b5a0e..32a50f36 100644 --- a/crates/vst3-host/tests/common/mod.rs +++ b/crates/vst3-host/tests/common/mod.rs @@ -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), 2); + assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 5); dir }) } diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index 2b7f73d9..68703d27 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -1,4 +1,4 @@ -//! Test-only VST3 plugin for soundcraft-vst3-host (two audio module classes): +//! Test-only VST3 plugin for soundcraft-vst3-host (five 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,6 +11,11 @@ //! 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 +//! of output bus 0 when the host's buffers match the advertised layout, -1.0 when they do not. +//! Report 3 samples of latency. //! //! The module checks the host's module lifecycle the way real plugins depend on it (see "module //! lifecycle" below) and aborts the process when it is broken. @@ -28,6 +33,9 @@ use vst3::{Class, ComRef, ComWrapper, uid}; 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 WIDE65_CID: TUID = uid(0x5C0A1D05, 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))) { @@ -626,6 +634,98 @@ impl IEditControllerTrait for Synth { } } +// ---- wide layouts -------------------------------------------------------------------------- + +struct Wide { + buses: i32, + channels: i32, + _live: Live, +} + +impl Class for Wide { + type Interfaces = (IComponent, IAudioProcessor); +} + +impl IPluginBaseTrait for Wide { + unsafe fn initialize(&self, _context: *mut FUnknown) -> tresult { + kResultOk + } + unsafe fn terminate(&self) -> tresult { + kResultOk + } +} + +impl IComponentTrait for Wide { + unsafe fn getControllerClassId(&self, _class_id: *mut TUID) -> tresult { + kNotImplemented + } + unsafe fn setIoMode(&self, _mode: IoMode) -> tresult { + kResultOk + } + unsafe fn getBusCount(&self, media: MediaType, _dir: BusDirection) -> i32 { + if media == MediaTypes_::kAudio as MediaType { self.buses } else { 0 } + } + unsafe fn getBusInfo(&self, media: MediaType, dir: BusDirection, index: i32, bus: *mut BusInfo) -> tresult { + if media != MediaTypes_::kAudio as MediaType || !(0..self.buses).contains(&index) { + return kInvalidArgument; + } + audio_bus(bus, dir, "Wide"); + unsafe { (*bus).channelCount = self.channels }; + kResultOk + } + unsafe fn getRoutingInfo(&self, _i: *mut RoutingInfo, _o: *mut RoutingInfo) -> tresult { + kNotImplemented + } + unsafe fn activateBus(&self, _m: MediaType, _d: BusDirection, _i: i32, _s: TBool) -> tresult { + kResultOk + } + unsafe fn setActive(&self, _state: TBool) -> tresult { + kResultOk + } + unsafe fn setState(&self, _state: *mut IBStream) -> tresult { + kResultOk + } + unsafe fn getState(&self, _state: *mut IBStream) -> tresult { + kResultOk + } +} + +impl IAudioProcessorTrait for Wide { + unsafe fn setBusArrangements(&self, _i: *mut SpeakerArrangement, _n_in: i32, _o: *mut SpeakerArrangement, _n_out: i32) -> tresult { + kResultFalse + } + unsafe fn getBusArrangement(&self, _dir: BusDirection, _index: i32, _arr: *mut SpeakerArrangement) -> tresult { + kNotImplemented + } + unsafe fn canProcessSampleSize(&self, size: i32) -> tresult { + if size == SymbolicSampleSizes_::kSample32 as i32 { kResultOk } else { kNotImplemented } + } + unsafe fn getLatencySamples(&self) -> u32 { + 3 + } + unsafe fn setupProcessing(&self, _setup: *mut ProcessSetup) -> tresult { + kResultOk + } + unsafe fn setProcessing(&self, _state: TBool) -> tresult { + kResultOk + } + unsafe fn process(&self, data: *mut ProcessData) -> tresult { + let d = unsafe { &*data }; + let fits = + |bufs: *mut AudioBusBuffers, n: i32| n == self.buses && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.numChannels == self.channels); + let ok = fits(d.inputs, d.numInputs) && fits(d.outputs, d.numOutputs); + if d.numOutputs > 0 { + for ch in unsafe { channels(&*d.outputs, d.numSamples.max(0) as usize) } { + ch.fill(if ok { 1.0 } else { -1.0 }); + } + } + kResultOk + } + unsafe fn getTailSamples(&self) -> u32 { + 0 + } +} + // ---- factory ------------------------------------------------------------------------------- struct Factory { @@ -636,10 +736,13 @@ impl Class for Factory { type Interfaces = (IPluginFactory2,); } -const CLASSES: [(TUID, &str, &str, &str); 3] = [ +const CLASSES: [(TUID, &str, &str, &str); 6] = [ (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"), + (WIDE65_CID, "Audio Module Class", "Fixture Wide 65 channels", "Fx"), ]; impl IPluginFactoryTrait for Factory { @@ -682,6 +785,10 @@ impl IPluginFactoryTrait for Factory { .to_com_ptr::() } else if cid == SYNTH_CID { ComWrapper::new(Synth { level: AtomicU32::new(0), _live: Live::new(&OBJECTS) }).to_com_ptr::() + } 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)) + { + ComWrapper::new(Wide { buses, channels, _live: Live::new(&OBJECTS) }).to_com_ptr::() } else { None }; diff --git a/crates/vst3-host/tests/it/fixture_e2e.rs b/crates/vst3-host/tests/it/fixture_e2e.rs index 01b4dadd..ac7d2b77 100644 --- a/crates/vst3-host/tests/it/fixture_e2e.rs +++ b/crates/vst3-host/tests/it/fixture_e2e.rs @@ -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(), 2, "{found:?}"); + assert_eq!(found.len(), 5, "{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" }; @@ -311,6 +311,26 @@ fn host_set_controller_param_survives_state_round_trip() { assert!((r.param("12").unwrap() - 200.0).abs() < 0.01, "a loaded state replaces earlier edits: {:?}", r.param("12")); } +#[test] +fn layouts_beyond_the_host_limits_are_rejected_not_truncated() { + // Control: exactly at the limits (16 buses of 64 channels) activates and gets its full layout. + let mut p = soundcraft_vst3_host::create(&id_of("Fixture Wide 16x64")).unwrap(); + p.prepare(44_100.0, 64, 2); + assert_eq!(p.latency(), 3); + 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"] { + 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"); + let mut io = vec![vec![0.5f32; 64]; 2]; + p.process(&mut io, 64); + assert!(io.iter().flatten().all(|&x| x == 0.5), "{name}: the plugin never ran: {:?}", &io[0][..4]); + } +} + #[test] fn editor_view_is_probed_and_edits_come_back_as_plain_values() { let id = id_of("Fixture Gain");