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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-06 00:39:59 -04:00
parent 8269ca77f6
commit cc86842abe
44 changed files with 5348 additions and 188 deletions

View File

@ -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<T, E>` + `?`. 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.

31
Cargo.lock generated
View File

@ -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"

View File

@ -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" }

View File

@ -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.

View File

@ -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<Vec<u8>> {
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<Vec<u8>> {
/// 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<Vec<u8>> {
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<Vec<u8>> {
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<u32> {
wav::read_channel_mask(bytes)
}

View File

@ -190,6 +190,8 @@ pub(crate) fn decode(b: &[u8], layout: &WavLayout) -> Result<AudioBuffer> {
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<u32> {
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<u8>, 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<u8>, 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<Vec<u8>> {
/// `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<u32>,
) -> Result<Vec<u8>> {
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);
}

View File

@ -248,3 +248,23 @@ fn serde_types_roundtrip() {
let s = serde_json::to_string(&info).unwrap();
assert_eq!(serde_json::from_str::<soundcraft_audio_io::AudioInfo>(&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);
}

View File

@ -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();

View File

@ -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<SpeakerPos> {
[(-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<SpeakerPos> {
let mut v: Vec<SpeakerPos> =
[-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, &params(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, &params(-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, &params(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()));
}
}

View File

@ -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

View File

@ -24,6 +24,7 @@ fn audio_as_midi(e: &mut Engine, p: &Value) -> Result<Value> {
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;

View File

@ -25,9 +25,9 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// A built-in plugin's description, else a hosted CLAP plugin's (`clap:<id>`).
/// A built-in plugin's description, else a hosted CLAP (`clap:<id>`) or VST3 (`vst3:<class id>`) 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)]

View File

@ -282,7 +282,7 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
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();

View File

@ -82,8 +82,8 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<Value> {
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<bool> {
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<Value> {
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<Value> {
};
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}),

View File

@ -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<Value>;
/// `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<Vec<TrackId>>
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<Option<Samples>> {
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<Option<Samples>> {
let Some(v) = p.get(key) else { return Ok(None) };
let s = e.session();
if let Some(n) = v.as_i64() {

View File

@ -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<CommandSpec> {
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<Value> {
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<Value> {
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<usize> {
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::<usize>().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<Value> {
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);
}
}

View File

@ -24,13 +24,13 @@ pub fn specs() -> Vec<CommandSpec> {
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)
}),

View File

@ -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<CommandSpec> {
vec![cmd!(
query "engine.vst3_plugins",
"List VST3 Plugins",
[],
None,
"{rescan?: bool} → [{id: \"vst3:<class id hex>\", 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());
}
}

View File

@ -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::<Vec<_>>(),
@ -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::<Vec<_>>(),
"busses": s.busses.iter().map(|b| json!({"id": b.id, "name": b.name, "format": b.format.label()})).collect::<Vec<_>>(),

View File

@ -129,11 +129,17 @@ pub fn export_midi(e: &Engine) -> Result<Vec<u8>> {
/// 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<u8>, (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<u8>, (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<Vec<f32>>, 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<Vec<String>> {
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<Vec<String>> {
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<Samples> {
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<bool> {
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 };

View File

@ -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))]

View File

@ -81,7 +81,7 @@ fn hostile_value() -> impl Strategy<Value = Value> {
}
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();

View File

@ -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)]

View File

@ -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

View File

@ -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<f32>,
/// 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<f32>], 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<Vec<f32>>,
}
struct PluginSlot {
id: String,
/// Channel count the plugin was prepared for.
ch: usize,
plugin: Box<dyn Plugin>,
/// Last values pushed to the plugin, in the insert's parameter-map order.
applied: Vec<f32>,
@ -131,8 +185,9 @@ pub struct MixEngine {
sample_rate: f32,
max_block: usize,
strips: HashMap<TrackId, Strip>,
busses: HashMap<BusId, Vec<Vec<f32>>>,
busses: HashMap<BusId, BusBuf>,
main: Vec<Vec<f32>>,
main_fmt: ChannelFormat,
pub meters: HashMap<TrackId, StripMeter>,
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<BusId, Vec<Vec<f32>>>,
busses: &HashMap<BusId, BusBuf>,
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<T: Send>(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<usize> {
let mut order: Vec<usize> =
@ -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<f32>], frames: usize, dst: &mut [Vec<f32>], 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<f32>], frames: usize, dst: &mut [Vec<f32>], 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<f32>],
frames: usize,
busses: &mut HashMap<BusId, Vec<Vec<f32>>>,
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<Vec<f32>> {
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<Vec<f32>> {
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<f32>]) -> Vec<Vec<f32>> {
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<Vec<f32>> {
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<Vec<f32>>
/// Render one track through its inserts (pre-fader) — Commit / Freeze.
pub fn render_track_pre_fader(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
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:<id>`).
fn create_plugin(id: &str) -> Option<Box<dyn Plugin>> {
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<Vec<f32>> = (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");
}
}

View File

@ -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};

View File

@ -143,6 +143,74 @@ impl ChannelFormat {
pub fn from_id(s: &str) -> Option<ChannelFormat> {
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<SurroundPan>,
}
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<u64>,
/// Surround panner, used when the output is multichannel (None = the stereo pan to L/R).
#[serde(default)]
pub surround: Option<SurroundPan>,
}
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<u32> = 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);

View File

@ -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

View File

@ -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<Samples>,
looped: Option<Range>,
block: Vec<Vec<f32>>,
/// 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<Vec<f32>>,
/// 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<Cmd>, shared: Arc<Shared>, session: Arc<Session>, 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<cpal::Stream>,
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<Shared>, tx: Sender<Cmd>, _rx: Receiver<Cmd>, session: Arc<Session>) -> 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<Session>) {
@ -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<cpal::StreamConfig> = 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<Vec<f32>> = (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());

View File

@ -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

View File

@ -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<TrackId> = 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<PannerSpeaker> {
let sp = fmt.speakers();
let layout: Vec<soundcraft_dsp::pan::SpeakerPos> =
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<f32> {
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<f32> = 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", &params, &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<String> = (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:<id>`).
/// A built-in plugin's description, else a hosted CLAP (`clap:<id>`) or VST3 (`vst3:<class id>`) 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 {

View File

@ -213,3 +213,134 @@ pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option<f32> {
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<f32>) -> Option<PannerEdit> {
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));
}
}
}

View File

@ -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));
}

View File

@ -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"

1251
crates/vst3-host/src/ffi.rs Normal file

File diff suppressed because it is too large Load Diff

286
crates/vst3-host/src/lib.rs Normal file
View File

@ -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:<class id hex>` (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:<class_id>`.
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<String>,
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<Vst3Descriptor> {
registry::scan()
}
pub fn rescan() -> Vec<Vst3Descriptor> {
registry::rescan()
}
pub fn add_search_dir(dir: &Path) -> usize {
registry::add_search_dir(dir)
}
pub fn scan_paths(dirs: &[PathBuf]) -> Vec<Vst3Descriptor> {
registry::scan_paths(dirs)
}
pub fn load_bundle(path: &Path) -> Result<Vec<Vst3Descriptor>, Vst3Error> {
registry::load_bundle(path)
}
pub fn instantiate(id: &str) -> Result<Box<dyn Plugin>, 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<Vst3Descriptor> {
Vec::new()
}
pub fn rescan() -> Vec<Vst3Descriptor> {
Vec::new()
}
pub fn add_search_dir(_dir: &Path) -> usize {
0
}
pub fn scan_paths(_dirs: &[PathBuf]) -> Vec<Vst3Descriptor> {
Vec::new()
}
pub fn load_bundle(_path: &Path) -> Result<Vec<Vst3Descriptor>, Vst3Error> {
Err(Vst3Error::Unsupported)
}
pub fn instantiate(_id: &str) -> Result<Box<dyn Plugin>, 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<Vst3Descriptor> {
api::scan()
}
/// Forgets the cached scan (and remembered failures) and scans again.
pub fn rescan() -> Vec<Vst3Descriptor> {
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<Vst3Descriptor> {
api::scan_paths(dirs)
}
/// The audio module classes in one `.vst3` bundle or file.
pub fn load_bundle(path: &Path) -> Result<Vec<Vst3Descriptor>, Vst3Error> {
api::load_bundle(path)
}
/// Creates a plugin by `vst3:<class id>`, 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<Box<dyn Plugin>, Vst3Error> {
api::instantiate(id)
}
/// Creates a plugin by `vst3:<class id>`; `None` if unknown or it fails to load.
pub fn create(id: &str) -> Option<Box<dyn Plugin>> {
parse_id(id)?;
match api::instantiate(id) {
Ok(p) => Some(p),
Err(e) => {
log::warn!("{e}");
None
}
}
}
/// The description of a `vst3:<class id>` 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);
}
}

View File

@ -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<u32>,
maps: Vec<Mapping>,
values: Vec<f32>,
/// 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<NoteEvent>,
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<u32> = info.params.iter().map(|p| p.id.parse::<u32>().unwrap_or(u32::MAX)).collect();
let maps: Vec<Mapping> = 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<f32> = (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<f32> {
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<f64> {
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<f32> {
let i = self.info.params.iter().position(|p| p.id == id)?;
self.values.get(i).copied()
}
fn process(&mut self, io: &mut [Vec<f32>], 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);
}
}
}
}

View File

@ -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<Vec<Vst3Descriptor>>,
extra_dirs: Vec<PathBuf>,
/// One leaked `PluginInfo` per plugin type that was actually instantiated (bounded by the
/// number of distinct installed plugins).
infos: HashMap<String, &'static PluginInfo>,
/// Ids that failed to instantiate (not retried every UI frame).
failed: HashSet<String>,
}
fn registry() -> &'static Mutex<Registry> {
static R: OnceLock<Mutex<Registry>> = 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<Vec<Vst3Descriptor>, 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<Vst3Descriptor> {
let mut out: Vec<Vst3Descriptor> = 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<Vst3Descriptor> {
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<Vst3Descriptor> {
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
ensure_scanned(&mut r).clone()
}
pub fn rescan() -> Vec<Vst3Descriptor> {
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<ParamInfo> =
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<Box<dyn Plugin>, 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);
}
}

