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:
parent
8269ca77f6
commit
cc86842abe
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
15
ROADMAP.md
15
ROADMAP.md
|
|
@ -9,8 +9,8 @@ the honest status: what works today, what is missing, and how far we are.
|
|||
| Measure | Value | How |
|
||||
|---|---|---|
|
||||
| Menu-catalog parity (engine + UI) | **462 / 512 menu items (90 %)**; engine alone 388 / 512 (76 %), see [`docs/parity.md`](docs/parity.md) | `cargo xtask parity` compares the incumbent's 512 menu leaves (names observed black-box) with our command registry; the remaining items are mostly video, Atmos, collaboration, notation and third-party services |
|
||||
| Estimated overall feature parity | **~50 %** | judgement across the areas below, weighted by how much professional users rely on them |
|
||||
| Estimated remaining effort | **~170–230 wall-clock hours** of Claude Opus 5.5 work (single agent; roughly 55–80 hours with 3–4 agents in parallel) | sum of the per-area estimates below |
|
||||
| Estimated overall feature parity | **~55 %** | judgement across the areas below, weighted by how much professional users rely on them |
|
||||
| Estimated remaining effort | **~150–200 wall-clock hours** of Claude Opus 5.5 work (single agent; roughly 50–70 hours with 3–4 agents in parallel) | sum of the per-area estimates below |
|
||||
|
||||
### Area by area
|
||||
|
||||
|
|
@ -22,15 +22,22 @@ the honest status: what works today, what is missing, and how far we are.
|
|||
| Mixer & routing | Faders, pan, mute/solo (SIP, implicit solo), sends pre/post, busses, aux inputs, master faders, VCAs, routing folders, 10 inserts, plugin delay compensation, clip effects | 70 % | 20 |
|
||||
| Automation | Breakpoint lanes for volume/pan/mute/sends/plugin params, trim automation; live Write/Touch/Latch passes; write/thin/glide/convert/coalesce commands | 60 % | 18 |
|
||||
| Plugins (built-in) | 26 original processors + 2 instruments, AudioSuite offline processing | 45 % | 30 |
|
||||
| Third-party plugins (CLAP/VST3/AU) | CLAP hosting (audio, params, latency, notes) in an isolated unsafe crate; no plugin GUIs, no state save, no VST3/AU | 30 % | 30 |
|
||||
| Third-party plugins (CLAP/VST3/AU) | CLAP hosting (audio, params, latency, notes); VST3 hosting in progress; no plugin GUIs, no state save, no AU | 35 % | 28 |
|
||||
| MIDI | MIDI/instrument tracks, SMF import/export, MIDI editor (piano roll + velocity), event list, step input, quantize/transpose/velocity/duration ops | 40 % | 30 |
|
||||
| Recording | Input capture, punch in/out (selection, on the fly, pre/post-roll), loop record into playlists, input monitoring through the channel strip, autosave and recovery | 60 % | 12 |
|
||||
| Elastic Audio / TCE / Beat Detective | Pitch-preserving warp on Elastic tracks, TCE to timeline, conform to tempo, Beat Detective and Identify Beat windows | 40 % | 18 |
|
||||
| Video, surround panning, Atmos | Formats modelled; no video track, no surround panner | 5 % | 40 |
|
||||
| Surround | Main and bus formats from stereo to 9.1.6 and Ambisonics, surround panner with divergence/centre/LFE/height, ITU fold-down, multichannel bounce, meters and device output | 60 % | 15 |
|
||||
| Video | Movie audio import; no picture track yet | 10 % | 35 |
|
||||
| UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog, floating windows, dialogs | 65 % | 25 |
|
||||
| Agent control | CLI, JSON control channel, MCP server (headless + bridged), offscreen UI renders | 90 % (ahead of the incumbent) | 5 |
|
||||
| Release engineering | Signed macOS universal, Windows x64/x86, Linux AppImage/deb/rpm/tar/Flatpak, FreeBSD, Web/WASM on every push to `release` | 80 % | 5 |
|
||||
|
||||
## Robustness
|
||||
|
||||
Property tests drive random edit sequences (with full undo), mutated session files and hostile
|
||||
command parameters through the engine and mixer; they have already caught fade, overflow and
|
||||
unbounded-allocation bugs. Every command also runs with empty parameters on empty and demo sessions.
|
||||
|
||||
## Current focus
|
||||
|
||||
1. Play back the model-only state the engine already stores: clip effects, trim automation, MIDI real-time properties.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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, ¶ms(0.0, 1.0), &mut g);
|
||||
assert!((g[2] - 1.0).abs() < 1e-5 && g.iter().enumerate().all(|(i, x)| i == 2 || x.abs() < 1e-3), "{g:?}");
|
||||
// Divergence spreads the centre image.
|
||||
surround_gains(&l, &SurroundParams { divergence: 0.5, ..params(0.0, 1.0) }, &mut g);
|
||||
assert!(g[2] < 0.99 && g[0] > 0.05 && g[1] > 0.05);
|
||||
// Full left (front-left corner): L only.
|
||||
surround_gains(&l, ¶ms(-1.0, 1.0), &mut g);
|
||||
assert!((g[0] - 1.0).abs() < 1e-5 && g.iter().enumerate().all(|(i, x)| i == 0 || x.abs() < 1e-3), "{g:?}");
|
||||
// Rear-right corner: Rs only.
|
||||
surround_gains(&l, ¶ms(1.0, -1.0), &mut g);
|
||||
assert!((g[5] - 1.0).abs() < 1e-5, "{g:?}");
|
||||
// Phantom centre (centre 0 %): L and R at -3 dB, no C.
|
||||
surround_gains(&l, &SurroundParams { center: 0.0, ..params(0.0, 1.0) }, &mut g);
|
||||
assert!(g[2].abs() < 1e-6 && (g[0] - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-4);
|
||||
// Overhead on 7.1.4: heights only.
|
||||
let l = layout_714();
|
||||
surround_gains(&l, &SurroundParams { z: 1.0, ..params(-1.0, 1.0) }, &mut g);
|
||||
let top: f32 = g[8..12].iter().map(|x| x * x).sum();
|
||||
assert!((top - 1.0).abs() < 1e-4 && g[8] > 0.9, "{g:?}");
|
||||
// Corner positions map onto speaker angles and back.
|
||||
let (x, y) = azimuth_to_puck(&l, -150.0);
|
||||
assert!((x + 1.0).abs() < 1e-3 && (y + 1.0).abs() < 1e-3, "{x} {y}");
|
||||
assert!((puck_azimuth(&l, 0.0, -1.0).abs() - 180.0).abs() < 1e-3);
|
||||
// Hostile input never produces NaN.
|
||||
surround_gains(&l, &SurroundParams { x: f32::NAN, y: f32::INFINITY, z: f32::NAN, divergence: f32::NAN, center: f32::NAN }, &mut g);
|
||||
assert!(g.iter().all(|x| x.is_finite()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<_>>(),
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", ¶ms, &format!("span:{}", id.0));
|
||||
}
|
||||
y += size + rows * 11.0 + 6.0;
|
||||
} else if !track.mixer.pan.is_empty() && track.kind != TrackKind::Vca {
|
||||
let n = track.mixer.pan.len().min(2);
|
||||
let ks = if n == 2 { (inner_w / 2.0 - 6.0).min(32.0) } else { 34.0 };
|
||||
for i in 0..n {
|
||||
|
|
@ -214,15 +288,24 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
|
|||
// Fader + meter.
|
||||
let bottom_h = 64.0;
|
||||
let fh = (r.max.y - bottom_h - y).max(120.0);
|
||||
let fr = Rect::from_min_size(pos2(x0, y), vec2(inner_w * 0.6, fh));
|
||||
let wide_meter = (if track.kind == TrackKind::Master { app.engine.session().main_format() } else { track.format }).channels() > 2;
|
||||
let fr = Rect::from_min_size(pos2(x0, y), vec2(inner_w * if wide_meter { 0.45 } else { 0.6 }, fh));
|
||||
if let Some(db) = fader(ui, fr, track.mixer.volume_db, ui.id().with(("fader", id.0))) {
|
||||
app.gesture = Some(crate::Gesture::Fader { track: id, start_db: track.mixer.volume_db });
|
||||
let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0));
|
||||
}
|
||||
let md = app.meters.get(&id).copied().unwrap_or_default();
|
||||
let mr = Rect::from_min_max(pos2(fr.max.x + 4.0, fr.min.y + 6.0), pos2(x0 + inner_w - 2.0, fr.max.y - 6.0));
|
||||
let strip_fmt = if track.kind == TrackKind::Master { app.engine.session().main_format() } else { track.format };
|
||||
let all_chans = strip_fmt.channels().min(soundcraft_mix::MAX_CHANNELS);
|
||||
let chans = if track.kind == TrackKind::Master { 2 } else { track.channels().min(2) };
|
||||
if chans >= 2 {
|
||||
if all_chans > 2 {
|
||||
// One bar per channel, labelled (L R C LFE …).
|
||||
let peaks = snap.and_then(|s| s.tracks.get(&id)).map(|m| m.peaks.as_slice()).unwrap_or(&[]);
|
||||
let lv = channel_levels(ui, ("mc_meter", id.0), peaks, all_chans);
|
||||
let labels: Vec<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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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]
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ const LAYERS: &[(&str, u8)] = &[
|
|||
("midi", 0),
|
||||
("dsp", 1),
|
||||
("clap-host", 1),
|
||||
("vst3-host", 1),
|
||||
("model", 2),
|
||||
("mix", 3),
|
||||
("engine", 4),
|
||||
|
|
|
|||
Loading…
Reference in New Issue