From cc86842abe1b68c8e02dec31c204985e9ba98b8c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 00:39:59 -0400 Subject: [PATCH] Surround mixing, VST3 hosting, and hardening against hostile inputs Surround: - main and bus formats up to 9.1.6 and Ambisonics; pairwise constant-power surround panner (divergence, centre, LFE, height) per track and send; ITU fold-down; multichannel bounce with correct WAV channel masks; multichannel device output; surround panner and multichannel meters in the Mix window; setup.main_format, mix.surround_pan, mix.send_surround_pan VST3: - soundcraft-vst3-host: isolated unsafe crate (vst3 bindings, MIT), scans standard folders, hosts components/processors/controllers, params, latency, notes; VST3 menu; engine.vst3_plugins; e2e tests with a fixture Hardening: - positions from parameters clamped; offline renders capped and bounded to track material; loop/transport limits; hostile-parameter property test Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + Cargo.lock | 31 + Cargo.toml | 1 + ROADMAP.md | 15 +- crates/audio-io/src/lib.rs | 19 +- crates/audio-io/src/wav.rs | 34 +- crates/audio-io/tests/roundtrip.rs | 20 + crates/automation/tests/agent_tasks.rs | 2 +- crates/dsp/src/pan.rs | 319 ++++- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/audio_midi.rs | 1 + crates/engine/src/cmd/clap.rs | 4 +- crates/engine/src/cmd/clip.rs | 2 +- crates/engine/src/cmd/file.rs | 19 +- crates/engine/src/cmd/mod.rs | 13 + crates/engine/src/cmd/surround.rs | 231 +++ crates/engine/src/cmd/transport.rs | 4 +- crates/engine/src/cmd/vst3.rs | 50 + crates/engine/src/inspect.rs | 6 +- crates/engine/src/io.rs | 41 +- .../tests/hostile_params.proptest-regressions | 8 + crates/engine/tests/hostile_params.rs | 2 +- .../tests/random_edits.proptest-regressions | 7 + crates/mix/Cargo.toml | 1 + crates/mix/src/lib.rs | 674 +++++++-- crates/model/src/lib.rs | 4 +- crates/model/src/mixer.rs | 287 +++- crates/playback/Cargo.toml | 3 + crates/playback/src/lib.rs | 154 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/mix_window.rs | 120 +- crates/ui-egui/src/widgets.rs | 131 ++ crates/ui-egui/src/windows.rs | 13 + crates/vst3-host/Cargo.toml | 30 + crates/vst3-host/src/ffi.rs | 1251 +++++++++++++++++ crates/vst3-host/src/lib.rs | 286 ++++ crates/vst3-host/src/plugin.rs | 298 ++++ crates/vst3-host/src/registry.rs | 264 ++++ crates/vst3-host/src/scan.rs | 324 +++++ crates/vst3-host/tests/fixture/Cargo.lock | 25 + crates/vst3-host/tests/fixture/Cargo.toml | 18 + crates/vst3-host/tests/fixture/src/lib.rs | 612 ++++++++ crates/vst3-host/tests/fixture_e2e.rs | 208 +++ xtask/src/main.rs | 1 + 44 files changed, 5348 insertions(+), 188 deletions(-) create mode 100644 crates/engine/src/cmd/surround.rs create mode 100644 crates/engine/src/cmd/vst3.rs create mode 100644 crates/engine/tests/hostile_params.proptest-regressions create mode 100644 crates/engine/tests/random_edits.proptest-regressions create mode 100644 crates/vst3-host/Cargo.toml create mode 100644 crates/vst3-host/src/ffi.rs create mode 100644 crates/vst3-host/src/lib.rs create mode 100644 crates/vst3-host/src/plugin.rs create mode 100644 crates/vst3-host/src/registry.rs create mode 100644 crates/vst3-host/src/scan.rs create mode 100644 crates/vst3-host/tests/fixture/Cargo.lock create mode 100644 crates/vst3-host/tests/fixture/Cargo.toml create mode 100644 crates/vst3-host/tests/fixture/src/lib.rs create mode 100644 crates/vst3-host/tests/fixture_e2e.rs diff --git a/AGENTS.md b/AGENTS.md index 9943d23..a1125ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ Pro Tools is installed on the dev machine and may be *observed* black-box: run i People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). - No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). - The one named exception is `soundcraft-clap-host` (`crates/clap-host`), the isolated unsafe helper crate for CLAP plugin FFI: `unsafe_code = "deny"` crate-wide, allowed only in `src/ffi.rs` with a `// SAFETY:` comment on every block, safe `Result` API. +- The same exception, same rules, for `soundcraft-vst3-host` (`crates/vst3-host`), the isolated unsafe helper crate for VST3 plugin FFI (COM vtables via the `vst3` bindings, module loading, host-side COM objects). - Errors are `Result` + `?`. An unfinished feature returns an "unsupported" error. - Input-derived numbers are hostile (files, commands, MCP/control params): `get()` not `[i]`, checked/saturating arithmetic, no NaN casts, cap allocations. - The audio callback never blocks, allocates in steady state, or panics. diff --git a/Cargo.lock b/Cargo.lock index 3af133d..bc14aaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,6 +668,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "com-scrape-types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ce183b975b8fc736a20919c4002717255fbd867df575c792aeefa9a56a73ad0" + [[package]] name = "combine" version = "4.6.8" @@ -3437,6 +3443,7 @@ dependencies = [ "soundcraft-mix", "soundcraft-model", "soundcraft-time", + "soundcraft-vst3-host", "thiserror 2.0.21", ] @@ -3462,6 +3469,7 @@ dependencies = [ "soundcraft-midi", "soundcraft-model", "soundcraft-time", + "soundcraft-vst3-host", ] [[package]] @@ -3483,6 +3491,7 @@ version = "0.1.0" dependencies = [ "cpal", "log", + "soundcraft-audio-io", "soundcraft-dsp", "soundcraft-mix", "soundcraft-model", @@ -3518,6 +3527,19 @@ dependencies = [ "soundcraft-model", "soundcraft-playback", "soundcraft-time", + "soundcraft-vst3-host", +] + +[[package]] +name = "soundcraft-vst3-host" +version = "0.1.0" +dependencies = [ + "libloading", + "log", + "serde", + "soundcraft-dsp", + "thiserror 2.0.21", + "vst3", ] [[package]] @@ -4090,6 +4112,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vst3" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31e3fe7a6f028ded8c5a9984cf283319e6dc63572c9b71da1d4ee9d71d3b6bf" +dependencies = [ + "com-scrape-types", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index f78c5b8..c9e7a51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ soundcraft-audio-io = { path = "crates/audio-io" } soundcraft-midi = { path = "crates/midi" } soundcraft-dsp = { path = "crates/dsp" } soundcraft-clap-host = { path = "crates/clap-host" } +soundcraft-vst3-host = { path = "crates/vst3-host" } soundcraft-model = { path = "crates/model" } soundcraft-mix = { path = "crates/mix" } soundcraft-engine = { path = "crates/engine" } diff --git a/ROADMAP.md b/ROADMAP.md index 5feaca8..e23cce7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,8 +9,8 @@ the honest status: what works today, what is missing, and how far we are. | Measure | Value | How | |---|---|---| | Menu-catalog parity (engine + UI) | **462 / 512 menu items (90 %)**; engine alone 388 / 512 (76 %), see [`docs/parity.md`](docs/parity.md) | `cargo xtask parity` compares the incumbent's 512 menu leaves (names observed black-box) with our command registry; the remaining items are mostly video, Atmos, collaboration, notation and third-party services | -| Estimated overall feature parity | **~50 %** | judgement across the areas below, weighted by how much professional users rely on them | -| Estimated remaining effort | **~170–230 wall-clock hours** of Claude Opus 5.5 work (single agent; roughly 55–80 hours with 3–4 agents in parallel) | sum of the per-area estimates below | +| Estimated overall feature parity | **~55 %** | judgement across the areas below, weighted by how much professional users rely on them | +| Estimated remaining effort | **~150–200 wall-clock hours** of Claude Opus 5.5 work (single agent; roughly 50–70 hours with 3–4 agents in parallel) | sum of the per-area estimates below | ### Area by area @@ -22,15 +22,22 @@ the honest status: what works today, what is missing, and how far we are. | Mixer & routing | Faders, pan, mute/solo (SIP, implicit solo), sends pre/post, busses, aux inputs, master faders, VCAs, routing folders, 10 inserts, plugin delay compensation, clip effects | 70 % | 20 | | Automation | Breakpoint lanes for volume/pan/mute/sends/plugin params, trim automation; live Write/Touch/Latch passes; write/thin/glide/convert/coalesce commands | 60 % | 18 | | Plugins (built-in) | 26 original processors + 2 instruments, AudioSuite offline processing | 45 % | 30 | -| Third-party plugins (CLAP/VST3/AU) | CLAP hosting (audio, params, latency, notes) in an isolated unsafe crate; no plugin GUIs, no state save, no VST3/AU | 30 % | 30 | +| Third-party plugins (CLAP/VST3/AU) | CLAP hosting (audio, params, latency, notes); VST3 hosting in progress; no plugin GUIs, no state save, no AU | 35 % | 28 | | MIDI | MIDI/instrument tracks, SMF import/export, MIDI editor (piano roll + velocity), event list, step input, quantize/transpose/velocity/duration ops | 40 % | 30 | | Recording | Input capture, punch in/out (selection, on the fly, pre/post-roll), loop record into playlists, input monitoring through the channel strip, autosave and recovery | 60 % | 12 | | Elastic Audio / TCE / Beat Detective | Pitch-preserving warp on Elastic tracks, TCE to timeline, conform to tempo, Beat Detective and Identify Beat windows | 40 % | 18 | -| Video, surround panning, Atmos | Formats modelled; no video track, no surround panner | 5 % | 40 | +| Surround | Main and bus formats from stereo to 9.1.6 and Ambisonics, surround panner with divergence/centre/LFE/height, ITU fold-down, multichannel bounce, meters and device output | 60 % | 15 | +| Video | Movie audio import; no picture track yet | 10 % | 35 | | UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog, floating windows, dialogs | 65 % | 25 | | Agent control | CLI, JSON control channel, MCP server (headless + bridged), offscreen UI renders | 90 % (ahead of the incumbent) | 5 | | Release engineering | Signed macOS universal, Windows x64/x86, Linux AppImage/deb/rpm/tar/Flatpak, FreeBSD, Web/WASM on every push to `release` | 80 % | 5 | +## Robustness + +Property tests drive random edit sequences (with full undo), mutated session files and hostile +command parameters through the engine and mixer; they have already caught fade, overflow and +unbounded-allocation bugs. Every command also runs with empty parameters on empty and demo sessions. + ## Current focus 1. Play back the model-only state the engine already stores: clip effects, trim automation, MIDI real-time properties. diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index c73a00e..eb1813d 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -250,7 +250,7 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB /// Encode `buf` as WAV/BWF, AIFF/AIFF-C or FLAC. Samples are clamped to [-1, 1]. pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result> { match opts.format { - FileFormat::Wav => wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), false), + FileFormat::Wav => wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), false, None), FileFormat::Aiff => aiff::encode(buf, opts.bit_depth, opts.dither), FileFormat::Flac => flac::encode(buf, opts.bit_depth, opts.dither), other => Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))), @@ -259,5 +259,20 @@ pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result> { /// Encode a WAV file in the RF64 layout regardless of size (normally used only above 4 GiB). pub fn encode_wav_rf64(buf: &AudioBuffer, opts: &EncodeOptions) -> Result> { - wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), true) + wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), true, None) +} + +/// Like [`encode`], with an explicit WAV speaker mask (`dwChannelMask`, e.g. 0x60F) for layouts +/// a channel count alone cannot identify (7.0 vs 6.1, 5.1.4 vs 7.1.2, Ambisonics = 0). AIFF and +/// FLAC have fixed channel orders and ignore it. +pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u32) -> Result> { + match opts.format { + FileFormat::Wav => wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), false, Some(mask)), + _ => encode(buf, opts), + } +} + +/// The WAVE_FORMAT_EXTENSIBLE speaker mask of a WAV file, if it has one. +pub fn wav_channel_mask(bytes: &[u8]) -> Option { + wav::read_channel_mask(bytes) } diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index 102a5c3..cf14fb7 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -190,6 +190,8 @@ pub(crate) fn decode(b: &[u8], layout: &WavLayout) -> Result { Ok(AudioBuffer { sample_rate: layout.info.sample_rate, channels }) } +/// Default speaker mask for a channel count (callers that know the layout pass their own): +/// mono, stereo, LCR, quad, 5.0, 5.1, 6.1, 7.1 (sides + rears), 7.1.4. fn channel_mask(channels: usize) -> u32 { match channels { 1 => 0x4, @@ -200,11 +202,29 @@ fn channel_mask(channels: usize) -> u32 { 6 => 0x3F, 7 => 0x13F, 8 => 0x63F, + // L R C LFE Lrs Rrs Lss Rss Ltf Rtf Ltr Rtr (7.1.4). + 12 => 0x2_D63F, n if n <= 18 => (1u32 << n) - 1, _ => 0, } } +/// The `dwChannelMask` of a WAVE_FORMAT_EXTENSIBLE file (None for plain PCM/float headers). +pub(crate) fn read_channel_mask(b: &[u8]) -> Option { + if !is_wav(b) { + return None; + } + let mut pos = 12usize; + for _ in 0..10_000 { + let (id, size) = (tag(b, pos)?, usize::try_from(le_u32(b, pos + 4)?).ok()?); + if &id == b"fmt " { + return (le_u16(b, pos + 8)? == WAVE_FORMAT_EXTENSIBLE).then(|| le_u32(b, pos + 8 + 20)).flatten(); + } + pos = pos.checked_add(8)?.checked_add(size)?.checked_add(size & 1)?; + } + None +} + fn put_fixed_str(out: &mut Vec, s: &str, len: usize) { let bytes = s.as_bytes(); let n = bytes.len().min(len); @@ -237,7 +257,15 @@ fn push_chunk(out: &mut Vec, id: &[u8; 4], body: &[u8]) { /// Encode a WAV file. `force_rf64` writes the RF64 layout even for small files (for tests); /// it is used automatically when the file would exceed 4 GiB. -pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool, bwf: Option<&BwfInfo>, force_rf64: bool) -> Result> { +/// `mask` overrides the speaker mask derived from the channel count. +pub(crate) fn encode( + buf: &AudioBuffer, + depth: BitDepth, + dither: bool, + bwf: Option<&BwfInfo>, + force_rf64: bool, + mask: Option, +) -> Result> { validate_buffer(buf)?; let channels = buf.channels.len(); let frames = buf.frames(); @@ -251,7 +279,7 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool, bwf: Opti let block = bytes.checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?; let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?; let data_len = block.checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; - let extensible = channels > 2 || bits > 16; + let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels)); let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM }; let mut fmt = Vec::with_capacity(40); @@ -264,7 +292,7 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool, bwf: Opti if extensible { fmt.extend_from_slice(&22u16.to_le_bytes()); fmt.extend_from_slice(&bits.to_le_bytes()); - fmt.extend_from_slice(&channel_mask(channels).to_le_bytes()); + fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes()); fmt.extend_from_slice(&fmt_tag.to_le_bytes()); fmt.extend_from_slice(&GUID_TAIL); } diff --git a/crates/audio-io/tests/roundtrip.rs b/crates/audio-io/tests/roundtrip.rs index d9b1571..b2291e1 100644 --- a/crates/audio-io/tests/roundtrip.rs +++ b/crates/audio-io/tests/roundtrip.rs @@ -248,3 +248,23 @@ fn serde_types_roundtrip() { let s = serde_json::to_string(&info).unwrap(); assert_eq!(serde_json::from_str::(&s).unwrap(), info); } + +#[test] +fn multichannel_wav_speaker_masks() { + let buf = |n: usize| AudioBuffer { sample_rate: 48_000, channels: (0..n).map(|c| vec![0.01 * c as f32; 64]).collect() }; + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None }; + for (n, mask) in [(6usize, 0x3Fu32), (8, 0x63F), (12, 0x2_D63F)] { + let bytes = soundcraft_audio_io::encode(&buf(n), &opts).unwrap(); + assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(mask), "{n} channels"); + assert_eq!(soundcraft_audio_io::decode(&bytes, Some("wav")).unwrap().1.channels.len(), n); + } + // Explicit masks: 7.0 (sides + rears, no LFE) and Ambisonics (no positions). + let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(7), &opts, 0x637).unwrap(); + assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(0x637)); + let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(4), &opts, 0).unwrap(); + assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(0)); + // Plain 16-bit stereo stays a plain PCM header. + let o16 = EncodeOptions { bit_depth: BitDepth::Int16, ..opts }; + let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(2), &o16, 0x3).unwrap(); + assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), None); +} diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/agent_tasks.rs index 4907cc3..04fe256 100644 --- a/crates/automation/tests/agent_tasks.rs +++ b/crates/automation/tests/agent_tasks.rs @@ -81,7 +81,7 @@ fn agent_builds_and_mixes_a_session() { tool( &mut s, "execute", - json!({"command": "edit.select", "params": {"tracks": ["Snare"], "start": "bars_beats:5|1|000", "end": "bars_beats:9|1|000"}}), + json!({"command": "edit.select", "params": {"tracks": ["Snare"], "start": "bars_beats:6|1|000", "end": "bars_beats:9|1|000"}}), ); tool(&mut s, "execute", json!({"command": "edit.cut", "params": {}})); let mid = track(&session(&mut s), "Snare")["clips"].as_array().unwrap().len(); diff --git a/crates/dsp/src/pan.rs b/crates/dsp/src/pan.rs index 94ec196..4cd6870 100644 --- a/crates/dsp/src/pan.rs +++ b/crates/dsp/src/pan.rs @@ -1,4 +1,4 @@ -//! Mono-to-stereo pan laws. +//! Mono-to-stereo pan laws and the surround (multichannel) panner. use std::f32::consts::FRAC_PI_4; @@ -43,6 +43,248 @@ pub fn gains(pan: f32, law: PanLaw) -> (f32, f32) { (l, r) } +/// Most loudspeakers the surround panner handles (9.1.6 = 16). +pub const MAX_SPEAKERS: usize = 16; + +/// A loudspeaker direction for the surround panner: azimuth in degrees (0 = front, positive = +/// right), elevation in degrees (> 0 = height layer), and whether it is the LFE (never panned to). +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct SpeakerPos { + pub az: f32, + pub el: f32, + pub lfe: bool, +} + +/// Surround panner controls (see `soundcraft_model::SurroundPan`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SurroundParams { + /// -1 (left) ..= 1 (right). + pub x: f32, + /// -1 (back) ..= 1 (front). + pub y: f32, + /// 0 (ear level) ..= 1 (overhead). + pub z: f32, + /// 0 = point source ..= 1 = every speaker. + pub divergence: f32, + /// 0..=1 share of centre-speaker energy kept on C (the rest goes phantom to L/R). + pub center: f32, +} + +fn fin(v: f32, d: f32) -> f32 { + if v.is_finite() { v } else { d } +} + +fn wrap180(a: f32) -> f32 { + let mut a = fin(a, 0.0) % 360.0; + if a > 180.0 { + a -= 360.0; + } else if a <= -180.0 { + a += 360.0; + } + a +} + +/// Azimuth (degrees) of a square puck position. The square's front corners land on the front +/// L/R speakers (±30°), its side midpoints at ±90° and its rear corners on the layout's rear-most +/// surround (±110° for 5.x, ±150° for 7.x), so a puck in a corner hits exactly one speaker. +pub fn puck_azimuth(layout: &[SpeakerPos], x: f32, y: f32) -> f32 { + let (x, y) = (fin(x, 0.0).clamp(-1.0, 1.0), fin(y, 1.0).clamp(-1.0, 1.0)); + if x.abs() < 1e-9 && y.abs() < 1e-9 { + return 0.0; + } + let rear = + layout.iter().filter(|s| !s.lfe && s.el.abs() < 1.0 && s.az.abs() > 90.5 && s.az.abs() < 179.5).map(|s| s.az.abs()).fold(0.0f32, f32::max); + let rear = if rear > 0.0 { rear } else { 135.0 }; + let front = if layout.iter().any(|s| !s.lfe && s.el.abs() < 1.0 && (s.az.abs() - 30.0).abs() < 0.5) { 30.0 } else { 45.0 }; + let phi = x.atan2(y).to_degrees(); + let a = phi.abs(); + let warped = if a <= 45.0 { + a / 45.0 * front + } else if a <= 90.0 { + front + (a - 45.0) / 45.0 * (90.0 - front) + } else if a <= 135.0 { + 90.0 + (a - 90.0) / 45.0 * (rear - 90.0) + } else { + rear + (a - 135.0) / 45.0 * (180.0 - rear) + }; + warped.copysign(phi) +} + +/// Inverse of [`puck_azimuth`] on the square's edge (for drawing speakers on a square panner). +pub fn azimuth_to_puck(layout: &[SpeakerPos], az: f32) -> (f32, f32) { + // Bisect the monotonic warp on the square angle. + let target = wrap180(az); + let (mut lo, mut hi) = (0.0f32, 180.0f32); + for _ in 0..40 { + let mid = 0.5 * (lo + hi); + let r = mid.to_radians(); + let w = puck_azimuth(layout, r.sin(), r.cos()); + if w < target.abs() { + lo = mid; + } else { + hi = mid; + } + } + let phi = (0.5 * (lo + hi)).to_radians().copysign(if target < 0.0 { -1.0 } else { 1.0 }); + let (sx, sy) = (phi.sin(), phi.cos()); + let m = sx.abs().max(sy.abs()).max(1e-6); + (sx / m, sy / m) +} + +/// Constant-power pairwise pan of a direction onto one layer (bed or height) of `layout`, +/// accumulating `weight × gain²` into `pow`. Returns false when the layer has no speakers. +fn pair_power(layout: &[SpeakerPos], height: bool, az: f32, weight: f32, pow: &mut [f32; MAX_SPEAKERS]) -> bool { + let mut idx = [0usize; MAX_SPEAKERS]; + let mut n = 0; + for (i, s) in layout.iter().enumerate().take(MAX_SPEAKERS) { + if !s.lfe + && (s.el > 1.0) == height + && let Some(slot) = idx.get_mut(n) + { + *slot = i; + n += 1; + } + } + let ids = idx.get(..n).unwrap_or(&[]); + let az_of = |i: usize| layout.get(i).map_or(0.0, |s| wrap180(s.az)); + match ids { + [] => false, + [only] => { + if let Some(p) = pow.get_mut(*only) { + *p += weight; + } + true + } + [first, ..] => { + let a = wrap180(az); + // The nearest speaker clockwise (to the right, increasing azimuth) and anticlockwise. + let (mut cw, mut cw_d) = (*first, f32::MAX); + let (mut acw, mut acw_d) = (*first, f32::MAX); + for &i in ids { + let d = (az_of(i) - a).rem_euclid(360.0); + if d < cw_d { + cw_d = d; + cw = i; + } + let d2 = (a - az_of(i)).rem_euclid(360.0); + if d2 < acw_d { + acw_d = d2; + acw = i; + } + } + if cw == acw || cw_d < 1e-4 { + if let Some(p) = pow.get_mut(cw) { + *p += weight; + } + return true; + } + let span = (cw_d + acw_d).max(1e-6); + let t = (acw_d / span).clamp(0.0, 1.0) * std::f32::consts::FRAC_PI_2; + if let Some(p) = pow.get_mut(acw) { + *p += weight * t.cos() * t.cos(); + } + if let Some(p) = pow.get_mut(cw) { + *p += weight * t.sin() * t.sin(); + } + true + } + } +} + +/// Point-source gains for a direction (azimuth, elevation in degrees) on `layout`: pairwise +/// constant power on the speaker layer nearest in elevation; a height direction on a layout +/// without heights folds to the bed at -3 dB. Writes `out[..layout.len()]`. +pub fn direction_gains(layout: &[SpeakerPos], az: f32, el: f32, out: &mut [f32]) { + let mut pow = [0.0f32; MAX_SPEAKERS]; + let height = fin(el, 0.0) > 1.0; + if height && !pair_power(layout, true, az, 1.0, &mut pow) { + pair_power(layout, false, az, 0.5, &mut pow); + } else if !height { + pair_power(layout, false, az, 1.0, &mut pow); + } + for (o, p) in out.iter_mut().zip(pow.iter()) { + *o = p.max(0.0).sqrt(); + } +} + +/// Surround panner: gains for every speaker of `layout` (LFE gets 0; the caller adds an LFE send). +/// Energy-preserving: the squared gains of the non-LFE speakers always sum to 1. +/// +/// The puck's azimuth comes from [`puck_azimuth`]. Divergence (and moving the puck towards the +/// middle of the room) spreads the image: the gains are the power average of point sources +/// sampled across `±spread × 180°`. `center` < 1 moves that share of the centre speaker's power +/// to a phantom centre on L/R. Elevation crossfades bed and height layers (constant power). +pub fn surround_gains(layout: &[SpeakerPos], p: &SurroundParams, out: &mut [f32]) { + out.iter_mut().for_each(|g| *g = 0.0); + let layout = layout.get(..layout.len().min(MAX_SPEAKERS)).unwrap_or(&[]); + if layout.is_empty() { + return; + } + let az = puck_azimuth(layout, p.x, p.y); + let dist = fin(p.x, 0.0).abs().max(fin(p.y, 1.0).abs()).clamp(0.0, 1.0); + let div = fin(p.divergence, 0.0).clamp(0.0, 1.0); + let spread = 1.0 - dist * (1.0 - div); + let k = 1 + (spread * 24.0).round() as usize; + let width = spread * 180.0; + let has_height = layout.iter().any(|s| !s.lfe && s.el > 1.0); + let z = if has_height { fin(p.z, 0.0).clamp(0.0, 1.0) } else { 0.0 }; + let (bed_w, top_w) = { + let a = z * std::f32::consts::FRAC_PI_2; + (a.cos() * a.cos(), a.sin() * a.sin()) + }; + let mut pow = [0.0f32; MAX_SPEAKERS]; + for i in 0..k { + let a = if k == 1 { az } else { az - width + 2.0 * width * (i as f32 + 0.5) / k as f32 }; + let w = 1.0 / k as f32; + if bed_w > 1e-9 && !pair_power(layout, false, a, w * bed_w, &mut pow) { + pair_power(layout, true, a, w * bed_w, &mut pow); + } + if top_w > 1e-9 { + pair_power(layout, true, a, w * top_w, &mut pow); + } + } + // Centre %: move energy from C to a phantom centre on L/R. + let find = |az: f32| layout.iter().position(|s| !s.lfe && s.el.abs() < 1.0 && (s.az - az).abs() < 0.5); + let c_share = fin(p.center, 1.0).clamp(0.0, 1.0); + if let (Some(c), Some(l), Some(r)) = (find(0.0), find(-30.0), find(30.0)) + && c_share < 1.0 + { + let pc = pow.get(c).copied().unwrap_or(0.0); + let moved = pc * (1.0 - c_share); + if let Some(x) = pow.get_mut(c) { + *x = pc - moved; + } + for i in [l, r] { + if let Some(x) = pow.get_mut(i) { + *x += 0.5 * moved; + } + } + } + for (o, p) in out.iter_mut().zip(pow.iter()) { + *o = p.max(0.0).sqrt(); + } +} + +/// First-order Ambisonics (ACN channel order, SN3D) encoding gains for a direction, with the +/// directional components scaled by `1 - spread`. Higher-order channels get 0. +pub fn ambisonic_encode(az: f32, el: f32, spread: f32, out: &mut [f32]) { + out.iter_mut().for_each(|g| *g = 0.0); + let (a, e) = (fin(az, 0.0).to_radians(), fin(el, 0.0).to_radians()); + let d = 1.0 - fin(spread, 0.0).clamp(0.0, 1.0); + // Ambisonic azimuth is anticlockwise (left positive); ours is clockwise. + let vals = [1.0, -a.sin() * e.cos() * d, e.sin() * d, a.cos() * e.cos() * d]; + for (o, v) in out.iter_mut().zip(vals) { + *o = v; + } +} + +/// Basic first-order Ambisonics decode gains (W, Y, Z, X) for one speaker of an `n`-speaker layout. +pub fn ambisonic_decode(az: f32, el: f32, n: usize) -> [f32; 4] { + let (a, e) = (fin(az, 0.0).to_radians(), fin(el, 0.0).to_radians()); + let k = 1.0 / n.max(1) as f32; + [k, 3.0 * k * -a.sin() * e.cos(), 3.0 * k * e.sin(), 3.0 * k * a.cos() * e.cos()] +} + #[cfg(test)] mod tests { use super::*; @@ -58,4 +300,79 @@ mod tests { assert!((l - 1.0).abs() < 1e-6 && r.abs() < 1e-6); } } + + fn layout_51() -> Vec { + [(-30.0, false), (30.0, false), (0.0, false), (0.0, true), (-110.0, false), (110.0, false)] + .iter() + .map(|&(az, lfe)| SpeakerPos { az, el: 0.0, lfe }) + .collect() + } + + fn layout_714() -> Vec { + let mut v: Vec = + [-30.0, 30.0, 0.0, 0.0, -150.0, 150.0, -90.0, 90.0].iter().enumerate().map(|(i, &az)| SpeakerPos { az, el: 0.0, lfe: i == 3 }).collect(); + for az in [-45.0, 45.0, -135.0, 135.0] { + v.push(SpeakerPos { az, el: 45.0, lfe: false }); + } + v + } + + fn params(x: f32, y: f32) -> SurroundParams { + SurroundParams { x, y, z: 0.0, divergence: 0.0, center: 1.0 } + } + + #[test] + fn surround_energy_is_constant() { + for layout in [layout_51(), layout_714()] { + let mut g = [0.0f32; MAX_SPEAKERS]; + for xi in -4..=4 { + for yi in -4..=4 { + for div in [0.0, 0.3, 1.0] { + for z in [0.0, 0.5, 1.0] { + for center in [0.0, 0.5, 1.0] { + let p = SurroundParams { x: xi as f32 / 4.0, y: yi as f32 / 4.0, z, divergence: div, center }; + surround_gains(&layout, &p, &mut g); + let e: f32 = g.iter().zip(layout.iter()).filter(|(_, s)| !s.lfe).map(|(x, _)| x * x).sum(); + assert!((e - 1.0).abs() < 1e-4, "energy {e} at {p:?}"); + assert!(g.iter().zip(layout.iter()).all(|(x, s)| !s.lfe || *x == 0.0), "LFE is never panned to"); + } + } + } + } + } + } + } + + #[test] + fn surround_point_positions() { + let l = layout_51(); + let mut g = [0.0f32; MAX_SPEAKERS]; + // Front centre with no divergence: C only. + surround_gains(&l, ¶ms(0.0, 1.0), &mut g); + assert!((g[2] - 1.0).abs() < 1e-5 && g.iter().enumerate().all(|(i, x)| i == 2 || x.abs() < 1e-3), "{g:?}"); + // Divergence spreads the centre image. + surround_gains(&l, &SurroundParams { divergence: 0.5, ..params(0.0, 1.0) }, &mut g); + assert!(g[2] < 0.99 && g[0] > 0.05 && g[1] > 0.05); + // Full left (front-left corner): L only. + surround_gains(&l, ¶ms(-1.0, 1.0), &mut g); + assert!((g[0] - 1.0).abs() < 1e-5 && g.iter().enumerate().all(|(i, x)| i == 0 || x.abs() < 1e-3), "{g:?}"); + // Rear-right corner: Rs only. + surround_gains(&l, ¶ms(1.0, -1.0), &mut g); + assert!((g[5] - 1.0).abs() < 1e-5, "{g:?}"); + // Phantom centre (centre 0 %): L and R at -3 dB, no C. + surround_gains(&l, &SurroundParams { center: 0.0, ..params(0.0, 1.0) }, &mut g); + assert!(g[2].abs() < 1e-6 && (g[0] - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-4); + // Overhead on 7.1.4: heights only. + let l = layout_714(); + surround_gains(&l, &SurroundParams { z: 1.0, ..params(-1.0, 1.0) }, &mut g); + let top: f32 = g[8..12].iter().map(|x| x * x).sum(); + assert!((top - 1.0).abs() < 1e-4 && g[8] > 0.9, "{g:?}"); + // Corner positions map onto speaker angles and back. + let (x, y) = azimuth_to_puck(&l, -150.0); + assert!((x + 1.0).abs() < 1e-3 && (y + 1.0).abs() < 1e-3, "{x} {y}"); + assert!((puck_azimuth(&l, 0.0, -1.0).abs() - 180.0).abs() < 1e-3); + // Hostile input never produces NaN. + surround_gains(&l, &SurroundParams { x: f32::NAN, y: f32::INFINITY, z: f32::NAN, divergence: f32::NAN, center: f32::NAN }, &mut g); + assert!(g.iter().all(|x| x.is_finite())); + } } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 0548e44..5e24d6b 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -13,6 +13,7 @@ soundcraft-audio-io.workspace = true soundcraft-midi.workspace = true soundcraft-dsp.workspace = true soundcraft-clap-host.workspace = true +soundcraft-vst3-host.workspace = true soundcraft-model.workspace = true soundcraft-mix.workspace = true serde.workspace = true diff --git a/crates/engine/src/cmd/audio_midi.rs b/crates/engine/src/cmd/audio_midi.rs index 9e81090..786e66d 100644 --- a/crates/engine/src/cmd/audio_midi.rs +++ b/crates/engine/src/cmd/audio_midi.rs @@ -24,6 +24,7 @@ fn audio_as_midi(e: &mut Engine, p: &Value) -> Result { if s.track(t).is_none_or(|x| x.kind != soundcraft_model::TrackKind::Audio) { return Err(bad(id, "select audio on an audio track")); } + let r = crate::io::bounded_to_track(s, t, r); let audio = soundcraft_mix::render_clips(s, t, r); let n = audio.first().map_or(0, Vec::len); let k = audio.len().max(1) as f32; diff --git a/crates/engine/src/cmd/clap.rs b/crates/engine/src/cmd/clap.rs index 51a045b..7b39c1f 100644 --- a/crates/engine/src/cmd/clap.rs +++ b/crates/engine/src/cmd/clap.rs @@ -25,9 +25,9 @@ pub fn specs() -> Vec { )] } -/// A built-in plugin's description, else a hosted CLAP plugin's (`clap:`). +/// A built-in plugin's description, else a hosted CLAP (`clap:`) or VST3 (`vst3:`) plugin's. pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> { - soundcraft_dsp::plugin_info(id).or_else(|| soundcraft_clap_host::plugin_info(id)) + soundcraft_dsp::plugin_info(id).or_else(|| soundcraft_clap_host::plugin_info(id)).or_else(|| soundcraft_vst3_host::plugin_info(id)) } #[cfg(test)] diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 2e343d4..8b00ed1 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -282,7 +282,7 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result { let mut made = 0; for id in ids { let Some((tid, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; - let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1); + let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1).clamp(0, 1000); let mut copies = Vec::new(); for k in 1..=reps { let mut nc = c.clone(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 83c0f29..d9548fa 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -82,8 +82,8 @@ pub fn specs() -> Vec { Ok(r) } ), - cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true}", always, bounce), - cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?}", has_tracks, |e, p| { + cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true, fold_down?: stereo}", always, bounce), + cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, fold_down?: stereo}", has_tracks, |e, p| { let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string(); let r = range_param(e, "file.bounce_stems", p)?; let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r }; @@ -97,7 +97,8 @@ pub fn specs() -> Vec { Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32, _ => soundcraft_audio_io::BitDepth::Int24, }; - let files = crate::io::bounce_stems(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None })?; + let fold = fold_down_param(p, "file.bounce_stems")?; + let files = crate::io::bounce_stems_with(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None }, fold)?; Ok(json!({"files": files})) }), cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| { @@ -172,6 +173,15 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result { Ok(json!({"imported": out})) } +/// `fold_down: "stereo"` bounces an ITU stereo fold-down of a surround mix. +fn fold_down_param(p: &Value, id: &str) -> Result { + match str_param(p, "fold_down").map(str::trim) { + None | Some("") | Some("none") => Ok(false), + Some(f) if f.eq_ignore_ascii_case("stereo") => Ok(true), + Some(f) => Err(bad(id, format!("unknown fold_down `{f}` (use \"stereo\")"))), + } +} + fn bounce(e: &mut Engine, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string(); let r = range_param(e, "file.bounce_mix", p)?; @@ -189,7 +199,8 @@ fn bounce(e: &mut Engine, p: &Value) -> Result { }; let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None }; let normalize = bool_or(p, "normalize", false); - let (bytes, stats) = crate::io::bounce_bytes(e, r, &opts, normalize)?; + let fold = fold_down_param(p, "file.bounce_mix")?; + let (bytes, stats) = crate::io::bounce_bytes_with(e, r, &opts, normalize, fold)?; std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; Ok( json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 34cc14e..da716a3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -22,11 +22,13 @@ mod more_util; mod options; mod query; mod setup_more; +mod surround; mod track; mod track_more; mod transport; mod view; mod view_more; +mod vst3; pub type Run = fn(&mut Engine, &Value) -> Result; /// `Err(reason)` = disabled. @@ -112,6 +114,8 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(event_more::specs()); v.extend(setup_more::specs()); v.extend(clap::specs()); + v.extend(vst3::specs()); + v.extend(surround::specs()); v }) } @@ -222,8 +226,17 @@ pub fn tracks_required(e: &Engine, cmd: &str, p: &Value) -> Result> if t.is_empty() { Err(bad(cmd, "no tracks: pass `track`/`tracks` or select tracks")) } else { Ok(t) } } +/// Largest position accepted from parameters: 2^40 samples (about 265 days at 48 kHz). Clamping +/// here keeps every later addition and subtraction on positions far away from overflow. +pub const MAX_POSITION: Samples = 1 << 40; + /// Position from `key`: a number of samples, or `{"seconds": x}`, or a string in the main counter format. +/// Values are clamped to ±[`MAX_POSITION`]. pub fn position_param(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result> { + Ok(position_param_raw(e, cmd, p, key)?.map(|v| v.clamp(-MAX_POSITION, MAX_POSITION))) +} + +fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result> { let Some(v) = p.get(key) else { return Ok(None) }; let s = e.session(); if let Some(n) = v.as_i64() { diff --git a/crates/engine/src/cmd/surround.rs b/crates/engine/src/cmd/surround.rs new file mode 100644 index 0000000..5a6630d --- /dev/null +++ b/crates/engine/src/cmd/surround.rs @@ -0,0 +1,231 @@ +//! Surround mixing: the main output format and the surround panners of tracks and sends. + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_model::{ChannelFormat, Mixer, OutputPath, Route, SEND_SLOTS, Session, SurroundPan, TrackKind}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "mix.surround_pan", + "Set Surround Pan", + [], + None, + "{tracks?, x?: -1..1 (left..right), y?: -1..1 (back..front), z?: 0..1 (height), divergence?: 0..1, center?: 0..100, lfe_db?: -144..12, clear?: bool}", + has_selection, + surround_pan + ), + cmd!( + "mix.send_surround_pan", + "Set Send Surround Pan", + [], + None, + "{track?, slot: 0..9|a..j, x?, y?, z?, divergence?, center?, lfe_db?, clear?: bool}", + has_selection, + send_surround_pan + ), + cmd!( + "setup.main_format", + "Main Output Format", + [], + None, + "{format: stereo|LCR|Quad|5.0|5.1|6.1|7.0|7.1|7.1.2|5.1.4|7.1.4|9.1.6|1st Order Ambisonics…}", + always, + main_format + ), + ] +} + +/// Apply the provided fields of `p` on top of `base`, clamped. +fn apply_fields(base: SurroundPan, p: &Value) -> SurroundPan { + let mut sp = base; + sp.x = f32_or(p, "x", sp.x); + sp.y = f32_or(p, "y", sp.y); + sp.z = f32_or(p, "z", sp.z); + sp.divergence = f32_or(p, "divergence", sp.divergence); + sp.center = f32_or(p, "center", sp.center); + sp.lfe_db = f32_or(p, "lfe_db", sp.lfe_db); + sp.sanitize(); + sp +} + +/// The surround pan a mixer starts from: its own, else its stereo pan position on the front edge. +fn current(m: &Mixer) -> SurroundPan { + m.surround.unwrap_or_else(|| SurroundPan::from_stereo(if m.pan.len() == 1 { m.pan.first().copied().unwrap_or(0.0) } else { 0.0 })) +} + +fn surround_pan(e: &mut Engine, p: &Value) -> Result { + let id = "mix.surround_pan"; + let tracks = tracks_required(e, id, p)?; + let clear = bool_or(p, "clear", false); + let s = e.session_mut(); + let mut out = Vec::new(); + for t in tracks { + let Some(tr) = s.track_mut(t) else { continue }; + if matches!(tr.kind, TrackKind::Master | TrackKind::Vca) { + continue; + } + tr.mixer.surround = if clear { None } else { Some(apply_fields(current(&tr.mixer), p)) }; + out.push(json!({"track": t, "surround": tr.mixer.surround})); + } + Ok(json!({"tracks": out})) +} + +fn send_surround_pan(e: &mut Engine, p: &Value) -> Result { + let id = "mix.send_surround_pan"; + let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?; + let slot = send_slot(p).ok_or_else(|| bad(id, format!("`slot` must be 0..{} or a..j", SEND_SLOTS - 1)))?; + let clear = bool_or(p, "clear", false); + let s = e.session_mut(); + let tr = s.track_mut(t).ok_or_else(|| bad(id, "no track"))?; + let base = current(&tr.mixer); + let snd = tr.mixer.sends.get_mut(slot).and_then(Option::as_mut).ok_or_else(|| bad(id, "empty send slot"))?; + let start = snd.surround.unwrap_or(if snd.follow_main_pan { base } else { SurroundPan::from_stereo(snd.pan) }); + snd.surround = if clear { None } else { Some(apply_fields(start, p)) }; + Ok(json!({"track": t, "slot": slot, "surround": snd.surround})) +} + +fn send_slot(p: &Value) -> Option { + let v = p.get("slot")?; + let i = match (v.as_u64(), v.as_str()) { + (Some(n), _) => usize::try_from(n).ok()?, + (_, Some(s)) => { + let s = s.trim().to_ascii_lowercase(); + match s.as_bytes() { + [c @ b'a'..=b'j'] => usize::from(c - b'a'), + _ => s.parse().ok()?, + } + } + _ => return None, + }; + (i < SEND_SLOTS).then_some(i) +} + +/// Set the main output path's format (creating it if missing) and resize master faders to match. +pub fn set_main_format(s: &mut Session, fmt: ChannelFormat) { + let n = fmt.channels(); + let new_name = format!("Out 1-{n}"); + match s.outputs.first_mut() { + Some(o) => { + o.format = fmt; + // Rename the default-style "Out 1-N" path to the new width; custom names stay. + let default_style = o.name.strip_prefix("Out 1-").is_some_and(|x| x.parse::().is_ok()); + if default_style && o.name != new_name { + let old = std::mem::replace(&mut o.name, new_name.clone()); + for t in &mut s.tracks { + if t.mixer.output == Route::Hardware(old.clone()) { + t.mixer.output = Route::Hardware(new_name.clone()); + } + } + } + } + None => s.outputs.push(OutputPath { name: new_name, first_channel: 0, format: fmt }), + } + for t in s.tracks.iter_mut().filter(|t| t.kind == TrackKind::Master) { + if t.format != fmt { + t.format = fmt; + t.mixer.pan = Mixer::new(fmt).pan; + } + } +} + +fn main_format(e: &mut Engine, p: &Value) -> Result { + let id = "setup.main_format"; + let name = str_param(p, "format").ok_or_else(|| bad(id, "`format` required"))?; + let fmt = ChannelFormat::from_id(name).ok_or_else(|| bad(id, format!("unknown format `{name}`")))?; + if !(2..=16).contains(&fmt.channels()) { + return Err(bad(id, format!("the main output must have 2 to 16 channels (`{}` has {})", fmt.label(), fmt.channels()))); + } + let s = e.session_mut(); + set_main_format(s, fmt); + Ok(json!({"format": fmt.label(), "channels": fmt.channels(), "output": s.outputs.first().map(|o| o.name.clone())})) +} + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_audio_io::AudioBuffer; + use soundcraft_model::{Clip, SourceAudio, SourceId}; + use std::sync::Arc; + + #[test] + fn main_format_resizes_masters_and_renames_the_path() { + let mut e = crate::demo::demo_engine(); + assert_eq!(e.session().main_format(), ChannelFormat::Stereo); + let r = e.execute("setup.main_format", &json!({"format": "5.1"})).unwrap(); + assert_eq!(r["channels"], 6); + assert_eq!(e.session().main_format(), ChannelFormat::Surround51); + assert_eq!(e.session().outputs[0].name, "Out 1-6"); + assert!(e.session().tracks.iter().filter(|t| t.kind == TrackKind::Master).all(|t| t.format == ChannelFormat::Surround51)); + let out = soundcraft_mix::render_range(e.session(), soundcraft_time::Range::new(0, 4800), 512); + assert_eq!(out.len(), 6); + assert!(e.execute("setup.main_format", &json!({"format": "mono"})).is_err()); + assert!(e.execute("setup.main_format", &json!({"format": "nope"})).is_err()); + assert!(e.undo()); + assert_eq!(e.session().main_format(), ChannelFormat::Stereo); + let insp = crate::inspect::session(&e, false); + assert_eq!(insp["main_format"], "Stereo"); + } + + #[test] + fn surround_pan_commands() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 1, "format": "mono"})).unwrap(); + let t = e.session().tracks[0].id; + e.execute("mix.surround_pan", &json!({"track": t.0, "x": -0.5, "divergence": 2.0})).unwrap(); + let sp = e.session().track(t).unwrap().mixer.surround.unwrap(); + assert_eq!((sp.x, sp.y, sp.divergence), (-0.5, 1.0, 1.0)); + e.execute("mix.surround_pan", &json!({"track": t.0, "y": f64::MAX})).unwrap(); + assert_eq!(e.session().track(t).unwrap().mixer.surround.unwrap().x, -0.5, "fields not given are kept"); + let insp = crate::inspect::session(&e, false); + assert_eq!(insp["tracks"][0]["surround"]["x"], -0.5); + e.execute("mix.surround_pan", &json!({"track": t.0, "clear": true})).unwrap(); + assert!(e.session().track(t).unwrap().mixer.surround.is_none()); + // Sends. + assert!(e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0})).is_err(), "empty slot"); + e.execute("mix.send", &json!({"track": t.0, "slot": 0, "bus": "FX"})).unwrap(); + e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0, "y": -1.0})).unwrap(); + let snd = e.session().track(t).unwrap().mixer.sends[0].clone().unwrap(); + assert_eq!(snd.surround.map(|s| (s.x, s.y)), Some((1.0, -1.0))); + assert!(e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": 12})).is_err()); + } + + #[test] + fn surround_bounce_writes_every_channel_with_its_mask() { + let mut e = Engine::default(); + e.execute("setup.main_format", &json!({"format": "7.1"})).unwrap(); + e.execute("track.new", &json!({"count": 1, "format": "mono"})).unwrap(); + let t = e.session().tracks[0].id; + { + let s = e.session_mut(); + s.pool.insert(SourceId(77), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 4800]] }))); + let cid = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(77), 0, 0, 4800)); + } + e.execute("mix.surround_pan", &json!({"track": t.0, "x": 0.0, "y": 1.0})).unwrap(); + let dir = std::env::temp_dir().join(format!("sc-surround-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("mix.wav"); + let ps = path.to_string_lossy().to_string(); + e.execute("file.bounce_mix", &json!({"path": ps, "start": 0, "end": 4800, "bit_depth": "32f"})).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(0x63F)); + let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).unwrap(); + assert_eq!(buf.channels.len(), 8); + assert!((buf.channels[2][1000] - 0.25).abs() < 1e-5, "front centre lands on C"); + // ITU stereo fold-down. + e.execute("file.bounce_mix", &json!({"path": ps, "start": 0, "end": 4800, "bit_depth": "32f", "fold_down": "stereo"})).unwrap(); + let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(&path).unwrap(), Some("wav")).unwrap(); + assert_eq!(buf.channels.len(), 2); + assert!((buf.channels[0][1000] - 0.25 * std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-4); + assert!(e.execute("file.bounce_mix", &json!({"path": ps, "fold_down": "quad"})).is_err()); + // Stems follow the main format too. + let sdir = dir.join("stems"); + let r = e.execute("file.bounce_stems", &json!({"dir": sdir.to_string_lossy(), "start": 0, "end": 4800})).unwrap(); + let f = r["files"][0].as_str().unwrap().to_string(); + let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(&f).unwrap(), Some("wav")).unwrap(); + assert_eq!(buf.channels.len(), 8); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/transport.rs b/crates/engine/src/cmd/transport.rs index 1015026..5fbd48c 100644 --- a/crates/engine/src/cmd/transport.rs +++ b/crates/engine/src/cmd/transport.rs @@ -24,13 +24,13 @@ pub fn specs() -> Vec { cmd!(noundo "transport.go_to_end", "Go to End", [], Some("End"), "{}", always, |e, _| { let end = e.session().content_end(); locate(e, end) }), cmd!(noundo "transport.rewind", "Rewind", [], None, "{seconds?: 1}", always, |e, p| { let s = e.session(); - let d = s.sample_rate.samples(f64_or(p, "seconds", 1.0)); + let d = s.sample_rate.samples(f64_or(p, "seconds", 1.0).clamp(0.0, 86_400.0)); let at = (cur(e) - d).max(0); locate(e, at) }), cmd!(noundo "transport.fast_forward", "Fast Forward", [], None, "{seconds?: 1}", always, |e, p| { let s = e.session(); - let d = s.sample_rate.samples(f64_or(p, "seconds", 1.0)); + let d = s.sample_rate.samples(f64_or(p, "seconds", 1.0).clamp(0.0, 86_400.0)); let at = cur(e) + d; locate(e, at) }), diff --git a/crates/engine/src/cmd/vst3.rs b/crates/engine/src/cmd/vst3.rs new file mode 100644 index 0000000..927bb43 --- /dev/null +++ b/crates/engine/src/cmd/vst3.rs @@ -0,0 +1,50 @@ +//! Third-party VST3 plugins: discovery (`engine.vst3_plugins`). Hosting itself lives in +//! `soundcraft-vst3-host`; id lookup for the mixer commands is shared with CLAP in +//! `cmd::clap::plugin_info`. + +use super::*; +use crate::cmd; +use serde_json::json; + +pub fn specs() -> Vec { + vec![cmd!( + query "engine.vst3_plugins", + "List VST3 Plugins", + [], + None, + "{rescan?: bool} → [{id: \"vst3:\", class_id, name, vendor, version, sdk_version, sub_categories, category, is_instrument, path}]", + always, + |_, p| { + let list = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) { + soundcraft_vst3_host::rescan() + } else { + soundcraft_vst3_host::scan() + }; + Ok(json!(list)) + } + )] +} + +#[cfg(test)] +mod tests { + use crate::Engine; + use serde_json::json; + + #[test] + fn vst3_plugins_query_returns_a_list() { + let mut e = Engine::default(); + let v = e.execute("engine.vst3_plugins", &json!({})).unwrap(); + assert!(v.is_array()); + } + + #[test] + fn unknown_vst3_insert_is_an_error() { + let mut e = Engine::default(); + e.execute("track.new", &json!({})).unwrap(); + let t = e.session().tracks[0].id.0; + let id = "vst3:00000000000000000000000000000000"; + assert!(e.execute("mix.insert", &json!({"track": t, "plugin": id})).is_err()); + assert!(e.execute("mix.insert", &json!({"track": t, "plugin": "vst3:not-hex"})).is_err()); + assert!(crate::cmd::clap::plugin_info(id).is_none()); + } +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index e81b3e0..9e1fdd2 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -44,12 +44,12 @@ fn track_json(e: &Engine, t: &Track, full: bool) -> Value { .iter() .enumerate() .filter_map(|(i, x)| { - x.as_ref().map(|x| json!({"slot": i, "target": x.target, "level_db": x.level_db, "pan": x.pan, "mute": x.mute, "pre_fader": x.pre_fader})) + x.as_ref().map(|x| json!({"slot": i, "target": x.target, "level_db": x.level_db, "pan": x.pan, "mute": x.mute, "pre_fader": x.pre_fader, "surround": x.surround})) }) .collect(); let mut v = json!({ "id": t.id, "name": t.name, "kind": t.kind.id(), "format": t.format.label(), - "volume_db": t.mixer.volume_db, "pan": t.mixer.pan, "mute": t.mixer.mute, "solo": t.mixer.solo, + "volume_db": t.mixer.volume_db, "pan": t.mixer.pan, "surround": t.mixer.surround, "mute": t.mixer.mute, "solo": t.mixer.solo, "record_arm": t.mixer.record_arm, "input": t.mixer.input, "output": t.mixer.output, "automation_mode": t.mixer.automation_mode.label(), "inserts": inserts, "sends": sends, "clips": t.clips().iter().map(|c| clip_json(e, c)).collect::>(), @@ -70,7 +70,7 @@ pub fn session(e: &Engine, full: bool) -> Value { let s = e.session(); json!({ "name": s.name, "path": e.path, "dirty": e.is_dirty(), "sample_rate": s.sample_rate.hz(), "bit_depth": s.bit_depth, - "frame_rate": s.frame_rate.label(), "tempo": s.tempo.tempos(), "meter": s.tempo.meters(), + "frame_rate": s.frame_rate.label(), "tempo": s.tempo.tempos(), "meter": s.tempo.meters(), "main_format": s.main_format().label(), "length_samples": s.content_end(), "tracks": s.tracks.iter().map(|t| track_json(e, t, full)).collect::>(), "busses": s.busses.iter().map(|b| json!({"id": b.id, "name": b.name, "format": b.format.label()})).collect::>(), diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index c2cf798..24096a1 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -129,11 +129,17 @@ pub fn export_midi(e: &Engine) -> Result> { /// Render the main mix. Returns the encoded file and (peak dBFS, integrated LUFS). pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) -> Result<(Vec, (f32, f32, f32))> { + bounce_bytes_with(e, r, opts, normalize, false) +} + +/// Render the main mix in its own format (every main channel, SMPTE/WAV order, with the WAV +/// speaker mask of the main format), or an ITU stereo fold-down when `fold_stereo`. +pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool, fold_stereo: bool) -> Result<(Vec, (f32, f32, f32))> { let s = e.session(); if r.len() > s.sample_rate.samples(4.0 * 3600.0) { return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into())); } - let mut ch = soundcraft_mix::render_range(s, r, 1024); + let (mut ch, fmt) = render_main(s, r, fold_stereo); if normalize { soundcraft_dsp::offline::normalize(&mut ch, -0.1, false); } @@ -144,13 +150,25 @@ pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) let lufs = lm.integrated_lufs(); let true_peak = lm.true_peak_db(); let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch }; - let bytes = soundcraft_audio_io::encode(&buf, opts).map_err(|err| EngineError::Io(err.to_string()))?; + let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf, opts, fmt.channel_mask()).map_err(|err| EngineError::Io(err.to_string()))?; Ok((bytes, (peak, lufs, true_peak))) } +/// The main mix over `r` and its format (folded to stereo on request). +fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec>, ChannelFormat) { + let ch = soundcraft_mix::render_range(s, r, 1024); + let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() }; + if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) } +} + /// Bounce one file per audio/instrument track (each soloed, so its sends and auxes are included) /// into `dir`. Returns the written paths. pub fn bounce_stems(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions) -> Result> { + bounce_stems_with(e, dir, r, opts, false) +} + +/// [`bounce_stems`] in the main format, or folded down to stereo when `fold_stereo`. +pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, fold_stereo: bool) -> Result> { let s = e.session(); std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?; let ext = match opts.format { @@ -170,9 +188,9 @@ pub fn bounce_stems(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions) -> Re for t in &mut solo.tracks { t.mixer.solo = t.id == id; } - let ch = soundcraft_mix::render_range(&solo, r, 1024); + let (ch, fmt) = render_main(&solo, r, fold_stereo); let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch }; - let bytes = soundcraft_audio_io::encode(&buf, opts).map_err(|err| EngineError::Io(err.to_string()))?; + let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf, opts, fmt.channel_mask()).map_err(|err| EngineError::Io(err.to_string()))?; let path = Path::new(dir).join(format!("{} - {}.{ext}", sanitize_name(&s.name), sanitize_name(&name))); std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?; out.push(path.to_string_lossy().into_owned()); @@ -180,15 +198,26 @@ pub fn bounce_stems(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions) -> Re Ok(out) } +/// Limit a range used for rendering to the track's material (plus a second), so a huge +/// selection never renders hours of silence. +pub fn bounded_to_track(s: &Session, t: TrackId, r: Range) -> Range { + let Some(tr) = s.track(t) else { return Range::point(r.start) }; + let end = tr.clips().iter().map(Clip::end).max().unwrap_or(0).saturating_add(s.sample_rate.samples(1.0)); + let start = r.start.max(0).min(end); + Range::new(start, r.end.min(end).max(start)) +} + /// Transient positions (absolute) in a track's clips within `r`. pub fn transients_in(s: &Session, t: TrackId, r: Range, sens: f32) -> Vec { let r = if r.is_empty() { Range::new(0, s.content_end()) } else { r }; + let r = bounded_to_track(s, t, r); let audio = soundcraft_mix::render_clips(s, t, r); soundcraft_dsp::offline::detect_transients(&audio, s.sample_rate.as_f64() as f32, sens).into_iter().map(|i| r.start + i as i64).collect() } /// Strip Silence: keep only the non-silent ranges of clips inside `r`. pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> usize { + let r = bounded_to_track(s, t, r); let audio = soundcraft_mix::render_clips(s, t, r); let keep = soundcraft_dsp::offline::non_silent_ranges( &audio, @@ -231,6 +260,10 @@ pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result { if s.track(t).is_none_or(|tr| !tr.kind.has_playlist() || tr.kind.is_midi()) { return Ok(false); } + let r = bounded_to_track(s, t, r); + if r.is_empty() { + return Ok(false); + } let audio = soundcraft_mix::render_clips(s, t, r); let name = format!("{}_consolidated", s.track(t).map_or("Audio", |x| x.name.as_str())); let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio }; diff --git a/crates/engine/tests/hostile_params.proptest-regressions b/crates/engine/tests/hostile_params.proptest-regressions new file mode 100644 index 0000000..8e18b01 --- /dev/null +++ b/crates/engine/tests/hostile_params.proptest-regressions @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc b8aecee6f0dbe9222685b13489c3ee04a7fce88c69c74be36388e02b8ebf5cff # shrinks to fields = [(2, Number(9223372036854775807))] +cc c2a40198c2e3bc14569008d0ab896044ffbc0b2b29429e833a4c03e82df43f88 # shrinks to fields = [(44, Number(9223372036854775807))] diff --git a/crates/engine/tests/hostile_params.rs b/crates/engine/tests/hostile_params.rs index b776923..8a2a382 100644 --- a/crates/engine/tests/hostile_params.rs +++ b/crates/engine/tests/hostile_params.rs @@ -81,7 +81,7 @@ fn hostile_value() -> impl Strategy { } proptest! { - #![proptest_config(ProptestConfig { cases: 8, .. ProptestConfig::default() })] + #![proptest_config(ProptestConfig { cases: 6, .. ProptestConfig::default() })] #[test] fn no_command_panics_on_hostile_params(fields in proptest::collection::vec((0usize..KEYS.len(), hostile_value()), 0..6)) { let mut params = serde_json::Map::new(); diff --git a/crates/engine/tests/random_edits.proptest-regressions b/crates/engine/tests/random_edits.proptest-regressions new file mode 100644 index 0000000..3d33846 --- /dev/null +++ b/crates/engine/tests/random_edits.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 1dc212f0026723d05bfcfbb29fcff476eda7f491e9bea9cd5d26dccf006a81f6 # shrinks to ops = [(9, 0, 2420494, 1), (3, 0, 2396495, 1)] diff --git a/crates/mix/Cargo.toml b/crates/mix/Cargo.toml index 07c9001..c082515 100644 --- a/crates/mix/Cargo.toml +++ b/crates/mix/Cargo.toml @@ -13,6 +13,7 @@ soundcraft-audio-io.workspace = true soundcraft-midi.workspace = true soundcraft-dsp.workspace = true soundcraft-clap-host.workspace = true +soundcraft-vst3-host.workspace = true soundcraft-model.workspace = true log.workspace = true diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 865643f..7b657de 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1,31 +1,85 @@ //! The SoundCraft mix engine. //! -//! [`MixEngine::render`] produces one block of the main stereo output for a session at a timeline +//! [`MixEngine::render`] produces one block of the main output for a session at a timeline //! position: clips (with clip gain and fades) → trim/phase → inserts → pre-fader sends → fader and //! mute (with automation) → pan → post-fader sends → busses → aux inputs → main → master faders. //! The same code drives realtime playback and offline bounces, so what you hear is what you get. //! Steady-state rendering does not allocate. +//! +//! ## Surround +//! The main mix has the format of the session's main output path ([`Session::main_format`]: +//! stereo by default, up to 16 channels such as 7.1.4 or 9.1.6) and every bus has its own format. +//! Channels are in SMPTE / WAV order (L R C LFE Ls Rs …, see +//! [`ChannelFormat::speakers`](soundcraft_model::ChannelFormat::speakers)). Routing a strip into a +//! destination builds a gain matrix per block: +//! - mono/stereo sources into a stereo destination use the stereo pan exactly as before; +//! - into a multichannel destination they use the track's [`SurroundPan`] (pairwise +//! constant-power panner over the speaker layout, see `soundcraft_dsp::pan::surround_gains`), +//! or go to L/R with the stereo pan when the track has no surround pan; +//! - sources whose format equals the destination's pass straight through, channel for channel; +//! - other multichannel sources fold to stereo with ITU-R BS.775 coefficients (C and surrounds at +//! -3 dB, LFE dropped), or map speaker by speaker into other multichannel layouts. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use soundcraft_dsp::pan::{PanLaw, gains}; +use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains}; use soundcraft_dsp::{Plugin, db_to_gain}; -use soundcraft_model::{AutoParam, BusId, Clip, ClipContent, Route, Session, Track, TrackId, TrackKind}; +use soundcraft_model::{AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, Speaker, SurroundPan, Track, TrackId, TrackKind}; use soundcraft_time::{Range, Samples}; use std::collections::HashMap; -/// Main mix width. +/// Default (stereo) main mix width. The actual width is [`main_channels`]. pub const MAIN_CHANNELS: usize = 2; +/// Widest strip, bus or main mix (9.1.6 / 3rd-order Ambisonics). Wider formats are truncated. +pub const MAX_CHANNELS: usize = 16; + +/// Number of channels of a session's main mix. +pub fn main_channels(s: &Session) -> usize { + s.main_format().channels().clamp(MAIN_CHANNELS, MAX_CHANNELS) +} + /// Peak meter values for one strip (linear, per channel, max over the last block). -#[derive(Debug, Clone, Copy, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct StripMeter { + /// First two channels (mono strips repeat channel 0), for stereo meters. pub peak: [f32; 2], + /// Every channel of the strip (or of the main mix). + pub peaks: Vec, /// Gain reduction of the first dynamics insert, dB. pub gain_reduction_db: f32, } +impl StripMeter { + /// Copy another meter without reallocating once the channel count is stable. + pub fn copy_from(&mut self, o: &StripMeter) { + self.peak = o.peak; + self.peaks.clone_from(&o.peaks); + self.gain_reduction_db = o.gain_reduction_db; + } + + fn measure(&mut self, bufs: &[Vec], frames: usize, gr: f32) { + if self.peaks.len() != bufs.len() { + self.peaks.resize(bufs.len(), 0.0); + } + for (p, b) in self.peaks.iter_mut().zip(bufs.iter()) { + *p = peak(b.get(..frames).unwrap_or(&[])); + } + let last = self.peaks.len().saturating_sub(1); + self.peak = [self.peaks.first().copied().unwrap_or(0.0), self.peaks.get(1.min(last)).copied().unwrap_or(0.0)]; + self.gain_reduction_db = gr; + } +} + +/// A bus: its format and one buffer per channel. +struct BusBuf { + fmt: ChannelFormat, + buf: Vec>, +} + struct PluginSlot { id: String, + /// Channel count the plugin was prepared for. + ch: usize, plugin: Box, /// Last values pushed to the plugin, in the insert's parameter-map order. applied: Vec, @@ -131,8 +185,9 @@ pub struct MixEngine { sample_rate: f32, max_block: usize, strips: HashMap, - busses: HashMap>>, + busses: HashMap, main: Vec>, + main_fmt: ChannelFormat, pub meters: HashMap, pub main_meter: StripMeter, pub pan_law: PanLaw, @@ -160,6 +215,7 @@ impl MixEngine { strips: HashMap::new(), busses: HashMap::new(), main: vec![vec![0.0; max_block]; MAIN_CHANNELS], + main_fmt: ChannelFormat::Stereo, meters: HashMap::new(), main_meter: StripMeter::default(), pan_law: PanLaw::Minus3, @@ -177,6 +233,16 @@ impl MixEngine { self.max_block } + /// Format of the main mix as of the last sync. + pub fn main_format(&self) -> ChannelFormat { + self.main_fmt + } + + /// Channels of the main mix as of the last sync. + pub fn main_channels(&self) -> usize { + self.main.len() + } + /// Reset all plugin state (on stop / seek). pub fn reset(&mut self) { for s in self.strips.values_mut() { @@ -198,9 +264,14 @@ impl MixEngine { pub fn sync(&mut self, s: &Session) { let sr = self.sample_rate; let mb = self.max_block; + let main_ch = main_channels(s); + self.main_fmt = s.main_format(); + if self.main.len() != main_ch { + self.main = vec![vec![0.0; mb]; main_ch]; + } self.strips.retain(|id, _| s.track(*id).is_some()); for t in &s.tracks { - let ch = strip_channels(t); + let ch = strip_channels(t, main_ch); let strip = self.strips.entry(t.id).or_insert_with(Strip::new); if strip.buf.len() != ch || strip.buf.first().map_or(0, Vec::len) != mb { strip.buf = vec![vec![0.0; mb]; ch]; @@ -208,10 +279,10 @@ impl MixEngine { strip.plugins.resize_with(t.mixer.inserts.len(), || None); for (slot, ins) in strip.plugins.iter_mut().zip(t.mixer.inserts.iter()) { match ins { - Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin) => { + Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) => { *slot = create_plugin(&i.plugin).map(|mut p| { p.prepare(sr, mb, ch); - PluginSlot { id: i.plugin.clone(), plugin: p, applied: Vec::new() } + PluginSlot { id: i.plugin.clone(), ch, plugin: p, applied: Vec::new() } }); } None => *slot = None, @@ -223,13 +294,18 @@ impl MixEngine { strip.instrument = want.and_then(|id| { create_plugin(id).map(|mut p| { p.prepare(sr, mb, ch.max(2)); - PluginSlot { id: id.to_string(), plugin: p, applied: Vec::new() } + PluginSlot { id: id.to_string(), ch: ch.max(2), plugin: p, applied: Vec::new() } }) }); } } for b in &s.busses { - self.busses.entry(b.id).or_insert_with(|| vec![vec![0.0; mb]; MAIN_CHANNELS]); + let n = b.format.channels().clamp(1, MAX_CHANNELS); + let bus = self.busses.entry(b.id).or_insert_with(|| BusBuf { fmt: b.format, buf: Vec::new() }); + bus.fmt = b.format; + if bus.buf.len() != n { + bus.buf = vec![vec![0.0; mb]; n]; + } } self.busses.retain(|id, _| s.bus(*id).is_some()); } @@ -255,7 +331,7 @@ impl MixEngine { self.synced = key; } for b in self.busses.values_mut() { - for c in b.iter_mut() { + for c in b.buf.iter_mut() { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } } @@ -342,7 +418,7 @@ impl MixEngine { } let v0 = volume_db_at(t, pos); let v1 = volume_db_at(t, pos + frames as i64); - for (ch, src) in strip.buf.iter().enumerate().take(MAIN_CHANNELS) { + for (ch, src) in strip.buf.iter().enumerate().take(self.main.len()) { if let Some(m) = self.main.get_mut(ch) { for i in 0..frames { let g = lerp_gain(v0, v1, i, frames); @@ -352,47 +428,47 @@ impl MixEngine { } } } - let mut pk = [0.0f32; 2]; - for (c, p) in pk.iter_mut().enumerate() { - *p = self.main.get(c).map_or(0.0, |m| peak(m.get(..frames).unwrap_or(&[]))); - } - self.meters.insert(t.id, StripMeter { peak: pk, gain_reduction_db: 0.0 }); + self.meters.entry(t.id).or_default().measure(&self.main, frames, 0.0); } } - let mut pk = [0.0f32; 2]; + // Output: min(out.len(), main channels) channels; any further output channels are silent. for (c, o) in out.iter_mut().enumerate() { - let src = self.main.get(c.min(MAIN_CHANNELS - 1)); + let src = self.main.get(c); for i in 0..frames { let v = src.and_then(|m| m.get(i)).copied().unwrap_or(0.0); if let Some(d) = o.get_mut(i) { *d = if v.is_finite() { v } else { 0.0 }; } } - if let Some(p) = pk.get_mut(c) { - *p = peak(o.get(..frames).unwrap_or(&[])); - } } - self.main_meter = StripMeter { peak: pk, gain_reduction_db: 0.0 }; + self.main_meter.measure(&self.main, frames, 0.0); } /// Send, meter and pan a processed strip into the busses / main mix. fn route_strip(&mut self, t: &Track, strip: &mut Strip, pos: Samples, frames: usize) { - let mut pk = [0.0f32; 2]; - for (c, p) in pk.iter_mut().enumerate() { - let ch = c.min(strip.buf.len().saturating_sub(1)); - *p = strip.buf.get(ch).map_or(0.0, |b| peak(b.get(..frames).unwrap_or(&[]))); - } - self.meters.insert(t.id, StripMeter { peak: pk, gain_reduction_db: strip.gr }); + self.meters.entry(t.id).or_default().measure(&strip.buf, frames, strip.gr); + let src_fmt = strip_format(t, strip.buf.len()); + let law = self.pan_law; for (i, snd) in t.mixer.sends.iter().enumerate() { let Some(snd) = snd else { continue }; if snd.mute || strip.muted { continue; } let lvl = send_level(t, i, snd.level_db, pos); + let gain = db_to_gain(lvl); let src = if snd.pre_fader { &strip.pre } else { &strip.buf }; - mix_to_bus(src, frames, &mut self.busses, &snd.target, db_to_gain(lvl), snd.pan, self.pan_law, &t.mixer.pan); + let Route::Bus(b) = &snd.target else { continue }; + let Some(dst) = self.busses.get_mut(b) else { continue }; + if gain <= 0.0 { + continue; + } + // Mono sends use the send's own pan; stereo sends go straight to L/R. + let stereo = if src.len() == 1 { [snd.pan, 0.0] } else { [-1.0, 1.0] }; + let surround = snd.surround.as_ref().or(if snd.follow_main_pan { t.mixer.surround.as_ref() } else { None }); + let m = build_matrix(src_fmt, src.len(), dst.fmt, dst.buf.len(), &PanSpec { stereo, surround }, law); + apply_matrix(src, frames, &mut dst.buf, &m, gain); } - let pans: [f32; 2] = [pan_at(t, 0, pos), pan_at(t, 1, pos)]; + let spec = PanSpec { stereo: [pan_at(t, 0, pos), pan_at(t, 1, pos)], surround: t.mixer.surround.as_ref() }; match &t.mixer.output { Route::Main if strip.out_extra.len > 0 => { if strip.outbuf.len() != strip.buf.len() { @@ -403,12 +479,17 @@ impl MixEngine { d.extend_from_slice(src.get(..frames).unwrap_or(&[])); } strip.out_extra.process(&mut strip.outbuf, frames); - pan_into(&strip.outbuf, frames, &mut self.main, &pans, self.pan_law); + let m = build_matrix(src_fmt, strip.outbuf.len(), self.main_fmt, self.main.len(), &spec, law); + apply_matrix(&strip.outbuf, frames, &mut self.main, &m, 1.0); + } + Route::Main => { + let m = build_matrix(src_fmt, strip.buf.len(), self.main_fmt, self.main.len(), &spec, law); + apply_matrix(&strip.buf, frames, &mut self.main, &m, 1.0); } - Route::Main => pan_into(&strip.buf, frames, &mut self.main, &pans, self.pan_law), Route::Bus(b) => { if let Some(dst) = self.busses.get_mut(b) { - pan_into(&strip.buf, frames, dst, &pans, self.pan_law); + let m = build_matrix(src_fmt, strip.buf.len(), dst.fmt, dst.buf.len(), &spec, law); + apply_matrix(&strip.buf, frames, &mut dst.buf, &m, 1.0); } } _ => {} @@ -429,7 +510,7 @@ fn process_strip( s: &Session, t: &Track, strip: &mut Strip, - busses: &HashMap>>, + busses: &HashMap, live: &LiveInput<'_>, pos: Samples, frames: usize, @@ -491,20 +572,21 @@ fn process_strip( if let Route::Bus(b) = &t.mixer.input && let Some(src) = busses.get(b) { - let nch = strip.buf.len(); - for (ch, dst) in strip.buf.iter_mut().enumerate() { - for i in 0..frames { - let v = if nch == 1 { - 0.5 * (src.first().and_then(|c| c.get(i)).copied().unwrap_or(0.0) - + src.get(1).and_then(|c| c.get(i)).copied().unwrap_or(0.0)) - } else { - src.get(ch.min(1)).and_then(|c| c.get(i)).copied().unwrap_or(0.0) - }; - if let Some(d) = dst.get_mut(i) { - *d = v; - } - } - } + let (ns, nd) = (src.buf.len(), strip.buf.len()); + let dst_fmt = strip_format(t, nd); + let m = if src.fmt == dst_fmt && ns == nd { + identity_matrix(ns) + } else if ns == 1 && nd == 2 { + // A mono bus feeds both sides of a stereo aux. + let mut m = [[0.0; MAX_CHANNELS]; MAX_CHANNELS]; + m[0][0] = 1.0; + m[0][1] = 1.0; + m + } else { + let stereo = if ns == 1 { [0.0, 0.0] } else { [-1.0, 1.0] }; + build_matrix(src.fmt, ns, dst_fmt, nd, &PanSpec { stereo, surround: None }, PanLaw::Minus3) + }; + apply_matrix(&src.buf, frames, &mut strip.buf, &m, 1.0); } } TrackKind::Instrument | TrackKind::Midi => { @@ -675,6 +757,7 @@ fn structure_fingerprint(s: &Session) -> usize { t.kind.hash(&mut h); t.format.hash(&mut h); t.inactive.hash(&mut h); + t.mixer.inserts.len().hash(&mut h); t.mixer.input.hash(&mut h); t.mixer.output.hash(&mut h); for i in &t.mixer.inserts { @@ -687,7 +770,9 @@ fn structure_fingerprint(s: &Session) -> usize { } for b in &s.busses { b.id.hash(&mut h); + b.format.hash(&mut h); } + s.main_format().hash(&mut h); h.finish() as usize } @@ -707,14 +792,19 @@ fn run_strips(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) { work.iter_mut().for_each(f); } -fn strip_channels(t: &Track) -> usize { +fn strip_channels(t: &Track, main_ch: usize) -> usize { match t.kind { - TrackKind::Master => MAIN_CHANNELS, - TrackKind::Instrument => t.channels().max(2), - _ => t.channels().clamp(1, 16), + TrackKind::Master => main_ch, + TrackKind::Instrument => t.channels().clamp(2, MAX_CHANNELS), + _ => t.channels().clamp(1, MAX_CHANNELS), } } +/// The format a strip's buffer actually carries (a mono instrument renders in stereo). +fn strip_format(t: &Track, n: usize) -> ChannelFormat { + if t.format.channels().min(MAX_CHANNELS) == n { t.format } else { ChannelFormat::for_channels(n) } +} + /// Audio/instrument tracks first, then auxes ordered so bus producers come before consumers. fn processing_order(s: &Session) -> Vec { let mut order: Vec = @@ -921,103 +1011,285 @@ fn peak(x: &[f32]) -> f32 { x.iter().fold(0.0f32, |m, v| if v.is_finite() { m.max(v.abs()) } else { m }) } -/// Pan a strip (1, 2 or N channels) into a stereo destination. -fn pan_into(src: &[Vec], frames: usize, dst: &mut [Vec], pans: &[f32; 2], law: PanLaw) { - let (l, r) = dst.split_at_mut(1); - let (Some(dl), Some(dr)) = (l.first_mut(), r.first_mut()) else { return }; - match src.len() { - 0 => {} - 1 => { - let (gl, gr) = gains(pans[0], law); - if let Some(s0) = src.first() { - for i in 0..frames { - let v = s0.get(i).copied().unwrap_or(0.0); - if let (Some(a), Some(b)) = (dl.get_mut(i), dr.get_mut(i)) { - *a += v * gl; - *b += v * gr; +/// Per-block routing gains: `m[src][dst]`. +type Matrix = [[f32; MAX_CHANNELS]; MAX_CHANNELS]; + +/// How a source is panned: the stereo pan pair (mono uses `[0]`) and the optional surround pan. +struct PanSpec<'a> { + stereo: [f32; 2], + surround: Option<&'a SurroundPan>, +} + +fn identity_matrix(n: usize) -> Matrix { + let mut m = [[0.0; MAX_CHANNELS]; MAX_CHANNELS]; + for (i, row) in m.iter_mut().enumerate().take(n) { + if let Some(g) = row.get_mut(i) { + *g = 1.0; + } + } + m +} + +/// ITU-R BS.775 stereo fold-down gains (to L, to R) for one speaker: C and surrounds at -3 dB, +/// the centre surround at -6 dB per side, LFE dropped. +pub fn itu_stereo_fold(sp: Speaker) -> (f32, f32) { + use Speaker::*; + let h = std::f32::consts::FRAC_1_SQRT_2; + match sp { + L | Lc | Lw => (1.0, 0.0), + R | Rc | Rw => (0.0, 1.0), + C => (h, h), + Lfe => (0.0, 0.0), + Cs => (0.5, 0.5), + Ls | Lss | Lrs | Ltf | Ltm | Ltr => (h, 0.0), + Rs | Rss | Rrs | Rtf | Rtm | Rtr => (0.0, h), + } +} + +/// Speaker directions of a format (for the DSP panner). Returns the count written. +fn layout_of(fmt: ChannelFormat, out: &mut [SpeakerPos; MAX_CHANNELS]) -> usize { + let sp = fmt.speakers(); + for (o, s) in out.iter_mut().zip(sp.iter()) { + *o = SpeakerPos { az: s.azimuth(), el: s.elevation(), lfe: s.is_lfe() }; + } + sp.len().min(MAX_CHANNELS) +} + +fn surround_params(sp: &SurroundPan, dx: f32) -> SurroundParams { + SurroundParams { x: (sp.x + dx).clamp(-1.0, 1.0), y: sp.y, z: sp.z, divergence: sp.divergence, center: sp.center / 100.0 } +} + +/// Routing gains from a source (`ns` channels of `src_fmt`) into a destination (`nd` channels of +/// `dst_fmt`). See the crate docs for the rules. +fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFormat, nd: usize, pan: &PanSpec<'_>, law: PanLaw) -> Matrix { + let mut m = [[0.0f32; MAX_CHANNELS]; MAX_CHANNELS]; + let (ns, nd) = (ns.min(MAX_CHANNELS), nd.min(MAX_CHANNELS)); + if ns == 0 || nd == 0 { + return m; + } + let set = |m: &mut Matrix, s: usize, d: usize, g: f32| { + if let Some(x) = m.get_mut(s).and_then(|r| r.get_mut(d)) { + *x += g; + } + }; + if nd == 1 { + // Mono destination: the stereo routing summed at -6 dB. + let st = build_matrix(src_fmt, ns, ChannelFormat::Stereo, 2, pan, law); + for (row, srow) in m.iter_mut().zip(st.iter()).take(ns) { + row[0] = 0.5 * (srow[0] + srow[1]); + } + return m; + } + let src_sp = src_fmt.speakers(); + let src_amb = src_fmt.is_ambisonic() && ns >= 4; + if nd == 2 && !dst_fmt.is_ambisonic() { + match ns { + 1 => { + let (gl, gr) = gains(pan.stereo[0], law); + m[0][0] = gl; + m[0][1] = gr; + } + 2 if !src_amb => { + let (a_l, a_r) = gains(pan.stereo[0], PanLaw::Zero); + let (b_l, b_r) = gains(pan.stereo[1], PanLaw::Zero); + m[0] = [0.0; MAX_CHANNELS]; + m[0][0] = a_l; + m[0][1] = a_r; + m[1][0] = b_l; + m[1][1] = b_r; + } + _ if src_amb => { + for (d, az) in [(0usize, -30.0f32), (1, 30.0)] { + let g = soundcraft_dsp::pan::ambisonic_decode(az, 0.0, 2); + for (c, gc) in g.iter().enumerate() { + set(&mut m, c, d, *gc); + } + } + } + _ if src_sp.len() == ns => { + for (s, sp) in src_sp.iter().enumerate() { + let (l, r) = itu_stereo_fold(*sp); + set(&mut m, s, 0, l); + set(&mut m, s, 1, r); + } + } + _ => { + // Unknown layout: first two channels to L/R, the rest to the centre. + let h = std::f32::consts::FRAC_1_SQRT_2; + m[0][0] = 1.0; + m[1][1] = 1.0; + for row in m.iter_mut().take(ns).skip(2) { + row[0] = h; + row[1] = h; + } + } + } + return m; + } + if ns > 2 && src_fmt == dst_fmt && ns == nd { + return identity_matrix(ns); + } + // Multichannel destination. + let mut layout = [SpeakerPos::default(); MAX_CHANNELS]; + let nl = layout_of(dst_fmt, &mut layout); + let layout = layout.get(..nl).unwrap_or(&[]); + let dst_sp = dst_fmt.speakers(); + let dst_amb = dst_fmt.is_ambisonic(); + let find = |sp: Speaker| dst_sp.iter().position(|x| *x == sp).filter(|i| *i < nd); + let mut g = [0.0f32; MAX_CHANNELS]; + if dst_amb { + if src_amb || (src_fmt.is_ambisonic() && ns > 2) { + return identity_matrix(ns.min(nd)); + } + let mut enc = |m: &mut Matrix, s: usize, az: f32, el: f32, spread: f32| { + soundcraft_dsp::pan::ambisonic_encode(az, el, spread, &mut g); + for (d, gd) in g.iter().enumerate().take(nd) { + set(m, s, d, *gd); + } + }; + match (ns, pan.surround) { + (1 | 2, Some(sp)) => { + for s in 0..ns { + let dx = if ns == 2 { if s == 0 { -1.0 } else { 1.0 } } else { 0.0 }; + let p = surround_params(sp, dx); + enc(&mut m, s, soundcraft_dsp::pan::puck_azimuth(&[], p.x, p.y), p.z * 90.0, p.divergence); + } + } + (1, None) => enc(&mut m, 0, pan.stereo[0].clamp(-1.0, 1.0) * 30.0, 0.0, 0.0), + (2, None) => { + enc(&mut m, 0, -30.0, 0.0, 0.0); + enc(&mut m, 1, 30.0, 0.0, 0.0); + } + _ => { + for (s, sp) in src_sp.iter().enumerate().take(ns) { + if !sp.is_lfe() { + enc(&mut m, s, sp.azimuth(), sp.elevation(), 0.0); } } } } - n => { - // Stereo: each side has its own panner. Wider formats fold extra channels to centre. - let (a_l, a_r) = gains(pans[0], PanLaw::Zero); - let (b_l, b_r) = gains(pans[1], PanLaw::Zero); - for i in 0..frames { - let x0 = src.first().and_then(|c| c.get(i)).copied().unwrap_or(0.0); - let x1 = src.get(1).and_then(|c| c.get(i)).copied().unwrap_or(0.0); - let mut c = 0.0; - for ch in src.iter().skip(2).take(n.saturating_sub(2)) { - c += ch.get(i).copied().unwrap_or(0.0) * std::f32::consts::FRAC_1_SQRT_2; + return m; + } + if dst_sp.len() != nd { + // No layout to pan on: channel for channel. + return identity_matrix(ns.min(nd)); + } + let (li, ri) = (find(Speaker::L), find(Speaker::R)); + match (ns, pan.surround) { + (1 | 2, Some(sp)) if !src_amb => { + for s in 0..ns { + let dx = if ns == 2 { if s == 0 { -1.0 } else { 1.0 } } else { 0.0 }; + soundcraft_dsp::pan::surround_gains(layout, &surround_params(sp, dx), &mut g); + for (d, gd) in g.iter().enumerate().take(nd) { + set(&mut m, s, d, *gd); } - if let (Some(a), Some(b)) = (dl.get_mut(i), dr.get_mut(i)) { - *a += x0 * a_l + x1 * b_l + c; - *b += x0 * a_r + x1 * b_r + c; + if let Some(lfe) = find(Speaker::Lfe) + && sp.lfe_db > -143.9 + { + set(&mut m, s, lfe, db_to_gain(sp.lfe_db.min(12.0)) / ns as f32); } } } + (1, None) => { + let (gl, gr) = gains(pan.stereo[0], law); + if let (Some(l), Some(r)) = (li, ri) { + set(&mut m, 0, l, gl); + set(&mut m, 0, r, gr); + } + } + (2, None) if !src_amb => { + let (a_l, a_r) = gains(pan.stereo[0], PanLaw::Zero); + let (b_l, b_r) = gains(pan.stereo[1], PanLaw::Zero); + if let (Some(l), Some(r)) = (li, ri) { + set(&mut m, 0, l, a_l); + set(&mut m, 0, r, a_r); + set(&mut m, 1, l, b_l); + set(&mut m, 1, r, b_r); + } + } + _ if src_amb => { + let n_spk = dst_sp.iter().filter(|s| !s.is_lfe()).count(); + for (d, sp) in dst_sp.iter().enumerate().take(nd) { + if sp.is_lfe() { + continue; + } + let dec = soundcraft_dsp::pan::ambisonic_decode(sp.azimuth(), sp.elevation(), n_spk); + for (c, gc) in dec.iter().enumerate() { + set(&mut m, c, d, *gc); + } + } + } + _ if src_sp.len() == ns => { + for (s, sp) in src_sp.iter().enumerate() { + if let Some(d) = find(*sp) { + set(&mut m, s, d, 1.0); + } else if sp.is_lfe() { + // No LFE in the destination: dropped. + } else { + soundcraft_dsp::pan::direction_gains(layout, sp.azimuth(), sp.elevation(), &mut g); + for (d, gd) in g.iter().enumerate().take(nd) { + set(&mut m, s, d, *gd); + } + } + } + } + _ => return identity_matrix(ns.min(nd)), + } + m +} + +/// `dst[d] += Σ_s src[s] × gain × m[s][d]` for every destination channel (no allocation). +fn apply_matrix(src: &[Vec], frames: usize, dst: &mut [Vec], m: &Matrix, gain: f32) { + let ns = src.len().min(MAX_CHANNELS); + for (d, out) in dst.iter_mut().enumerate().take(MAX_CHANNELS) { + let mut idx = [(0usize, 0.0f32); MAX_CHANNELS]; + let mut k = 0; + for (s, row) in m.iter().enumerate().take(ns) { + let g = row.get(d).copied().unwrap_or(0.0); + if g != 0.0 + && let Some(slot) = idx.get_mut(k) + { + *slot = (s, g); + k += 1; + } + } + let taps = idx.get(..k).unwrap_or(&[]); + if taps.is_empty() { + continue; + } + for (i, o) in out.iter_mut().enumerate().take(frames) { + let mut acc = 0.0f32; + for &(s, g) in taps { + acc += src.get(s).and_then(|c| c.get(i)).copied().unwrap_or(0.0) * gain * g; + } + *o += acc; + } } } -fn mix_to_bus( - src: &[Vec], - frames: usize, - busses: &mut HashMap>>, - target: &Route, - gain: f32, - send_pan: f32, - law: PanLaw, - track_pan: &[f32], -) { - let Route::Bus(b) = target else { return }; - let Some(dst) = busses.get_mut(b) else { return }; - let pans = if src.len() == 1 { [send_pan, 0.0] } else { [track_pan.first().copied().unwrap_or(-1.0), track_pan.get(1).copied().unwrap_or(1.0)] }; - if gain <= 0.0 { - return; - } - // Scale via a temporary pass over the destination: add gain*src. - let (l, r) = dst.split_at_mut(1); - let (Some(dl), Some(dr)) = (l.first_mut(), r.first_mut()) else { return }; - if src.len() == 1 { - let (gl, gr) = gains(pans[0], law); - if let Some(s0) = src.first() { - for i in 0..frames { - let v = s0.get(i).copied().unwrap_or(0.0) * gain; - if let (Some(a), Some(bb)) = (dl.get_mut(i), dr.get_mut(i)) { - *a += v * gl; - *bb += v * gr; - } - } - } - } else { - for i in 0..frames { - let x0 = src.first().and_then(|c| c.get(i)).copied().unwrap_or(0.0) * gain; - let x1 = src.get(1).and_then(|c| c.get(i)).copied().unwrap_or(0.0) * gain; - if let (Some(a), Some(bb)) = (dl.get_mut(i), dr.get_mut(i)) { - *a += x0; - *bb += x1; - } - } - } -} +/// Ceiling on one offline render (samples summed over channels): 2^29 f32 = 2 GiB, about 93 +/// minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory. +pub const MAX_RENDER_SAMPLES: usize = 1 << 29; -/// Offline render of the main mix over `range` (stereo, planar). The mix's delay-compensation -/// latency is removed, so the result lines up with the timeline. +/// Offline render of the main mix over `range` (planar, [`main_channels`] channels in SMPTE/WAV +/// order). The mix's delay-compensation latency is removed, so the result lines up with the +/// timeline. pub fn render_range(s: &Session, range: Range, block: usize) -> Vec> { - let len = usize::try_from(range.len().max(0)).unwrap_or(0); + let nch = main_channels(s); + let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch.max(1)); // Probe the latency with one block, then render `len + latency` and drop the head. let latency = { let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block); let b = probe.max_block(); - let mut tmp = vec![vec![0.0f32; b]; MAIN_CHANNELS]; + let mut tmp = vec![vec![0.0f32; b]; nch]; probe.render(s, range.start, b.min(len.max(1)), &mut tmp); probe.latency }; let total = len.saturating_add(latency); - let mut out = vec![vec![0.0f32; total]; MAIN_CHANNELS]; + let mut out = vec![vec![0.0f32; total]; nch]; let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); let b = eng.max_block(); - let mut tmp = vec![vec![0.0f32; b]; MAIN_CHANNELS]; + let mut tmp = vec![vec![0.0f32; b]; nch]; let mut done = 0usize; while done < total { let n = (total - done).min(b); @@ -1037,10 +1309,40 @@ pub fn render_range(s: &Session, range: Range, block: usize) -> Vec> { out } +/// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped; +/// Ambisonics play their W channel). Mono/stereo input is returned unchanged. +pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec]) -> Vec> { + if ch.len() <= 2 { + return ch.to_vec(); + } + let len = ch.iter().map(Vec::len).max().unwrap_or(0); + let mut out = vec![vec![0.0f32; len]; 2]; + let sp = fmt.speakers(); + let h = std::f32::consts::FRAC_1_SQRT_2; + for (c, src) in ch.iter().enumerate() { + let (gl, gr) = match sp.get(c) { + Some(x) if sp.len() == ch.len() => itu_stereo_fold(*x), + _ if c == 0 => (h, h), + _ => (0.0, 0.0), + }; + for (g, dst) in [(gl, 0usize), (gr, 1)] { + if g == 0.0 { + continue; + } + if let Some(d) = out.get_mut(dst) { + for (o, x) in d.iter_mut().zip(src.iter()) { + *o += x * g; + } + } + } + } + out +} + /// Render only one track's clips (no mixer processing) — Consolidate. pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec> { let Some(t) = s.track(track) else { return Vec::new() }; - let len = usize::try_from(range.len().max(0)).unwrap_or(0); + let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / t.channels().max(1)); let mut out = vec![vec![0.0f32; len]; t.channels().max(1)]; let mut scratch = Vec::new(); for c in t.clips() { @@ -1077,6 +1379,9 @@ pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec> /// Render one track through its inserts (pre-fader) — Commit / Freeze. pub fn render_track_pre_fader(s: &Session, track: TrackId, range: Range) -> Vec> { let mut solo = s.clone(); + // Multichannel tracks render into a main mix of their own format (straight through). + let fmt = s.track(track).map_or(ChannelFormat::Stereo, |t| if t.channels() > 2 { t.format } else { ChannelFormat::Stereo }); + solo.outputs = vec![soundcraft_model::OutputPath { name: "Out".into(), first_channel: 0, format: fmt }]; for t in &mut solo.tracks { if t.id == track { t.mixer.volume_db = 0.0; @@ -1089,6 +1394,7 @@ pub fn render_track_pre_fader(s: &Session, track: TrackId, range: Range) -> Vec< }; t.automation.retain(|l| matches!(l.param, AutoParam::Plugin { .. })); t.mixer.sends.iter_mut().for_each(|x| *x = None); + t.mixer.surround = None; } else { t.inactive = true; } @@ -1106,14 +1412,14 @@ pub fn render_track_pre_fader(s: &Session, track: TrackId, range: Range) -> Vec< /// A built-in plugin, else a hosted CLAP plugin (`clap:`). fn create_plugin(id: &str) -> Option> { - soundcraft_dsp::create(id).or_else(|| soundcraft_clap_host::create(id)) + soundcraft_dsp::create(id).or_else(|| soundcraft_clap_host::create(id)).or_else(|| soundcraft_vst3_host::create(id)) } #[cfg(test)] mod tests { use super::*; use soundcraft_audio_io::AudioBuffer; - use soundcraft_model::{ChannelFormat, Insert, SourceAudio, SourceId}; + use soundcraft_model::{Insert, SourceAudio, SourceId}; use std::sync::Arc; fn session_with_dc(level: f32, frames: usize) -> (Session, TrackId) { @@ -1293,4 +1599,110 @@ mod tests { let out = render_range(&s, Range::new(0, 1000), 128); assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0))); } + + /// A session with one track of `fmt` playing a distinct DC level per channel (0.1, 0.2, …). + fn multichannel_session(fmt: ChannelFormat, main: ChannelFormat, frames: usize) -> (Session, TrackId) { + let mut s = Session::default(); + s.outputs[0].format = main; + let t = s.add_track(TrackKind::Audio, fmt, None); + let chans: Vec> = (0..fmt.channels()).map(|c| (0..frames).map(|i| 0.1 * (c + 1) as f32 + (i % 7) as f32 * 1e-3).collect()).collect(); + s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: chans }))); + let id = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "mc", SourceId(901), 0, 0, frames as i64)); + (s, t) + } + + #[test] + fn surround_track_passes_through_bit_exact() { + let (s, _) = multichannel_session(ChannelFormat::Surround51, ChannelFormat::Surround51, 4800); + let src = s.pool.get(SourceId(901)).unwrap().buffer.channels.clone(); + let out = render_range(&s, Range::new(0, 4800), 512); + assert_eq!(out.len(), 6); + for (o, x) in out.iter().zip(src.iter()) { + assert_eq!(o, x, "5.1 → 5.1 must be bit-exact"); + } + } + + #[test] + fn surround_to_stereo_folds_down_itu() { + let (s, _) = multichannel_session(ChannelFormat::Surround51, ChannelFormat::Stereo, 4800); + let out = render_range(&s, Range::new(0, 4800), 512); + assert_eq!(out.len(), 2); + let h = std::f32::consts::FRAC_1_SQRT_2; + let x = |c: usize| 0.1 * (c + 1) as f32 + (1000 % 7) as f32 * 1e-3; + // L R C LFE Ls Rs. + let l = x(0) + h * x(2) + h * x(4); + let r = x(1) + h * x(2) + h * x(5); + assert!((out[0][1000] - l).abs() < 1e-5, "{} vs {l}", out[0][1000]); + assert!((out[1][1000] - r).abs() < 1e-5, "{} vs {r}", out[1][1000]); + } + + #[test] + fn mono_track_on_surround_main_pans() { + let (mut s, t) = session_with_dc(0.5, 4800); + s.outputs[0].format = ChannelFormat::Surround51; + // No surround pan: stereo behaviour on L/R. + let out = render_range(&s, Range::new(0, 4800), 512); + assert_eq!(out.len(), 6); + let h = 0.5 * gains(0.0, PanLaw::Minus3).0; + assert!((out[0][100] - h).abs() < 1e-6 && (out[1][100] - h).abs() < 1e-6); + assert!(out[2..].iter().all(|c| c[100].abs() < 1e-7)); + // Front centre: the centre speaker only. + s.track_mut(t).unwrap().mixer.surround = Some(SurroundPan::default()); + let out = render_range(&s, Range::new(0, 4800), 512); + assert!((out[2][100] - 0.5).abs() < 1e-4, "{}", out[2][100]); + assert!(out.iter().enumerate().all(|(c, x)| c == 2 || x[100].abs() < 1e-4)); + // Rear right with LFE: Rs plus the LFE send. + s.track_mut(t).unwrap().mixer.surround = Some(SurroundPan { x: 1.0, y: -1.0, lfe_db: -6.0, ..SurroundPan::default() }); + let mut eng = MixEngine::new(48_000.0, 512); + let mut buf = vec![vec![0.0; 512]; 6]; + eng.render(&s, 0, 512, &mut buf); + assert!((buf[5][100] - 0.5).abs() < 1e-4 && (buf[3][100] - 0.5 * db_to_gain(-6.0)).abs() < 1e-4); + assert_eq!(eng.main_meter.peaks.len(), 6); + assert_eq!(eng.meters.get(&t).map(|m| m.peaks.len()), Some(1)); + // A stereo device buffer gets the first two channels. + let mut two = vec![vec![1.0; 512]; 2]; + eng.render(&s, 512, 512, &mut two); + assert!(two[0][10].abs() < 1e-6); + } + + #[test] + fn surround_busses_and_master_fader() { + let (mut s, t) = multichannel_session(ChannelFormat::Surround51, ChannelFormat::Surround51, 4800); + let bus = s.add_bus("Stem", ChannelFormat::Surround51); + let aux = s.add_track(TrackKind::Aux, ChannelFormat::Surround51, Some("Stem")); + s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus); + s.track_mut(t).unwrap().mixer.output = Route::Bus(bus); + let m = s.add_track(TrackKind::Master, ChannelFormat::Surround51, None); + s.track_mut(m).unwrap().mixer.volume_db = -6.0; + let out = render_range(&s, Range::new(0, 4800), 512); + let g = db_to_gain(-6.0); + for (c, ch) in out.iter().enumerate() { + let x = 0.1 * (c + 1) as f32 + (1000 % 7) as f32 * 1e-3; + assert!((ch[1000] - x * g).abs() < 1e-5, "channel {c}: {} vs {}", ch[1000], x * g); + } + // A mono bus sums a stereo source like the old stereo bus did. + let (mut s, t) = session_with_dc(0.5, 4800); + let bus = s.add_bus("Mono", ChannelFormat::Mono); + let aux = s.add_track(TrackKind::Aux, ChannelFormat::Mono, Some("M")); + s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus); + s.track_mut(aux).unwrap().mixer.pan = vec![-1.0]; + s.track_mut(t).unwrap().mixer.output = Route::Bus(bus); + let out = render_range(&s, Range::new(0, 4800), 512); + assert!((out[0][1000] - 0.5 * gains(0.0, PanLaw::Minus3).0).abs() < 1e-6, "{}", out[0][1000]); + } + + #[test] + fn surround_upmix_and_downmix_between_layouts() { + // 5.1 → 7.1: shared speakers map 1:1; 5.x surrounds pan between the 7.1 sides and rears. + let (s, _) = multichannel_session(ChannelFormat::Surround51, ChannelFormat::Surround71, 2000); + let out = render_range(&s, Range::new(0, 2000), 512); + assert_eq!(out.len(), 8); + let x = |c: usize| 0.1 * (c + 1) as f32 + (100 % 7) as f32 * 1e-3; + for (c, ch) in out.iter().enumerate().take(4) { + assert!((ch[100] - x(c)).abs() < 1e-6); + } + let e_ls = out[4][100].powi(2) + out[6][100].powi(2); + assert!((e_ls - x(4).powi(2)).abs() < 1e-5, "Ls energy is preserved"); + } } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 1a126f0..7bf81af 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -18,7 +18,9 @@ pub use automation::{AutoParam, AutomationLane, AutomationMode, AutomationPoint} pub use clip::{Clip, ClipContent, Fade, FadeShape}; pub use ids::{BusId, ClipId, GroupId, MarkerId, SourceId, TrackId}; pub use markers::{Group, MarkerKind, MemoryLocation}; -pub use mixer::{Bus, ChannelFormat, INSERT_SLOTS, Insert, Mixer, OutputPath, Route, SEND_SLOTS, SendSlot, fader_db_to_pos, fader_pos_to_db}; +pub use mixer::{ + Bus, ChannelFormat, INSERT_SLOTS, Insert, Mixer, OutputPath, Route, SEND_SLOTS, SendSlot, Speaker, SurroundPan, fader_db_to_pos, fader_pos_to_db, +}; pub use session::{BitDepthSetting, EditMode, EditState, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState}; pub use source::{Source, SourceAudio, SourcePool}; pub use track::{Playlist, TRACK_COLORS, Track, TrackHeight, TrackKind}; diff --git a/crates/model/src/mixer.rs b/crates/model/src/mixer.rs index af11a36..d1f3346 100644 --- a/crates/model/src/mixer.rs +++ b/crates/model/src/mixer.rs @@ -143,6 +143,74 @@ impl ChannelFormat { pub fn from_id(s: &str) -> Option { ChannelFormat::ALL.into_iter().find(|f| f.label().eq_ignore_ascii_case(s.trim())) } + /// Speakers in channel order, or empty for Ambisonics (ACN order, SN3D). + /// + /// Channel order is SMPTE / WAV order: speakers with a WAVE_FORMAT_EXTENSIBLE mask bit come in + /// ascending bit order (L R C LFE Ls Rs Lc Rc Cs Lss Rss Ltf Rtf Ltr Rtr; 7.x puts the rear + /// surrounds Lrs/Rrs on the "back" bits before the sides Lss/Rss), followed by speakers WAV + /// cannot name (the top-middle pair of .6 layouts). E.g. 5.1 = L R C LFE Ls Rs, + /// 7.1 = L R C LFE Lrs Rrs Lss Rss, 7.1.4 = L R C LFE Lrs Rrs Lss Rss Ltf Rtf Ltr Rtr. + pub fn speakers(self) -> &'static [Speaker] { + use Speaker::*; + match self { + ChannelFormat::Mono => &[C], + ChannelFormat::Stereo => &[L, R], + ChannelFormat::Lcr => &[L, R, C], + ChannelFormat::Quad => &[L, R, Ls, Rs], + ChannelFormat::Lcrs => &[L, R, C, Cs], + ChannelFormat::Surround50 => &[L, R, C, Ls, Rs], + ChannelFormat::Surround51 => &[L, R, C, Lfe, Ls, Rs], + ChannelFormat::Surround60 => &[L, R, C, Ls, Rs, Cs], + ChannelFormat::Surround61 => &[L, R, C, Lfe, Ls, Rs, Cs], + ChannelFormat::Sdds70 => &[L, R, C, Ls, Rs, Lc, Rc], + ChannelFormat::Sdds71 => &[L, R, C, Lfe, Ls, Rs, Lc, Rc], + ChannelFormat::Surround70 => &[L, R, C, Lrs, Rrs, Lss, Rss], + ChannelFormat::Surround71 => &[L, R, C, Lfe, Lrs, Rrs, Lss, Rss], + ChannelFormat::Atmos702 => &[L, R, C, Lrs, Rrs, Lss, Rss, Ltm, Rtm], + ChannelFormat::Atmos712 => &[L, R, C, Lfe, Lrs, Rrs, Lss, Rss, Ltm, Rtm], + ChannelFormat::Atmos502 => &[L, R, C, Ls, Rs, Ltm, Rtm], + ChannelFormat::Atmos512 => &[L, R, C, Lfe, Ls, Rs, Ltm, Rtm], + ChannelFormat::Atmos504 => &[L, R, C, Ls, Rs, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos514 => &[L, R, C, Lfe, Ls, Rs, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos704 => &[L, R, C, Lrs, Rrs, Lss, Rss, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos714 => &[L, R, C, Lfe, Lrs, Rrs, Lss, Rss, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos706 => &[L, R, C, Lrs, Rrs, Lss, Rss, Ltf, Rtf, Ltr, Rtr, Ltm, Rtm], + ChannelFormat::Atmos716 => &[L, R, C, Lfe, Lrs, Rrs, Lss, Rss, Ltf, Rtf, Ltr, Rtr, Ltm, Rtm], + ChannelFormat::Atmos904 => &[L, R, C, Lrs, Rrs, Lw, Rw, Lss, Rss, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos914 => &[L, R, C, Lfe, Lrs, Rrs, Lw, Rw, Lss, Rss, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos906 => &[L, R, C, Lrs, Rrs, Lw, Rw, Lss, Rss, Ltf, Rtf, Ltr, Rtr, Ltm, Rtm], + ChannelFormat::Atmos916 => &[L, R, C, Lfe, Lrs, Rrs, Lw, Rw, Lss, Rss, Ltf, Rtf, Ltr, Rtr, Ltm, Rtm], + ChannelFormat::Ambisonics(_) => &[], + } + } + pub fn is_ambisonic(self) -> bool { + matches!(self, ChannelFormat::Ambisonics(_)) + } + pub fn has_lfe(self) -> bool { + self.speakers().contains(&Speaker::Lfe) + } + pub fn has_height(self) -> bool { + self.speakers().iter().any(|s| s.is_height()) + } + /// Short per-channel labels for meters (`W Y Z X 5…` for Ambisonics). + pub fn channel_label(self, ch: usize) -> String { + match self.speakers().get(ch) { + Some(s) => s.label().to_string(), + None if self.is_ambisonic() => ["W", "Y", "Z", "X"].get(ch).map_or_else(|| format!("{}", ch + 1), |s| s.to_string()), + None => format!("{}", ch + 1), + } + } + /// WAVE_FORMAT_EXTENSIBLE channel mask (0 = no positions, e.g. Ambisonics). Speakers that WAV + /// cannot name (top middle) borrow the top-front bits when the layout has no top fronts. + pub fn channel_mask(self) -> u32 { + let sp = self.speakers(); + let has_tf = sp.contains(&Speaker::Ltf); + sp.iter().fold(0u32, |m, s| match s { + Speaker::Ltm if !has_tf => m | 0x1000, + Speaker::Rtm if !has_tf => m | 0x4000, + s => m | s.wave_bit(), + }) + } /// Best format for a channel count. pub fn for_channels(n: usize) -> ChannelFormat { [ @@ -161,6 +229,168 @@ impl ChannelFormat { } } +/// A loudspeaker position in a channel format. +/// +/// Angles follow ITU-R BS.775 / Dolby conventions: azimuth in degrees, 0 = front centre, positive +/// = to the right; elevation in degrees above the listener plane. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum Speaker { + L, + R, + C, + Lfe, + /// 5.x surrounds (±110°). + Ls, + Rs, + /// SDDS inner fronts (±15°). + Lc, + Rc, + /// Centre surround (180°). + Cs, + /// 7.x side surrounds (±90°). + Lss, + Rss, + /// 7.x rear surrounds (±150°). + Lrs, + Rrs, + /// 9.x front wides (±60°). + Lw, + Rw, + /// Top front (±45°, elevation 45°). + Ltf, + Rtf, + /// Top middle (±90°, elevation 45°). + Ltm, + Rtm, + /// Top rear (±135°, elevation 45°). + Ltr, + Rtr, +} + +impl Speaker { + pub fn azimuth(self) -> f32 { + match self { + Speaker::L => -30.0, + Speaker::R => 30.0, + Speaker::C | Speaker::Lfe => 0.0, + Speaker::Ls => -110.0, + Speaker::Rs => 110.0, + Speaker::Lc => -15.0, + Speaker::Rc => 15.0, + Speaker::Cs => 180.0, + Speaker::Lss => -90.0, + Speaker::Rss => 90.0, + Speaker::Lrs => -150.0, + Speaker::Rrs => 150.0, + Speaker::Lw => -60.0, + Speaker::Rw => 60.0, + Speaker::Ltf => -45.0, + Speaker::Rtf => 45.0, + Speaker::Ltm => -90.0, + Speaker::Rtm => 90.0, + Speaker::Ltr => -135.0, + Speaker::Rtr => 135.0, + } + } + pub fn elevation(self) -> f32 { + if self.is_height() { 45.0 } else { 0.0 } + } + pub fn is_height(self) -> bool { + matches!(self, Speaker::Ltf | Speaker::Rtf | Speaker::Ltm | Speaker::Rtm | Speaker::Ltr | Speaker::Rtr) + } + pub fn is_lfe(self) -> bool { + self == Speaker::Lfe + } + pub fn label(self) -> &'static str { + match self { + Speaker::L => "L", + Speaker::R => "R", + Speaker::C => "C", + Speaker::Lfe => "LFE", + Speaker::Ls => "Ls", + Speaker::Rs => "Rs", + Speaker::Lc => "Lc", + Speaker::Rc => "Rc", + Speaker::Cs => "Cs", + Speaker::Lss => "Lss", + Speaker::Rss => "Rss", + Speaker::Lrs => "Lrs", + Speaker::Rrs => "Rrs", + Speaker::Lw => "Lw", + Speaker::Rw => "Rw", + Speaker::Ltf => "Ltf", + Speaker::Rtf => "Rtf", + Speaker::Ltm => "Ltm", + Speaker::Rtm => "Rtm", + Speaker::Ltr => "Ltr", + Speaker::Rtr => "Rtr", + } + } + /// WAVE_FORMAT_EXTENSIBLE `dwChannelMask` bit (0 for speakers WAV cannot name). + pub fn wave_bit(self) -> u32 { + match self { + Speaker::L => 0x1, + Speaker::R => 0x2, + Speaker::C => 0x4, + Speaker::Lfe => 0x8, + Speaker::Ls | Speaker::Lrs => 0x10, + Speaker::Rs | Speaker::Rrs => 0x20, + Speaker::Lc | Speaker::Lw => 0x40, + Speaker::Rc | Speaker::Rw => 0x80, + Speaker::Cs => 0x100, + Speaker::Lss => 0x200, + Speaker::Rss => 0x400, + Speaker::Ltf => 0x1000, + Speaker::Rtf => 0x4000, + Speaker::Ltr => 0x8000, + Speaker::Rtr => 0x2_0000, + Speaker::Ltm | Speaker::Rtm => 0, + } + } +} + +/// Surround panner position (Pro Tools-style X/Y puck). `None` on a mixer means "use the stereo +/// pan", which keeps sessions made before surround mixing sounding exactly as they did. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct SurroundPan { + /// -1 (left) ..= 1 (right). + pub x: f32, + /// -1 (back) ..= 1 (front). + pub y: f32, + /// Elevation 0 (ear level) ..= 1 (overhead), used by height formats. + pub z: f32, + /// 0 = point source ..= 1 = spread over every speaker. + pub divergence: f32, + /// Centre speaker share of front-centre images, 0..=100 % (0 = phantom centre on L/R). + pub center: f32, + /// LFE send level, dB (-144 = off). + pub lfe_db: f32, +} + +impl Default for SurroundPan { + fn default() -> Self { + SurroundPan { x: 0.0, y: 1.0, z: 0.0, divergence: 0.0, center: 100.0, lfe_db: -144.0 } + } +} + +impl SurroundPan { + /// Puck at the stereo pan position (front edge). + pub fn from_stereo(pan: f32) -> Self { + SurroundPan { x: finite_or(pan, 0.0).clamp(-1.0, 1.0), ..SurroundPan::default() } + } + /// Clamp into range (NaN → defaults). + pub fn sanitize(&mut self) { + let d = SurroundPan::default(); + self.x = finite_or(self.x, d.x).clamp(-1.0, 1.0); + self.y = finite_or(self.y, d.y).clamp(-1.0, 1.0); + self.z = finite_or(self.z, d.z).clamp(0.0, 1.0); + self.divergence = finite_or(self.divergence, d.divergence).clamp(0.0, 1.0); + self.center = finite_or(self.center, d.center).clamp(0.0, 100.0); + self.lfe_db = finite_or(self.lfe_db, d.lfe_db).clamp(-144.0, 12.0); + } +} + /// An internal mix bus (Pro Tools "bus" paths). #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Bus { @@ -226,11 +456,14 @@ pub struct SendSlot { pub pre_fader: bool, #[serde(default = "yes")] pub follow_main_pan: bool, + /// Surround position into a multichannel bus (None = follow the track / stereo pan). + #[serde(default)] + pub surround: Option, } impl SendSlot { pub fn new(target: Route) -> Self { - SendSlot { target, level_db: -144.0, pan: 0.0, mute: false, pre_fader: false, follow_main_pan: true } + SendSlot { target, level_db: -144.0, pan: 0.0, mute: false, pre_fader: false, follow_main_pan: true, surround: None } } } @@ -261,6 +494,9 @@ pub struct Mixer { /// VCA master that controls this track, if any (track id of the VCA). #[serde(default)] pub vca: Option, + /// Surround panner, used when the output is multichannel (None = the stereo pan to L/R). + #[serde(default)] + pub surround: Option, } impl Mixer { @@ -286,6 +522,7 @@ impl Mixer { automation_mode: AutomationMode::Read, trim_db: 0.0, vca: None, + surround: None, } } @@ -300,11 +537,28 @@ impl Mixer { for s in self.sends.iter_mut().flatten() { s.level_db = finite_or(s.level_db, -144.0).clamp(-144.0, 12.0); s.pan = finite_or(s.pan, 0.0).clamp(-1.0, 1.0); + if let Some(sp) = &mut s.surround { + sp.sanitize(); + } + } + if let Some(sp) = &mut self.surround { + sp.sanitize(); } self.trim_db = finite_or(self.trim_db, 0.0).clamp(-144.0, 24.0); } } +impl crate::Session { + /// The main mix format: the format of the first (main) output path, at least stereo and at + /// most 16 channels (wider formats fall back to stereo). + pub fn main_format(&self) -> ChannelFormat { + match self.outputs.first().map(|o| o.format) { + Some(f) if (2..=16).contains(&f.channels()) => f, + _ => ChannelFormat::Stereo, + } + } +} + pub(crate) fn finite_or(v: f32, d: f32) -> f32 { if v.is_finite() { v } else { d } } @@ -357,6 +611,37 @@ mod tests { assert_eq!(m.inserts.len(), INSERT_SLOTS); } + #[test] + fn speaker_layouts_match_channel_counts_and_masks() { + for f in ChannelFormat::ALL { + if !f.is_ambisonic() { + assert_eq!(f.speakers().len(), f.channels(), "{f:?}"); + // Mask bits are unique and in ascending channel order. + let bits: Vec = f.speakers().iter().map(|s| s.wave_bit()).filter(|b| *b != 0).collect(); + assert!(bits.windows(2).all(|w| w[0] < w[1]), "{f:?} not in WAV order"); + } + } + assert_eq!(ChannelFormat::Surround51.channel_mask(), 0x3F); + assert_eq!(ChannelFormat::Surround71.channel_mask(), 0x63F); + assert_eq!(ChannelFormat::Atmos714.channel_mask(), 0x2_D63F); + assert_eq!(ChannelFormat::Ambisonics(1).channel_mask(), 0); + } + + #[test] + fn surround_pan_defaults_and_old_sessions() { + let m = Mixer::new(ChannelFormat::Mono); + assert_eq!(m.surround, None); + // A mixer saved before surround existed still loads. + let mut v = serde_json::to_value(&m).unwrap(); + v.as_object_mut().unwrap().remove("surround"); + let back: Mixer = serde_json::from_value(v).unwrap(); + assert_eq!(back.surround, None); + let mut sp: SurroundPan = serde_json::from_str(r#"{"x": 5.0}"#).unwrap(); + sp.sanitize(); + assert_eq!(sp.x, 1.0); + assert_eq!(sp.center, 100.0); + } + #[test] fn formats() { assert_eq!(ChannelFormat::for_channels(6), ChannelFormat::Surround51); diff --git a/crates/playback/Cargo.toml b/crates/playback/Cargo.toml index e6af720..6f728a5 100644 --- a/crates/playback/Cargo.toml +++ b/crates/playback/Cargo.toml @@ -20,5 +20,8 @@ cpal.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] cpal = { workspace = true, features = ["wasm-bindgen"] } +[dev-dependencies] +soundcraft-audio-io.workspace = true + [lints] workspace = true diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 466ae23..0ac1113 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -6,11 +6,16 @@ //! the callback through a channel; position and meters come back through atomics and a //! `try_lock`ed snapshot, so the callback never blocks. When no device is available the player //! falls back to a silent clock thread so the transport, meters and automation still run. +//! +//! Surround: the device is opened with as many output channels as it offers, up to the width of +//! the session's main format. Main channels (SMPTE/WAV order) go to device channels in order; when +//! the device has fewer channels than the main mix, the mix is folded down to stereo (ITU-R +//! BS.775) on the first two device channels. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod record; -use soundcraft_mix::{MixEngine, StripMeter}; +use soundcraft_mix::{MAX_CHANNELS, MixEngine, StripMeter, itu_stereo_fold, main_channels}; use soundcraft_model::{Session, TrackId}; use soundcraft_time::{Range, Samples}; use std::collections::HashMap; @@ -55,8 +60,10 @@ struct AudioState { end: Option, looped: Option, block: Vec>, - /// Rendered frames waiting to be consumed (stereo planar) and the fractional read index. + /// Rendered frames waiting to be consumed (planar, main channels) and the fractional read index. pending: Vec>, + /// Stereo fold-down gains per main channel (used when the device has fewer channels). + fold: [(f32, f32); MAX_CHANNELS], pending_len: usize, read: f64, device_rate: f64, @@ -67,7 +74,9 @@ struct AudioState { impl AudioState { fn new(rx: Receiver, shared: Arc, session: Arc, device_rate: f64) -> Self { let sr = session.sample_rate.as_f64() as f32; + let nch = main_channels(&session); AudioState { + fold: fold_gains(&session), rx, shared, mix: MixEngine::new(sr, BLOCK), @@ -76,8 +85,8 @@ impl AudioState { playing: false, end: None, looped: None, - block: vec![vec![0.0; BLOCK]; 2], - pending: vec![vec![0.0; BLOCK]; 2], + block: vec![vec![0.0; BLOCK]; nch], + pending: vec![vec![0.0; BLOCK]; nch], pending_len: 0, read: 0.0, device_rate, @@ -95,6 +104,15 @@ impl AudioState { if (s.sample_rate.as_f64() - self.session.sample_rate.as_f64()).abs() > 0.5 { self.mix = MixEngine::new(s.sample_rate.as_f64() as f32, BLOCK); } + // The main format changed (a document edit, not steady state): resize. + let nch = main_channels(&s); + if self.block.len() != nch { + self.block = vec![vec![0.0; BLOCK]; nch]; + self.pending = vec![vec![0.0; BLOCK]; nch]; + self.pending_len = 0; + self.read = 0.0; + } + self.fold = fold_gains(&s); self.session = s; } Cmd::Play { from, end, looped } => { @@ -168,9 +186,14 @@ impl AudioState { self.pos += n as i64; self.shared.position.store(self.pos, Ordering::Relaxed); if let Ok(mut m) = self.shared.meters.try_lock() { - m.main = self.mix.main_meter; + m.main.copy_from(&self.mix.main_meter); for (k, v) in &self.mix.meters { - m.tracks.insert(*k, *v); + match m.tracks.get_mut(k) { + Some(d) => d.copy_from(v), + None => { + m.tracks.insert(*k, v.clone()); + } + } } } } @@ -203,11 +226,36 @@ impl AudioState { } let i0 = self.read.floor() as usize; let fr = (self.read - i0 as f64) as f32; - for (c, o) in frame.iter_mut().enumerate() { - let ch = self.pending.get(c.min(1)); + let sounding = self.playing || monitoring; + let nch = self.pending.len(); + let sample = |c: usize| { + let ch = self.pending.get(c); let a = ch.and_then(|v| v.get(i0)).copied().unwrap_or(0.0); let b = ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a); - *o = if c < 2 && (self.playing || monitoring) { (a + (b - a) * fr).clamp(-1.0, 1.0) } else { 0.0 }; + a + (b - a) * fr + }; + if nch > 2 && channels < nch { + // Fewer device channels than the main mix: ITU fold-down to stereo. + let (mut l, mut r) = (0.0f32, 0.0f32); + if sounding { + for (c, (gl, gr)) in self.fold.iter().enumerate().take(nch) { + let v = sample(c); + l += v * gl; + r += v * gr; + } + } + for (c, o) in frame.iter_mut().enumerate() { + *o = match c { + 0 => l.clamp(-1.0, 1.0), + 1 => r.clamp(-1.0, 1.0), + _ => 0.0, + }; + } + } else { + for (c, o) in frame.iter_mut().enumerate() { + // Stereo mixes on a mono device play the left channel (as before). + *o = if c < nch.max(1) && sounding { sample(c).clamp(-1.0, 1.0) } else { 0.0 }; + } } self.read += step; } @@ -224,6 +272,8 @@ pub struct Player { _stream: Option, pub device_name: String, pub device_rate: u32, + /// Output channels the device was opened with (0 for the silent clock). + pub device_channels: u16, /// True when no audio device could be opened (silent clock). pub silent: bool, } @@ -239,7 +289,8 @@ impl Player { }); let (tx, rx) = channel(); let want = session.sample_rate.hz(); - match open_device(want) { + let want_ch = u16::try_from(main_channels(&session)).unwrap_or(2); + match open_device(want, want_ch) { Ok((device, config, name)) => { let rate = config.sample_rate.0; let channels = usize::from(config.channels); @@ -257,7 +308,15 @@ impl Player { log::warn!("audio stream failed to start: {e}"); } log::info!("audio device: {name} @ {rate} Hz, {channels} ch"); - Player { shared, tx, _stream: Some(stream), device_name: name, device_rate: rate, silent: false } + Player { + shared, + tx, + _stream: Some(stream), + device_name: name, + device_rate: rate, + device_channels: config.channels, + silent: false, + } } Err(e) => { log::warn!("cannot open audio stream: {e}; using a silent clock"); @@ -304,12 +363,20 @@ impl Player { if let Err(e) = spawn { log::warn!("clock thread failed: {e}"); } - Player { shared, tx, _stream: None, device_name: "No audio device (silent)".into(), device_rate: rate, silent: true } + Player { shared, tx, _stream: None, device_name: "No audio device (silent)".into(), device_rate: rate, device_channels: 0, silent: true } } #[cfg(target_arch = "wasm32")] fn silent_clock_with(shared: Arc, tx: Sender, _rx: Receiver, session: Arc) -> Player { - Player { shared, tx, _stream: None, device_name: "No audio device".into(), device_rate: session.sample_rate.hz(), silent: true } + Player { + shared, + tx, + _stream: None, + device_name: "No audio device".into(), + device_rate: session.sample_rate.hz(), + device_channels: 0, + silent: true, + } } pub fn update_session(&self, s: Arc) { @@ -360,13 +427,32 @@ impl Player { } } -fn open_device(want_rate: u32) -> Result<(cpal::Device, cpal::StreamConfig, String), String> { +/// ITU stereo fold-down gains for each main channel of a session. +fn fold_gains(s: &Session) -> [(f32, f32); MAX_CHANNELS] { + let mut g = [(0.0, 0.0); MAX_CHANNELS]; + let f = s.main_format(); + let sp = f.speakers(); + if sp.len() == f.channels() { + for (o, x) in g.iter_mut().zip(sp.iter()) { + *o = itu_stereo_fold(*x); + } + } else { + // Ambisonics: W only, at -3 dB per side. + g[0] = (std::f32::consts::FRAC_1_SQRT_2, std::f32::consts::FRAC_1_SQRT_2); + } + g +} + +/// Open the default output device: f32 at the session rate if possible, with as many channels as +/// it offers up to `want_ch` (at least stereo when available). +fn open_device(want_rate: u32, want_ch: u16) -> Result<(cpal::Device, cpal::StreamConfig, String), String> { use cpal::traits::{DeviceTrait, HostTrait}; let host = cpal::default_host(); let device = host.default_output_device().ok_or_else(|| "no default output device".to_string())?; #[allow(deprecated)] let name = device.name().unwrap_or_else(|_| "Audio device".into()); // Prefer an f32 config at the session rate; else the device default (we resample). + let want_ch = want_ch.max(2); let mut chosen: Option = None; if let Ok(configs) = device.supported_output_configs() { for c in configs { @@ -374,9 +460,10 @@ fn open_device(want_rate: u32) -> Result<(cpal::Device, cpal::StreamConfig, Stri && c.min_sample_rate().0 <= want_rate && c.max_sample_rate().0 >= want_rate && c.channels() >= 2 + && c.channels() <= want_ch + && chosen.as_ref().is_none_or(|x| c.channels() > x.channels) { chosen = Some(c.with_sample_rate(cpal::SampleRate(want_rate)).config()); - break; } } } @@ -415,6 +502,43 @@ mod tests { assert!(!shared.playing.load(Ordering::Relaxed)); } + #[test] + fn surround_main_maps_to_device_channels_or_folds_down() { + use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind}; + let mut s = soundcraft_model::Session::default(); + s.outputs[0].format = ChannelFormat::Surround51; + let t = s.add_track(TrackKind::Audio, ChannelFormat::Surround51, None); + let chans: Vec> = (0..6).map(|c| vec![0.1 * (c + 1) as f32; 48_000]).collect(); + s.pool.insert(SourceId(5), Arc::new(SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: chans }))); + let id = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "x", SourceId(5), 0, 0, 48_000)); + let s = Arc::new(s); + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::clone(&s), 48_000.0); + tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap(); + // An 8-channel device: channels in order, the rest silent. + let mut out = vec![0.0f32; 8 * 256]; + st.fill(&mut out, 8); + let f = &out[8 * 100..8 * 101]; + for c in 0..6 { + assert!((f[c] - 0.1 * (c + 1) as f32).abs() < 1e-5, "{f:?}"); + } + assert!(f[6] == 0.0 && f[7] == 0.0); + // A stereo device: ITU fold-down. + let mut out = vec![0.0f32; 2 * 256]; + st.fill(&mut out, 2); + let h = std::f32::consts::FRAC_1_SQRT_2; + let l = (0.1 + h * 0.3 + h * 0.5f32).min(1.0); + assert!((out[200] - l).abs() < 1e-4, "{} vs {l}", out[200]); + assert_eq!(shared.meters.lock().unwrap().main.peaks.len(), 6); + } + #[test] fn looping_wraps() { let s = Arc::new(soundcraft_model::Session::default()); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 39f627f..ca4e5f8 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -13,6 +13,7 @@ soundcraft-audio-io.workspace = true soundcraft-midi.workspace = true soundcraft-dsp.workspace = true soundcraft-clap-host.workspace = true +soundcraft-vst3-host.workspace = true soundcraft-model.workspace = true soundcraft-mix.workspace = true soundcraft-engine.workspace = true diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 8b93453..a23ca95 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -1,11 +1,14 @@ //! The Mix window: one channel strip per track. use crate::theme::{Tokens, bold, regular, rgb}; -use crate::widgets::{db_text, fader, meter, pan_knob, pan_text, rec_toggle, selector_box, text_toggle}; +use crate::widgets::{ + PannerEdit, PannerSpeaker, db_text, fader, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, surround_panner, text_toggle, +}; use crate::{SoundApp, panels}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; -use soundcraft_model::{Route, Track, TrackId, TrackKind}; +use soundcraft_model::{ChannelFormat, Route, SurroundPan, Track, TrackId, TrackKind}; +use soundcraft_playback::MeterSnapshot; pub fn strip_width(narrow: bool) -> f32 { if narrow { 64.0 } else { 98.0 } @@ -21,17 +24,65 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { } egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| { let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden).map(|x| x.id).collect(); + // Per-channel peaks for multichannel meters (read once per frame). + let snap = app + .player + .as_ref() + .filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor)) + .map(|p| p.meters()); egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| { ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 1.0; for id in ids { - strip(app, ui, id); + strip(app, ui, id, snap.as_ref()); } }); }); }); } +/// Format of the destination a track's output feeds (the main mix, a bus, or stereo). +fn output_format(app: &SoundApp, track: &Track) -> ChannelFormat { + let s = app.engine.session(); + match &track.mixer.output { + Route::Main => s.main_format(), + Route::Bus(b) => s.bus(*b).map_or(ChannelFormat::Stereo, |b| b.format), + _ => ChannelFormat::Stereo, + } +} + +/// Speaker dots of a format, placed on the panner square. +fn panner_speakers(fmt: ChannelFormat) -> Vec { + let sp = fmt.speakers(); + let layout: Vec = + sp.iter().map(|s| soundcraft_dsp::pan::SpeakerPos { az: s.azimuth(), el: s.elevation(), lfe: s.is_lfe() }).collect(); + sp.iter() + .filter(|s| !s.is_lfe()) + .map(|s| { + let (x, y) = soundcraft_dsp::pan::azimuth_to_puck(&layout, s.azimuth()); + PannerSpeaker { label: s.label(), x, y, height: s.is_height() } + }) + .collect() +} + +/// Ballistic per-channel meter levels for multichannel strips, kept in egui memory. +fn channel_levels(ui: &Ui, key: (&str, u64), peaks: &[f32], n: usize) -> Vec { + let id = egui::Id::new(key); + let dt = ui.input(|i| i.stable_dt).clamp(0.0, 0.25); + let fall = 10f32.powf(-26.0 * dt / 20.0); + let mut lv: Vec = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + lv.resize(n, 0.0); + for (i, l) in lv.iter_mut().enumerate() { + let p = peaks.get(i).copied().filter(|x| x.is_finite()).unwrap_or(0.0); + *l = if p >= *l { p } else { (*l * fall).max(p) }; + if *l < 1e-5 { + *l = 0.0; + } + } + ui.data_mut(|d| d.insert_temp(id, lv.clone())); + lv +} + fn section_label(ui: &Ui, r: Rect, text: &str) { ui.painter().text(pos2(r.center().x, r.min.y + 7.0), Align2::CENTER_CENTER, text, bold(10.0), Color32::from_rgb(200, 200, 200)); } @@ -39,13 +90,13 @@ fn section_label(ui: &Ui, r: Rect, text: &str) { fn route_name(app: &SoundApp, r: &Route) -> String { match r { Route::None => "no output".into(), - Route::Main => "Out 1-2".into(), + Route::Main => app.engine.session().outputs.first().map_or_else(|| "Out 1-2".into(), |o| o.name.clone()), Route::Bus(b) => app.engine.session().bus(*b).map_or_else(|| "bus?".into(), |b| b.name.clone()), Route::Hardware(h) => h.clone(), } } -fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { +fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>) { let t = Tokens::DARK; let Some(track) = app.engine.session().track(id).cloned() else { return }; let narrow = app.ui.narrow_mix; @@ -162,8 +213,31 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let mut c = ui.new_child(egui::UiBuilder::new().max_rect(gr)); let _ = selector_box(&mut c, gr.width(), gr.height(), &gname, t.text_dim); y += 22.0; - // Pan. - if !track.mixer.pan.is_empty() && track.kind != TrackKind::Vca { + // Pan: a surround panner when the output is multichannel, else the stereo knobs. + let out_fmt = output_format(app, &track); + let surround_out = + out_fmt.channels() > 2 && !out_fmt.is_ambisonic() && !matches!(track.kind, TrackKind::Master | TrackKind::Vca) && track.channels() <= 2; + if surround_out { + let size = (inner_w - 4.0).min(88.0); + let sp = track.mixer.surround.unwrap_or_else(|| { + SurroundPan::from_stereo(if track.mixer.pan.len() == 1 { track.mixer.pan.first().copied().unwrap_or(0.0) } else { 0.0 }) + }); + let rows = if out_fmt.has_height() { 2.0 } else { 1.0 }; + let pr = Rect::from_min_size(pos2(x0 + (inner_w - size) * 0.5, y), vec2(size, size + rows * 11.0)); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(pr).layout(egui::Layout::top_down(egui::Align::Min))); + c.spacing_mut().item_spacing.y = 1.0; + let speakers = panner_speakers(out_fmt); + let z = out_fmt.has_height().then_some(sp.z); + if let Some(edit) = surround_panner(&mut c, size, &speakers, (sp.x, sp.y), sp.divergence, z) { + let params = match edit { + PannerEdit::Position(x, y) => json!({"track": id.0, "x": x, "y": y}), + PannerEdit::Divergence(d) => json!({"track": id.0, "divergence": d}), + PannerEdit::Height(h) => json!({"track": id.0, "z": h}), + }; + let _ = app.engine.execute_merged("mix.surround_pan", ¶ms, &format!("span:{}", id.0)); + } + y += size + rows * 11.0 + 6.0; + } else if !track.mixer.pan.is_empty() && track.kind != TrackKind::Vca { let n = track.mixer.pan.len().min(2); let ks = if n == 2 { (inner_w / 2.0 - 6.0).min(32.0) } else { 34.0 }; for i in 0..n { @@ -214,15 +288,24 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { // Fader + meter. let bottom_h = 64.0; let fh = (r.max.y - bottom_h - y).max(120.0); - let fr = Rect::from_min_size(pos2(x0, y), vec2(inner_w * 0.6, fh)); + let wide_meter = (if track.kind == TrackKind::Master { app.engine.session().main_format() } else { track.format }).channels() > 2; + let fr = Rect::from_min_size(pos2(x0, y), vec2(inner_w * if wide_meter { 0.45 } else { 0.6 }, fh)); if let Some(db) = fader(ui, fr, track.mixer.volume_db, ui.id().with(("fader", id.0))) { app.gesture = Some(crate::Gesture::Fader { track: id, start_db: track.mixer.volume_db }); let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0)); } let md = app.meters.get(&id).copied().unwrap_or_default(); let mr = Rect::from_min_max(pos2(fr.max.x + 4.0, fr.min.y + 6.0), pos2(x0 + inner_w - 2.0, fr.max.y - 6.0)); + let strip_fmt = if track.kind == TrackKind::Master { app.engine.session().main_format() } else { track.format }; + let all_chans = strip_fmt.channels().min(soundcraft_mix::MAX_CHANNELS); let chans = if track.kind == TrackKind::Master { 2 } else { track.channels().min(2) }; - if chans >= 2 { + if all_chans > 2 { + // One bar per channel, labelled (L R C LFE …). + let peaks = snap.and_then(|s| s.tracks.get(&id)).map(|m| m.peaks.as_slice()).unwrap_or(&[]); + let lv = channel_levels(ui, ("mc_meter", id.0), peaks, all_chans); + let labels: Vec = (0..all_chans).map(|c| strip_fmt.channel_label(c)).collect(); + multi_meter(ui, mr, &lv, &labels, md.clip); + } else if chans >= 2 { let mw = mr.width() / 2.0 - 1.0; meter(ui, Rect::from_min_size(mr.min, vec2(mw, mr.height())), md.level[0], md.hold[0], md.clip); meter(ui, Rect::from_min_size(pos2(mr.min.x + mw + 2.0, mr.min.y), vec2(mw, mr.height())), md.level[1], md.hold[1], md.clip); @@ -336,11 +419,23 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc } } }); + // Third-party VST3 plugins (scanned once, cached by soundcraft-vst3-host). + ui.menu_button("VST3", |ui| { + let found: Vec<_> = soundcraft_vst3_host::scan().into_iter().filter(|d| !d.is_instrument).collect(); + if found.is_empty() { + ui.label("No VST3 plugins found"); + } + for d in found { + if ui.button(format!("{} ({})", d.name, d.vendor)).clicked() { + let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": d.id})); + } + } + }); } -/// A built-in plugin's description, else a hosted CLAP plugin's (`clap:`). +/// A built-in plugin's description, else a hosted CLAP (`clap:`) or VST3 (`vst3:`) plugin's. pub fn plugin_info(id: &str) -> Option<&'static soundcraft_dsp::PluginInfo> { - soundcraft_dsp::plugin_info(id).or_else(|| soundcraft_clap_host::plugin_info(id)) + soundcraft_dsp::plugin_info(id).or_else(|| soundcraft_clap_host::plugin_info(id)).or_else(|| soundcraft_vst3_host::plugin_info(id)) } fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { @@ -406,7 +501,8 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { if ui.button(if input { "no input" } else { "no output" }).clicked() { let _ = app.run(cmd, json!({"track": id.0, key: "none"})); } - if !input && ui.button("Out 1-2").clicked() { + let main_name = app.engine.session().outputs.first().map_or_else(|| "Out 1-2".to_string(), |o| o.name.clone()); + if !input && ui.button(main_name).clicked() { let _ = app.run(cmd, json!({"track": id.0, key: "main"})); } if input { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 7b25f2c..4566a10 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -213,3 +213,134 @@ pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option { pub fn db_text(db: f32) -> String { if db <= -143.9 { "-inf".into() } else { format!("{db:.1}") } } + +/// A speaker dot on the surround panner: label, square position (-1..1, y up = front), height layer. +pub struct PannerSpeaker { + pub label: &'static str, + pub x: f32, + pub y: f32, + pub height: bool, +} + +/// What the user did on a [`surround_panner`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PannerEdit { + /// The puck moved (x, y). + Position(f32, f32), + /// Divergence changed (Alt-drag on the square, or the slider). + Divergence(f32), + /// Elevation changed (height formats). + Height(f32), +} + +/// Pro Tools-style X/Y surround panner: a square room with speaker dots and a draggable puck. +/// Drag moves the puck; double-click returns it to front centre; Alt-drag (or the slider below) +/// sets divergence; height layouts get a second slider for elevation. Returns the edit, if any. +pub fn surround_panner(ui: &mut Ui, size: f32, speakers: &[PannerSpeaker], puck: (f32, f32), divergence: f32, z: Option) -> Option { + let t = Tokens::DARK; + let mut edit = None; + let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag()); + let half = size * 0.5 - 6.0; + let c = r.center(); + let to_screen = |x: f32, y: f32| pos2(c.x + x * half, c.y - y * half); + let painter = ui.painter_at(r.expand(1.0)); + painter.rect(r, CornerRadius::same(2), Color32::from_rgb(14, 18, 22), Stroke::new(1.0, Color32::from_rgb(70, 74, 80)), StrokeKind::Inside); + let grid = Stroke::new(1.0, Color32::from_rgb(38, 44, 50)); + painter.line_segment([to_screen(-1.0, 0.0), to_screen(1.0, 0.0)], grid); + painter.line_segment([to_screen(0.0, -1.0), to_screen(0.0, 1.0)], grid); + painter.rect_stroke(Rect::from_two_pos(to_screen(-1.0, -1.0), to_screen(1.0, 1.0)), 0.0, grid, StrokeKind::Middle); + // "Front" marker. + painter.line_segment([pos2(c.x - 5.0, r.min.y + 2.0), pos2(c.x + 5.0, r.min.y + 2.0)], Stroke::new(2.0, Color32::from_rgb(90, 110, 130))); + for s in speakers { + let (x, y) = if s.height { (s.x * 0.55, s.y * 0.55) } else { (s.x, s.y) }; + let p = to_screen(x, y); + if s.height { + painter.circle_stroke(p, 2.5, Stroke::new(1.0, Color32::from_rgb(150, 150, 170))); + } else { + painter.circle_filled(p, 3.0, Color32::from_rgb(170, 170, 176)); + } + if size >= 70.0 && !s.height { + let inward = vec2(-(x.signum() * if x.abs() > 0.5 { 1.0 } else { 0.0 }), if y.abs() > 0.5 { y.signum() } else { 0.0 }); + let lp = p + vec2(inward.x * 9.0, inward.y * 7.0); + painter.text(lp, Align2::CENTER_CENTER, s.label, regular(7.5), t.text_dim); + } + } + let alt = ui.input(|i| i.modifiers.alt); + if resp.double_clicked() { + edit = Some(PannerEdit::Position(0.0, 1.0)); + } else if resp.dragged() && alt { + let d = (divergence - resp.drag_delta().y * 0.01).clamp(0.0, 1.0); + edit = Some(PannerEdit::Divergence(d)); + } else if (resp.dragged() || resp.clicked()) + && !alt + && let Some(p) = resp.interact_pointer_pos() + { + let x = ((p.x - c.x) / half).clamp(-1.0, 1.0); + let y = ((c.y - p.y) / half).clamp(-1.0, 1.0); + edit = Some(PannerEdit::Position(x, y)); + } + let (px, py) = match edit { + Some(PannerEdit::Position(x, y)) => (x, y), + _ => puck, + }; + let pp = to_screen(px, py); + let div = match edit { + Some(PannerEdit::Divergence(d)) => d, + _ => divergence, + }; + if div > 0.01 { + painter.circle(pp, 5.0 + div * half, Color32::from_rgba_unmultiplied(80, 160, 230, 28), Stroke::new(1.0, Color32::from_rgb(70, 130, 190))); + } + painter.circle(pp, 5.0, t.counter_text, Stroke::new(1.0, Color32::BLACK)); + let tip = "Surround pan: drag the puck · double-click: front centre · Alt-drag: divergence"; + let _ = resp.on_hover_text(tip); + // Sliders below: divergence, then elevation for height formats. + let mut sliders: Vec<(&str, f32, u8)> = vec![("div", div, 0)]; + if let Some(z) = z { + sliders.push(("z", z, 1)); + } + for (label, v, kind) in sliders { + let (sr, sresp) = ui.allocate_exact_size(vec2(size, 11.0), Sense::click_and_drag()); + let track = Rect::from_min_max(pos2(sr.min.x + 16.0, sr.center().y - 2.0), pos2(sr.max.x - 2.0, sr.center().y + 2.0)); + ui.painter().text(pos2(sr.min.x + 1.0, sr.center().y), Align2::LEFT_CENTER, label, regular(8.0), t.text_dim); + ui.painter().rect_filled(track, 1.0, Color32::from_rgb(24, 26, 30)); + ui.painter().rect_filled( + Rect::from_min_max(track.min, pos2(track.min.x + track.width() * v.clamp(0.0, 1.0), track.max.y)), + 1.0, + t.counter_text, + ); + if (sresp.dragged() || sresp.clicked()) + && let Some(p) = sresp.interact_pointer_pos() + { + let nv = ((p.x - track.min.x) / track.width().max(1.0)).clamp(0.0, 1.0); + edit = Some(if kind == 0 { PannerEdit::Divergence(nv) } else { PannerEdit::Height(nv) }); + } + if sresp.double_clicked() { + edit = Some(if kind == 0 { PannerEdit::Divergence(0.0) } else { PannerEdit::Height(0.0) }); + } + let _ = sresp.on_hover_text(if kind == 0 { "Divergence (0 = point source)" } else { "Elevation (height speakers)" }); + } + edit +} + +/// A bank of thin vertical peak meters, one per channel, labelled underneath (rotated text). +pub fn multi_meter(ui: &Ui, r: Rect, levels: &[f32], labels: &[String], clip: bool) { + let n = levels.len().max(1); + let label_h = 16.0; + let mr = Rect::from_min_max(r.min, pos2(r.max.x, r.max.y - label_h)); + let gap = 1.0; + let w = ((mr.width() - gap * (n as f32 - 1.0)) / n as f32).max(1.0); + for (i, lv) in levels.iter().enumerate() { + let x = mr.min.x + i as f32 * (w + gap); + let br = Rect::from_min_size(pos2(x, mr.min.y), vec2(w, mr.height())); + meter(ui, br, *lv, *lv, clip); + if let Some(l) = labels.get(i) { + let galley = ui.painter().layout_no_wrap(l.clone(), regular(7.0), Tokens::DARK.text_dim); + let gw = galley.size().x; + let gh = galley.size().y; + // Rotated 90° anticlockwise, reading bottom-to-top, centred under the bar. + let pos = pos2(br.center().x - gh * 0.5, mr.max.y + 2.0 + gw.min(label_h - 2.0)); + ui.painter().add(egui::epaint::TextShape::new(pos, galley, Tokens::DARK.text_dim).with_angle(-std::f32::consts::FRAC_PI_2)); + } + } +} diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 39fce0d..48a3eb8 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -403,6 +403,19 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) { } ui.separator(); ui.label(egui::RichText::new("Outputs").strong()); + // Main output format: stereo, or a surround format (the mix and master faders follow). + let main = app.engine.session().main_format(); + ui.horizontal(|ui| { + ui.label("Main output"); + egui::ComboBox::from_id_salt("main_output_format").selected_text(main.label()).show_ui(ui, |ui| { + for f in soundcraft_model::ChannelFormat::ALL.into_iter().filter(|f| (2..=16).contains(&f.channels())) { + if ui.selectable_label(f == main, f.label()).clicked() && f != main { + let _ = app.run("setup.main_format", json!({"format": f.label()})); + } + } + }); + ui.label(egui::RichText::new(format!("{} ch", main.channels())).small()); + }); for o in app.engine.session().outputs.clone() { ui.label(format!("{} — {} from channel {}", o.name, o.format.label(), o.first_channel + 1)); } diff --git a/crates/vst3-host/Cargo.toml b/crates/vst3-host/Cargo.toml new file mode 100644 index 0000000..406e472 --- /dev/null +++ b/crates/vst3-host/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "soundcraft-vst3-host" +description = "SoundCraft VST3 plugin host: scans, loads and runs third-party VST3 plugins behind the safe soundcraft-dsp Plugin trait (an isolated unsafe FFI crate)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +soundcraft-dsp.workspace = true +serde.workspace = true +thiserror.workspace = true +log.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +vst3 = "0.3" +libloading = "0.8" + +# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-clap-host: this crate +# opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere +# and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`. +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +dbg_macro = "warn" +todo = "warn" +too_many_arguments = "allow" +type_complexity = "allow" diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs new file mode 100644 index 0000000..d4e5c36 --- /dev/null +++ b/crates/vst3-host/src/ffi.rs @@ -0,0 +1,1251 @@ +//! The FFI boundary: the only module of this crate allowed to use `unsafe`. +//! +//! It loads VST3 binaries (`libloading`), runs the platform module entry, calls the COM +//! interfaces of the factory, component, audio processor and edit controller (`vst3` raw +//! bindings), and implements the host-side COM objects (host context, parameter changes, event +//! lists, a memory stream). Everything it exposes to the rest of the crate is a safe API. +//! +//! Soundness rests on the VST3 contract (the plugin honours the COM vtable signatures and the +//! documented pointer lifetimes). A plugin that violates it can still crash the process: hosting +//! in-process can't prevent that. What this module guarantees is that *SoundCraft* never hands +//! the plugin dangling or mis-sized pointers and never reads plugin strings beyond their bounds. + +// VST3 enum constants are `c_int` on Windows and `c_uint` elsewhere, so casts that are no-ops on +// one platform are needed on another. +#![allow(clippy::unnecessary_cast)] + +use crate::{Vst3Error, scan}; +use std::collections::HashMap; +use std::ffi::{c_char, c_void}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; +use vst3::Steinberg::Vst::{self as sv}; +use vst3::Steinberg::Vst::{ + IAudioProcessorTrait, IComponentHandlerTrait, IComponentTrait, IConnectionPointTrait, IEditControllerTrait, IEventListTrait, + IHostApplicationTrait, IParamValueQueueTrait, IParameterChangesTrait, +}; +use vst3::Steinberg::{self as sb}; +use vst3::Steinberg::{FUnknown, IBStreamTrait, IPluginBaseTrait, IPluginFactory2Trait, IPluginFactoryTrait}; +use vst3::{Class, ComPtr, ComWrapper, Interface}; + +/// Most classes one factory may report (hostile-input cap). +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 channels per audio bus. +const MAX_BUS_CHANNELS: u32 = 64; +/// Capacity of the per-block event lists (preallocated: `process` never allocates). +pub(crate) const EVENT_CAP: usize = 1024; +/// Most parameter queues per block (preallocated). +pub(crate) const PARAM_QUEUE_CAP: usize = 1024; +/// Largest plugin state we buffer when syncing the controller to the component. +const MAX_STATE_BYTES: usize = 64 << 20; + +// ---- strings ------------------------------------------------------------------------------- + +/// Reads a fixed-size `char8[N]` buffer, stopping at the first NUL or the end of the array. +fn read_c8(buf: &[c_char]) -> String { + let bytes: Vec = buf.iter().take_while(|c| **c != 0).map(|c| *c as u8).collect(); + String::from_utf8_lossy(&bytes).trim().to_string() +} + +/// Reads a fixed-size UTF-16 `String128`, stopping at the first NUL or the end of the array. +fn read_c16(buf: &[u16]) -> String { + let units: Vec = buf.iter().take_while(|c| **c != 0).copied().collect(); + String::from_utf16_lossy(&units).trim().to_string() +} + +/// Writes `s` into a UTF-16 buffer, truncated and always NUL-terminated. +fn write_c16(s: &str, dst: &mut [u16]) { + let Some(room) = dst.len().checked_sub(1) else { return }; + let mut n = 0; + for (d, u) in dst.iter_mut().zip(s.encode_utf16().take(room)) { + *d = u; + n += 1; + } + if let Some(z) = dst.get_mut(n) { + *z = 0; + } +} + +fn ok(r: sb::tresult) -> bool { + r == sb::kResultOk +} + +// ---- loaded binaries ----------------------------------------------------------------------- + +/// One class as reported by the factory. +#[derive(Debug, Clone)] +pub(crate) struct RawClass { + pub cid: [u8; 16], + pub category: String, + pub name: String, + pub vendor: String, + pub version: String, + pub sdk_version: String, + pub sub_categories: String, +} + +/// A loaded VST3 binary and its plugin factory. Loaded binaries are cached for the life of the +/// process (see [`Bundle::load`]): plugin code must never be unmapped while an instance may run, +/// and VST3 modules are not required to support being re-entered after their exit function. +pub(crate) struct Bundle { + factory: ComPtr, + // Dropped last: the library must outlive the factory pointer. + _lib: libloading::Library, +} + +fn loaded() -> &'static Mutex>> { + static L: OnceLock>>> = OnceLock::new(); + L.get_or_init(|| Mutex::new(HashMap::new())) +} + +type GetFactory = unsafe extern "system" fn() -> *mut sb::IPluginFactory; + +#[cfg(target_os = "macos")] +mod cf { + use std::ffi::c_void; + #[link(name = "CoreFoundation", kind = "framework")] + unsafe extern "C" { + pub fn CFURLCreateFromFileSystemRepresentation(alloc: *const c_void, buf: *const u8, len: isize, is_dir: u8) -> *const c_void; + pub fn CFBundleCreate(alloc: *const c_void, url: *const c_void) -> *mut c_void; + pub fn CFRelease(cf: *const c_void); + } +} + +/// Opens the shared library and runs the platform module entry (`bundleEntry` with a +/// `CFBundleRef` on macOS, `InitDll` on Windows, `ModuleEntry` with the `dlopen` handle on Linux). +fn open_library(bundle: &Path, exe: &Path) -> Result { + #[cfg(all(unix, not(target_os = "macos")))] + { + use libloading::os::unix::{Library as UnixLib, RTLD_LOCAL, RTLD_NOW}; + // SAFETY: loading a shared library runs its static initialisers. That is inherent to + // hosting plugins; we only load files found in VST3 plugin folders or named explicitly. + let lib = unsafe { UnixLib::open(Some(exe), RTLD_NOW | RTLD_LOCAL) }.map_err(|e| e.to_string())?; + type ModuleEntry = unsafe extern "C" fn(*mut c_void) -> bool; + // SAFETY: `ModuleEntry` is the VST3-mandated Linux entry with this signature. + let entry = unsafe { lib.get::(b"ModuleEntry\0") }.ok().map(|s| *s); + let raw = lib.into_raw(); + // SAFETY: `raw` is the handle we just took out of `lib`; it is rewrapped right away. + let lib = unsafe { UnixLib::from_raw(raw) }; + if let Some(f) = entry { + // SAFETY: called once, with the module's own `dlopen` handle, as the spec requires. + if !unsafe { f(raw) } { + return Err("ModuleEntry failed".into()); + } + } + let _ = bundle; + Ok(lib.into()) + } + #[cfg(not(all(unix, not(target_os = "macos"))))] + { + // SAFETY: loading a shared library runs its static initialisers. That is inherent to + // hosting plugins; we only load files found in VST3 plugin folders or named explicitly. + let lib = unsafe { libloading::Library::new(exe) }.map_err(|e| e.to_string())?; + #[cfg(windows)] + { + let _ = bundle; + type InitDll = unsafe extern "system" fn() -> bool; + // SAFETY: `InitDll` is the VST3-mandated Windows entry with this signature. + if let Ok(f) = unsafe { lib.get::(b"InitDll\0") } { + // SAFETY: called once after loading, as the spec requires. + if !unsafe { f() } { + return Err("InitDll failed".into()); + } + } + } + #[cfg(target_os = "macos")] + { + type BundleEntry = unsafe extern "C" fn(*mut c_void) -> bool; + // SAFETY: `bundleEntry`/`BundleEntry` is the VST3-mandated macOS entry with this signature. + let entry = unsafe { lib.get::(b"bundleEntry\0") } + .or_else(|_| { + // SAFETY: as above (older SDKs export the capitalised name). + unsafe { lib.get::(b"BundleEntry\0") } + }) + .ok() + .map(|s| *s); + if let Some(f) = entry + && bundle.is_dir() + { + use std::os::unix::ffi::OsStrExt; + let bytes = bundle.as_os_str().as_bytes(); + let len = isize::try_from(bytes.len()).map_err(|_| "path too long".to_string())?; + // SAFETY: `bytes` is a valid buffer of `len` bytes for the duration of the call; + // a null allocator means the default one. + let url = unsafe { cf::CFURLCreateFromFileSystemRepresentation(std::ptr::null(), bytes.as_ptr(), len, 1) }; + if url.is_null() { + return Err("cannot make a CFURL for the bundle".into()); + } + // SAFETY: `url` is a valid CFURL created above. + let cfbundle = unsafe { cf::CFBundleCreate(std::ptr::null(), url) }; + // SAFETY: we own `url` (Create rule) and no longer need it. + unsafe { cf::CFRelease(url) }; + if cfbundle.is_null() { + return Err("not a loadable bundle".into()); + } + // SAFETY: called once with a valid CFBundleRef. The bundle ref is intentionally + // kept (never released): the module may hold on to it for its whole life, and + // loaded modules are never unloaded. + if !unsafe { f(cfbundle) } { + return Err("bundleEntry failed".into()); + } + } + } + Ok(lib) + } +} + +impl Bundle { + /// Loads (once per path, then cached for the life of the process) a `.vst3`. + pub fn load(path: &Path) -> Result, Vst3Error> { + let mut map = loaded().lock().unwrap_or_else(PoisonError::into_inner); + if let Some(b) = map.get(path) { + return Ok(b.clone()); + } + let b = Arc::new(Bundle::open(path)?); + map.insert(path.to_path_buf(), b.clone()); + Ok(b) + } + + fn open(path: &Path) -> Result { + let err = |m: String| Vst3Error::Load(path.display().to_string(), m); + let exe = scan::bundle_binary(path).ok_or_else(|| err("no loadable binary".into()))?; + let lib = open_library(path, &exe).map_err(err)?; + // SAFETY: `GetPluginFactory` is the VST3-mandated export with this signature. + let get = match unsafe { lib.get::(b"GetPluginFactory\0") } { + Ok(sym) => *sym, + Err(e) => return Err(err(format!("not a VST3 plugin: {e}"))), + }; + // SAFETY: the module is loaded and its entry function (if any) has run. + let raw = unsafe { get() }; + // SAFETY: a non-null result is a factory pointer with one reference owned by the caller + // (VST3 contract), which the `ComPtr` takes over. + let factory = unsafe { ComPtr::from_raw(raw) }.ok_or_else(|| err("GetPluginFactory returned null".into()))?; + Ok(Bundle { factory, _lib: lib }) + } + + /// The vendor from the factory info (fallback for classes without one). + fn factory_vendor(&self) -> String { + // SAFETY: `PFactoryInfo` is plain data (char arrays and an int); all-zero is valid. + let mut info: sb::PFactoryInfo = unsafe { std::mem::zeroed() }; + // SAFETY: valid factory; `info` is a writable struct of the right type. + if ok(unsafe { self.factory.getFactoryInfo(&mut info) }) { read_c8(&info.vendor) } else { String::new() } + } + + /// Every class this binary exports. + pub fn classes(&self) -> Vec { + // SAFETY: valid factory. + let n = unsafe { self.factory.countClasses() }.clamp(0, MAX_CLASSES); + let f2 = self.factory.cast::(); + let vendor = self.factory_vendor(); + let mut out = Vec::new(); + for i in 0..n { + if let Some(f2) = &f2 { + // SAFETY: plain data; all-zero is valid. + let mut c: sb::PClassInfo2 = unsafe { std::mem::zeroed() }; + // SAFETY: index below the count; `c` is writable. + if ok(unsafe { f2.getClassInfo2(i, &mut c) }) { + let v = read_c8(&c.vendor); + out.push(RawClass { + cid: tuid_bytes(&c.cid), + category: read_c8(&c.category), + name: read_c8(&c.name), + vendor: if v.is_empty() { vendor.clone() } else { v }, + version: read_c8(&c.version), + sdk_version: read_c8(&c.sdkVersion), + sub_categories: read_c8(&c.subCategories), + }); + continue; + } + } + // SAFETY: plain data; all-zero is valid. + let mut c: sb::PClassInfo = unsafe { std::mem::zeroed() }; + // SAFETY: index below the count; `c` is writable. + if ok(unsafe { self.factory.getClassInfo(i, &mut c) }) { + out.push(RawClass { + cid: tuid_bytes(&c.cid), + category: read_c8(&c.category), + name: read_c8(&c.name), + vendor: vendor.clone(), + version: String::new(), + sdk_version: String::new(), + sub_categories: String::new(), + }); + } + } + out + } + + /// Creates an object of class `cid` and queries interface `I` on it. + fn create(&self, cid: &[u8; 16]) -> Option> { + let mut obj: *mut c_void = std::ptr::null_mut(); + let iid = I::IID; + // SAFETY: valid factory; `cid` and `iid` point to 16 readable bytes each (VST3 passes TUIDs + // as `FIDString`); `obj` is writable. + let r = unsafe { self.factory.createInstance(cid.as_ptr().cast::(), iid.as_ptr().cast::(), &mut obj) }; + if !ok(r) { + return None; + } + // SAFETY: on success `obj` holds an `I` pointer with one reference owned by us. + unsafe { ComPtr::from_raw(obj.cast::()) } + } + + /// Creates, initialises and wires up one plugin instance. + pub fn instantiate(self: &Arc, cid: &[u8; 16]) -> Result { + let name = hex(cid); + let err = |m: &str| Vst3Error::Instantiate(name.clone(), m.to_string()); + let host = ComWrapper::new(HostContext::default()); + let host_unknown = host.as_com_ref::().ok_or_else(|| err("host context"))?.upcast::().as_ptr(); + let component = self.create::(cid).ok_or_else(|| err("factory cannot create the component"))?; + // SAFETY: fresh component; the host context outlives it (owned by the returned Instance). + if !ok(unsafe { component.initialize(host_unknown) }) { + return Err(err("component initialize failed")); + } + let mut inst = Instance { + processor: None, + controller: None, + single: false, + connections: None, + component_initialized: true, + controller_initialized: false, + active: false, + processing: false, + max_frames: 0, + in_params: ComWrapper::new(ParamChanges::new(PARAM_QUEUE_CAP)), + out_params: ComWrapper::new(ParamChanges::new(PARAM_QUEUE_CAP)), + in_events: ComWrapper::new(EventList::new(EVENT_CAP)), + out_events: ComWrapper::new(EventList::new(EVENT_CAP)), + // SAFETY: `ProcessContext` is plain data (numbers and small structs); all-zero is valid. + context: unsafe { std::mem::zeroed() }, + event_in: false, + component, + host, + _bundle: self.clone(), + }; + let processor = inst.component.cast::().ok_or_else(|| err("no IAudioProcessor"))?; + inst.processor = Some(processor); + // Edit controller: the component itself (single-component plugin) or a separate class. + if let Some(c) = inst.component.cast::() { + inst.controller = Some(c); + inst.single = true; + } else { + let mut ccid: sb::TUID = [0; 16]; + // SAFETY: valid component; `ccid` is a writable 16-byte TUID. + if ok(unsafe { inst.component.getControllerClassId(&mut ccid) }) + && let Some(c) = self.create::(&tuid_bytes(&ccid)) + { + // SAFETY: fresh controller; the host context outlives it. + if ok(unsafe { c.initialize(host_unknown) }) { + inst.controller = Some(c); + inst.controller_initialized = true; + } else { + log::warn!("vst3 {name}: controller initialize failed; parameters unavailable"); + } + } + } + if let Some(c) = &inst.controller { + if !inst.single + && let (Some(a), Some(b)) = (inst.component.cast::(), c.cast::()) + { + // SAFETY: both connection points are valid for as long as `inst` lives; they are + // disconnected in `Drop` before either side terminates. + unsafe { + a.connect(b.as_ptr()); + b.connect(a.as_ptr()); + } + inst.connections = Some((a, b)); + } + inst.sync_controller_state(); + if let Some(h) = inst.host.as_com_ref::() { + // SAFETY: valid controller; the handler (host context) outlives it. + unsafe { c.setComponentHandler(h.as_ptr()) }; + } + } + Ok(inst) + } +} + +/// A `TUID` (`[i8; 16]`) as raw bytes. +fn tuid_bytes(t: &sb::TUID) -> [u8; 16] { + let mut out = [0u8; 16]; + for (o, b) in out.iter_mut().zip(t.iter()) { + *o = *b as u8; + } + out +} + +/// Upper-case hex of a class id (the form used in `vst3:` ids). +pub(crate) fn hex(cid: &[u8; 16]) -> String { + cid.iter().map(|b| format!("{b:02X}")).collect() +} + +// ---- host-side COM objects ----------------------------------------------------------------- + +/// The host context handed to `initialize` (`IHostApplication`) and to the controller +/// (`IComponentHandler`: plugin-side parameter edits are accepted and ignored, SoundCraft's +/// session is the source of truth). +#[derive(Default)] +pub(crate) struct HostContext { + pub latency_changed: AtomicBool, + pub restart_requested: AtomicBool, +} + +impl Class for HostContext { + type Interfaces = (sv::IHostApplication, sv::IComponentHandler); +} + +impl IHostApplicationTrait for HostContext { + unsafe fn getName(&self, name: *mut sv::String128) -> sb::tresult { + // SAFETY: the plugin passes a pointer to a writable String128 (or null, handled). + match unsafe { name.as_mut() } { + Some(n) => { + write_c16("SoundCraft", n); + sb::kResultOk + } + None => sb::kInvalidArgument, + } + } + + 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. + // SAFETY: `obj` is the plugin's out-pointer (or null, handled). + if let Some(o) = unsafe { obj.as_mut() } { + *o = std::ptr::null_mut(); + } + sb::kResultFalse + } +} + +impl IComponentHandlerTrait for HostContext { + unsafe fn beginEdit(&self, _id: sv::ParamID) -> sb::tresult { + sb::kResultOk + } + unsafe fn performEdit(&self, _id: sv::ParamID, _value: sv::ParamValue) -> sb::tresult { + sb::kResultOk + } + unsafe fn endEdit(&self, _id: sv::ParamID) -> sb::tresult { + sb::kResultOk + } + unsafe fn restartComponent(&self, flags: sb::int32) -> sb::tresult { + if flags as u32 & sv::RestartFlags_::kLatencyChanged as u32 != 0 { + self.latency_changed.store(true, Ordering::Relaxed); + } + self.restart_requested.store(true, Ordering::Relaxed); + sb::kResultOk + } +} + +/// 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)] +pub(crate) struct ParamQueue { + id: AtomicU32, + offset: AtomicI32, + value: AtomicU64, + points: AtomicI32, +} + +impl Class for ParamQueue { + type Interfaces = (sv::IParamValueQueue,); +} + +impl IParamValueQueueTrait for ParamQueue { + unsafe fn getParameterId(&self) -> sv::ParamID { + self.id.load(Ordering::Relaxed) + } + unsafe fn getPointCount(&self) -> sb::int32 { + self.points.load(Ordering::Relaxed) + } + unsafe fn getPoint(&self, index: sb::int32, offset: *mut sb::int32, value: *mut sv::ParamValue) -> sb::tresult { + if index != 0 || self.points.load(Ordering::Relaxed) < 1 { + return sb::kInvalidArgument; + } + // SAFETY: out-pointers from the plugin (or null, handled). + if let (Some(o), Some(v)) = (unsafe { offset.as_mut() }, unsafe { value.as_mut() }) { + *o = self.offset.load(Ordering::Relaxed); + *v = f64::from_bits(self.value.load(Ordering::Relaxed)); + sb::kResultOk + } else { + sb::kInvalidArgument + } + } + unsafe fn addPoint(&self, offset: sb::int32, value: sv::ParamValue, index: *mut sb::int32) -> sb::tresult { + self.offset.store(offset, Ordering::Relaxed); + self.value.store(value.to_bits(), Ordering::Relaxed); + self.points.store(1, Ordering::Relaxed); + // SAFETY: out-pointer from the plugin (or null, handled). + if let Some(i) = unsafe { index.as_mut() } { + *i = 0; + } + sb::kResultOk + } +} + +/// A fixed pool of parameter queues (`IParameterChanges`), reused every block. +pub(crate) struct ParamChanges { + queues: Vec>, + count: AtomicI32, +} + +impl Class for ParamChanges { + type Interfaces = (sv::IParameterChanges,); +} + +impl ParamChanges { + fn new(cap: usize) -> ParamChanges { + ParamChanges { queues: (0..cap).map(|_| ComWrapper::new(ParamQueue::default())).collect(), count: AtomicI32::new(0) } + } + + fn clear(&self) { + self.count.store(0, Ordering::Relaxed); + } + + /// Finds or adds the queue for `id`; `None` when the pool is full. + fn queue_for(&self, id: u32) -> Option<(i32, &ParamQueue)> { + let n = self.count.load(Ordering::Relaxed).max(0); + for (i, q) in self.queues.iter().enumerate().take(n as usize) { + if q.id.load(Ordering::Relaxed) == id { + return Some((i32::try_from(i).ok()?, q)); + } + } + let q = self.queues.get(n as usize)?; + q.id.store(id, Ordering::Relaxed); + q.points.store(0, Ordering::Relaxed); + self.count.store(n + 1, Ordering::Relaxed); + Some((n, q)) + } + + /// Host side: one point for `id` at `offset` (replacing an earlier one this block). + fn set(&self, id: u32, offset: i32, value: f64) { + if let Some((_, q)) = self.queue_for(id) { + q.offset.store(offset, Ordering::Relaxed); + q.value.store(value.to_bits(), Ordering::Relaxed); + q.points.store(1, Ordering::Relaxed); + } + } + + fn queue_ptr(&self, index: usize) -> *mut sv::IParamValueQueue { + self.queues.get(index).and_then(|q| q.as_com_ref::()).map_or(std::ptr::null_mut(), |r| r.as_ptr()) + } +} + +impl IParameterChangesTrait for ParamChanges { + unsafe fn getParameterCount(&self) -> sb::int32 { + self.count.load(Ordering::Relaxed) + } + unsafe fn getParameterData(&self, index: sb::int32) -> *mut sv::IParamValueQueue { + if index < 0 || index >= self.count.load(Ordering::Relaxed) { + return std::ptr::null_mut(); + } + self.queue_ptr(index as usize) + } + unsafe fn addParameterData(&self, id: *const sv::ParamID, index: *mut sb::int32) -> *mut sv::IParamValueQueue { + // SAFETY: in-pointer from the plugin (or null, handled). + let Some(&id) = (unsafe { id.as_ref() }) else { return std::ptr::null_mut() }; + let Some((i, _)) = self.queue_for(id) else { return std::ptr::null_mut() }; + // SAFETY: out-pointer from the plugin (or null, handled). + if let Some(out) = unsafe { index.as_mut() } { + *out = i; + } + self.queue_ptr(i as usize) + } +} + +/// A bounded event list (`IEventList`). Only ever touched by one thread at a time (the host +/// fills it, then the plugin reads it inside `process`), so the lock is never contended; the +/// plugin side uses `try_lock` and never blocks. +pub(crate) struct EventList { + events: Mutex>, + cap: usize, +} + +impl Class for EventList { + type Interfaces = (sv::IEventList,); +} + +impl EventList { + fn new(cap: usize) -> EventList { + EventList { events: Mutex::new(Vec::with_capacity(cap)), cap } + } + fn with(&self, f: impl FnOnce(&mut Vec) -> R) -> Option { + match self.events.try_lock() { + Ok(mut g) => Some(f(&mut g)), + Err(std::sync::TryLockError::Poisoned(p)) => Some(f(&mut p.into_inner())), + Err(std::sync::TryLockError::WouldBlock) => None, + } + } +} + +impl IEventListTrait for EventList { + unsafe fn getEventCount(&self) -> sb::int32 { + self.with(|v| i32::try_from(v.len()).unwrap_or(i32::MAX)).unwrap_or(0) + } + unsafe fn getEvent(&self, index: sb::int32, e: *mut sv::Event) -> sb::tresult { + // SAFETY: out-pointer from the plugin (or null, handled). + let Some(out) = (unsafe { e.as_mut() }) else { return sb::kInvalidArgument }; + let Ok(i) = usize::try_from(index) else { return sb::kInvalidArgument }; + match self.with(|v| v.get(i).copied()).flatten() { + Some(ev) => { + *out = ev; + sb::kResultOk + } + None => sb::kInvalidArgument, + } + } + unsafe fn addEvent(&self, e: *mut sv::Event) -> sb::tresult { + // SAFETY: in-pointer from the plugin (or null, handled). + let Some(&ev) = (unsafe { e.as_ref() }) else { return sb::kInvalidArgument }; + let cap = self.cap; + // Output events (e.g. MIDI out) are accepted while there is room; SoundCraft ignores them. + match self.with(|v| { + if v.len() < cap { + v.push(ev); + true + } else { + false + } + }) { + Some(true) => sb::kResultOk, + _ => sb::kResultFalse, + } + } +} + +/// An in-memory `IBStream`, used to hand the component's state to its controller. +pub(crate) struct MemoryStream { + inner: Mutex<(Vec, usize)>, +} + +impl Class for MemoryStream { + type Interfaces = (sb::IBStream,); +} + +impl MemoryStream { + fn lock(&self) -> std::sync::MutexGuard<'_, (Vec, usize)> { + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl IBStreamTrait for MemoryStream { + unsafe fn read(&self, buffer: *mut c_void, num_bytes: sb::int32, num_read: *mut sb::int32) -> sb::tresult { + let mut g = self.lock(); + let (data, pos) = &mut *g; + let want = usize::try_from(num_bytes).unwrap_or(0); + let avail = data.len().saturating_sub(*pos); + let n = want.min(avail); + if n > 0 && !buffer.is_null() { + let Some(src) = data.get(*pos..*pos + n) else { return sb::kInternalError }; + // SAFETY: the plugin provides `buffer` with room for `num_bytes` ≥ `n` bytes; `src` + // is `n` valid bytes; the regions cannot overlap (ours is a private Vec). + unsafe { std::ptr::copy_nonoverlapping(src.as_ptr(), buffer.cast::(), n) }; + *pos += n; + } + // SAFETY: out-pointer from the plugin (or null, handled). + if let Some(r) = unsafe { num_read.as_mut() } { + *r = i32::try_from(n).unwrap_or(0); + } + sb::kResultOk + } + unsafe fn write(&self, buffer: *mut c_void, num_bytes: sb::int32, num_written: *mut sb::int32) -> sb::tresult { + let mut g = self.lock(); + let (data, pos) = &mut *g; + let n = usize::try_from(num_bytes).unwrap_or(0); + let end = pos.saturating_add(n); + if buffer.is_null() || end > MAX_STATE_BYTES { + return sb::kResultFalse; + } + if data.len() < end { + data.resize(end, 0); + } + // SAFETY: the plugin provides `n` readable bytes at `buffer`; the destination range was + // just made valid. + let src = unsafe { std::slice::from_raw_parts(buffer.cast::(), n) }; + if let Some(dst) = data.get_mut(*pos..end) { + dst.copy_from_slice(src); + } + *pos = end; + // SAFETY: out-pointer from the plugin (or null, handled). + if let Some(w) = unsafe { num_written.as_mut() } { + *w = num_bytes; + } + sb::kResultOk + } + unsafe fn seek(&self, pos: sb::int64, mode: sb::int32, result: *mut sb::int64) -> sb::tresult { + let mut g = self.lock(); + let len = i64::try_from(g.0.len()).unwrap_or(i64::MAX); + let cur = i64::try_from(g.1).unwrap_or(0); + let base = match mode as u32 { + x if x == sb::IBStream_::IStreamSeekMode_::kIBSeekSet as u32 => 0, + x if x == sb::IBStream_::IStreamSeekMode_::kIBSeekCur as u32 => cur, + x if x == sb::IBStream_::IStreamSeekMode_::kIBSeekEnd as u32 => len, + _ => return sb::kInvalidArgument, + }; + let new = base.saturating_add(pos).clamp(0, len); + g.1 = usize::try_from(new).unwrap_or(0); + // SAFETY: out-pointer from the plugin (or null, handled). + if let Some(r) = unsafe { result.as_mut() } { + *r = new; + } + sb::kResultOk + } + unsafe fn tell(&self, pos: *mut sb::int64) -> sb::tresult { + let g = self.lock(); + // SAFETY: out-pointer from the plugin (or null, handled). + match unsafe { pos.as_mut() } { + Some(p) => { + *p = i64::try_from(g.1).unwrap_or(0); + sb::kResultOk + } + None => sb::kInvalidArgument, + } + } +} + +// ---- events and buffers -------------------------------------------------------------------- + +/// A host → plugin note event, safe to build anywhere; converted inside `process`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum NoteEvent { + On { time: u32, key: u8, velocity: f32 }, + Off { time: u32, key: u8 }, +} + +impl NoteEvent { + pub fn time(&self) -> u32 { + match *self { + NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } => time, + } + } + pub fn set_time(&mut self, t: u32) { + match self { + NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } => *time = t, + } + } + + fn to_raw(self, offset: u32) -> sv::Event { + // SAFETY: `Event` is plain data (numbers and a union of plain structs); all-zero is valid. + let mut e: sv::Event = unsafe { std::mem::zeroed() }; + e.busIndex = 0; + e.sampleOffset = i32::try_from(self.time().saturating_sub(offset)).unwrap_or(0); + e.flags = sv::Event_::EventFlags_::kIsLive as u16; + match self { + NoteEvent::On { key, velocity, .. } => { + e.r#type = sv::Event_::EventTypes_::kNoteOnEvent as u16; + e.__field0.noteOn = sv::NoteOnEvent { channel: 0, pitch: i16::from(key), tuning: 0.0, velocity, length: 0, noteId: -1 }; + } + NoteEvent::Off { key, .. } => { + e.r#type = sv::Event_::EventTypes_::kNoteOffEvent as u16; + e.__field0.noteOff = sv::NoteOffEvent { channel: 0, pitch: i16::from(key), velocity: 0.0, noteId: -1, tuning: 0.0 }; + } + } + e + } +} + +/// Planar f32 buffers for every audio bus in one direction, with the pointer tables VST3 needs. +/// Allocated in `new`; `process` only rewrites pointers in place (no allocation). +pub(crate) struct BusBuffers { + data: Vec>>, + ptrs: Vec>, + bufs: Vec, + frames: usize, +} + +// SAFETY: the raw pointers only ever point into `data`, which this struct owns; moving the struct +// to another thread moves that ownership with it. +unsafe impl Send for BusBuffers {} + +impl BusBuffers { + /// One buffer set per bus, `channels[b]` channels each (capped), `frames` long. + pub fn new(channels: &[u32], frames: usize) -> BusBuffers { + let data: Vec>> = + channels.iter().take(MAX_BUSES as usize).map(|&c| (0..c.min(MAX_BUS_CHANNELS)).map(|_| vec![0.0; frames]).collect()).collect(); + let ptrs = data.iter().map(|p| vec![std::ptr::null_mut(); p.len()]).collect(); + let bufs = data + .iter() + .map(|_| sv::AudioBusBuffers { + numChannels: 0, + silenceFlags: 0, + __field0: sv::AudioBusBuffers__type0 { channelBuffers32: std::ptr::null_mut() }, + }) + .collect(); + BusBuffers { data, ptrs, bufs, frames } + } + + pub fn channels(&self, bus: usize) -> usize { + self.data.get(bus).map_or(0, Vec::len) + } + + pub fn channel(&self, bus: usize, ch: usize) -> Option<&[f32]> { + self.data.get(bus).and_then(|p| p.get(ch)).map(Vec::as_slice) + } + + pub fn channel_mut(&mut self, bus: usize, ch: usize) -> Option<&mut [f32]> { + self.data.get_mut(bus).and_then(|p| p.get_mut(ch)).map(Vec::as_mut_slice) + } + + /// Points the VST3 tables at the current data (no allocation). + fn refresh(&mut self) { + for ((d, p), b) in self.data.iter_mut().zip(self.ptrs.iter_mut()).zip(self.bufs.iter_mut()) { + for (dc, pc) in d.iter_mut().zip(p.iter_mut()) { + *pc = dc.as_mut_ptr(); + } + b.numChannels = i32::try_from(p.len()).unwrap_or(0); + b.silenceFlags = 0; + b.__field0.channelBuffers32 = p.as_mut_ptr(); + } + } +} + +// ---- instances ----------------------------------------------------------------------------- + +/// One parameter as reported by `IEditController::getParameterInfo`, plus its plain range. +#[derive(Debug, Clone)] +pub(crate) struct RawParam { + pub id: u32, + pub title: String, + pub units: String, + pub step_count: i32, + pub default_normalized: f64, + pub flags: i32, +} + +pub(crate) const PARAM_HIDDEN: i32 = sv::ParameterInfo_::ParameterFlags_::kIsHidden; +pub(crate) const PARAM_READ_ONLY: i32 = sv::ParameterInfo_::ParameterFlags_::kIsReadOnly; +pub(crate) const PARAM_BYPASS: i32 = sv::ParameterInfo_::ParameterFlags_::kIsBypass; + +/// The audio bus layout chosen by [`Instance::configure`]. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct Layout { + pub inputs: Vec, + pub outputs: Vec, + pub event_input: bool, +} + +/// A live plugin: component + audio processor (+ edit controller) and the host objects. +pub(crate) struct Instance { + processor: Option>, + controller: Option>, + /// The controller is the component itself (single-component plugin). + single: bool, + connections: Option<(ComPtr, ComPtr)>, + component_initialized: bool, + controller_initialized: bool, + active: bool, + processing: bool, + max_frames: usize, + in_params: ComWrapper, + out_params: ComWrapper, + in_events: ComWrapper, + out_events: ComWrapper, + context: sv::ProcessContext, + event_in: bool, + // Field order matters for drop: `Drop::drop` shuts the plugin down first; then the component + // and host objects are released, and the bundle (which keeps the code mapped) goes last. + component: ComPtr, + host: ComWrapper, + _bundle: Arc, +} + +// SAFETY: an `Instance` is used through `&mut self` only, so calls into the plugin are always +// serialised. SoundCraft creates instances on the engine/UI thread and then moves them into the +// mix engine (documented limitation: VST3 expects `setActive`, controller calls etc. on the UI +// thread; they may then happen on the audio thread). +unsafe impl Send for Instance {} + +impl Instance { + fn processor(&self) -> Option<&ComPtr> { + self.processor.as_ref() + } + + pub fn host(&self) -> &HostContext { + &self.host + } + + /// Copies the component's state into the controller (`getState` → `setComponentState`). + fn sync_controller_state(&self) { + let Some(c) = &self.controller else { return }; + let stream = ComWrapper::new(MemoryStream { inner: Mutex::new((Vec::new(), 0)) }); + let Some(s) = stream.as_com_ref::() else { return }; + // SAFETY: valid component; the stream lives on this frame for the whole call. + if ok(unsafe { self.component.getState(s.as_ptr()) }) { + stream.lock().1 = 0; + // SAFETY: valid controller; same stream, rewound. + unsafe { c.setComponentState(s.as_ptr()) }; + } + } + + pub fn params(&self) -> Vec { + let Some(c) = &self.controller else { return Vec::new() }; + // SAFETY: valid controller. + let n = unsafe { c.getParameterCount() }.clamp(0, MAX_PARAMS); + let mut out = Vec::new(); + for i in 0..n { + // SAFETY: `ParameterInfo` is plain data; all-zero is valid. + let mut info: sv::ParameterInfo = unsafe { std::mem::zeroed() }; + // SAFETY: index below the count; `info` is writable. + if !ok(unsafe { c.getParameterInfo(i, &mut info) }) { + continue; + } + let d = info.defaultNormalizedValue; + out.push(RawParam { + id: info.id, + title: read_c16(&info.title), + units: read_c16(&info.units), + step_count: info.stepCount.max(0), + default_normalized: if d.is_finite() { d.clamp(0.0, 1.0) } else { 0.0 }, + flags: info.flags, + }); + } + out + } + + /// Normalized → plain (`None` without a controller or for a non-finite answer). + pub fn to_plain(&self, id: u32, normalized: f64) -> Option { + let c = self.controller.as_ref()?; + // SAFETY: valid controller. + let v = unsafe { c.normalizedParamToPlain(id, normalized.clamp(0.0, 1.0)) }; + v.is_finite().then_some(v) + } + + /// Plain → normalized, clamped to 0..1. + pub fn to_normalized(&self, id: u32, plain: f64) -> Option { + let c = self.controller.as_ref()?; + if !plain.is_finite() { + return None; + } + // SAFETY: valid controller. + let v = unsafe { c.plainParamToNormalized(id, plain) }; + v.is_finite().then(|| v.clamp(0.0, 1.0)) + } + + pub fn normalized_value(&self, id: u32) -> Option { + let c = self.controller.as_ref()?; + // SAFETY: valid controller. + let v = unsafe { c.getParamNormalized(id) }; + v.is_finite().then(|| v.clamp(0.0, 1.0)) + } + + pub fn value_text(&self, id: u32, normalized: f64) -> Option { + let c = self.controller.as_ref()?; + let mut s: sv::String128 = [0; 128]; + // SAFETY: valid controller; `s` is a writable String128. + ok(unsafe { c.getParamStringByValue(id, normalized.clamp(0.0, 1.0), &mut s) }).then(|| read_c16(&s)).filter(|t| !t.is_empty()) + } + + pub fn latency(&self) -> u32 { + // SAFETY: valid processor. + self.processor().map_or(0, |p| unsafe { p.getLatencySamples() }) + } + + pub fn tail(&self) -> u32 { + // SAFETY: valid processor. + self.processor().map_or(0, |p| unsafe { p.getTailSamples() }) + } + + pub fn is_active(&self) -> bool { + self.active + } + + fn bus_count(&self, media: u32, dir: u32) -> i32 { + // SAFETY: valid component. + unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.clamp(0, MAX_BUSES) + } + + fn arrangement(&self, dir: u32, index: i32) -> Option { + let p = self.processor()?; + let mut arr: sv::SpeakerArrangement = 0; + // SAFETY: valid processor; `arr` is writable. + ok(unsafe { p.getBusArrangement(dir as sv::BusDirection, index, &mut arr) }).then_some(arr) + } + + fn bus_channels(&self, dir: u32, 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) + } else { + 0 + } + } + + /// 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. + 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 current = |s: &Self, dir: u32, n: i32| -> Vec { + (0..n).map(|i| s.arrangement(dir, i).unwrap_or(sv::SpeakerArr::kStereo)).collect() + }; + let want = if mono { sv::SpeakerArr::kMono } else { sv::SpeakerArr::kStereo }; + let p = self.processor().ok_or_else(|| Vst3Error::Process("no audio processor".into()))?.clone(); + let mut tried_ok = false; + for main in [want, sv::SpeakerArr::kStereo] { + let mut ins = current(self, din, nin); + let mut outs = current(self, dout, nout); + if let Some(a) = ins.first_mut() { + *a = main; + } + if let Some(a) = outs.first_mut() { + *a = main; + } + // SAFETY: valid processor; the arrays hold exactly `nin`/`nout` arrangements and + // outlive the call. + if ok(unsafe { p.setBusArrangements(ins.as_mut_ptr(), nin, outs.as_mut_ptr(), nout) }) { + tried_ok = true; + break; + } + if main == sv::SpeakerArr::kStereo { + break; + } + } + if !tried_ok { + 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(), + event_input: self.bus_count(event, din) > 0, + }; + // Main buses on, aux buses off (VST3 still gets buffers for every bus). + for (dir, n) in [(din, nin), (dout, nout)] { + for i in 0..n { + // SAFETY: valid component; index below the bus count. + unsafe { self.component.activateBus(audio as sv::MediaType, dir as sv::BusDirection, i, u8::from(i == 0)) }; + } + } + if layout.event_input { + // SAFETY: valid component; event input bus 0 exists. + unsafe { self.component.activateBus(event as sv::MediaType, din as sv::BusDirection, 0, 1) }; + } + self.event_in = layout.event_input; + let sample32 = sv::SymbolicSampleSizes_::kSample32 as i32; + // SAFETY: valid processor. + if !ok(unsafe { p.canProcessSampleSize(sample32) }) { + return Err(Vst3Error::Process("plugin cannot process 32-bit float".into())); + } + let max = i32::try_from(max_frames).unwrap_or(i32::MAX); + let mut setup = sv::ProcessSetup { + processMode: sv::ProcessModes_::kRealtime as i32, + symbolicSampleSize: sample32, + maxSamplesPerBlock: max, + sampleRate: sample_rate, + }; + // SAFETY: valid processor, inactive; `setup` is a valid struct for the call. + if !ok(unsafe { p.setupProcessing(&mut setup) }) { + return Err(Vst3Error::Process("setupProcessing failed".into())); + } + // SAFETY: valid component, configured above. + if !ok(unsafe { self.component.setActive(1) }) { + return Err(Vst3Error::Process("setActive failed".into())); + } + self.active = true; + self.max_frames = max_frames; + self.context.sampleRate = sample_rate; + self.context.tempo = 120.0; + self.context.timeSigNumerator = 4; + self.context.timeSigDenominator = 4; + self.context.state = sv::ProcessContext_::StatesAndFlags_::kTempoValid | sv::ProcessContext_::StatesAndFlags_::kTimeSigValid; + self.host.latency_changed.store(false, Ordering::Relaxed); + Ok(layout) + } + + /// `setProcessing(false)` + `setActive(false)` when needed. + pub fn shutdown(&mut self) { + if self.processing { + if let Some(p) = self.processor() { + // SAFETY: valid processor in the processing state. + unsafe { p.setProcessing(0) }; + } + self.processing = false; + } + if self.active { + // SAFETY: valid, active component. + unsafe { self.component.setActive(0) }; + self.active = false; + } + } + + /// Runs one `process` call over `frames` frames: `params` become one point each at offset 0, + /// `notes` (sorted by time) have their times made relative to `offset`. + pub fn process( + &mut self, + frames: usize, + inputs: &mut BusBuffers, + outputs: &mut BusBuffers, + params: &[(u32, f64)], + notes: &[NoteEvent], + offset: u32, + project_time: i64, + ) -> Result<(), Vst3Error> { + if !self.active { + return Err(Vst3Error::Process("not active".into())); + } + if frames == 0 || frames > self.max_frames || frames > inputs.frames || frames > outputs.frames { + return Err(Vst3Error::Process("block larger than the configured size".into())); + } + let p = self.processor.clone().ok_or_else(|| Vst3Error::Process("no audio processor".into()))?; + if !self.processing { + // Many plugins return kNotImplemented here; that is not an error. + // SAFETY: valid, active processor. + unsafe { p.setProcessing(1) }; + self.processing = true; + } + self.in_params.clear(); + for &(id, v) in params.iter().take(PARAM_QUEUE_CAP) { + self.in_params.set(id, 0, v.clamp(0.0, 1.0)); + } + self.out_params.clear(); + let event_in = self.event_in; + self.in_events.with(|v| { + v.clear(); + if event_in { + for n in notes.iter().take(EVENT_CAP) { + v.push(n.to_raw(offset)); + } + } + }); + self.out_events.with(Vec::clear); + inputs.refresh(); + outputs.refresh(); + self.context.projectTimeSamples = project_time; + self.context.continousTimeSamples = project_time; + let mut data = sv::ProcessData { + processMode: sv::ProcessModes_::kRealtime as i32, + symbolicSampleSize: sv::SymbolicSampleSizes_::kSample32 as i32, + numSamples: i32::try_from(frames).unwrap_or(0), + numInputs: i32::try_from(inputs.bufs.len()).unwrap_or(0), + numOutputs: i32::try_from(outputs.bufs.len()).unwrap_or(0), + inputs: if inputs.bufs.is_empty() { std::ptr::null_mut() } else { inputs.bufs.as_mut_ptr() }, + outputs: if outputs.bufs.is_empty() { std::ptr::null_mut() } else { outputs.bufs.as_mut_ptr() }, + inputParameterChanges: self.in_params.as_com_ref::().map_or(std::ptr::null_mut(), |r| r.as_ptr()), + outputParameterChanges: self.out_params.as_com_ref::().map_or(std::ptr::null_mut(), |r| r.as_ptr()), + inputEvents: self.in_events.as_com_ref::().map_or(std::ptr::null_mut(), |r| r.as_ptr()), + outputEvents: self.out_events.as_com_ref::().map_or(std::ptr::null_mut(), |r| r.as_ptr()), + processContext: &mut self.context, + }; + // SAFETY: valid, active processor. Every channel pointer refers to `frames` (or more) valid + // f32s owned by `inputs`/`outputs`, borrowed for the whole call; the bus tables, the + // parameter/event objects and the context are owned by `self` and untouched until the + // call returns. + let r = unsafe { p.process(&mut data) }; + if ok(r) { Ok(()) } else { Err(Vst3Error::Process(format!("process returned {r}"))) } + } +} + +impl Drop for Instance { + fn drop(&mut self) { + self.shutdown(); + if let Some((a, b)) = self.connections.take() { + // SAFETY: the pair was connected in `instantiate`; disconnect before terminating. + unsafe { + a.disconnect(b.as_ptr()); + b.disconnect(a.as_ptr()); + } + } + if let Some(c) = self.controller.take() { + // SAFETY: valid controller; detach our handler before it goes away. + unsafe { c.setComponentHandler(std::ptr::null_mut()) }; + if self.controller_initialized { + // SAFETY: separately created controller, initialised once; terminated once. + unsafe { c.terminate() }; + } + } + self.processor = None; + if self.component_initialized { + // SAFETY: valid component, inactive; terminated exactly once. + unsafe { self.component.terminate() }; + self.component_initialized = false; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strings_are_bounded() { + let mut b = [0u16; 4]; + write_c16("SoundCraft", &mut b); + assert_eq!(read_c16(&b), "Sou"); + let full: Vec = "abcd".encode_utf16().collect(); + assert_eq!(read_c16(&full), "abcd"); + let c: Vec = b"Fx|EQ\0junk".iter().map(|&x| x as c_char).collect(); + assert_eq!(read_c8(&c), "Fx|EQ"); + write_c16("x", &mut []); + } + + #[test] + fn host_objects_behave() { + let pc = ParamChanges::new(2); + pc.set(5, 0, 0.25); + pc.set(5, 0, 0.5); + pc.set(6, 0, 1.0); + pc.set(7, 0, 1.0); + // SAFETY: test calls through our own implementation. + unsafe { + assert_eq!(pc.getParameterCount(), 2, "full pool drops extra params"); + assert!(pc.getParameterData(2).is_null()); + assert!(pc.getParameterData(-1).is_null()); + let q = pc.queues[0].as_com_ref::().unwrap(); + let (mut o, mut v) = (9, 0.0); + assert_eq!(q.getPoint(0, &mut o, &mut v), sb::kResultOk); + assert_eq!((o, v), (0, 0.5)); + assert_ne!(q.getPoint(1, &mut o, &mut v), sb::kResultOk); + assert_ne!(q.getPoint(0, std::ptr::null_mut(), &mut v), sb::kResultOk); + } + let el = EventList::new(1); + let mut e = NoteEvent::On { time: 5, key: 60, velocity: 1.0 }.to_raw(2); + // SAFETY: as above. + unsafe { + assert_eq!(el.addEvent(&mut e), sb::kResultOk); + assert_ne!(el.addEvent(&mut e), sb::kResultOk, "bounded"); + assert_eq!(el.getEventCount(), 1); + let mut out: sv::Event = std::mem::zeroed(); + assert_eq!(el.getEvent(0, &mut out), sb::kResultOk); + assert_eq!(out.sampleOffset, 3); + assert_eq!(out.__field0.noteOn.pitch, 60); + assert_ne!(el.getEvent(1, &mut out), sb::kResultOk); + } + let s = MemoryStream { inner: Mutex::new((Vec::new(), 0)) }; + let mut buf = *b"hello"; + // SAFETY: as above; buffers are valid for the given sizes. + unsafe { + let mut n = 0; + assert_eq!(s.write(buf.as_mut_ptr().cast(), 5, &mut n), sb::kResultOk); + assert_eq!(n, 5); + let mut pos = 0i64; + assert_eq!(s.seek(1, sb::IBStream_::IStreamSeekMode_::kIBSeekSet as i32, &mut pos), sb::kResultOk); + let mut out = [0u8; 8]; + assert_eq!(s.read(out.as_mut_ptr().cast(), 8, &mut n), sb::kResultOk); + assert_eq!((n, &out[..4]), (4, &b"ello"[..])); + assert_eq!(s.seek(-100, sb::IBStream_::IStreamSeekMode_::kIBSeekEnd as i32, &mut pos), sb::kResultOk); + assert_eq!(pos, 0); + assert_eq!(s.write(std::ptr::null_mut(), 5, &mut n), sb::kResultFalse); + } + let h = HostContext::default(); + let mut name: sv::String128 = [0; 128]; + // SAFETY: as above. + unsafe { + assert_eq!(h.getName(&mut name), sb::kResultOk); + assert_eq!(h.restartComponent(sv::RestartFlags_::kLatencyChanged as i32), sb::kResultOk); + } + assert_eq!(read_c16(&name), "SoundCraft"); + assert!(h.latency_changed.load(Ordering::Relaxed)); + assert_eq!(hex(&[0xAB; 16]), "AB".repeat(16)); + } +} diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs new file mode 100644 index 0000000..97afb0d --- /dev/null +++ b/crates/vst3-host/src/lib.rs @@ -0,0 +1,286 @@ +//! SoundCraft's VST3 plugin host. +//! +//! Scans the standard VST3 folders (plus `VST3_PATH`), loads `.vst3` bundles in-process and +//! exposes each "Audio Module Class" as a [`soundcraft_dsp::Plugin`], so the mix engine treats +//! third-party plugins exactly like the built-in ones. Plugin ids are `vst3:` (32 +//! upper-case hex digits of the component's class id); parameter ids are VST3 `ParamID`s as +//! decimal strings, with titles, units and plain ranges from the edit controller in +//! [`PluginInfo::params`]. Values are plain (display) values; the host converts to and from the +//! VST3 normalized 0..1 values. Stepped parameters become toggles or choices. +//! +//! Like `soundcraft-clap-host`, this is an isolated `unsafe` crate (craftrules never-crash): +//! `unsafe` is denied crate-wide and allowed only in the private `ffi` module. The public API is +//! safe, returns `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that +//! itself crashes (segfault) takes the process down with it; we cannot sandbox native code. +//! +//! `PluginInfo` must be `&'static`: the registry leaks exactly one `PluginInfo` (with its param +//! table) per plugin *type* the first time that type is instantiated, and caches it. The leak is +//! bounded by the number of distinct installed plugins actually used. Loaded binaries likewise +//! stay mapped for the life of the process (module exit functions are never called). +//! +//! On `wasm32` the crate compiles to stubs: scans are empty and nothing can be created. +//! +//! Not hosted yet: plugin editors (`IPlugView`), `IMessage`/`IAttributeList` creation through the +//! host (plugins that need it for processor ↔ controller messaging may lose those messages), +//! plugin state save/restore, sidechain (aux buses are kept inactive) and 64-bit processing. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod scan; + +#[cfg(not(target_arch = "wasm32"))] +#[allow(unsafe_code)] +mod ffi; +#[cfg(not(target_arch = "wasm32"))] +mod plugin; +#[cfg(not(target_arch = "wasm32"))] +mod registry; + +#[cfg(not(target_arch = "wasm32"))] +pub use plugin::Vst3Plugin; + +use soundcraft_dsp::{Category, Plugin, PluginInfo}; +use std::path::{Path, PathBuf}; + +/// Prefix of every hosted-VST3 plugin id. +pub const ID_PREFIX: &str = "vst3:"; + +/// The factory class category of audio processors (the only classes SoundCraft lists). +pub const AUDIO_MODULE_CLASS: &str = "Audio Module Class"; + +/// The 16-byte class id inside a `vst3:<32 hex digits>` SoundCraft id (`None` for other ids). +pub fn parse_id(id: &str) -> Option<[u8; 16]> { + let hex = id.strip_prefix(ID_PREFIX)?; + if hex.len() != 32 || !hex.is_ascii() { + return None; + } + let mut out = [0u8; 16]; + for (i, o) in out.iter_mut().enumerate() { + let pair = hex.get(i * 2..i * 2 + 2)?; + *o = u8::from_str_radix(pair, 16).ok()?; + } + Some(out) +} + +/// One plugin found by a scan. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct Vst3Descriptor { + /// SoundCraft id: `vst3:`. + pub id: String, + /// The component's class id as 32 upper-case hex digits. + pub class_id: String, + pub name: String, + pub vendor: String, + pub version: String, + /// The VST3 SDK version the plugin was built with (when reported). + pub sdk_version: String, + /// The `|`-separated sub-categories, split (`["Fx", "Reverb"]`). + pub sub_categories: Vec, + pub category: Category, + pub is_instrument: bool, + /// The `.vst3` bundle or file it lives in. + pub path: PathBuf, +} + +/// Why a VST3 operation failed. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum Vst3Error { + #[error("cannot load VST3 plugin {0}: {1}")] + Load(String, String), + #[error("no VST3 plugin `{0}`")] + NotFound(String), + #[error("cannot create VST3 plugin `{0}`: {1}")] + Instantiate(String, String), + #[error("VST3 processing: {0}")] + Process(String), + #[error("VST3 plugins are not supported on this platform")] + Unsupported, +} + +#[cfg(not(target_arch = "wasm32"))] +mod api { + use super::*; + + pub fn scan() -> Vec { + registry::scan() + } + pub fn rescan() -> Vec { + registry::rescan() + } + pub fn add_search_dir(dir: &Path) -> usize { + registry::add_search_dir(dir) + } + pub fn scan_paths(dirs: &[PathBuf]) -> Vec { + registry::scan_paths(dirs) + } + pub fn load_bundle(path: &Path) -> Result, Vst3Error> { + registry::load_bundle(path) + } + pub fn instantiate(id: &str) -> Result, Vst3Error> { + registry::instantiate(id) + } + pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> { + registry::plugin_info(id) + } +} + +#[cfg(target_arch = "wasm32")] +mod api { + use super::*; + + pub fn scan() -> Vec { + Vec::new() + } + pub fn rescan() -> Vec { + Vec::new() + } + pub fn add_search_dir(_dir: &Path) -> usize { + 0 + } + pub fn scan_paths(_dirs: &[PathBuf]) -> Vec { + Vec::new() + } + pub fn load_bundle(_path: &Path) -> Result, Vst3Error> { + Err(Vst3Error::Unsupported) + } + pub fn instantiate(_id: &str) -> Result, Vst3Error> { + Err(Vst3Error::Unsupported) + } + pub fn plugin_info(_id: &str) -> Option<&'static PluginInfo> { + None + } +} + +/// Every plugin in the standard VST3 folders, `VST3_PATH` and folders added with +/// [`add_search_dir`]. Scanned on first call (loads each binary and reads its factory, but +/// creates no plugin instances), then cached in memory. +pub fn scan() -> Vec { + api::scan() +} + +/// Forgets the cached scan (and remembered failures) and scans again. +pub fn rescan() -> Vec { + api::rescan() +} + +/// Adds a folder to the search path and merges its plugins into the cache. Returns how many +/// plugins the folder holds. +pub fn add_search_dir(dir: &Path) -> usize { + api::add_search_dir(dir) +} + +/// Scans the given folders without touching the cache. +pub fn scan_paths(dirs: &[PathBuf]) -> Vec { + api::scan_paths(dirs) +} + +/// The audio module classes in one `.vst3` bundle or file. +pub fn load_bundle(path: &Path) -> Result, Vst3Error> { + api::load_bundle(path) +} + +/// Creates a plugin by `vst3:`, prepared for 48 kHz stereo with 1024-frame blocks (like +/// `soundcraft_dsp::create`), with the reason when it fails. +pub fn instantiate(id: &str) -> Result, Vst3Error> { + api::instantiate(id) +} + +/// Creates a plugin by `vst3:`; `None` if unknown or it fails to load. +pub fn create(id: &str) -> Option> { + parse_id(id)?; + match api::instantiate(id) { + Ok(p) => Some(p), + Err(e) => { + log::warn!("{e}"); + None + } + } +} + +/// The description of a `vst3:` plugin. The first call for a type instantiates it once +/// to read its parameters; the result is cached (and leaked, see the crate docs). +pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> { + api::plugin_info(id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_parse() { + let id = "vst3:0123456789ABCDEFfedcba9876543210"; + assert_eq!(parse_id(id), Some([0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10])); + assert_eq!(parse_id("vst3:"), None); + assert_eq!(parse_id("vst3:0123"), None); + assert_eq!(parse_id("vst3:0123456789ABCDEF0123456789ABCDEG"), None); + assert_eq!(parse_id("vst3:ééééééééééééééééé"), None); + assert_eq!(parse_id("VST3:0123456789ABCDEF0123456789ABCDEF"), None); + assert_eq!(parse_id("clap:x"), None); + assert_eq!(parse_id("eq_7band"), None); + } + + #[test] + fn non_vst3_ids_are_rejected_without_scanning() { + assert!(create("eq_7band").is_none()); + assert!(create("clap:com.x").is_none()); + assert!(plugin_info("reverb").is_none()); + } + + #[test] + fn garbage_files_are_errors_not_crashes() { + let d = std::env::temp_dir().join(format!("soundcraft-vst3-garbage-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + let arch = if cfg!(target_os = "macos") { + "MacOS" + } else if cfg!(windows) { + "x86_64-win" + } else { + "x86_64-linux" + }; + let exe = if cfg!(target_os = "macos") { + "Bundle" + } else if cfg!(windows) { + "Bundle.vst3" + } else { + "Bundle.so" + }; + std::fs::create_dir_all(d.join(format!("Bundle.vst3/Contents/{arch}"))).unwrap(); + std::fs::write(d.join("garbage.vst3"), b"\x7fELF this is not a plugin \x00\x01\x02").unwrap(); + std::fs::write(d.join("empty.vst3"), b"").unwrap(); + std::fs::write(d.join(format!("Bundle.vst3/Contents/{arch}/{exe}")), [0xcf, 0xfa, 0xed, 0xfe, 1, 2, 3]).unwrap(); + std::fs::create_dir_all(d.join("NoBinary.vst3/Contents")).unwrap(); + for f in ["garbage.vst3", "empty.vst3", "Bundle.vst3", "NoBinary.vst3", "missing.vst3"] { + assert!(load_bundle(&d.join(f)).is_err(), "{f}"); + } + assert!(scan_paths(std::slice::from_ref(&d)).is_empty()); + let missing = "vst3:00000000000000000000000000000000"; + assert!(create(missing).is_none()); + assert!(plugin_info(missing).is_none()); + assert!(instantiate(missing).is_err()); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_real_library_without_the_vst3_export_is_rejected() { + // A system library loads fine but has no `GetPluginFactory`. + // (macOS system libraries live only in the dyld shared cache, not as files.) + let lib = if cfg!(target_os = "macos") || cfg!(windows) { + None + } else { + ["/lib/x86_64-linux-gnu/libz.so.1", "/usr/lib/libz.so.1", "/lib64/libz.so.1"].iter().map(PathBuf::from).find(|p| p.exists()) + }; + let Some(lib) = lib else { return }; + let d = std::env::temp_dir().join(format!("soundcraft-vst3-nolib-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + // Point a single-file ".vst3" at it via a symlink (no copying of binaries). + #[cfg(unix)] + { + std::os::unix::fs::symlink(&lib, d.join("z.vst3")).unwrap(); + let e = load_bundle(&d.join("z.vst3")).unwrap_err(); + assert!(e.to_string().contains("not a VST3 plugin"), "{e}"); + } + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs new file mode 100644 index 0000000..661420b --- /dev/null +++ b/crates/vst3-host/src/plugin.rs @@ -0,0 +1,298 @@ +//! `Vst3Plugin`: a loaded VST3 instance behind the safe `soundcraft_dsp::Plugin` trait. + +use crate::ffi::{BusBuffers, EVENT_CAP, Instance, NoteEvent, PARAM_QUEUE_CAP}; +use soundcraft_dsp::{Plugin, PluginInfo, Unit}; +use std::sync::atomic::Ordering; + +/// Largest block we set a plugin up for. +const MAX_BLOCK: usize = 1 << 16; +/// Most channels a host strip may ask for. +const MAX_CHANNELS: usize = 64; + +/// How a SoundCraft parameter value maps to the VST3 normalized value. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Mapping { + /// Toggle/choice index `0..=steps` ↔ `index / steps`. + Stepped(f64), + /// Plain value through the controller's `plainParamToNormalized`. + Plain, + /// Linear over the info range (the controller gave no usable plain range). + Linear, +} + +/// A hosted VST3 plugin. Parameter ids are VST3 `ParamID`s as decimal strings ("0", "17", …); +/// values are plain (display) values with their names and ranges in `info().params`. +pub struct Vst3Plugin { + inst: Instance, + info: &'static PluginInfo, + /// VST3 id of each `info.params` entry (same order). + ids: Vec, + maps: Vec, + values: Vec, + /// Normalized values waiting for the next block (coalesced per id; bounded). + pending: Vec<(u32, f64)>, + /// Pending notes, kept sorted by time. Never grows past `EVENT_CAP`. + notes: Vec, + inputs: BusBuffers, + outputs: BusBuffers, + has_input: bool, + has_output: bool, + event_input: bool, + held: [bool; 128], + channels: usize, + max_block: usize, + latency: usize, + tail: usize, + position: i64, +} + +impl Vst3Plugin { + pub(crate) fn new(inst: Instance, info: &'static PluginInfo) -> Vst3Plugin { + let ids: Vec = info.params.iter().map(|p| p.id.parse::().unwrap_or(u32::MAX)).collect(); + let maps: Vec = info + .params + .iter() + .zip(&ids) + .map(|(p, &id)| match p.unit { + Unit::Toggle | Unit::Choice => Mapping::Stepped(f64::from(p.max.max(1.0))), + _ => { + let lo = inst.to_plain(id, 0.0); + let hi = inst.to_plain(id, 1.0); + match (lo, hi) { + (Some(lo), Some(hi)) + if (lo as f32 - p.min).abs() <= 1e-6 * p.min.abs().max(1.0) + && (hi as f32 - p.max).abs() <= 1e-6 * p.max.abs().max(1.0) => + { + Mapping::Plain + } + _ => Mapping::Linear, + } + } + }) + .collect(); + let mut p = Vst3Plugin { + info, + values: Vec::with_capacity(ids.len()), + pending: Vec::with_capacity(ids.len().min(PARAM_QUEUE_CAP)), + notes: Vec::with_capacity(EVENT_CAP), + inputs: BusBuffers::new(&[], 0), + outputs: BusBuffers::new(&[], 0), + has_input: false, + has_output: false, + event_input: false, + held: [false; 128], + channels: 2, + max_block: 0, + latency: 0, + tail: 0, + position: 0, + ids, + maps, + inst, + }; + let values: Vec = (0..p.ids.len()) + .map(|i| { + let (Some(pi), Some(&id)) = (p.info.params.get(i), p.ids.get(i)) else { return 0.0 }; + p.inst.normalized_value(id).and_then(|n| p.plain_from_normalized(i, n)).map_or(pi.default, |v| pi.clamp(v)) + }) + .collect(); + p.values = values; + p + } + + fn plain_from_normalized(&self, i: usize, n: f64) -> Option { + let pi = self.info.params.get(i)?; + let id = *self.ids.get(i)?; + let v = match self.maps.get(i)? { + Mapping::Stepped(steps) => (n * steps).round(), + Mapping::Plain => self.inst.to_plain(id, n)?, + Mapping::Linear => f64::from(pi.min) + n * f64::from(pi.max - pi.min), + }; + v.is_finite().then_some(v as f32) + } + + fn normalized_from_plain(&self, i: usize, v: f32) -> Option { + let pi = self.info.params.get(i)?; + let id = *self.ids.get(i)?; + let span = f64::from(pi.max - pi.min); + let linear = if span > 0.0 { (f64::from(v) - f64::from(pi.min)) / span } else { 0.0 }; + let n = match self.maps.get(i)? { + Mapping::Stepped(steps) if *steps > 0.0 => f64::from(v) / steps, + Mapping::Stepped(_) => 0.0, + Mapping::Plain => self.inst.to_normalized(id, f64::from(v)).unwrap_or(linear), + Mapping::Linear => linear, + }; + n.is_finite().then(|| n.clamp(0.0, 1.0)) + } + + /// Inserts keeping the queue time-sorted; drops the event when the queue is full (never + /// allocates on the audio thread). + fn push_note(&mut self, e: NoteEvent) { + if self.notes.len() >= self.notes.capacity() { + return; + } + let pos = self.notes.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1); + self.notes.insert(pos, e); + } +} + +impl Plugin for Vst3Plugin { + fn info(&self) -> &'static PluginInfo { + self.info + } + + fn prepare(&mut self, sample_rate: f32, max_block: usize, channels: usize) { + let sr = if sample_rate.is_finite() && sample_rate > 0.0 { f64::from(sample_rate) } else { 48_000.0 }; + let mb = max_block.clamp(1, MAX_BLOCK); + self.channels = channels.clamp(1, MAX_CHANNELS); + self.max_block = 0; + match self.inst.configure(sr, mb, self.channels == 1) { + Ok(layout) => { + self.inputs = BusBuffers::new(&layout.inputs, mb); + self.outputs = BusBuffers::new(&layout.outputs, mb); + self.has_input = layout.inputs.first().is_some_and(|&c| c > 0); + self.has_output = layout.outputs.first().is_some_and(|&c| c > 0); + self.event_input = layout.event_input; + self.max_block = mb; + self.latency = self.inst.latency() as usize; + self.tail = self.inst.tail() as usize; + } + Err(e) => { + log::warn!("{}: {e}", self.info.id); + self.latency = 0; + } + } + // Re-send every value so a re-activated plugin matches the session. + self.pending.clear(); + for i in 0..self.ids.len().min(PARAM_QUEUE_CAP) { + if let (Some(&id), Some(n)) = (self.ids.get(i), self.values.get(i).and_then(|&v| self.normalized_from_plain(i, v))) { + self.pending.push((id, n)); + } + } + } + + fn reset(&mut self) { + self.notes.clear(); + self.held = [false; 128]; + } + + fn set_param(&mut self, id: &str, value: f32) -> bool { + let Some(i) = self.info.params.iter().position(|p| p.id == id) else { return false }; + let (Some(pi), Some(&vid)) = (self.info.params.get(i), self.ids.get(i)) else { return false }; + let v = pi.clamp(value); + if let Some(slot) = self.values.get_mut(i) { + *slot = v; + } + let Some(n) = self.normalized_from_plain(i, v) else { return true }; + if let Some(e) = self.pending.iter_mut().find(|(pid, _)| *pid == vid) { + e.1 = n; + } else if self.pending.len() < self.pending.capacity() { + self.pending.push((vid, n)); + } + true + } + + fn param(&self, id: &str) -> Option { + let i = self.info.params.iter().position(|p| p.id == id)?; + self.values.get(i).copied() + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + if !self.inst.is_active() || self.max_block == 0 { + return; + } + let ch = self.channels.min(io.len()); + let frames = io.iter().take(ch).map(Vec::len).fold(frames, usize::min); + if frames == 0 || ch == 0 { + return; + } + if self.inst.host().latency_changed.swap(false, Ordering::Relaxed) { + self.latency = self.inst.latency() as usize; + } + let last = u32::try_from(frames - 1).unwrap_or(u32::MAX); + for e in &mut self.notes { + if e.time() > last { + e.set_time(last); + } + } + let mut start = 0usize; + while start < frames { + let n = (frames - start).min(self.max_block); + let end = u32::try_from(start + n).unwrap_or(u32::MAX); + // Feed the main input bus (mono sources fill every bus channel). + if self.has_input { + for c in 0..self.inputs.channels(0) { + let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n)); + if let (Some(src), Some(dst)) = (src, self.inputs.channel_mut(0, c).and_then(|d| d.get_mut(..n))) { + dst.copy_from_slice(src); + } + } + } + let k = self.notes.iter().position(|e| e.time() >= end).unwrap_or(self.notes.len()); + let offset = u32::try_from(start).unwrap_or(u32::MAX); + let notes = self.notes.get(..k).unwrap_or(&[]); + let r = self.inst.process(n, &mut self.inputs, &mut self.outputs, &self.pending, notes, offset, self.position); + self.pending.clear(); + self.notes.drain(..k); + self.position = self.position.saturating_add(i64::try_from(n).unwrap_or(0)); + match r { + Ok(()) if self.has_output => { + let oc = self.outputs.channels(0); + for (c, dst) in io.iter_mut().take(ch).enumerate() { + let Some(dst) = dst.get_mut(start..start + n) else { continue }; + if ch == 1 && oc >= 2 { + if let (Some(l), Some(r)) = (self.outputs.channel(0, 0), self.outputs.channel(0, 1)) { + for ((d, a), b) in dst.iter_mut().zip(l).zip(r) { + *d = 0.5 * (a + b); + } + } + } else if let Some(src) = self.outputs.channel(0, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) { + dst.copy_from_slice(src); + } + } + } + Err(e) => log::debug!("{}: {e}", self.info.id), + _ => {} + } + start += n; + } + } + + fn latency(&self) -> usize { + self.latency + } + + fn tail_samples(&self) -> usize { + self.tail + } + + fn note_on(&mut self, offset: usize, note: u8, velocity: u8) { + if note > 127 || !self.event_input { + return; + } + let time = u32::try_from(offset).unwrap_or(u32::MAX); + self.push_note(NoteEvent::On { time, key: note, velocity: f32::from(velocity.min(127)) / 127.0 }); + if let Some(h) = self.held.get_mut(note as usize) { + *h = true; + } + } + + fn note_off(&mut self, offset: usize, note: u8) { + if note > 127 || !self.event_input { + return; + } + let time = u32::try_from(offset).unwrap_or(u32::MAX); + self.push_note(NoteEvent::Off { time, key: note }); + if let Some(h) = self.held.get_mut(note as usize) { + *h = false; + } + } + + fn all_notes_off(&mut self) { + for k in 0..128u8 { + if self.held.get(k as usize).copied().unwrap_or(false) { + self.note_off(0, k); + } + } + } +} diff --git a/crates/vst3-host/src/registry.rs b/crates/vst3-host/src/registry.rs new file mode 100644 index 0000000..c63db7b --- /dev/null +++ b/crates/vst3-host/src/registry.rs @@ -0,0 +1,264 @@ +//! The process-wide VST3 registry: scan cache, leaked `PluginInfo`s, instantiation. + +use crate::ffi::{Bundle, Instance, PARAM_BYPASS, PARAM_HIDDEN, PARAM_READ_ONLY, RawParam}; +use crate::plugin::Vst3Plugin; +use crate::scan as paths; +use crate::{AUDIO_MODULE_CLASS, Vst3Descriptor, Vst3Error, parse_id}; +use soundcraft_dsp::{ParamInfo, Plugin, PluginInfo, Taper, Unit}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock, PoisonError}; + +/// Most labels read for a stepped parameter (more steps are shown as a plain range). +const MAX_CHOICES: i32 = 128; + +#[derive(Default)] +struct Registry { + scanned: Option>, + extra_dirs: Vec, + /// One leaked `PluginInfo` per plugin type that was actually instantiated (bounded by the + /// number of distinct installed plugins). + infos: HashMap, + /// Ids that failed to instantiate (not retried every UI frame). + failed: HashSet, +} + +fn registry() -> &'static Mutex { + static R: OnceLock> = OnceLock::new(); + R.get_or_init(|| Mutex::new(Registry::default())) +} + +/// Descriptors of every audio module class in one `.vst3`. +pub fn load_bundle(path: &Path) -> Result, Vst3Error> { + let b = Bundle::load(path)?; + Ok(b.classes() + .into_iter() + .filter(|c| c.category == AUDIO_MODULE_CLASS) + .map(|c| { + let subs = paths::split_sub_categories(&c.sub_categories); + let class_id = crate::ffi::hex(&c.cid); + Vst3Descriptor { + id: format!("{}{class_id}", crate::ID_PREFIX), + category: paths::category_from_sub_categories(&subs), + is_instrument: paths::is_instrument(&subs), + class_id, + name: if c.name.is_empty() { "VST3 plugin".to_string() } else { c.name }, + vendor: c.vendor, + version: c.version, + sdk_version: c.sdk_version, + sub_categories: subs, + path: path.to_path_buf(), + } + }) + .collect()) +} + +/// Scans `dirs` (uncached). Unloadable files are logged and skipped; the first plugin with a +/// given id wins. +pub fn scan_paths(dirs: &[PathBuf]) -> Vec { + let mut out: Vec = Vec::new(); + for path in paths::find_bundles(dirs) { + match load_bundle(&path) { + Ok(ds) => { + for d in ds { + if !out.iter().any(|o| o.id == d.id) { + out.push(d); + } + } + } + Err(e) => log::warn!("{e}"), + } + } + out.sort_by_key(|d| d.name.to_lowercase()); + out +} + +fn ensure_scanned(r: &mut Registry) -> &Vec { + if r.scanned.is_none() { + let mut dirs = paths::default_search_paths(); + dirs.extend(r.extra_dirs.iter().cloned()); + r.scanned = Some(scan_paths(&dirs)); + } + r.scanned.get_or_insert_with(Vec::new) +} + +pub fn scan() -> Vec { + let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); + ensure_scanned(&mut r).clone() +} + +pub fn rescan() -> Vec { + let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); + r.scanned = None; + r.failed.clear(); + ensure_scanned(&mut r).clone() +} + +pub fn add_search_dir(dir: &Path) -> usize { + let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); + if !r.extra_dirs.iter().any(|d| d == dir) { + r.extra_dirs.push(dir.to_path_buf()); + } + let found = scan_paths(&[dir.to_path_buf()]); + let n = found.len(); + let list = r.scanned.get_or_insert_with(Vec::new); + for d in found { + if !list.iter().any(|o| o.id == d.id) { + list.push(d); + } + } + n +} + +fn leak(s: String) -> &'static str { + Box::leak(s.into_boxed_str()) +} + +/// Maps a VST3 unit label to a SoundCraft display unit. +pub(crate) fn unit_from_label(units: &str) -> Unit { + match units.trim().to_ascii_lowercase().as_str() { + "db" => Unit::Db, + "hz" => Unit::Hz, + "ms" => Unit::Ms, + "%" => Unit::Percent, + "s" | "sec" | "secs" | "seconds" => Unit::Seconds, + "st" | "semi" | "semitones" => Unit::Semitones, + "ct" | "cent" | "cents" => Unit::Cents, + _ => Unit::None, + } +} + +fn param_info(inst: &Instance, p: &RawParam) -> ParamInfo { + let name = if p.title.is_empty() { format!("Param {}", p.id) } else { p.title.clone() }; + let base = ParamInfo { + id: leak(p.id.to_string()), + name: leak(name), + min: 0.0, + max: 1.0, + default: 0.0, + unit: Unit::None, + taper: Taper::Linear, + choices: &[], + }; + if p.step_count == 1 { + return ParamInfo { unit: Unit::Toggle, default: p.default_normalized.round() as f32, ..base }; + } + if (2..=MAX_CHOICES).contains(&p.step_count) { + let steps = f64::from(p.step_count); + let labels: Vec<&'static str> = + (0..=p.step_count).map(|i| leak(inst.value_text(p.id, f64::from(i) / steps).unwrap_or_else(|| i.to_string()))).collect(); + return ParamInfo { + max: p.step_count as f32, + default: (p.default_normalized * steps).round() as f32, + unit: Unit::Choice, + choices: Box::leak(labels.into_boxed_slice()), + ..base + }; + } + // Continuous (or too many steps for a menu): plain range from the controller. + let unit = unit_from_label(&p.units); + match (inst.to_plain(p.id, 0.0), inst.to_plain(p.id, 1.0)) { + (Some(lo), Some(hi)) if hi > lo && (lo as f32).is_finite() && (hi as f32).is_finite() && (hi as f32) > (lo as f32) => { + let default = inst.to_plain(p.id, p.default_normalized).map_or(lo, |d| d.clamp(lo, hi)); + // A logarithmic plugin mapping (frequency knobs) shows its geometric mean at mid travel. + let taper = match inst.to_plain(p.id, 0.5) { + Some(mid) + if lo > 0.0 + && ((mid - (lo * hi).sqrt()).abs() <= 0.01 * (lo * hi).sqrt()) + && (mid - (lo + hi) / 2.0).abs() > 0.01 * (hi - lo) => + { + Taper::Log + } + _ => Taper::Linear, + }; + ParamInfo { min: lo as f32, max: hi as f32, default: default as f32, unit, taper, ..base } + } + _ => ParamInfo { default: p.default_normalized as f32, unit, ..base }, + } +} + +/// Builds and leaks the `PluginInfo` for one plugin type. Called at most once per id (cached). +fn build_info(d: &Vst3Descriptor, inst: &Instance) -> &'static PluginInfo { + let params: Vec = + inst.params().iter().filter(|p| p.flags & (PARAM_HIDDEN | PARAM_READ_ONLY | PARAM_BYPASS) == 0).map(|p| param_info(inst, p)).collect(); + let short: String = d.name.chars().take(8).collect(); + Box::leak(Box::new(PluginInfo { + id: leak(d.id.clone()), + name: leak(d.name.clone()), + short_name: leak(short), + category: d.category, + params: Box::leak(params.into_boxed_slice()), + is_instrument: d.is_instrument, + audiosuite: false, + })) +} + +/// Finds the descriptor, loads its binary and creates an instance plus its (cached) info. +fn instantiate_raw(id: &str) -> Result<(Instance, &'static PluginInfo), Vst3Error> { + let cid = parse_id(id).ok_or_else(|| Vst3Error::NotFound(id.to_string()))?; + let desc = { + let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); + if r.failed.contains(id) { + return Err(Vst3Error::Instantiate(id.to_string(), "failed before".into())); + } + ensure_scanned(&mut r).iter().find(|d| d.id == id).cloned().ok_or_else(|| Vst3Error::NotFound(id.to_string()))? + }; + let made = Bundle::load(&desc.path).and_then(|b| b.instantiate(&cid)); + let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); + match made { + Ok(inst) => { + let info = match r.infos.get(id) { + Some(i) => *i, + None => { + let i = build_info(&desc, &inst); + r.infos.insert(id.to_string(), i); + i + } + }; + Ok((inst, info)) + } + Err(e) => { + r.failed.insert(id.to_string()); + Err(e) + } + } +} + +pub fn instantiate(id: &str) -> Result, Vst3Error> { + let (inst, info) = instantiate_raw(id)?; + let mut p = Vst3Plugin::new(inst, info); + p.prepare(48_000.0, 1024, 2); + Ok(Box::new(p)) +} + +pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> { + parse_id(id)?; + { + let r = registry().lock().unwrap_or_else(PoisonError::into_inner); + if let Some(i) = r.infos.get(id) { + return Some(*i); + } + if r.failed.contains(id) { + return None; + } + } + // Instantiate once to learn the parameters (VST3 exposes them only on a live controller). + instantiate_raw(id).ok().map(|(_, info)| info) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unit_labels_map() { + assert_eq!(unit_from_label("dB"), Unit::Db); + assert_eq!(unit_from_label(" Hz "), Unit::Hz); + assert_eq!(unit_from_label("%"), Unit::Percent); + assert_eq!(unit_from_label("ms"), Unit::Ms); + assert_eq!(unit_from_label("sec"), Unit::Seconds); + assert_eq!(unit_from_label("cents"), Unit::Cents); + assert_eq!(unit_from_label("st"), Unit::Semitones); + assert_eq!(unit_from_label("furlongs"), Unit::None); + } +} diff --git a/crates/vst3-host/src/scan.rs b/crates/vst3-host/src/scan.rs new file mode 100644 index 0000000..f6a2b21 --- /dev/null +++ b/crates/vst3-host/src/scan.rs @@ -0,0 +1,324 @@ +//! Finding VST3 plugins on disk (safe, pure `std::fs`), resolving bundles to their binaries and +//! mapping VST3 sub-categories to SoundCraft categories. + +use soundcraft_dsp::Category; +use std::path::{Path, PathBuf}; + +/// Directory recursion limit while scanning (vendors nest folders, never deeply). +const MAX_DEPTH: usize = 8; +/// Upper bound on bundles collected by one scan (hostile or huge folders). +const MAX_BUNDLES: usize = 4096; +/// Upper bound on directory entries visited by one scan. +const MAX_ENTRIES: usize = 100_000; + +/// The operating systems whose VST3 bundle layouts we know. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Os { + MacOs, + Windows, + /// Linux and the BSDs (same bundle layout). + Unix, +} + +impl Os { + /// The OS this build runs on. + pub fn current() -> Os { + if cfg!(target_os = "macos") { + Os::MacOs + } else if cfg!(windows) { + Os::Windows + } else { + Os::Unix + } + } +} + +/// The standard VST3 search locations for this OS (existing or not), followed by every entry of +/// the `VST3_PATH` environment variable. +pub fn default_search_paths() -> Vec { + let mut v = os_search_paths( + Os::current(), + std::env::var_os("HOME").map(PathBuf::from), + std::env::var_os("COMMONPROGRAMFILES").map(PathBuf::from), + std::env::var_os("LOCALAPPDATA").map(PathBuf::from), + ); + if let Some(cp) = std::env::var_os("VST3_PATH") { + v.extend(std::env::split_paths(&cp).filter(|p| !p.as_os_str().is_empty())); + } + v +} + +/// The OS-specific standard locations, given the relevant environment values. +pub fn os_search_paths(os: Os, home: Option, common_program_files: Option, local_app_data: Option) -> Vec { + let mut v = Vec::new(); + match os { + Os::MacOs => { + if let Some(h) = home { + v.push(h.join("Library/Audio/Plug-Ins/VST3")); + } + v.push(PathBuf::from("/Library/Audio/Plug-Ins/VST3")); + } + Os::Windows => { + if let Some(c) = common_program_files { + v.push(c.join("VST3")); + } + if let Some(l) = local_app_data { + v.push(l.join("Programs").join("Common").join("VST3")); + } + } + Os::Unix => { + if let Some(h) = home { + v.push(h.join(".vst3")); + } + v.push(PathBuf::from("/usr/lib/vst3")); + v.push(PathBuf::from("/usr/local/lib/vst3")); + } + } + v +} + +/// True when the path names a VST3 plugin (`*.vst3`, case-insensitive). +pub fn is_vst3_path(p: &Path) -> bool { + p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("vst3")) +} + +/// Recursively collects `.vst3` bundles and files under `dirs` (missing directories are skipped). +/// A `.vst3` directory is a bundle and is not descended into. The result is sorted and deduplicated. +pub fn find_bundles(dirs: &[PathBuf]) -> Vec { + let mut out = Vec::new(); + let mut budget = MAX_ENTRIES; + for d in dirs { + if is_vst3_path(d) && d.exists() { + out.push(d.clone()); + } else { + walk(d, 0, &mut out, &mut budget); + } + } + out.sort(); + out.dedup(); + out.truncate(MAX_BUNDLES); + out +} + +fn walk(dir: &Path, depth: usize, out: &mut Vec, budget: &mut usize) { + if depth > MAX_DEPTH || out.len() >= MAX_BUNDLES { + return; + } + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for entry in rd.flatten() { + if *budget == 0 || out.len() >= MAX_BUNDLES { + return; + } + *budget -= 1; + let p = entry.path(); + // `metadata` follows symlinks (plugins are often symlinked in); errors are skipped. + let Ok(md) = std::fs::metadata(&p) else { continue }; + if is_vst3_path(&p) { + out.push(p); + } else if md.is_dir() { + walk(&p, depth + 1, out, budget); + } + } +} + +/// The `Contents/` folder names that hold this machine's binaries, most specific first. +fn arch_dirs(os: Os) -> &'static [&'static str] { + match os { + Os::MacOs => &["MacOS"], + Os::Windows => { + if cfg!(target_arch = "aarch64") { + &["arm64-win", "arm64ec-win", "arm64x-win"] + } else if cfg!(target_arch = "x86") { + &["x86-win"] + } else { + &["x86_64-win"] + } + } + Os::Unix => { + if cfg!(target_arch = "aarch64") { + &["aarch64-linux"] + } else if cfg!(target_arch = "x86") { + &["i386-linux"] + } else { + &["x86_64-linux"] + } + } + } +} + +/// The loadable binary of a plugin for this OS: a single-file `.vst3` itself, or inside a bundle +/// directory `Contents/MacOS/` (macOS), `Contents/x86_64-win/.vst3` (Windows) or +/// `Contents/x86_64-linux/.so` (Linux/BSD), falling back to the only/first fitting file there. +pub fn bundle_binary(path: &Path) -> Option { + bundle_binary_for(Os::current(), path) +} + +/// [`bundle_binary`] for a given OS layout (testable everywhere). +pub fn bundle_binary_for(os: Os, path: &Path) -> Option { + if path.is_file() { + return Some(path.to_path_buf()); + } + if !path.is_dir() { + return None; + } + let stem = path.file_stem()?; + let ext = match os { + Os::MacOs => None, + Os::Windows => Some("vst3"), + Os::Unix => Some("so"), + }; + for dir in arch_dirs(os) { + let folder = path.join("Contents").join(dir); + let mut named = folder.join(stem); + if let Some(e) = ext { + named.set_extension(e); + } + if named.is_file() { + return Some(named); + } + let Ok(rd) = std::fs::read_dir(&folder) else { continue }; + let mut files: Vec = rd + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file()) + .filter(|p| ext.is_none_or(|e| p.extension().and_then(|x| x.to_str()).is_some_and(|x| x.eq_ignore_ascii_case(e)))) + .take(64) + .collect(); + files.sort(); + if let Some(f) = files.into_iter().next() { + return Some(f); + } + } + None +} + +/// The `|`-separated VST3 sub-category tokens (`"Fx|Reverb"` → `["Fx", "Reverb"]`). +pub fn split_sub_categories(s: &str) -> Vec { + s.split('|').map(str::trim).filter(|t| !t.is_empty()).take(32).map(str::to_string).collect() +} + +/// Maps VST3 sub-categories (`Fx|EQ`, `Instrument|Synth`, …) to a SoundCraft insert-menu category. +pub fn category_from_sub_categories>(subs: &[S]) -> Category { + let has = |names: &[&str]| subs.iter().any(|f| names.iter().any(|n| f.as_ref().eq_ignore_ascii_case(n))); + if is_instrument(subs) { + Category::Instrument + } else if has(&["EQ", "Filter"]) { + Category::Eq + } else if has(&["Dynamics"]) { + Category::Dynamics + } else if has(&["Reverb"]) { + Category::Reverb + } else if has(&["Delay"]) { + Category::Delay + } else if has(&["Modulation"]) { + Category::Modulation + } else if has(&["Distortion"]) { + Category::Harmonic + } else if has(&["Pitch Shift"]) { + Category::PitchShift + } else { + Category::Other + } +} + +/// True when the sub-categories mark an instrument (`Instrument`, `Instrument|Synth`, …). +pub fn is_instrument>(subs: &[S]) -> bool { + subs.first().is_some_and(|f| f.as_ref().eq_ignore_ascii_case("Instrument")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("soundcraft-vst3-scan-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn finds_files_and_bundles_recursively_but_not_inside_bundles() { + let d = tmp("find"); + std::fs::write(d.join("a.vst3"), b"x").unwrap(); + std::fs::write(d.join("readme.txt"), b"x").unwrap(); + std::fs::create_dir_all(d.join("vendor/sub")).unwrap(); + std::fs::write(d.join("vendor/sub/B.VST3"), b"x").unwrap(); + std::fs::create_dir_all(d.join("c.vst3/Contents/MacOS")).unwrap(); + std::fs::write(d.join("c.vst3/Contents/MacOS/c"), b"x").unwrap(); + std::fs::write(d.join("c.vst3/Contents/inner.vst3"), b"x").unwrap(); + let found = find_bundles(&[d.clone(), d.join("missing")]); + let names: Vec = found.iter().map(|p| p.strip_prefix(&d).unwrap().to_string_lossy().replace('\\', "/")).collect(); + assert_eq!(names, vec!["a.vst3", "c.vst3", "vendor/sub/B.VST3"]); + assert_eq!(find_bundles(&[d.join("a.vst3")]), vec![d.join("a.vst3")]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn bundles_resolve_per_os_layout() { + let d = tmp("layout"); + // macOS + std::fs::create_dir_all(d.join("M.vst3/Contents/MacOS")).unwrap(); + std::fs::write(d.join("M.vst3/Contents/MacOS/M"), b"x").unwrap(); + std::fs::write(d.join("M.vst3/Contents/MacOS/aaa"), b"x").unwrap(); + assert_eq!(bundle_binary_for(Os::MacOs, &d.join("M.vst3")), Some(d.join("M.vst3/Contents/MacOS/M"))); + // macOS fallback: first file when the name differs. + std::fs::create_dir_all(d.join("N.vst3/Contents/MacOS")).unwrap(); + std::fs::write(d.join("N.vst3/Contents/MacOS/other"), b"x").unwrap(); + assert_eq!(bundle_binary_for(Os::MacOs, &d.join("N.vst3")), Some(d.join("N.vst3/Contents/MacOS/other"))); + // Windows + let win = arch_dirs(Os::Windows)[0]; + std::fs::create_dir_all(d.join(format!("W.vst3/Contents/{win}"))).unwrap(); + std::fs::write(d.join(format!("W.vst3/Contents/{win}/W.vst3")), b"x").unwrap(); + std::fs::write(d.join(format!("W.vst3/Contents/{win}/helper.dll")), b"x").unwrap(); + assert_eq!(bundle_binary_for(Os::Windows, &d.join("W.vst3")), Some(d.join(format!("W.vst3/Contents/{win}/W.vst3")))); + // Linux + let lin = arch_dirs(Os::Unix)[0]; + std::fs::create_dir_all(d.join(format!("L.vst3/Contents/{lin}"))).unwrap(); + std::fs::write(d.join(format!("L.vst3/Contents/{lin}/notes.txt")), b"x").unwrap(); + assert_eq!(bundle_binary_for(Os::Unix, &d.join("L.vst3")), None, "only .so files qualify"); + std::fs::write(d.join(format!("L.vst3/Contents/{lin}/Renamed.so")), b"x").unwrap(); + assert_eq!(bundle_binary_for(Os::Unix, &d.join("L.vst3")), Some(d.join(format!("L.vst3/Contents/{lin}/Renamed.so")))); + std::fs::write(d.join(format!("L.vst3/Contents/{lin}/L.so")), b"x").unwrap(); + assert_eq!(bundle_binary_for(Os::Unix, &d.join("L.vst3")), Some(d.join(format!("L.vst3/Contents/{lin}/L.so")))); + // Single-file plugin, wrong layout, missing. + std::fs::write(d.join("single.vst3"), b"x").unwrap(); + assert_eq!(bundle_binary_for(Os::Windows, &d.join("single.vst3")), Some(d.join("single.vst3"))); + assert_eq!(bundle_binary_for(Os::Windows, &d.join("M.vst3")), None); + assert_eq!(bundle_binary_for(Os::MacOs, &d.join("nope.vst3")), None); + std::fs::create_dir_all(d.join("Empty.vst3")).unwrap(); + assert_eq!(bundle_binary(&d.join("Empty.vst3")), None); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn os_paths_cover_the_standard_locations() { + let mac = os_search_paths(Os::MacOs, Some("/Users/u".into()), None, None); + assert_eq!(mac, vec![PathBuf::from("/Users/u/Library/Audio/Plug-Ins/VST3"), PathBuf::from("/Library/Audio/Plug-Ins/VST3")]); + let win = os_search_paths(Os::Windows, None, Some("C:/CPF".into()), Some("C:/LAD".into())); + assert_eq!(win[0], PathBuf::from("C:/CPF").join("VST3")); + assert_eq!(win.len(), 2); + let unix = os_search_paths(Os::Unix, Some("/home/u".into()), None, None); + assert_eq!(unix, vec![PathBuf::from("/home/u/.vst3"), PathBuf::from("/usr/lib/vst3"), PathBuf::from("/usr/local/lib/vst3")]); + assert!(!default_search_paths().is_empty()); + } + + #[test] + fn sub_categories_map_to_categories() { + let c = |s: &str| category_from_sub_categories(&split_sub_categories(s)); + assert_eq!(c("Fx|Reverb"), Category::Reverb); + assert_eq!(c("Instrument|Synth"), Category::Instrument); + assert_eq!(c("Fx|Dynamics"), Category::Dynamics); + assert_eq!(c("Fx|EQ"), Category::Eq); + assert_eq!(c("Fx|Filter"), Category::Eq); + assert_eq!(c("Fx|Modulation"), Category::Modulation); + assert_eq!(c("Fx|Distortion"), Category::Harmonic); + assert_eq!(c("Fx|Pitch Shift"), Category::PitchShift); + assert_eq!(c("Fx|Delay"), Category::Delay); + assert_eq!(c("Fx"), Category::Other); + assert_eq!(c(""), Category::Other); + assert_eq!(split_sub_categories(" Fx || Delay |"), vec!["Fx", "Delay"]); + assert!(is_instrument(&split_sub_categories("Instrument|Sampler"))); + assert!(!is_instrument(&split_sub_categories("Fx|Instrument"))); + } +} diff --git a/crates/vst3-host/tests/fixture/Cargo.lock b/crates/vst3-host/tests/fixture/Cargo.lock new file mode 100644 index 0000000..66be195 --- /dev/null +++ b/crates/vst3-host/tests/fixture/Cargo.lock @@ -0,0 +1,25 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "com-scrape-types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ce183b975b8fc736a20919c4002717255fbd867df575c792aeefa9a56a73ad0" + +[[package]] +name = "vst3" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31e3fe7a6f028ded8c5a9984cf283319e6dc63572c9b71da1d4ee9d71d3b6bf" +dependencies = [ + "com-scrape-types", +] + +[[package]] +name = "vst3-fixture" +version = "0.0.0" +dependencies = [ + "vst3", +] diff --git a/crates/vst3-host/tests/fixture/Cargo.toml b/crates/vst3-host/tests/fixture/Cargo.toml new file mode 100644 index 0000000..d2b6f91 --- /dev/null +++ b/crates/vst3-host/tests/fixture/Cargo.toml @@ -0,0 +1,18 @@ +# A tiny VST3 plugin used only by soundcraft-vst3-host'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] +name = "vst3-fixture" +version = "0.0.0" +edition = "2024" +publish = false +license = "MIT OR Apache-2.0" + +[lib] +crate-type = ["cdylib"] +path = "src/lib.rs" + +[dependencies] +vst3 = "0.3" + +[workspace] diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs new file mode 100644 index 0000000..69c584d --- /dev/null +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -0,0 +1,612 @@ +//! Test-only VST3 plugin for soundcraft-vst3-host (two 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" +//! (plain 0..2, linear, units dB), 3 "Mode" (3 steps: A/B/C), 12 "Freq" (20..20000 Hz, log), +//! 9 hidden, 11 bypass. Starts at gain 0.5 (its state) although the declared default is 1.0. +//! Reports 3 samples of latency. +//! - "Fixture Synth" (`Instrument|Synth`): a single-component instrument (the component is also +//! 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. +//! +//! Test code: panics are fine here, the host must survive anything anyway. + +#![allow(non_snake_case, clippy::missing_safety_doc)] + +use std::ffi::{c_char, c_void}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use vst3::Steinberg::Vst::*; +use vst3::Steinberg::*; +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); + +fn copy_c8(src: &str, dst: &mut [c_char]) { + for (d, s) in dst.iter_mut().zip(src.bytes().chain(std::iter::once(0))) { + *d = s as c_char; + } +} + +fn copy_c16(src: &str, dst: &mut [TChar]) { + let mut n = 0; + for (d, s) in dst.iter_mut().zip(src.encode_utf16()) { + *d = s; + n += 1; + } + if let Some(z) = dst.get_mut(n) { + *z = 0; + } +} + +fn audio_bus(bus: *mut BusInfo, dir: BusDirection, name: &str) { + let b = unsafe { &mut *bus }; + b.mediaType = MediaTypes_::kAudio as MediaType; + b.direction = dir; + b.channelCount = 2; + copy_c16(name, &mut b.name); + b.busType = BusTypes_::kMain as BusType; + b.flags = BusInfo_::BusFlags_::kDefaultActive; +} + +unsafe fn channels<'a>(bus: &AudioBusBuffers, frames: usize) -> Vec<&'a mut [f32]> { + let n = bus.numChannels.max(0) as usize; + let table = unsafe { std::slice::from_raw_parts(bus.__field0.channelBuffers32, n) }; + table.iter().map(|&p| unsafe { std::slice::from_raw_parts_mut(p, frames) }).collect() +} + +// ---- gain ---------------------------------------------------------------------------------- + +struct GainProcessor { + /// Normalized gain (plain = 2 × normalized). + gain: AtomicU64, + arrangement: AtomicU64, +} + +impl Class for GainProcessor { + type Interfaces = (IComponent, IAudioProcessor, IConnectionPoint); +} + +impl IPluginBaseTrait for GainProcessor { + unsafe fn initialize(&self, context: *mut FUnknown) -> tresult { + assert!(!context.is_null(), "host context"); + kResultOk + } + unsafe fn terminate(&self) -> tresult { + kResultOk + } +} + +impl IConnectionPointTrait for GainProcessor { + unsafe fn connect(&self, other: *mut IConnectionPoint) -> tresult { + if other.is_null() { kInvalidArgument } else { kResultOk } + } + unsafe fn disconnect(&self, _other: *mut IConnectionPoint) -> tresult { + kResultOk + } + unsafe fn notify(&self, _message: *mut IMessage) -> tresult { + kResultOk + } +} + +impl IComponentTrait for GainProcessor { + unsafe fn getControllerClassId(&self, class_id: *mut TUID) -> tresult { + unsafe { *class_id = GAIN_CTRL_CID }; + kResultOk + } + unsafe fn setIoMode(&self, _mode: IoMode) -> tresult { + kResultOk + } + unsafe fn getBusCount(&self, media: MediaType, _dir: BusDirection) -> i32 { + if media == MediaTypes_::kAudio as MediaType { 1 } else { 0 } + } + unsafe fn getBusInfo(&self, media: MediaType, dir: BusDirection, index: i32, bus: *mut BusInfo) -> tresult { + if media != MediaTypes_::kAudio as MediaType || index != 0 { + return kInvalidArgument; + } + audio_bus(bus, dir, "Main"); + 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 { + let Some(s) = (unsafe { ComRef::from_raw(state) }) else { return kInvalidArgument }; + let mut bytes = f64::from_bits(self.gain.load(Ordering::Relaxed)).to_le_bytes(); + let mut n = 0; + unsafe { s.write(bytes.as_mut_ptr().cast(), 8, &mut n) } + } +} + +impl IAudioProcessorTrait for GainProcessor { + unsafe fn setBusArrangements(&self, inputs: *mut SpeakerArrangement, n_in: i32, outputs: *mut SpeakerArrangement, n_out: i32) -> tresult { + if n_in != 1 || n_out != 1 { + return kResultFalse; + } + let (i, o) = unsafe { (*inputs, *outputs) }; + if i != o || (i != SpeakerArr::kStereo && i != SpeakerArr::kMono) { + return kResultFalse; + } + self.arrangement.store(i, Ordering::Relaxed); + kResultTrue + } + unsafe fn getBusArrangement(&self, _dir: BusDirection, index: i32, arr: *mut SpeakerArrangement) -> tresult { + if index != 0 { + return kInvalidArgument; + } + unsafe { *arr = self.arrangement.load(Ordering::Relaxed) }; + kResultOk + } + 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 { + let s = unsafe { &*setup }; + assert_eq!(s.symbolicSampleSize, SymbolicSampleSizes_::kSample32 as i32); + assert!(s.maxSamplesPerBlock > 0 && s.sampleRate > 0.0); + kResultOk + } + unsafe fn setProcessing(&self, _state: TBool) -> tresult { + kNotImplemented + } + unsafe fn process(&self, data: *mut ProcessData) -> tresult { + let d = unsafe { &*data }; + assert!(!d.processContext.is_null()); + if let Some(changes) = unsafe { ComRef::from_raw(d.inputParameterChanges) } { + for i in 0..unsafe { changes.getParameterCount() } { + let Some(q) = (unsafe { ComRef::from_raw(changes.getParameterData(i)) }) else { continue }; + let (mut off, mut v) = (0, 0.0); + let n = unsafe { q.getPointCount() }; + if unsafe { q.getParameterId() } == 7 && n > 0 && unsafe { q.getPoint(n - 1, &mut off, &mut v) } == kResultOk { + self.gain.store(v.to_bits(), Ordering::Relaxed); + } + } + } + if d.numInputs != 1 || d.numOutputs != 1 { + return kResultOk; + } + let frames = d.numSamples.max(0) as usize; + let g = 2.0 * f64::from_bits(self.gain.load(Ordering::Relaxed)) as f32; + let (ins, outs) = unsafe { (channels(&*d.inputs, frames), channels(&*d.outputs, frames)) }; + for (i, o) in ins.iter().zip(outs) { + for (x, y) in i.iter().zip(o.iter_mut()) { + *y = g * x; + } + } + kResultOk + } + unsafe fn getTailSamples(&self) -> u32 { + 0 + } +} + +struct GainController { + gain: AtomicU64, +} + +impl Class for GainController { + type Interfaces = (IEditController, IConnectionPoint); +} + +impl IPluginBaseTrait for GainController { + unsafe fn initialize(&self, _context: *mut FUnknown) -> tresult { + kResultOk + } + unsafe fn terminate(&self) -> tresult { + kResultOk + } +} + +impl IConnectionPointTrait for GainController { + unsafe fn connect(&self, other: *mut IConnectionPoint) -> tresult { + if other.is_null() { kInvalidArgument } else { kResultOk } + } + unsafe fn disconnect(&self, _other: *mut IConnectionPoint) -> tresult { + kResultOk + } + unsafe fn notify(&self, _message: *mut IMessage) -> tresult { + kResultOk + } +} + +const GAIN_PARAMS: [(u32, &str, &str, i32, f64, i32); 5] = [ + (7, "Gain", "dB", 0, 0.5, ParameterInfo_::ParameterFlags_::kCanAutomate), + (9, "Secret", "", 0, 0.0, ParameterInfo_::ParameterFlags_::kIsHidden), + (3, "Mode", "", 2, 0.0, ParameterInfo_::ParameterFlags_::kIsList), + (11, "Bypass", "", 1, 0.0, ParameterInfo_::ParameterFlags_::kIsBypass), + (12, "Freq", "Hz", 0, 0.5, 0), +]; + +impl IEditControllerTrait for GainController { + unsafe fn setComponentState(&self, state: *mut IBStream) -> tresult { + let Some(s) = (unsafe { ComRef::from_raw(state) }) else { return kInvalidArgument }; + let mut bytes = [0u8; 8]; + let mut n = 0; + if unsafe { s.read(bytes.as_mut_ptr().cast(), 8, &mut n) } == kResultOk && n == 8 { + self.gain.store(f64::from_le_bytes(bytes).to_bits(), Ordering::Relaxed); + } + kResultOk + } + unsafe fn setState(&self, _state: *mut IBStream) -> tresult { + kResultOk + } + unsafe fn getState(&self, _state: *mut IBStream) -> tresult { + kResultOk + } + unsafe fn getParameterCount(&self) -> i32 { + GAIN_PARAMS.len() as i32 + } + unsafe fn getParameterInfo(&self, index: i32, info: *mut ParameterInfo) -> tresult { + let Some(&(id, title, units, steps, def, flags)) = GAIN_PARAMS.get(index as usize) else { return kInvalidArgument }; + let i = unsafe { &mut *info }; + i.id = id; + copy_c16(title, &mut i.title); + copy_c16(title, &mut i.shortTitle); + copy_c16(units, &mut i.units); + i.stepCount = steps; + i.defaultNormalizedValue = def; + i.unitId = 0; + i.flags = flags; + kResultOk + } + unsafe fn getParamStringByValue(&self, id: u32, n: f64, string: *mut String128) -> tresult { + let s = unsafe { &mut *string }; + let text = match id { + 3 => ["A", "B", "C"][((n * 2.0).round() as usize).min(2)].to_string(), + _ => format!("{:.2}", unsafe { self.normalizedParamToPlain(id, n) }), + }; + copy_c16(&text, s); + kResultOk + } + unsafe fn getParamValueByString(&self, _id: u32, _s: *mut TChar, _v: *mut f64) -> tresult { + kNotImplemented + } + unsafe fn normalizedParamToPlain(&self, id: u32, n: f64) -> f64 { + match id { + 7 => 2.0 * n, + 3 => (n * 2.0).round(), + 12 => 20.0 * 1000f64.powf(n), + _ => n, + } + } + unsafe fn plainParamToNormalized(&self, id: u32, v: f64) -> f64 { + match id { + 7 => v / 2.0, + 3 => v / 2.0, + 12 => (v / 20.0).ln() / 1000f64.ln(), + _ => v, + } + } + unsafe fn getParamNormalized(&self, id: u32) -> f64 { + match id { + 7 => f64::from_bits(self.gain.load(Ordering::Relaxed)), + 12 => 0.5, + _ => 0.0, + } + } + unsafe fn setParamNormalized(&self, id: u32, v: f64) -> tresult { + if id == 7 { + self.gain.store(v.to_bits(), Ordering::Relaxed); + } + kResultOk + } + unsafe fn setComponentHandler(&self, handler: *mut IComponentHandler) -> tresult { + // Exercise the host's handler: an edit from the "plugin GUI" must be accepted. + if let Some(h) = unsafe { ComRef::from_raw(handler) } { + assert_eq!(unsafe { h.beginEdit(7) }, kResultOk); + assert_eq!(unsafe { h.performEdit(7, 0.9) }, kResultOk); + assert_eq!(unsafe { h.endEdit(7) }, kResultOk); + } + kResultOk + } + unsafe fn createView(&self, _name: FIDString) -> *mut IPlugView { + std::ptr::null_mut() + } +} + +// ---- synth --------------------------------------------------------------------------------- + +struct Synth { + level: AtomicU32, +} + +impl Class for Synth { + type Interfaces = (IComponent, IAudioProcessor, IEditController); +} + +impl IPluginBaseTrait for Synth { + unsafe fn initialize(&self, _context: *mut FUnknown) -> tresult { + kResultOk + } + unsafe fn terminate(&self) -> tresult { + kResultOk + } +} + +impl IComponentTrait for Synth { + 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 { + let out = dir == BusDirections_::kOutput as BusDirection; + if media == MediaTypes_::kAudio as MediaType && out { + 1 + } else if media == MediaTypes_::kEvent as MediaType && !out { + 1 + } else { + 0 + } + } + unsafe fn getBusInfo(&self, media: MediaType, dir: BusDirection, index: i32, bus: *mut BusInfo) -> tresult { + if index != 0 { + return kInvalidArgument; + } + if media == MediaTypes_::kAudio as MediaType && dir == BusDirections_::kOutput as BusDirection { + audio_bus(bus, dir, "Out"); + return kResultOk; + } + if media == MediaTypes_::kEvent as MediaType && dir == BusDirections_::kInput as BusDirection { + let b = unsafe { &mut *bus }; + b.mediaType = media; + b.direction = dir; + b.channelCount = 16; + copy_c16("Notes", &mut b.name); + b.busType = BusTypes_::kMain as BusType; + b.flags = BusInfo_::BusFlags_::kDefaultActive; + return kResultOk; + } + kInvalidArgument + } + 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 Synth { + unsafe fn setBusArrangements(&self, _i: *mut SpeakerArrangement, n_in: i32, outputs: *mut SpeakerArrangement, n_out: i32) -> tresult { + if n_in == 0 && n_out == 1 && unsafe { *outputs } == SpeakerArr::kStereo { kResultTrue } else { kResultFalse } + } + unsafe fn getBusArrangement(&self, dir: BusDirection, index: i32, arr: *mut SpeakerArrangement) -> tresult { + if dir != BusDirections_::kOutput as BusDirection || index != 0 { + return kInvalidArgument; + } + unsafe { *arr = SpeakerArr::kStereo }; + kResultOk + } + unsafe fn canProcessSampleSize(&self, size: i32) -> tresult { + if size == SymbolicSampleSizes_::kSample32 as i32 { kResultOk } else { kNotImplemented } + } + unsafe fn getLatencySamples(&self) -> u32 { + 0 + } + 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 }; + if d.numOutputs != 1 { + return kResultOk; + } + let frames = d.numSamples.max(0) as usize; + let mut events = Vec::new(); + if let Some(list) = unsafe { ComRef::from_raw(d.inputEvents) } { + for i in 0..unsafe { list.getEventCount() } { + let mut e: Event = unsafe { std::mem::zeroed() }; + if unsafe { list.getEvent(i, &mut e) } == kResultOk { + events.push(e); + } + } + } + let outs = unsafe { channels(&*d.outputs, frames) }; + let mut level = f32::from_bits(self.level.load(Ordering::Relaxed)); + let mut out = vec![0.0f32; frames]; + for (i, o) in out.iter_mut().enumerate() { + for e in events.iter().filter(|e| e.sampleOffset as usize == i) { + if e.r#type == Event_::EventTypes_::kNoteOnEvent as u16 { + level = unsafe { e.__field0.noteOn.velocity }; + } else if e.r#type == Event_::EventTypes_::kNoteOffEvent as u16 { + level = 0.0; + } + } + *o = level; + } + self.level.store(level.to_bits(), Ordering::Relaxed); + for ch in outs { + ch.copy_from_slice(&out); + } + kResultOk + } + unsafe fn getTailSamples(&self) -> u32 { + 0 + } +} + +impl IEditControllerTrait for Synth { + unsafe fn setComponentState(&self, _state: *mut IBStream) -> tresult { + kResultOk + } + unsafe fn setState(&self, _state: *mut IBStream) -> tresult { + kResultOk + } + unsafe fn getState(&self, _state: *mut IBStream) -> tresult { + kResultOk + } + unsafe fn getParameterCount(&self) -> i32 { + 0 + } + unsafe fn getParameterInfo(&self, _index: i32, _info: *mut ParameterInfo) -> tresult { + kInvalidArgument + } + unsafe fn getParamStringByValue(&self, _id: u32, _n: f64, _s: *mut String128) -> tresult { + kInvalidArgument + } + unsafe fn getParamValueByString(&self, _id: u32, _s: *mut TChar, _v: *mut f64) -> tresult { + kInvalidArgument + } + unsafe fn normalizedParamToPlain(&self, _id: u32, n: f64) -> f64 { + n + } + unsafe fn plainParamToNormalized(&self, _id: u32, v: f64) -> f64 { + v + } + unsafe fn getParamNormalized(&self, _id: u32) -> f64 { + 0.0 + } + unsafe fn setParamNormalized(&self, _id: u32, _v: f64) -> tresult { + kResultOk + } + unsafe fn setComponentHandler(&self, _handler: *mut IComponentHandler) -> tresult { + kResultOk + } + unsafe fn createView(&self, _name: FIDString) -> *mut IPlugView { + std::ptr::null_mut() + } +} + +// ---- factory ------------------------------------------------------------------------------- + +struct Factory; + +impl Class for Factory { + type Interfaces = (IPluginFactory2,); +} + +const CLASSES: [(TUID, &str, &str, &str); 3] = [ + (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"), +]; + +impl IPluginFactoryTrait for Factory { + unsafe fn getFactoryInfo(&self, info: *mut PFactoryInfo) -> tresult { + let i = unsafe { &mut *info }; + copy_c8("SoundCraft tests", &mut i.vendor); + copy_c8("https://example.invalid", &mut i.url); + copy_c8("tests@example.invalid", &mut i.email); + i.flags = PFactoryInfo_::FactoryFlags_::kUnicode as i32; + kResultOk + } + unsafe fn countClasses(&self) -> i32 { + CLASSES.len() as i32 + } + unsafe fn getClassInfo(&self, index: i32, info: *mut PClassInfo) -> tresult { + let Some(&(cid, cat, name, _)) = CLASSES.get(index as usize) else { return kInvalidArgument }; + let i = unsafe { &mut *info }; + i.cid = cid; + i.cardinality = PClassInfo_::ClassCardinality_::kManyInstances as i32; + copy_c8(cat, &mut i.category); + copy_c8(name, &mut i.name); + kResultOk + } + unsafe fn createInstance(&self, cid: FIDString, iid: FIDString, obj: *mut *mut c_void) -> tresult { + let cid = unsafe { *(cid as *const TUID) }; + let unknown = if cid == GAIN_CID { + ComWrapper::new(GainProcessor { gain: AtomicU64::new(0.25f64.to_bits()), arrangement: AtomicU64::new(SpeakerArr::kStereo) }).to_com_ptr::() + } else if cid == GAIN_CTRL_CID { + ComWrapper::new(GainController { gain: AtomicU64::new(0.5f64.to_bits()) }).to_com_ptr::() + } else if cid == SYNTH_CID { + ComWrapper::new(Synth { level: AtomicU32::new(0) }).to_com_ptr::() + } else { + None + }; + match unknown { + Some(u) => { + let p = u.as_ptr(); + unsafe { ((*(*p).vtbl).queryInterface)(p, iid as *const TUID, obj) } + } + None => kInvalidArgument, + } + } +} + +impl IPluginFactory2Trait for Factory { + unsafe fn getClassInfo2(&self, index: i32, info: *mut PClassInfo2) -> tresult { + let Some(&(cid, cat, name, subs)) = CLASSES.get(index as usize) else { return kInvalidArgument }; + let i = unsafe { &mut *info }; + i.cid = cid; + i.cardinality = PClassInfo_::ClassCardinality_::kManyInstances as i32; + copy_c8(cat, &mut i.category); + copy_c8(name, &mut i.name); + i.classFlags = 0; + copy_c8(subs, &mut i.subCategories); + copy_c8("", &mut i.vendor); + copy_c8("1.2.3", &mut i.version); + copy_c8("VST 3.8.0", &mut i.sdkVersion); + kResultOk + } +} + +#[cfg(target_os = "windows")] +#[unsafe(no_mangle)] +extern "system" fn InitDll() -> bool { + true +} + +#[cfg(target_os = "windows")] +#[unsafe(no_mangle)] +extern "system" fn ExitDll() -> bool { + true +} + +#[cfg(target_os = "macos")] +#[unsafe(no_mangle)] +extern "C" fn bundleEntry(bundle: *mut c_void) -> bool { + !bundle.is_null() +} + +#[cfg(target_os = "macos")] +#[unsafe(no_mangle)] +extern "C" fn bundleExit() -> bool { + true +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[unsafe(no_mangle)] +extern "C" fn ModuleEntry(handle: *mut c_void) -> bool { + !handle.is_null() +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[unsafe(no_mangle)] +extern "C" fn ModuleExit() -> bool { + true +} + +#[unsafe(no_mangle)] +extern "system" fn GetPluginFactory() -> *mut IPluginFactory { + match ComWrapper::new(Factory).to_com_ptr::() { + Some(p) => p.into_raw(), + None => std::ptr::null_mut(), + } +} diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs new file mode 100644 index 0000000..59bd21c --- /dev/null +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -0,0 +1,208 @@ +//! End to end: build the test VST3 plugin in `tests/fixture` (a cdylib outside the workspace), +//! install it as a `.vst3` bundle in a temp folder, then scan → instantiate → process → params → +//! notes. + +use soundcraft_dsp::{Category, Taper, Unit}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; + +/// Builds the fixture once and returns a folder containing `Fixture.vst3`. +fn fixture_dir() -> &'static Path { + static DIR: OnceLock = OnceLock::new(); + DIR.get_or_init(|| { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/Cargo.toml"); + let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("vst3-fixture-target"); + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + let st = Command::new(cargo) + .args(["build", "--quiet", "--manifest-path"]) + .arg(&manifest) + .arg("--target-dir") + .arg(&target) + .env_remove("RUSTFLAGS") + .status() + .expect("run cargo"); + assert!(st.success(), "building the VST3 fixture failed"); + let (lib, folder, exe) = if cfg!(target_os = "macos") { + ("libvst3_fixture.dylib", "MacOS", "Fixture") + } else if cfg!(windows) { + ("vst3_fixture.dll", "x86_64-win", "Fixture.vst3") + } else { + ("libvst3_fixture.so", if cfg!(target_arch = "aarch64") { "aarch64-linux" } else { "x86_64-linux" }, "Fixture.so") + }; + let built = target.join("debug").join(lib); + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("vst3-fixture-install-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // Install as a real bundle to exercise bundle resolution and the module entry. + 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); + dir + }) +} + +fn id_of(name: &str) -> String { + let found = soundcraft_vst3_host::scan_paths(&[fixture_dir().to_path_buf()]); + found.into_iter().find(|d| d.name == name).unwrap().id +} + +#[test] +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:?}"); + 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" }; + assert_eq!(g.id, want); + assert_eq!(g.vendor, "SoundCraft tests", "factory vendor fills an empty class vendor"); + assert_eq!(g.version, "1.2.3"); + assert_eq!(g.sdk_version, "VST 3.8.0"); + assert_eq!(g.sub_categories, vec!["Fx"]); + assert_eq!(g.category, Category::Other); + assert!(!g.is_instrument); + assert!(g.path.ends_with("Fixture.vst3")); + let s = found.iter().find(|d| d.name == "Fixture Synth").unwrap(); + assert_eq!(s.category, Category::Instrument); + assert!(s.is_instrument); + let global = soundcraft_vst3_host::scan(); + assert!(global.iter().any(|d| d.id == g.id) && global.iter().any(|d| d.id == s.id)); +} + +#[test] +fn info_exposes_visible_params_with_plain_ranges() { + let gain = id_of("Fixture Gain"); + let info = soundcraft_vst3_host::plugin_info(&gain).unwrap(); + assert_eq!(info.id, gain); + assert_eq!(info.short_name, "Fixture "); + let ids: Vec<&str> = info.params.iter().map(|p| p.id).collect(); + assert_eq!(ids, vec!["7", "3", "12"], "hidden 9 and bypass 11 are skipped"); + let g = info.param("7").unwrap(); + assert_eq!((g.name, g.min, g.max, g.default, g.unit, g.taper), ("Gain", 0.0, 2.0, 1.0, Unit::Db, Taper::Linear)); + let mode = info.param("3").unwrap(); + assert_eq!((mode.unit, mode.min, mode.max), (Unit::Choice, 0.0, 2.0)); + assert_eq!(mode.choices, &["A", "B", "C"]); + let f = info.param("12").unwrap(); + assert_eq!((f.unit, f.taper), (Unit::Hz, Taper::Log)); + assert!((f.min - 20.0).abs() < 1e-3 && (f.max - 20_000.0).abs() < 1e-1, "{f:?}"); + // Cached: the same leaked info every time. + assert!(std::ptr::eq(info, soundcraft_vst3_host::plugin_info(&gain).unwrap())); +} + +#[test] +fn gain_processes_audio_and_follows_params() { + let mut p = soundcraft_vst3_host::create(&id_of("Fixture Gain")).unwrap(); + p.prepare(44_100.0, 256, 2); + assert_eq!(p.latency(), 3); + // The controller was synced from the component state (gain 0.5), not the declared default. + assert_eq!(p.param("7"), Some(0.5)); + let mut io = vec![vec![0.5f32; 256], vec![-0.25f32; 256]]; + p.process(&mut io, 256); + assert!(io[0].iter().all(|&x| (x - 0.25).abs() < 1e-6), "{:?}", &io[0][..4]); + assert!(p.set_param("7", 2.0)); + assert!(!p.set_param("9", 1.0), "hidden params are not exposed"); + assert!(!p.set_param("Gain", 1.0), "ids are VST3 ParamIDs"); + assert!(p.set_param("7", 5.0), "clamped"); + assert_eq!(p.param("7"), Some(2.0)); + assert!(p.set_param("12", 2000.0)); + assert!((p.param("12").unwrap() - 2000.0).abs() < 1e-2); + assert!(p.set_param("3", 1.6)); + assert_eq!(p.param("3"), Some(2.0), "choices round"); + // A block larger than max_block is chunked. + let mut io = vec![vec![0.5f32; 700], vec![-0.25f32; 700]]; + p.process(&mut io, 700); + assert!(io[0].iter().all(|&x| (x - 1.0).abs() < 1e-6)); + assert!(io[1].iter().all(|&x| (x + 0.5).abs() < 1e-6)); + // Mono strip: the plugin accepts a mono arrangement; the session value is re-sent. + p.prepare(48_000.0, 128, 1); + let mut mono = vec![vec![0.25f32; 128]]; + p.process(&mut mono, 128); + assert!(mono[0].iter().all(|&x| (x - 0.5).abs() < 1e-6), "{:?}", &mono[0][..4]); + // Hostile shapes don't crash. + p.process(&mut [], 64); + let mut short = vec![vec![1.0f32; 4]]; + p.process(&mut short, 1_000_000); + assert!(p.set_param("7", f32::NAN), "NaN becomes the default"); + assert_eq!(p.param("7"), Some(1.0)); + p.reset(); + p.prepare(f32::NAN, 0, 0); + let mut io = vec![vec![0.1f32; 8]]; + p.process(&mut io, 8); +} + +#[test] +fn synth_plays_notes_at_their_offsets() { + let id = id_of("Fixture Synth"); + let info = soundcraft_vst3_host::plugin_info(&id).unwrap(); + assert!(info.is_instrument); + assert!(info.params.is_empty()); + let mut p = soundcraft_vst3_host::create(&id).unwrap(); + p.prepare(48_000.0, 64, 2); + let mut io = vec![vec![9.0f32; 64], vec![9.0f32; 64]]; + p.note_on(10, 60, 127); + p.note_off(40, 60); + p.process(&mut io, 64); + for (i, &x) in io[0].iter().enumerate() { + let want = if (10..40).contains(&i) { 1.0 } else { 0.0 }; + assert!((x - want).abs() < 1e-6, "frame {i}: {x}"); + } + assert_eq!(io[0], io[1]); + // Offsets past the block are clamped into it; all_notes_off releases held notes. + p.note_on(1000, 61, 64); + let mut io = vec![vec![0.0f32; 64], vec![0.0f32; 64]]; + p.process(&mut io, 64); + assert!(io[0][62].abs() < 1e-6 && (io[0][63] - 64.0 / 127.0).abs() < 1e-6); + p.all_notes_off(); + p.process(&mut io, 64); + assert!(io[0].iter().all(|x| x.abs() < 1e-6)); + // Notes in a later sub-block of a chunked call land at the right frame. + p.prepare(48_000.0, 32, 2); + let mut io = vec![vec![0.0f32; 96], vec![0.0f32; 96]]; + p.note_on(50, 62, 127); + p.process(&mut io, 96); + assert!(io[0][49].abs() < 1e-6 && (io[0][50] - 1.0).abs() < 1e-6 && (io[0][95] - 1.0).abs() < 1e-6); + // A mono strip downmixes the stereo output. + p.prepare(48_000.0, 64, 1); + let mut mono = vec![vec![0.0f32; 64]]; + p.process(&mut mono, 64); + assert!(mono[0].iter().all(|&x| (x - 1.0).abs() < 1e-6), "the note is still held"); +} + +#[test] +fn many_instances_create_and_drop_cleanly() { + let (g, s) = (id_of("Fixture Gain"), id_of("Fixture Synth")); + for _ in 0..50 { + let mut a = soundcraft_vst3_host::create(&g).unwrap(); + let b = soundcraft_vst3_host::create(&s).unwrap(); + let mut io = vec![vec![0.0f32; 32]; 2]; + a.process(&mut io, 32); + drop(b); + } +} + +/// Smoke test against whatever real VST3 plugins are installed (none are required): scan, then +/// instantiate, prepare and process each one briefly. Set `SOUNDCRAFT_VST3_SMOKE=1` to run it. +#[test] +fn installed_plugins_smoke() { + if std::env::var_os("SOUNDCRAFT_VST3_SMOKE").is_none() { + return; + } + for d in soundcraft_vst3_host::scan() { + eprintln!("found {} ({}) {:?} [{}] at {}", d.name, d.vendor, d.category, d.sub_categories.join("|"), d.path.display()); + let info = soundcraft_vst3_host::plugin_info(&d.id); + eprintln!( + " info: {:?}", + info.map(|i| (i.params.len(), i.params.iter().take(5).map(|p| (p.id, p.name, p.min, p.max, p.unit)).collect::>())) + ); + match soundcraft_vst3_host::instantiate(&d.id) { + Ok(mut p) => { + p.prepare(48_000.0, 512, 2); + let mut io = vec![vec![0.1f32; 512], vec![0.1f32; 512]]; + p.process(&mut io, 512); + eprintln!(" processed; latency {} out[0] {}", p.latency(), io[0][0]); + } + Err(e) => eprintln!(" instantiate: {e}"), + } + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0cf30f3..5426733 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -23,6 +23,7 @@ const LAYERS: &[(&str, u8)] = &[ ("midi", 0), ("dsp", 1), ("clap-host", 1), + ("vst3-host", 1), ("model", 2), ("mix", 3), ("engine", 4),