View File

@ -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<PathBuf> {
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<PathBuf>, common_program_files: Option<PathBuf>, local_app_data: Option<PathBuf>) -> Vec<PathBuf> {
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<PathBuf> {
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<PathBuf>, 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/<dir>` 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/<name>` (macOS), `Contents/x86_64-win/<name>.vst3` (Windows) or
/// `Contents/x86_64-linux/<name>.so` (Linux/BSD), falling back to the only/first fitting file there.
pub fn bundle_binary(path: &Path) -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> = 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<String> {
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<S: AsRef<str>>(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<S: AsRef<str>>(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<String> = 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")));
}
}

View File

@ -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",
]

View File

@ -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]

View File

@ -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::<FUnknown>()
} else if cid == GAIN_CTRL_CID {
ComWrapper::new(GainController { gain: AtomicU64::new(0.5f64.to_bits()) }).to_com_ptr::<FUnknown>()
} else if cid == SYNTH_CID {
ComWrapper::new(Synth { level: AtomicU32::new(0) }).to_com_ptr::<FUnknown>()
} 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::<IPluginFactory>() {
Some(p) => p.into_raw(),
None => std::ptr::null_mut(),
}
}

View File

@ -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<PathBuf> = 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::<Vec<_>>()))
);
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}"),
}
}
}

View File

@ -23,6 +23,7 @@ const LAYERS: &[(&str, u8)] = &[
("midi", 0),
("dsp", 1),
("clap-host", 1),
("vst3-host", 1),
("model", 2),
("mix", 3),
("engine", 4),