mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-11 04:40:20 -06:00
2504 lines
103 KiB
Rust
2504 lines
103 KiB
Rust
//! The SoundCraft mix engine.
|
||
//!
|
||
//! [`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.
|
||
//!
|
||
//! ## Plugin instances and threads
|
||
//! [`MixEngine::sync`] brings plugin instances in line with the session. Built-in plugins are
|
||
//! cheap (allocation only) and are always created there, on whichever thread renders. Hosted
|
||
//! third-party plugins (`clap:` / `vst3:` ids, see [`is_third_party`]) are expensive and have
|
||
//! main-thread rules, so a realtime host keeps them off the audio thread:
|
||
//! - with [`MixEngine::set_external_instances`]`(true)` (realtime playback), `sync` never creates a
|
||
//! third-party plugin. The host's control (UI) thread creates them with [`create_instance`] from
|
||
//! [`instance_specs`] (restoring each insert's stored [`Insert::state`] and calling `prepare`
|
||
//! there), and hands them over with [`MixEngine::adopt`]; `sync` then moves the matching one
|
||
//! into its slot. A slot whose instance has not arrived yet passes audio through.
|
||
//! - every instance `sync` replaces or removes (built-in or not) goes to a retired list instead of
|
||
//! being dropped; the host takes it with [`MixEngine::take_retired`] and drops it on the control
|
||
//! thread, so plugin destruction never runs on the audio thread either.
|
||
//! - without external instances (the default: offline [`render_range`], bounces, tests) `sync`
|
||
//! creates third-party plugins itself, synchronously, restores their stored state before the
|
||
//! first `process`, and drops retired instances at the end of `sync`.
|
||
//!
|
||
//! An instance starts from its stored state; the insert's parameter values are then applied on
|
||
//! top before the first block (the session's values are kept in step with the plugin, including
|
||
//! edits made in its own editor, so both agree; the state carries everything else). A host reads
|
||
//! states back for saving through each instance's [`Plugin::state_reader`], on its UI thread.
|
||
//!
|
||
//! A built-in instrument's parameter values are applied the same way. A sample-playing
|
||
//! instrument (the Sampler) is handed the decoded audio of its insert's
|
||
//! [`Insert::sample`] source straight from the session's pool, shared and never copied.
|
||
//!
|
||
//! [`Insert::state`]: soundcraft_model::Insert::state
|
||
//! [`Insert::sample`]: soundcraft_model::Insert::sample
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
|
||
use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains};
|
||
use soundcraft_dsp::{Plugin, SampleSource, Transport, db_to_gain};
|
||
use soundcraft_midi::CtrlKind;
|
||
use soundcraft_model::{
|
||
AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind,
|
||
};
|
||
use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER};
|
||
use std::collections::HashMap;
|
||
use std::ops::Bound;
|
||
use std::sync::Arc;
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Slot index used for a track's instrument plugin in [`InstanceSpec`] and [`PreparedInstance`]
|
||
/// (insert slots use their index).
|
||
pub const INSTRUMENT_SLOT: usize = usize::MAX;
|
||
|
||
/// Hosted third-party plugin ids (CLAP or VST3): created off the audio thread in realtime use.
|
||
pub fn is_third_party(id: &str) -> bool {
|
||
id.starts_with(soundcraft_clap_host::ID_PREFIX)
|
||
|| id.starts_with(soundcraft_vst3_host::ID_PREFIX)
|
||
|| id.starts_with(soundcraft_au_host::ID_PREFIX)
|
||
}
|
||
|
||
/// A third-party plugin instance a session needs: which slot, which plugin, how many channels,
|
||
/// and the state to restore.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct InstanceSpec {
|
||
pub track: TrackId,
|
||
/// Insert slot index, or [`INSTRUMENT_SLOT`].
|
||
pub slot: usize,
|
||
pub id: String,
|
||
/// Channel count the instance is prepared for.
|
||
pub ch: usize,
|
||
/// Decoded [`Insert::state`](soundcraft_model::Insert::state).
|
||
pub state: Option<Vec<u8>>,
|
||
}
|
||
|
||
/// A plugin instance created and prepared off the audio thread, handed to [`MixEngine::adopt`].
|
||
pub struct PreparedInstance {
|
||
pub track: TrackId,
|
||
pub slot: usize,
|
||
pub id: String,
|
||
pub ch: usize,
|
||
pub plugin: Box<dyn Plugin>,
|
||
}
|
||
|
||
/// Every third-party plugin slot (inserts and instruments) of a session, with the channel count
|
||
/// [`MixEngine::sync`] prepares it for. Inactive and master tracks are included (they have strips).
|
||
pub fn instance_specs(s: &Session) -> Vec<InstanceSpec> {
|
||
let main_ch = main_channels(s);
|
||
let mut out = Vec::new();
|
||
for t in &s.tracks {
|
||
let ch = strip_channels(t, main_ch);
|
||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||
if let Some(ins) = ins
|
||
&& is_third_party(&ins.plugin)
|
||
{
|
||
out.push(InstanceSpec { track: t.id, slot: i, id: ins.plugin.clone(), ch, state: ins.state_bytes() });
|
||
}
|
||
}
|
||
if let Some(ins) = &t.instrument
|
||
&& is_third_party(&ins.plugin)
|
||
{
|
||
out.push(InstanceSpec { track: t.id, slot: INSTRUMENT_SLOT, id: ins.plugin.clone(), ch: ch.max(2), state: ins.state_bytes() });
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// A plugin made by [`create_instance`].
|
||
pub struct NewInstance {
|
||
pub plugin: Box<dyn Plugin>,
|
||
/// The plugin refused the stored state and starts from its defaults.
|
||
pub refused_state: bool,
|
||
}
|
||
|
||
/// Creates a plugin (built-in or hosted), restores `state` (if any) and prepares it. Expensive
|
||
/// for third-party plugins: in realtime use, call it on the control thread.
|
||
pub fn create_instance(id: &str, state: Option<&[u8]>, sample_rate: f32, max_block: usize, ch: usize) -> Option<NewInstance> {
|
||
let mut plugin = create_plugin(id)?;
|
||
let refused_state = state.is_some_and(|st| !plugin.load_state(st));
|
||
if refused_state {
|
||
log::warn!("{id}: the plugin did not accept its stored state");
|
||
}
|
||
plugin.prepare(sample_rate, max_block, ch);
|
||
Some(NewInstance { plugin, refused_state })
|
||
}
|
||
|
||
/// Peak meter values for one strip (linear, per channel). [`MixEngine`] measures one block; a
|
||
/// realtime host folds blocks together with [`StripMeter::accumulate`] so a reader polling less
|
||
/// often than blocks arrive sees the highest peak since it last looked, not just the latest block.
|
||
#[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,
|
||
pub plugin_failures: PluginFailures,
|
||
}
|
||
|
||
/// How often a strip's plugins were taken out of the signal path for putting out NaN or infinity.
|
||
/// Each stays out until the engine is next reset (playback starts or stops).
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||
pub struct PluginFailures {
|
||
/// It only grows.
|
||
pub count: u32,
|
||
/// The slot of the latest: an insert index or [`INSTRUMENT_SLOT`].
|
||
pub last: usize,
|
||
}
|
||
|
||
impl PluginFailures {
|
||
fn record(&mut self, slot: usize) {
|
||
self.count = self.count.wrapping_add(1);
|
||
self.last = slot;
|
||
}
|
||
}
|
||
|
||
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;
|
||
self.plugin_failures = o.plugin_failures;
|
||
}
|
||
|
||
/// Fold another (later) measurement in: per-channel peaks and the gain reduction keep their
|
||
/// maximum, plugin failures take the later value (the count only grows). Allocation-free once
|
||
/// the channel count is stable, so it can run on the audio thread every block.
|
||
pub fn accumulate(&mut self, o: &StripMeter) {
|
||
if self.peaks.len() != o.peaks.len() {
|
||
// The strip's format changed (a document edit): earlier channels no longer line up.
|
||
self.peaks.clone_from(&o.peaks);
|
||
self.peak = o.peak;
|
||
} else {
|
||
for (p, q) in self.peaks.iter_mut().zip(&o.peaks) {
|
||
*p = max_level(*p, *q);
|
||
}
|
||
for (p, q) in self.peak.iter_mut().zip(o.peak) {
|
||
*p = max_level(*p, q);
|
||
}
|
||
}
|
||
self.gain_reduction_db = max_level(self.gain_reduction_db, o.gain_reduction_db);
|
||
self.plugin_failures = o.plugin_failures;
|
||
}
|
||
|
||
/// Zero the levels (peaks and gain reduction) after a reader took them, keeping the channel
|
||
/// count and the plugin failure record. Never allocates.
|
||
pub fn reset_levels(&mut self) {
|
||
self.peak = [0.0; 2];
|
||
self.peaks.iter_mut().for_each(|p| *p = 0.0);
|
||
self.gain_reduction_db = 0.0;
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// The larger of two meter levels; a NaN (from a misbehaving plugin) never wins or sticks.
|
||
fn max_level(a: f32, b: f32) -> f32 {
|
||
match (a.is_finite(), b.is_finite()) {
|
||
(true, true) => a.max(b),
|
||
(true, false) => a,
|
||
(false, true) => b,
|
||
(false, false) => 0.0,
|
||
}
|
||
}
|
||
|
||
/// 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>,
|
||
failed: bool,
|
||
}
|
||
|
||
struct Strip {
|
||
buf: Vec<Vec<f32>>,
|
||
/// Pre-fader copy for pre-fader sends.
|
||
pre: Vec<Vec<f32>>,
|
||
scratch: Vec<f32>,
|
||
plugins: Vec<Option<PluginSlot>>,
|
||
instrument: Option<PluginSlot>,
|
||
/// The pool audio handed to the instrument with `set_sample` (sample-playing instruments).
|
||
sample: Option<Arc<SourceAudio>>,
|
||
/// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek.
|
||
held: Vec<u8>,
|
||
/// The instrument's next block starts somewhere new: send it the controllers in effect there.
|
||
chase: bool,
|
||
muted: bool,
|
||
gr: f32,
|
||
/// Per-clip effect chains (Clip Effects) and a scratch buffer to render clips into.
|
||
clip_fx: HashMap<u64, ClipFx>,
|
||
clipbuf: Vec<Vec<f32>>,
|
||
/// Total latency of the active inserts (samples).
|
||
latency: usize,
|
||
/// Delay compensation: align this strip with the slowest one (post- and pre-fader paths),
|
||
/// plus extra delay for direct-to-main outputs so they line up with aux returns.
|
||
align: DelayLine,
|
||
align_pre: DelayLine,
|
||
out_extra: DelayLine,
|
||
outbuf: Vec<Vec<f32>>,
|
||
failures: PluginFailures,
|
||
}
|
||
|
||
/// Most latency delay compensation handles: 2^20 samples, about 22 s at 48 kHz. A strip reports
|
||
/// at most this, whatever its plugins claim, so a bogus value can't stall offline renders.
|
||
const MAX_LATENCY: usize = 1 << 20;
|
||
|
||
/// A multichannel delay line (samples). The rings only grow (keeping their contents), so a delay
|
||
/// change in the callback moves the read offset instead of reallocating and zeroing.
|
||
#[derive(Default)]
|
||
struct DelayLine {
|
||
bufs: Vec<Vec<f32>>,
|
||
/// Write position in the rings.
|
||
idx: usize,
|
||
/// Current delay (samples).
|
||
len: usize,
|
||
}
|
||
|
||
impl DelayLine {
|
||
fn set(&mut self, len: usize, ch: usize) {
|
||
let len = len.min(MAX_LATENCY);
|
||
self.len = len;
|
||
let cap = self.bufs.first().map_or(0, Vec::len);
|
||
if self.bufs.len() != ch {
|
||
self.bufs.resize_with(ch, || vec![0.0; cap]);
|
||
}
|
||
let need = if len == 0 { 0 } else { len + 1 };
|
||
if need > cap {
|
||
let new_cap = need.next_power_of_two().min(MAX_LATENCY + 1);
|
||
let start = self.idx.min(cap);
|
||
for ring in &mut self.bufs {
|
||
let mut grown = Vec::with_capacity(new_cap);
|
||
grown.extend_from_slice(ring.get(start..).unwrap_or(&[]));
|
||
grown.extend_from_slice(ring.get(..start).unwrap_or(&[]));
|
||
grown.resize(new_cap, 0.0);
|
||
*ring = grown;
|
||
}
|
||
self.idx = cap;
|
||
}
|
||
}
|
||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||
if self.len == 0 {
|
||
return;
|
||
}
|
||
let start = self.idx;
|
||
let mut end = start;
|
||
for (c, ch) in io.iter_mut().enumerate() {
|
||
let Some(ring) = self.bufs.get_mut(c) else { continue };
|
||
let cap = ring.len();
|
||
if cap <= self.len {
|
||
continue;
|
||
}
|
||
let mut i = start % cap;
|
||
for x in ch.iter_mut().take(frames) {
|
||
if let Some(w) = ring.get_mut(i) {
|
||
*w = *x;
|
||
}
|
||
if let Some(r) = ring.get((i + cap - self.len) % cap) {
|
||
*x = *r;
|
||
}
|
||
i += 1;
|
||
if i >= cap {
|
||
i = 0;
|
||
}
|
||
}
|
||
end = i;
|
||
}
|
||
self.idx = end;
|
||
}
|
||
fn clear(&mut self) {
|
||
self.bufs.iter_mut().for_each(|b| b.fill(0.0));
|
||
}
|
||
}
|
||
|
||
/// Clip Effects: an EQ and a compressor per clip, configured from `edit.values`
|
||
/// (`clip_fx.<clip>.eq.<param>`, `clip_fx.<clip>.comp.<param>`, `clip_fx.<clip>.gain`).
|
||
struct ClipFx {
|
||
eq: Option<Box<dyn Plugin>>,
|
||
comp: Option<Box<dyn Plugin>>,
|
||
gain_db: f32,
|
||
sr: f32,
|
||
ch: usize,
|
||
}
|
||
|
||
impl Strip {
|
||
fn new() -> Self {
|
||
Strip {
|
||
buf: Vec::new(),
|
||
pre: Vec::new(),
|
||
scratch: Vec::new(),
|
||
plugins: Vec::new(),
|
||
instrument: None,
|
||
sample: None,
|
||
held: Vec::new(),
|
||
chase: true,
|
||
muted: false,
|
||
gr: 0.0,
|
||
clip_fx: HashMap::new(),
|
||
clipbuf: Vec::new(),
|
||
latency: 0,
|
||
align: DelayLine::default(),
|
||
align_pre: DelayLine::default(),
|
||
out_extra: DelayLine::default(),
|
||
outbuf: Vec::new(),
|
||
failures: PluginFailures::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Renders sessions. Keep one per playback stream; plugin state persists across blocks.
|
||
pub struct MixEngine {
|
||
sample_rate: f32,
|
||
max_block: usize,
|
||
strips: HashMap<TrackId, Strip>,
|
||
busses: HashMap<BusId, BusBuf>,
|
||
main: Vec<Vec<f32>>,
|
||
main_fmt: ChannelFormat,
|
||
pub meters: HashMap<TrackId, StripMeter>,
|
||
pub main_meter: StripMeter,
|
||
pub pan_law: PanLaw,
|
||
/// Last rendered position end (to detect seeks).
|
||
last_end: Samples,
|
||
/// Snapshot key of the session the strips/order were synced to.
|
||
synced: (usize, usize, usize),
|
||
order: Vec<usize>,
|
||
/// Current total compensation delay (samples) of the main mix: the slowest track and aux
|
||
/// returns, and the master faders' inserts.
|
||
pub latency: usize,
|
||
/// Delay already applied to audio written onto busses: the slowest track insert.
|
||
/// A bus render drops this so the bus lines up with the timeline. The extra delay
|
||
/// that lines direct outputs up with aux returns stays in `latency` and is removed
|
||
/// only from the main mix.
|
||
track_latency: usize,
|
||
/// Busses an aux returns onto, and the latency of the slowest aux return: audio written onto
|
||
/// such a bus waits for it, so a bounce of the bus drops it as well.
|
||
aux_fed: Vec<BusId>,
|
||
aux_latency: usize,
|
||
/// Live input for the next block (planar), set by the audio host for input monitoring.
|
||
pub input: Vec<Vec<f32>>,
|
||
/// Transport stopped: only monitored inputs sound (no clips).
|
||
pub monitor_only: bool,
|
||
/// Recording: record-armed tracks hear their input (auto input).
|
||
pub recording: bool,
|
||
/// Mix the metronome (Options > Click) into the output. Playback turns this on; offline renders
|
||
/// (bounces, stems, exports) leave it off so the click is never printed into a file.
|
||
pub metronome: bool,
|
||
/// Third-party instances come from [`MixEngine::adopt`] instead of being created in `sync`.
|
||
external: bool,
|
||
/// Instances handed over by the host, waiting for `sync` to place them.
|
||
adopt: HashMap<(TrackId, usize), PreparedInstance>,
|
||
/// Instances `sync` took out of service, for the host to drop (see [`MixEngine::take_retired`]).
|
||
retired: Vec<Box<dyn Plugin>>,
|
||
/// Ownership workspace reused for parallel strip processing.
|
||
work: Vec<(usize, Strip)>,
|
||
}
|
||
|
||
/// What `sync` needs to fill a slot.
|
||
struct SlotCtx<'a> {
|
||
sr: f32,
|
||
mb: usize,
|
||
external: bool,
|
||
adopt: &'a mut HashMap<(TrackId, usize), PreparedInstance>,
|
||
retired: &'a mut Vec<Box<dyn Plugin>>,
|
||
}
|
||
|
||
impl SlotCtx<'_> {
|
||
fn retire(&mut self, slot: Option<PluginSlot>) {
|
||
if let Some(s) = slot {
|
||
self.retired.push(s.plugin);
|
||
}
|
||
}
|
||
|
||
/// True when the host handed over an instance for this slot. It replaces whatever the slot
|
||
/// holds, even an instance of the same plugin: the host made it because it no longer tracks
|
||
/// the old one (it was removed and re-added between two renders), and drives its editor.
|
||
fn waiting(&self, t: TrackId, idx: usize) -> bool {
|
||
self.external && self.adopt.contains_key(&(t, idx))
|
||
}
|
||
|
||
/// A new instance for `ins` on track `t`, slot `idx`, prepared for `ch` channels.
|
||
fn make(&mut self, t: TrackId, idx: usize, ins: &soundcraft_model::Insert, ch: usize) -> Option<PluginSlot> {
|
||
let third = is_third_party(&ins.plugin);
|
||
let plugin = if third && self.external {
|
||
match self.adopt.remove(&(t, idx)) {
|
||
Some(p) if p.id == ins.plugin && p.ch == ch => Some(p.plugin),
|
||
Some(p) => {
|
||
self.retired.push(p.plugin);
|
||
None
|
||
}
|
||
None => None,
|
||
}
|
||
} else {
|
||
let state = if third { ins.state_bytes() } else { None };
|
||
create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch).map(|n| n.plugin)
|
||
}?;
|
||
Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), failed: false })
|
||
}
|
||
}
|
||
|
||
impl MixEngine {
|
||
pub fn new(sample_rate: f32, max_block: usize) -> Self {
|
||
let max_block = max_block.clamp(16, 16_384);
|
||
MixEngine {
|
||
sample_rate: if sample_rate.is_finite() && sample_rate > 0.0 { sample_rate } else { 48_000.0 },
|
||
max_block,
|
||
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,
|
||
last_end: 0,
|
||
synced: (0, 0, 0),
|
||
order: Vec::new(),
|
||
latency: 0,
|
||
track_latency: 0,
|
||
aux_fed: Vec::new(),
|
||
aux_latency: 0,
|
||
input: Vec::new(),
|
||
monitor_only: false,
|
||
recording: false,
|
||
metronome: false,
|
||
external: false,
|
||
adopt: HashMap::new(),
|
||
retired: Vec::new(),
|
||
work: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Realtime mode: third-party plugins are created by the host and handed over with
|
||
/// [`MixEngine::adopt`] instead of being created in `sync` (see the crate docs).
|
||
pub fn set_external_instances(&mut self, on: bool) {
|
||
self.external = on;
|
||
}
|
||
|
||
/// Takes instances created off the audio thread; the next render's `sync` places them.
|
||
/// An earlier instance waiting for the same slot is retired.
|
||
pub fn adopt(&mut self, instances: Vec<PreparedInstance>) {
|
||
for p in instances {
|
||
if let Some(old) = self.adopt.insert((p.track, p.slot), p) {
|
||
self.retired.push(old.plugin);
|
||
}
|
||
}
|
||
// Force a re-sync so waiting slots pick their instance up.
|
||
self.synced = (usize::MAX, usize::MAX, usize::MAX);
|
||
}
|
||
|
||
/// Instances taken out of service since the last call (drop them off the audio thread).
|
||
/// Does not allocate.
|
||
pub fn take_retired(&mut self) -> Vec<Box<dyn Plugin>> {
|
||
std::mem::take(&mut self.retired)
|
||
}
|
||
|
||
/// Every instance this engine holds (strips, instruments, clip effects excluded) plus the
|
||
/// retired and waiting ones, leaving the engine empty: hand them to another thread to drop
|
||
/// before replacing the engine.
|
||
pub fn drain_plugins(&mut self) -> Vec<Box<dyn Plugin>> {
|
||
let mut out = std::mem::take(&mut self.retired);
|
||
for st in self.strips.values_mut() {
|
||
out.extend(st.plugins.iter_mut().filter_map(Option::take).map(|s| s.plugin));
|
||
out.extend(st.instrument.take().map(|s| s.plugin));
|
||
}
|
||
out.extend(self.adopt.drain().map(|(_, p)| p.plugin));
|
||
self.synced = (usize::MAX, usize::MAX, usize::MAX);
|
||
out
|
||
}
|
||
|
||
pub fn max_block(&self) -> usize {
|
||
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() {
|
||
for p in s.plugins.iter_mut().flatten() {
|
||
p.plugin.reset();
|
||
p.failed = false;
|
||
}
|
||
if let Some(i) = &mut s.instrument {
|
||
i.plugin.all_notes_off();
|
||
i.plugin.reset();
|
||
i.failed = false;
|
||
}
|
||
s.held.clear();
|
||
s.chase = true;
|
||
s.align.clear();
|
||
s.align_pre.clear();
|
||
s.out_extra.clear();
|
||
for fx in s.clip_fx.values_mut() {
|
||
fx.reset();
|
||
}
|
||
}
|
||
// Zeroed in place: rebuilding the entries would allocate on the audio thread at every
|
||
// play, stop and locate.
|
||
self.meters.values_mut().for_each(StripMeter::reset_levels);
|
||
self.main_meter.reset_levels();
|
||
}
|
||
|
||
/// Bring plugin instances in line with the session (creates/destroys as needed). Allocates;
|
||
/// call outside the realtime thread when the document changes, or let `render` do it.
|
||
pub fn sync(&mut self, s: &Session) {
|
||
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];
|
||
}
|
||
let mut cx = SlotCtx { sr: self.sample_rate, mb, external: self.external, adopt: &mut self.adopt, retired: &mut self.retired };
|
||
let gone: Vec<TrackId> = self.strips.keys().filter(|id| s.track(**id).is_none()).copied().collect();
|
||
for id in gone {
|
||
self.meters.remove(&id);
|
||
if let Some(mut st) = self.strips.remove(&id) {
|
||
for p in st.plugins.drain(..) {
|
||
cx.retire(p);
|
||
}
|
||
cx.retire(st.instrument.take());
|
||
}
|
||
}
|
||
for t in &s.tracks {
|
||
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];
|
||
}
|
||
while strip.plugins.len() > t.mixer.inserts.len() {
|
||
let p = strip.plugins.pop().flatten();
|
||
cx.retire(p);
|
||
}
|
||
strip.plugins.resize_with(t.mixer.inserts.len(), || None);
|
||
for (idx, (slot, ins)) in strip.plugins.iter_mut().zip(t.mixer.inserts.iter()).enumerate() {
|
||
match ins {
|
||
Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) || cx.waiting(t.id, idx) => {
|
||
let old = slot.take();
|
||
cx.retire(old);
|
||
*slot = cx.make(t.id, idx, i, ch);
|
||
}
|
||
None => {
|
||
let old = slot.take();
|
||
cx.retire(old);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
let want = t.instrument.as_ref();
|
||
let ich = ch.max(2);
|
||
if strip.instrument.as_ref().map(|p| (p.id.as_str(), p.ch)) != want.map(|i| (i.plugin.as_str(), ich))
|
||
|| (want.is_some() && cx.waiting(t.id, INSTRUMENT_SLOT))
|
||
{
|
||
let old = strip.instrument.take();
|
||
cx.retire(old);
|
||
strip.instrument = want.and_then(|i| cx.make(t.id, INSTRUMENT_SLOT, i, ich));
|
||
strip.sample = None;
|
||
}
|
||
sync_sample(strip, want, s);
|
||
}
|
||
// Instances nobody claimed (the session changed again before they arrived).
|
||
let leftovers: Vec<(TrackId, usize)> = cx.adopt.keys().copied().collect();
|
||
for k in leftovers {
|
||
if let Some(p) = cx.adopt.remove(&k) {
|
||
cx.retired.push(p.plugin);
|
||
}
|
||
}
|
||
if !self.external {
|
||
self.retired.clear();
|
||
}
|
||
for b in &s.busses {
|
||
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());
|
||
}
|
||
|
||
/// Re-syncs plugin instances and the routing order when the session's structure changed
|
||
/// since the last sync (or instances were adopted). `render` calls it; a realtime host may
|
||
/// also call it right after swapping sessions so slots are filled before playback starts.
|
||
pub fn ensure_synced(&mut self, s: &Session) {
|
||
let key = (structure_fingerprint(s), s.tracks.len(), s.busses.len());
|
||
if key != self.synced {
|
||
self.sync(s);
|
||
self.order = processing_order(s);
|
||
self.synced = key;
|
||
}
|
||
}
|
||
|
||
/// Render `frames` (≤ max_block) samples starting at `pos` into `out` (≥ 2 channels).
|
||
pub fn render(&mut self, s: &Session, pos: Samples, frames: usize, out: &mut [Vec<f32>]) {
|
||
let frames = frames.min(self.max_block);
|
||
if pos != self.last_end {
|
||
// Seek: silence held notes and ringing plugins.
|
||
for st in self.strips.values_mut() {
|
||
if let Some(i) = &mut st.instrument {
|
||
i.plugin.all_notes_off();
|
||
}
|
||
st.held.clear();
|
||
st.chase = true;
|
||
}
|
||
}
|
||
self.last_end = pos.saturating_add(frames as i64);
|
||
self.ensure_synced(s);
|
||
for b in self.busses.values_mut() {
|
||
for c in b.buf.iter_mut() {
|
||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||
}
|
||
}
|
||
for c in &mut self.main {
|
||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||
}
|
||
let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive);
|
||
let order = std::mem::take(&mut self.order);
|
||
let live = |t: &Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca);
|
||
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
|
||
// Phase 1: independent strips (audio, instrument, MIDI) in parallel.
|
||
let mut work = std::mem::take(&mut self.work);
|
||
work.clear();
|
||
for &i in &order {
|
||
let Some(t) = s.tracks.get(i).filter(|t| live(t) && !is_aux(t)) else { continue };
|
||
if let Some(st) = self.strips.remove(&t.id) {
|
||
work.push((i, st));
|
||
}
|
||
}
|
||
for (_, st) in work.iter_mut() {
|
||
if st.scratch.len() < self.max_block {
|
||
st.scratch.resize(self.max_block, 0.0);
|
||
}
|
||
}
|
||
let busses = std::mem::take(&mut self.busses);
|
||
let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording };
|
||
run_strips(&mut work, |(i, st)| {
|
||
if let Some(t) = s.tracks.get(*i) {
|
||
process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo);
|
||
}
|
||
});
|
||
self.busses = busses;
|
||
// Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait
|
||
// for the slowest aux return. Aux latency has to be read before those outputs are routed,
|
||
// or the first block (and a one-block bounce probe) still sees zero.
|
||
let pdc = s.edit.delay_compensation;
|
||
if pdc {
|
||
for t in s.tracks.iter().filter(|t| is_aux(t) && !t.inactive) {
|
||
if let Some(st) = self.strips.get_mut(&t.id) {
|
||
note_insert_latency(t, st, pos);
|
||
}
|
||
}
|
||
}
|
||
// The master faders' inserts delay the whole main mix, so their latency is part of the
|
||
// mix's too (the faders run one after the other: it adds up).
|
||
let mut lmax_m = 0usize;
|
||
if pdc {
|
||
for t in s.tracks.iter().filter(|t| t.kind == TrackKind::Master && !t.inactive) {
|
||
if let Some(st) = self.strips.get_mut(&t.id) {
|
||
note_insert_latency(t, st, pos);
|
||
lmax_m = lmax_m.saturating_add(st.latency);
|
||
}
|
||
}
|
||
}
|
||
let lmax_t = if pdc { work.iter().map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
|
||
let lmax_a =
|
||
if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
|
||
self.track_latency = lmax_t;
|
||
self.latency = lmax_t + lmax_a + lmax_m;
|
||
// An aux that returns onto a bus delays it by the slowest aux return, so the tracks routed
|
||
// to that bus wait as well (as the ones routed to the main mix do) and the bus lines up.
|
||
self.aux_latency = lmax_a;
|
||
self.aux_fed.clear();
|
||
if pdc {
|
||
for t in s.tracks.iter().filter(|t| is_aux(t) && live(t)) {
|
||
if let Route::Bus(b) = &t.mixer.output
|
||
&& !self.aux_fed.contains(b)
|
||
{
|
||
self.aux_fed.push(*b);
|
||
}
|
||
}
|
||
}
|
||
for (i, st) in work.iter_mut() {
|
||
let Some(t) = s.tracks.get(*i) else { continue };
|
||
let ch = st.buf.len();
|
||
st.align.set(lmax_t.saturating_sub(st.latency), ch);
|
||
st.align.process(&mut st.buf, frames);
|
||
if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) {
|
||
st.align_pre.set(lmax_t.saturating_sub(st.latency), ch);
|
||
st.align_pre.process(&mut st.pre, frames);
|
||
}
|
||
let waits = match &t.mixer.output {
|
||
Route::Main => true,
|
||
Route::Bus(b) => self.aux_fed.contains(b),
|
||
_ => false,
|
||
};
|
||
st.out_extra.set(if waits { lmax_a } else { 0 }, ch);
|
||
}
|
||
for (i, st) in work.iter_mut() {
|
||
if let Some(t) = s.tracks.get(*i) {
|
||
self.route_strip(t, st, pos, frames);
|
||
}
|
||
}
|
||
for (i, st) in work.drain(..) {
|
||
if let Some(t) = s.tracks.get(i) {
|
||
self.strips.insert(t.id, st);
|
||
}
|
||
}
|
||
self.work = work;
|
||
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
|
||
// Phase 2: auxes in dependency order (they read busses).
|
||
for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| live(t)).filter(|t| is_aux(t)) {
|
||
let Some(mut st) = self.strips.remove(&t.id) else { continue };
|
||
if st.scratch.len() < self.max_block {
|
||
st.scratch.resize(self.max_block, 0.0);
|
||
}
|
||
let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording };
|
||
process_strip(s, t, &mut st, &self.busses, &live_in, pos, frames, any_solo);
|
||
let ch = st.buf.len();
|
||
st.align.set(if pdc { lmax_a.saturating_sub(st.latency) } else { 0 }, ch);
|
||
st.align.process(&mut st.buf, frames);
|
||
st.out_extra.set(0, ch);
|
||
self.route_strip(t, &mut st, pos, frames);
|
||
self.strips.insert(t.id, st);
|
||
}
|
||
self.order = order;
|
||
// Master faders process the main mix.
|
||
let transport = transport_at(s, pos, !self.monitor_only, self.recording);
|
||
for t in s.tracks.iter().filter(|t| t.kind == TrackKind::Master && !t.inactive) {
|
||
if let Some(strip) = self.strips.get_mut(&t.id) {
|
||
for (dst, src) in strip.buf.iter_mut().zip(self.main.iter()) {
|
||
if let (Some(d), Some(sr)) = (dst.get_mut(..frames), src.get(..frames)) {
|
||
d.copy_from_slice(sr);
|
||
}
|
||
}
|
||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||
if let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i))
|
||
&& ins.active
|
||
&& !ins.bypass
|
||
&& !slot.failed
|
||
{
|
||
apply_params(slot, ins, t, i, pos);
|
||
slot.plugin.set_transport(&transport);
|
||
slot.plugin.process(&mut strip.buf, frames);
|
||
if contain_failure(slot, &mut strip.buf, frames) {
|
||
strip.failures.record(i);
|
||
}
|
||
}
|
||
}
|
||
// Match track faders: the next block's first sample must not ramp this one.
|
||
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
|
||
let v0 = volume_db_at(t, pos);
|
||
let v1 = volume_db_at(t, last);
|
||
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.saturating_sub(1));
|
||
if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) {
|
||
*d = x * g;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let m = self.meters.entry(t.id).or_default();
|
||
m.measure(&self.main, frames, 0.0);
|
||
m.plugin_failures = strip.failures;
|
||
}
|
||
}
|
||
// Metronome: summed after the master fader, so it reaches the speakers without passing
|
||
// through any track, send or insert. Only the playback engine sets `metronome`.
|
||
// Not while monitoring only (transport stopped): the position does not advance there, so the
|
||
// start of the same beat would repeat every block as a buzz.
|
||
if self.metronome && !self.monitor_only && s.edit.click && (!s.edit.flag("click.only_during_record") || self.recording) {
|
||
click::render(s, pos, frames, &mut self.main);
|
||
}
|
||
// 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);
|
||
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 };
|
||
}
|
||
}
|
||
}
|
||
self.main_meter.measure(&self.main, frames, 0.0);
|
||
}
|
||
|
||
/// The delay-compensation latency written onto `bus`: the slowest track insert, plus the
|
||
/// slowest aux return when an aux returns onto this bus.
|
||
fn bus_latency(&self, bus: BusId) -> usize {
|
||
self.track_latency + if self.aux_fed.contains(&bus) { self.aux_latency } else { 0 }
|
||
}
|
||
|
||
/// Copy one rendered block of `bus` into `out`. A missing bus stays silent.
|
||
fn copy_bus(&self, bus: BusId, frames: usize, out: &mut [Vec<f32>]) {
|
||
for o in out.iter_mut() {
|
||
for x in o.iter_mut().take(frames) {
|
||
*x = 0.0;
|
||
}
|
||
}
|
||
let Some(src) = self.busses.get(&bus) else { return };
|
||
for (o, c) in out.iter_mut().zip(src.buf.iter()) {
|
||
for i in 0..frames {
|
||
let v = c.get(i).copied().unwrap_or(0.0);
|
||
if let Some(d) = o.get_mut(i) {
|
||
*d = if v.is_finite() { v } else { 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 m = self.meters.entry(t.id).or_default();
|
||
m.measure(&strip.buf, frames, strip.gr);
|
||
m.plugin_failures = strip.failures;
|
||
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 };
|
||
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 src: &[Vec<f32>] = match &t.mixer.output {
|
||
Route::Main | Route::Bus(_) if strip.out_extra.len > 0 => {
|
||
if strip.outbuf.len() != strip.buf.len() {
|
||
strip.outbuf = vec![Vec::new(); strip.buf.len()];
|
||
}
|
||
for (d, src) in strip.outbuf.iter_mut().zip(strip.buf.iter()) {
|
||
d.clear();
|
||
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
|
||
}
|
||
strip.out_extra.process(&mut strip.outbuf, frames);
|
||
&strip.outbuf
|
||
}
|
||
_ => &strip.buf,
|
||
};
|
||
let (dst, dst_fmt) = match &t.mixer.output {
|
||
Route::Main => (&mut self.main, self.main_fmt),
|
||
Route::Bus(b) => match self.busses.get_mut(b) {
|
||
Some(d) => (&mut d.buf, d.fmt),
|
||
None => return,
|
||
},
|
||
_ => return,
|
||
};
|
||
let mut start = 0;
|
||
while start < frames {
|
||
let (at, end) = pan_segment(t, pos, start, frames);
|
||
let spec = PanSpec { stereo: [pan_at(t, 0, at), pan_at(t, 1, at)], surround: t.mixer.surround.as_ref() };
|
||
let m = build_matrix(src_fmt, src.len(), dst_fmt, dst.len(), &spec, law);
|
||
apply_matrix_span(src, start..end, dst, &m, 1.0);
|
||
start = end;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Source → trim → inserts → (pre-fader copy) → mute/fader for one strip. Independent of other
|
||
/// strips except that aux inputs read `busses`, so non-aux strips can run in parallel.
|
||
/// Live input routed to monitored tracks.
|
||
struct LiveInput<'a> {
|
||
input: &'a [Vec<f32>],
|
||
monitor_only: bool,
|
||
recording: bool,
|
||
}
|
||
|
||
fn process_strip(
|
||
s: &Session,
|
||
t: &Track,
|
||
strip: &mut Strip,
|
||
busses: &HashMap<BusId, BusBuf>,
|
||
live: &LiveInput<'_>,
|
||
pos: Samples,
|
||
frames: usize,
|
||
any_solo: bool,
|
||
) {
|
||
for c in strip.buf.iter_mut() {
|
||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||
}
|
||
let transport = transport_at(s, pos, !live.monitor_only, live.recording);
|
||
match t.kind {
|
||
TrackKind::Audio if !live.input.is_empty() && (t.mixer.input_monitor || (t.mixer.record_arm && (live.monitor_only || live.recording))) => {
|
||
// Input monitoring: the track hears its hardware input instead of its clips.
|
||
let first = match &t.mixer.input {
|
||
Route::Hardware(h) => {
|
||
h.trim_start_matches("In ").split(['-', ' ']).next().and_then(|n| n.parse::<usize>().ok()).map_or(0, |n| n.saturating_sub(1))
|
||
}
|
||
_ => 0,
|
||
};
|
||
let n_in = live.input.len().max(1);
|
||
for (k, dst) in strip.buf.iter_mut().enumerate() {
|
||
if let Some(src) = live.input.get((first + k) % n_in) {
|
||
for (d, x) in dst.iter_mut().zip(src.iter()).take(frames) {
|
||
*d = *x;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
TrackKind::Audio if live.monitor_only => {}
|
||
TrackKind::Instrument | TrackKind::Midi if live.monitor_only => {}
|
||
TrackKind::Audio => {
|
||
let block = Range { start: pos, end: pos.saturating_add(frames as i64) };
|
||
for clip in t.clips() {
|
||
if clip.muted || !clip.is_audio() || !clip.range().overlaps(&block) {
|
||
continue;
|
||
}
|
||
match clip_fx_params(s, clip.id.0) {
|
||
None => render_clip(s, clip, pos, frames, &mut strip.buf, &mut strip.scratch),
|
||
Some(params) => {
|
||
let nch = strip.buf.len();
|
||
if strip.clipbuf.len() != nch || strip.clipbuf.first().map_or(0, Vec::len) < frames {
|
||
strip.clipbuf = vec![vec![0.0; frames.max(strip.scratch.len())]; nch];
|
||
}
|
||
for c in strip.clipbuf.iter_mut() {
|
||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||
}
|
||
render_clip(s, clip, pos, frames, &mut strip.clipbuf, &mut strip.scratch);
|
||
let fx = strip.clip_fx.entry(clip.id.0).or_insert_with(|| ClipFx::new(s.sample_rate.as_f64() as f32, nch));
|
||
fx.configure(params);
|
||
fx.process(&mut strip.clipbuf, frames);
|
||
for (d, c) in strip.buf.iter_mut().zip(strip.clipbuf.iter()) {
|
||
for (x, y) in d.iter_mut().zip(c.iter()).take(frames) {
|
||
*x += *y;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
TrackKind::Aux | TrackKind::Folder => {
|
||
if let Route::Bus(b) = &t.mixer.input
|
||
&& let Some(src) = busses.get(b)
|
||
{
|
||
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 => {
|
||
if let Some(inst) = strip.instrument.as_mut().filter(|i| !i.failed) {
|
||
// #152: push the instrument's stored parameter values into the live plugin
|
||
// before processing, the same way inserts do below. Without this, the values
|
||
// loaded from a session (or set via `mix.insert_state`) never reach the plugin
|
||
// instance and the rendered audio matches the plugin's defaults — e.g. a saved
|
||
// `level = -12 dB` sounds the same as `level = -6 dB`.
|
||
if let Some(ins) = &t.instrument {
|
||
apply_params(inst, ins, t, INSTRUMENT_SLOT, pos);
|
||
}
|
||
inst.plugin.set_transport(&transport);
|
||
if std::mem::take(&mut strip.chase) {
|
||
chase_controllers(s, t, pos, inst);
|
||
}
|
||
schedule_notes(s, t, pos, frames, inst, &mut strip.held);
|
||
if strip.buf.len() >= 2 {
|
||
inst.plugin.process(&mut strip.buf, frames);
|
||
} else if let Some(c0) = strip.buf.first_mut() {
|
||
// Mono MIDI track: render the instrument in stereo and fold.
|
||
let mut st = [std::mem::take(c0), std::mem::take(&mut strip.scratch)];
|
||
inst.plugin.process(&mut st, frames);
|
||
let [a, b] = st;
|
||
*c0 = a;
|
||
strip.scratch = b;
|
||
for (x, y) in c0.iter_mut().zip(strip.scratch.iter()).take(frames) {
|
||
*x = 0.5 * (*x + *y);
|
||
}
|
||
}
|
||
if contain_failure(inst, &mut strip.buf, frames) {
|
||
strip.failures.record(INSTRUMENT_SLOT);
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
let trim = db_to_gain(t.mixer.trim_db) * if t.mixer.phase_invert { -1.0 } else { 1.0 };
|
||
if (trim - 1.0).abs() > f32::EPSILON {
|
||
for c in strip.buf.iter_mut() {
|
||
c.iter_mut().take(frames).for_each(|x| *x *= trim);
|
||
}
|
||
}
|
||
let mut gr = 0.0f32;
|
||
let mut latency = 0usize;
|
||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
|
||
if !ins.active || ins.bypass || slot.failed {
|
||
continue;
|
||
}
|
||
apply_params(slot, ins, t, i, pos);
|
||
slot.plugin.set_transport(&transport);
|
||
slot.plugin.process(&mut strip.buf, frames);
|
||
if contain_failure(slot, &mut strip.buf, frames) {
|
||
strip.failures.record(i);
|
||
continue;
|
||
}
|
||
latency = latency.saturating_add(slot.plugin.latency());
|
||
if gr == 0.0 {
|
||
gr = slot.plugin.gain_reduction_db();
|
||
}
|
||
}
|
||
strip.gr = gr;
|
||
strip.latency = latency.min(MAX_LATENCY);
|
||
let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t);
|
||
strip.muted = t.mixer.mute || soloed_out;
|
||
if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) {
|
||
strip.pre.resize_with(strip.buf.len(), Vec::new);
|
||
for (d, src) in strip.pre.iter_mut().zip(strip.buf.iter()) {
|
||
d.clear();
|
||
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
|
||
}
|
||
}
|
||
// Endpoints are the first and last sample of this block. The next block starts on its own
|
||
// first sample, so a breakpoint there does not ramp the block before it.
|
||
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
|
||
let mut v0 = volume_db_at(t, pos);
|
||
let mut v1 = volume_db_at(t, last);
|
||
// Trim automation (an offset on top of the volume curve).
|
||
if let Some(l) = t
|
||
.automation
|
||
.iter()
|
||
.find(|l| matches!(&l.param, AutoParam::Plugin { slot: u8::MAX, param } if param == "trim"))
|
||
.filter(|l| !l.points.is_empty())
|
||
{
|
||
v0 += l.value_at(pos, 0.0);
|
||
v1 += l.value_at(last, 0.0);
|
||
}
|
||
// VCA master: its fader offsets the member's, and its mute mutes the member.
|
||
if let Some(vid) = t.mixer.vca
|
||
&& let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca)
|
||
{
|
||
v0 += volume_db_at(vca, pos);
|
||
v1 += volume_db_at(vca, last);
|
||
if vca.mixer.mute {
|
||
strip.muted = true;
|
||
}
|
||
}
|
||
let (g0, g1) = if strip.muted { (-144.0, -144.0) } else { (v0.min(12.0), v1.min(12.0)) };
|
||
for c in strip.buf.iter_mut() {
|
||
for i in 0..frames {
|
||
if let Some(x) = c.get_mut(i) {
|
||
*x *= lerp_gain(g0, g1, i, frames.saturating_sub(1));
|
||
}
|
||
}
|
||
}
|
||
// Track mute gates the audio and its pre-fader tap without splitting processor blocks.
|
||
// Keep the existing curve interpolation, first-lane and mode policy, and render-position
|
||
// clock. Processor/bus age and Master/VCA automation policy are separate concerns.
|
||
if t.mixer.automation_mode.reads()
|
||
&& let Some(lane) = t.lane(&AutoParam::Mute).filter(|lane| !lane.points.is_empty())
|
||
{
|
||
for i in 0..frames {
|
||
let at = pos.saturating_add(i64::try_from(i).unwrap_or(i64::MAX));
|
||
if lane.value_at(at, 0.0) >= 0.5 {
|
||
for channel in strip.buf.iter_mut().chain(strip.pre.iter_mut()) {
|
||
if let Some(sample) = channel.get_mut(i) {
|
||
*sample = 0.0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Silences a block the plugin filled with NaN or infinity and takes the plugin out until the
|
||
/// next [`MixEngine::reset`]. True then.
|
||
fn contain_failure(slot: &mut PluginSlot, buf: &mut [Vec<f32>], frames: usize) -> bool {
|
||
// A branch-free fold vectorizes; `all` runs about 8 times slower here.
|
||
if buf.iter().all(|c| c.get(..frames).unwrap_or(c).iter().fold(true, |ok, x| ok & x.is_finite())) {
|
||
return false;
|
||
}
|
||
for c in buf.iter_mut() {
|
||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||
}
|
||
slot.failed = true;
|
||
true
|
||
}
|
||
|
||
/// Clip-effect parameters of a clip, or None when it has none or they are bypassed.
|
||
fn clip_fx_params(s: &Session, clip: u64) -> Option<impl Iterator<Item = (&str, f32)> + Clone> {
|
||
if s.edit.values.is_empty() || Key::new(format_args!("clip_fx.bypass.{clip}")).is_none_or(|k| s.edit.flag(k.as_str())) {
|
||
return None;
|
||
}
|
||
let pre = Key::new(format_args!("clip_fx.{clip}."))?;
|
||
let mut params = s
|
||
.edit
|
||
.values
|
||
.range::<str, _>((Bound::Included(pre.as_str()), Bound::Unbounded))
|
||
.take_while(move |(k, _)| k.starts_with(pre.as_str()))
|
||
.map(move |(k, v)| (k.get(pre.len..).unwrap_or(""), *v as f32))
|
||
.peekable();
|
||
params.peek()?;
|
||
Some(params)
|
||
}
|
||
|
||
/// A short key formatted on the stack: lookups made on the audio thread must not allocate.
|
||
#[derive(Clone, Copy)]
|
||
struct Key {
|
||
buf: [u8; 64],
|
||
len: usize,
|
||
}
|
||
|
||
impl Key {
|
||
/// None when the key doesn't fit.
|
||
fn new(args: std::fmt::Arguments<'_>) -> Option<Key> {
|
||
let mut k = Key { buf: [0; 64], len: 0 };
|
||
std::fmt::Write::write_fmt(&mut k, args).ok()?;
|
||
Some(k)
|
||
}
|
||
|
||
fn as_str(&self) -> &str {
|
||
self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("")
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Write for Key {
|
||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||
let end = self.len.checked_add(s.len()).ok_or(std::fmt::Error)?;
|
||
self.buf.get_mut(self.len..end).ok_or(std::fmt::Error)?.copy_from_slice(s.as_bytes());
|
||
self.len = end;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
impl ClipFx {
|
||
fn new(sr: f32, ch: usize) -> ClipFx {
|
||
ClipFx { eq: None, comp: None, gain_db: 0.0, sr, ch }
|
||
}
|
||
/// Clear filter, detector and lookahead history (transport stop / locate).
|
||
fn reset(&mut self) {
|
||
for p in self.eq.iter_mut().chain(self.comp.iter_mut()) {
|
||
p.reset();
|
||
}
|
||
}
|
||
fn make(&self, id: &str) -> Option<Box<dyn Plugin>> {
|
||
soundcraft_dsp::create(id).map(|mut p| {
|
||
p.prepare(self.sr, 16_384, self.ch.max(1));
|
||
p
|
||
})
|
||
}
|
||
/// Modules exist only when one of their parameters is set.
|
||
fn configure<'a>(&mut self, params: impl Iterator<Item = (&'a str, f32)> + Clone) {
|
||
self.gain_db = 0.0;
|
||
let has_eq = params.clone().any(|(k, _)| k.starts_with("eq."));
|
||
let has_comp = params.clone().any(|(k, _)| k.starts_with("comp."));
|
||
if has_eq && self.eq.is_none() {
|
||
self.eq = self.make("eq_7band");
|
||
} else if !has_eq {
|
||
self.eq = None;
|
||
}
|
||
if has_comp && self.comp.is_none() {
|
||
self.comp = self.make("compressor");
|
||
} else if !has_comp {
|
||
self.comp = None;
|
||
}
|
||
for (k, v) in params {
|
||
if let Some(p) = k.strip_prefix("eq.") {
|
||
if let Some(eq) = &mut self.eq
|
||
&& eq.param(p) != Some(v)
|
||
{
|
||
eq.set_param(p, v);
|
||
}
|
||
} else if let Some(p) = k.strip_prefix("comp.") {
|
||
if let Some(c) = &mut self.comp
|
||
&& c.param(p) != Some(v)
|
||
{
|
||
c.set_param(p, v);
|
||
}
|
||
} else if k == "gain" {
|
||
self.gain_db = v;
|
||
}
|
||
}
|
||
}
|
||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||
if let Some(eq) = &mut self.eq {
|
||
eq.process(io, frames);
|
||
}
|
||
if let Some(c) = &mut self.comp {
|
||
c.process(io, frames);
|
||
}
|
||
if self.gain_db.abs() > 1e-6 {
|
||
let g = db_to_gain(self.gain_db);
|
||
for ch in io.iter_mut() {
|
||
ch.iter_mut().take(frames).for_each(|x| *x *= g);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Hash of everything `sync`/`processing_order` depend on (tracks, kinds, routing, plugins, sends).
|
||
fn structure_fingerprint(s: &Session) -> usize {
|
||
use std::hash::{Hash, Hasher};
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
for t in &s.tracks {
|
||
t.id.hash(&mut h);
|
||
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 {
|
||
i.as_ref().map(|x| x.plugin.as_str()).hash(&mut h);
|
||
}
|
||
for snd in t.mixer.sends.iter().flatten() {
|
||
snd.target.hash(&mut h);
|
||
}
|
||
t.instrument.as_ref().map(|x| x.plugin.as_str()).hash(&mut h);
|
||
let sample = t.instrument.as_ref().and_then(|x| x.sample);
|
||
sample.hash(&mut h);
|
||
// Which decoded audio it is, so new audio under the same id (another session) re-syncs.
|
||
sample.and_then(|id| s.pool.get(id)).map(|a| Arc::as_ptr(a) as usize).hash(&mut h);
|
||
}
|
||
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
|
||
}
|
||
|
||
std::thread_local! {
|
||
static AUDIO_THREAD: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||
}
|
||
|
||
/// Mark the calling thread as the realtime audio thread (`soundcraft_playback`'s cpal callbacks
|
||
/// do, every call: one thread-local store). Strips [`MixEngine::render`] hands to worker threads
|
||
/// count as the audio thread while they render for it. Code that might run there asks
|
||
/// [`on_audio_thread`] before doing anything that blocks; the desktop app's logger does, so a
|
||
/// `log::` record from the audio thread never takes a lock or touches a file.
|
||
pub fn mark_audio_thread() {
|
||
AUDIO_THREAD.set(true);
|
||
}
|
||
|
||
/// True on the thread [`mark_audio_thread`] marked, and on a worker while it renders strips for
|
||
/// it. Lock-free and allocation-free.
|
||
pub fn on_audio_thread() -> bool {
|
||
AUDIO_THREAD.get()
|
||
}
|
||
|
||
/// Run `f` on this thread as (or as not) the audio thread, then restore the thread's own mark
|
||
/// (also when `f` unwinds): a pool worker renders for realtime playback and offline bounces alike.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn as_audio_thread(audio: bool, f: impl FnOnce()) {
|
||
struct Restore(bool);
|
||
impl Drop for Restore {
|
||
fn drop(&mut self) {
|
||
AUDIO_THREAD.set(self.0);
|
||
}
|
||
}
|
||
let _restore = Restore(AUDIO_THREAD.replace(audio));
|
||
f();
|
||
}
|
||
|
||
/// Process strips, in parallel on native targets when there are enough of them.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) {
|
||
use rayon::prelude::*;
|
||
if work.len() >= 4 {
|
||
// The workers render on behalf of this thread: on the audio thread, they are it too.
|
||
let audio = on_audio_thread();
|
||
work.par_iter_mut().for_each(|w| as_audio_thread(audio, || f(w)));
|
||
} else {
|
||
work.iter_mut().for_each(f);
|
||
}
|
||
}
|
||
|
||
#[cfg(target_arch = "wasm32")]
|
||
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, main_ch: usize) -> usize {
|
||
match t.kind {
|
||
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> =
|
||
(0..s.tracks.len()).filter(|&i| s.tracks.get(i).is_some_and(|t| !matches!(t.kind, TrackKind::Aux | TrackKind::Folder))).collect();
|
||
let mut auxes: Vec<usize> =
|
||
(0..s.tracks.len()).filter(|&i| s.tracks.get(i).is_some_and(|t| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))).collect();
|
||
if let Some(depths) = acyclic_aux_depths(s, &auxes) {
|
||
// Stable sorting preserves session order among auxes at the same longest-path depth.
|
||
auxes.sort_by_key(|&i| depths.get(i).copied().unwrap_or(0));
|
||
} else {
|
||
// A cycle keeps the existing bounded ordering for the entire graph, including any
|
||
// independent acyclic branches. Feedback does not have a finite longest-path depth.
|
||
auxes.sort_by_key(|&i| bounded_aux_depth(s, i));
|
||
}
|
||
order.extend(auxes);
|
||
order
|
||
}
|
||
|
||
/// Exact aux-hop depths when the whole Aux/Folder graph is acyclic. Bus joins retain only
|
||
/// producer counts and consumers, avoiding a producer-by-consumer adjacency matrix.
|
||
fn acyclic_aux_depths(s: &Session, auxes: &[usize]) -> Option<Vec<usize>> {
|
||
#[derive(Default)]
|
||
struct BusDependencies {
|
||
remaining: usize,
|
||
depth: usize,
|
||
last_producer: Option<usize>,
|
||
last_processed: Option<usize>,
|
||
consumers: Vec<usize>,
|
||
}
|
||
let mut busses = HashMap::<BusId, BusDependencies>::new();
|
||
for &i in auxes {
|
||
for bus in aux_output_busses(s.tracks.get(i)?) {
|
||
let dependency = busses.entry(bus).or_default();
|
||
// Output and multiple sends from this producer may name the same bus.
|
||
if dependency.last_producer != Some(i) {
|
||
dependency.last_producer = Some(i);
|
||
dependency.remaining = dependency.remaining.checked_add(1)?;
|
||
}
|
||
}
|
||
}
|
||
let mut depths = vec![0usize; s.tracks.len()];
|
||
let mut ready = Vec::with_capacity(auxes.len());
|
||
for &i in auxes {
|
||
if let Route::Bus(bus) = &s.tracks.get(i)?.mixer.input {
|
||
let dependency = busses.entry(*bus).or_default();
|
||
dependency.consumers.push(i);
|
||
if dependency.remaining > 0 {
|
||
continue;
|
||
}
|
||
}
|
||
ready.push(i);
|
||
}
|
||
let mut processed = 0usize;
|
||
while let Some(&i) = ready.get(processed) {
|
||
processed = processed.checked_add(1)?;
|
||
let next_depth = depths.get(i)?.checked_add(1)?;
|
||
for bus in aux_output_busses(s.tracks.get(i)?) {
|
||
let dependency = busses.get_mut(&bus)?;
|
||
if dependency.last_processed == Some(i) {
|
||
continue;
|
||
}
|
||
dependency.last_processed = Some(i);
|
||
dependency.depth = dependency.depth.max(next_depth);
|
||
dependency.remaining = dependency.remaining.checked_sub(1)?;
|
||
if dependency.remaining == 0 {
|
||
for &consumer in &dependency.consumers {
|
||
*depths.get_mut(consumer)? = dependency.depth;
|
||
ready.push(consumer);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
(processed == auxes.len()).then_some(depths)
|
||
}
|
||
|
||
fn aux_output_busses(t: &Track) -> impl Iterator<Item = BusId> + '_ {
|
||
std::iter::once(&t.mixer.output)
|
||
.chain(t.mixer.sends.iter().flatten().map(|send| &send.target))
|
||
.filter_map(|route| if let Route::Bus(bus) = route { Some(*bus) } else { None })
|
||
}
|
||
|
||
/// The previous bounded depth calculation, retained unchanged for any cyclic graph.
|
||
fn bounded_aux_depth(s: &Session, i: usize) -> usize {
|
||
let mut d = 0;
|
||
let mut cur = vec![i];
|
||
for _ in 0..8 {
|
||
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
|
||
let feeders: Vec<usize> = auxes_feeding(s, &inputs);
|
||
if feeders.is_empty() {
|
||
break;
|
||
}
|
||
d += 1;
|
||
cur = feeders;
|
||
}
|
||
d
|
||
}
|
||
|
||
fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {
|
||
s.tracks
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))
|
||
.filter(|(_, t)| {
|
||
inputs.iter().any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r)))
|
||
})
|
||
.map(|(i, _)| i)
|
||
.collect()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[allow(clippy::unwrap_used)]
|
||
mod processing_order_tests {
|
||
use super::*;
|
||
use soundcraft_model::SendSlot;
|
||
|
||
#[test]
|
||
fn acyclic_bus_joins_use_longest_depth_and_keep_session_order_for_ties() {
|
||
let mut s = Session::default();
|
||
s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||
let consumer = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
|
||
let first_root = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
|
||
let second_root = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
|
||
let middle = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
|
||
let first_bus = s.add_bus("First", ChannelFormat::Stereo);
|
||
let join = s.add_bus("Join", ChannelFormat::Stereo);
|
||
s.track_mut(consumer).unwrap().mixer.input = Route::Bus(join);
|
||
let root = s.track_mut(first_root).unwrap();
|
||
root.mixer.output = Route::Main;
|
||
root.mixer.sends[0] = Some(SendSlot::new(Route::Bus(first_bus)));
|
||
let root = s.track_mut(second_root).unwrap();
|
||
root.mixer.output = Route::Bus(join);
|
||
root.mixer.sends[0] = Some(SendSlot::new(Route::Bus(join)));
|
||
root.mixer.sends[1] = Some(SendSlot::new(Route::Bus(join)));
|
||
let folder = s.track_mut(middle).unwrap();
|
||
folder.mixer.input = Route::Bus(first_bus);
|
||
folder.mixer.output = Route::Bus(join);
|
||
folder.mixer.sends[0] = Some(SendSlot::new(Route::Bus(join)));
|
||
|
||
assert_eq!(processing_order(&s), vec![0, 2, 3, 4, 1]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_cycle_preserves_bounded_order_for_the_whole_graph() {
|
||
let mut s = Session::default();
|
||
let busses: Vec<_> = (0..10).map(|i| s.add_bus(&format!("Chain {i}"), ChannelFormat::Stereo)).collect();
|
||
let tracks: Vec<_> = (0..10).map(|_| s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None)).collect();
|
||
for (i, id) in tracks.iter().enumerate() {
|
||
let track = s.track_mut(*id).unwrap();
|
||
track.mixer.input = Route::Bus(busses[i]);
|
||
track.mixer.output = if i == 0 { Route::Main } else { Route::Bus(busses[i - 1]) };
|
||
}
|
||
assert_eq!(processing_order(&s), vec![9, 8, 7, 6, 5, 4, 3, 2, 1, 0]);
|
||
let cycle_bus = s.add_bus("Feedback", ChannelFormat::Stereo);
|
||
let cycle = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
|
||
let track = s.track_mut(cycle).unwrap();
|
||
track.mixer.input = Route::Bus(cycle_bus);
|
||
track.mixer.output = Route::Bus(cycle_bus);
|
||
|
||
// Both deepest independent returns tie with the cycle at the legacy eight-hop cap.
|
||
assert_eq!(processing_order(&s), vec![9, 8, 7, 6, 5, 4, 3, 2, 0, 1, 10]);
|
||
}
|
||
}
|
||
|
||
/// Implicit solo: an aux is not muted when a soloed track feeds its input bus.
|
||
fn receives_solo(s: &Session, t: &Track) -> bool {
|
||
let Route::Bus(b) = &t.mixer.input else { return false };
|
||
s.tracks
|
||
.iter()
|
||
.any(|o| o.mixer.solo && (o.mixer.output == Route::Bus(*b) || o.mixer.sends.iter().flatten().any(|sn| sn.target == Route::Bus(*b))))
|
||
}
|
||
|
||
/// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of
|
||
/// each bar. Settings come from `setup.click_countoff` (`click.*` values).
|
||
mod click {
|
||
use super::db_to_gain;
|
||
use soundcraft_model::Session;
|
||
use soundcraft_time::Samples;
|
||
|
||
/// Blip length in seconds.
|
||
const LEN_S: f64 = 0.03;
|
||
|
||
pub fn render(s: &Session, pos: Samples, frames: usize, main: &mut [Vec<f32>]) {
|
||
let sr = s.sample_rate;
|
||
let rate = sr.as_f64();
|
||
let len = (LEN_S * rate) as i64;
|
||
let end = pos.saturating_add(frames as i64);
|
||
let gain = f64::from(db_to_gain(s.edit.value("click.volume_db", -6.0).clamp(-60.0, 12.0) as f32));
|
||
let accent = (note_hz(s.edit.value("click.accent_note", 84.0)), s.edit.value("click.accent_velocity", 127.0));
|
||
let normal = (note_hz(s.edit.value("click.normal_note", 72.0)), s.edit.value("click.normal_velocity", 100.0));
|
||
// Start one blip length early so a click that began in an earlier block still rings into this one.
|
||
let t0 = s.tempo.samples_to_ticks(pos.saturating_sub(len), sr).max(0).saturating_sub(1);
|
||
let mut tick = t0.saturating_sub(s.tempo.bar_beat_at_tick(t0).tick);
|
||
// Blocks hold a few beats at most; the guard only protects against a malformed tempo map.
|
||
for _ in 0..4096 {
|
||
let at = s.tempo.tick_to_samples(tick, sr);
|
||
if at >= end {
|
||
break;
|
||
}
|
||
if at.saturating_add(len) > pos {
|
||
let (hz, vel) = if s.tempo.bar_beat_at_tick(tick).beat == 1 { accent } else { normal };
|
||
let amp = gain * (vel.clamp(1.0, 127.0) / 127.0);
|
||
blip(main, at - pos, frames, hz, amp as f32, rate);
|
||
}
|
||
tick = tick.saturating_add(s.tempo.meter_at_tick(tick).ticks_per_beat());
|
||
}
|
||
}
|
||
|
||
/// Adds one decaying sine blip whose first sample sits `rel` frames into the block (negative
|
||
/// when it began before the block) to every channel.
|
||
fn blip(main: &mut [Vec<f32>], rel: i64, frames: usize, hz: f64, amp: f32, rate: f64) {
|
||
let len = (LEN_S * rate) as usize;
|
||
let w = std::f64::consts::TAU * hz / rate;
|
||
let first = usize::try_from(-rel).unwrap_or(0);
|
||
for i in first..len {
|
||
let Ok(at) = usize::try_from(rel + i as i64) else { continue };
|
||
if at >= frames {
|
||
break;
|
||
}
|
||
let env = (-6.0 * i as f64 / len as f64).exp() as f32;
|
||
let v = ((w * i as f64).sin() as f32) * env * amp;
|
||
for ch in main.iter_mut() {
|
||
if let Some(d) = ch.get_mut(at) {
|
||
*d += v;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Click pitch for a MIDI note setting (clamped to the MIDI range).
|
||
fn note_hz(note: f64) -> f64 {
|
||
soundcraft_dsp::midi_to_hz(note.clamp(0.0, 127.0))
|
||
}
|
||
}
|
||
|
||
fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut [Vec<f32>], _scratch: &mut [f32]) {
|
||
let ClipContent::Audio { source, offset } = clip.content else { return };
|
||
let block = Range { start: pos, end: pos.saturating_add(frames as i64) };
|
||
let Some(isect) = clip.range().intersect(&block) else { return };
|
||
let Some(audio) = s.pool.get(source) else { return };
|
||
let nsrc = audio.buffer.channels.len();
|
||
if nsrc == 0 {
|
||
return;
|
||
}
|
||
let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
|
||
let i0 = usize::try_from(isect.start - pos).unwrap_or(0);
|
||
let i1 = usize::try_from(isect.end - pos).unwrap_or(0).min(frames);
|
||
// Gain is evaluated every 32 samples and interpolated (fades and envelopes stay smooth).
|
||
const STEP: usize = 32;
|
||
for (ch, dst) in buf.iter_mut().enumerate() {
|
||
let src = audio.buffer.channels.get(ch % nsrc).map(Vec::as_slice).unwrap_or(&[]);
|
||
let mut i = i0;
|
||
while i < i1 {
|
||
let seg_end = (i + STEP).min(i1);
|
||
let rel0 = pos.saturating_add(i as i64).saturating_sub(clip.start);
|
||
// The last sample of this step, not the first sample of the next one, so a breakpoint
|
||
// on the boundary does not ramp the audio before it.
|
||
let rel1 = pos.saturating_add(seg_end.saturating_sub(1) as i64).saturating_sub(clip.start);
|
||
let g0 = clip.gain_at(rel0);
|
||
let g1 = clip.gain_at(rel1.min(clip.length));
|
||
let n = (seg_end - i).saturating_sub(1).max(1) as f32;
|
||
for k in i..seg_end {
|
||
let rel = pos.saturating_add(k as i64).saturating_sub(clip.start);
|
||
let v = if (stretch - 1.0).abs() < 1e-9 {
|
||
usize::try_from(offset.saturating_add(rel)).ok().and_then(|x| src.get(x)).copied().unwrap_or(0.0)
|
||
} else {
|
||
let f = offset as f64 + rel as f64 / stretch;
|
||
let x0 = f.floor();
|
||
let fr = (f - x0) as f32;
|
||
let a = if x0 >= 0.0 { src.get(x0 as usize).copied().unwrap_or(0.0) } else { 0.0 };
|
||
let b = if x0 + 1.0 >= 0.0 { src.get((x0 + 1.0) as usize).copied().unwrap_or(0.0) } else { 0.0 };
|
||
a + (b - a) * fr
|
||
};
|
||
let t = (k - i) as f32 / n;
|
||
if let Some(d) = dst.get_mut(k) {
|
||
*d += v * (g0 + (g1 - g0) * t);
|
||
}
|
||
}
|
||
i = seg_end;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The track's MIDI Track Offset (`midi.offset.<id>` in the edit values), in samples. Looked up
|
||
/// without formatting the key, since this runs on the audio thread every block.
|
||
fn midi_offset(s: &Session, t: &Track) -> i64 {
|
||
const PREFIX: &str = "midi.offset.";
|
||
s.edit
|
||
.values
|
||
.range::<str, _>((std::ops::Bound::Included(PREFIX), std::ops::Bound::Unbounded))
|
||
.take_while(|(k, _)| k.starts_with(PREFIX))
|
||
.find(|(k, _)| k.get(PREFIX.len()..).and_then(|id| id.parse::<u64>().ok()) == Some(t.id.0))
|
||
.map(|(_, v)| *v)
|
||
.filter(|v| v.is_finite())
|
||
.map_or(0, |v| v as i64)
|
||
}
|
||
|
||
/// Send note on/offs and controllers for MIDI clips overlapping the block.
|
||
fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mut PluginSlot, held: &mut Vec<u8>) {
|
||
let sr = s.sample_rate;
|
||
// MIDI Track Offsets: the track's notes sound `offset` samples later (earlier if negative), so
|
||
// the block looks that far back into the clips.
|
||
let offset = midi_offset(s, t);
|
||
let pos = pos.saturating_sub(offset);
|
||
let end = pos.saturating_add(frames as i64);
|
||
for clip in t.clips() {
|
||
if clip.muted {
|
||
continue;
|
||
}
|
||
let ClipContent::Midi { sequence } = &clip.content else { continue };
|
||
if !clip.range().overlaps(&Range { start: pos, end }) {
|
||
continue;
|
||
}
|
||
let base = s.tempo.samples_to_ticks(clip.start, sr);
|
||
// MIDI Real-Time Properties (non-destructive velocity/transpose/duration/delay).
|
||
let rtp = |name: &str, d: f64| Key::new(format_args!("rtp.{}.{name}", t.id.0)).map_or(d, |k| s.edit.value(k.as_str(), d));
|
||
let (dv, dp, dur, delay) = if s.edit.values.is_empty() {
|
||
(0i64, 0i64, 1.0f64, 0i64)
|
||
} else {
|
||
(
|
||
rtp("velocity", 0.0).clamp(-127.0, 127.0) as i64,
|
||
rtp("transpose", 0.0).clamp(-127.0, 127.0) as i64,
|
||
rtp("duration", 100.0).clamp(1.0, 1000.0) / 100.0,
|
||
rtp("delay", 0.0).clamp(-1e7, 1e7) as i64,
|
||
)
|
||
};
|
||
for n0 in &sequence.notes {
|
||
let mut n = *n0;
|
||
n.velocity = (i64::from(n.velocity) + dv).clamp(1, 127) as u8;
|
||
n.pitch = (i64::from(n.pitch) + dp).clamp(0, 127) as u8;
|
||
n.length = ((n.length as f64 * dur) as i64).max(1);
|
||
n.start = n.start.saturating_add(delay).max(0);
|
||
let on = s.tempo.tick_to_samples(base.saturating_add(n.start), sr);
|
||
let off = s.tempo.tick_to_samples(base.saturating_add(n.start).saturating_add(n.length), sr).min(clip.end());
|
||
if on >= clip.end() {
|
||
continue;
|
||
}
|
||
if on >= pos && on < end {
|
||
inst.plugin.note_on(usize::try_from(on - pos).unwrap_or(0), n.pitch, n.velocity);
|
||
if !held.contains(&n.pitch) && held.len() < 128 {
|
||
held.push(n.pitch);
|
||
}
|
||
}
|
||
if off >= pos && off < end {
|
||
inst.plugin.note_off(usize::try_from(off - pos).unwrap_or(0), n.pitch);
|
||
held.retain(|p| *p != n.pitch);
|
||
}
|
||
}
|
||
let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1)
|
||
..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1);
|
||
// Events before tick 0 are what a start trim hid: they don't play.
|
||
for c in sequence.ctrls.iter().filter(|c| c.tick >= 0) {
|
||
let tick = c.tick.saturating_add(delay).max(0);
|
||
if !near.contains(&tick) {
|
||
continue;
|
||
}
|
||
let at = s.tempo.tick_to_samples(base.saturating_add(tick), sr);
|
||
if at >= pos && at < end && at < clip.end() {
|
||
inst.plugin.midi(usize::try_from(at - pos).unwrap_or(0), ctrl_message(c.kind, c.value, dp));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A controller event as a MIDI 1.0 message on channel 1 (where the notes go), with `transpose`
|
||
/// applied to a poly-pressure key.
|
||
fn ctrl_message(kind: CtrlKind, value: i32, transpose: i64) -> [u8; 3] {
|
||
let v7 = value.clamp(0, 127) as u8;
|
||
match kind {
|
||
CtrlKind::Cc(n) => [0xB0, n & 0x7F, v7],
|
||
CtrlKind::PitchBend => {
|
||
let v = value.saturating_add(8192).clamp(0, 16383);
|
||
[0xE0, (v & 0x7F) as u8, (v >> 7) as u8]
|
||
}
|
||
CtrlKind::ChannelPressure => [0xD0, v7, 0],
|
||
CtrlKind::PolyPressure(k) => [0xA0, (i64::from(k) + transpose).clamp(0, 127) as u8, v7],
|
||
CtrlKind::Program => [0xC0, v7, 0],
|
||
}
|
||
}
|
||
|
||
/// Before an instrument plays from `pos`: reset its controllers, then send each one the last value
|
||
/// it had on this track before `pos`.
|
||
fn chase_controllers(s: &Session, t: &Track, pos: Samples, inst: &mut PluginSlot) {
|
||
inst.plugin.midi(0, [0xB0, 121, 0]);
|
||
let pos = pos.saturating_sub(midi_offset(s, t));
|
||
const BEND: usize = 128;
|
||
const PRESSURE: usize = 129;
|
||
const PROGRAM: usize = 130;
|
||
let mut last = [(i64::MIN, 0i32); 131];
|
||
let sr = s.sample_rate;
|
||
for clip in t.clips() {
|
||
let ClipContent::Midi { sequence } = &clip.content else { continue };
|
||
if clip.muted || clip.start >= pos {
|
||
continue;
|
||
}
|
||
let base = s.tempo.samples_to_ticks(clip.start, sr);
|
||
let until = pos.min(clip.end());
|
||
for c in sequence.ctrls.iter().filter(|c| c.tick >= 0) {
|
||
let slot = match c.kind {
|
||
CtrlKind::Cc(n) => usize::from(n & 0x7F),
|
||
CtrlKind::PitchBend => BEND,
|
||
CtrlKind::ChannelPressure => PRESSURE,
|
||
CtrlKind::Program => PROGRAM,
|
||
CtrlKind::PolyPressure(_) => continue,
|
||
};
|
||
let at = s.tempo.tick_to_samples(base.saturating_add(c.tick), sr);
|
||
if at < until
|
||
&& let Some(l) = last.get_mut(slot)
|
||
&& at >= l.0
|
||
{
|
||
*l = (at, c.value);
|
||
}
|
||
}
|
||
}
|
||
for (slot, &(at, value)) in last.iter().enumerate() {
|
||
let kind = match slot {
|
||
BEND => CtrlKind::PitchBend,
|
||
PRESSURE => CtrlKind::ChannelPressure,
|
||
PROGRAM => CtrlKind::Program,
|
||
n => CtrlKind::Cc(u8::try_from(n).unwrap_or(0)),
|
||
};
|
||
if at != i64::MIN {
|
||
inst.plugin.midi(0, ctrl_message(kind, value, 0));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Session audio shared with a sample-playing instrument.
|
||
struct PoolSample(Arc<SourceAudio>);
|
||
|
||
impl SampleSource for PoolSample {
|
||
fn sample_rate(&self) -> f32 {
|
||
self.0.buffer.sample_rate as f32
|
||
}
|
||
fn num_channels(&self) -> usize {
|
||
self.0.buffer.num_channels()
|
||
}
|
||
fn channel(&self, ch: usize) -> Option<&[f32]> {
|
||
self.0.channel(ch)
|
||
}
|
||
}
|
||
|
||
/// Hands a strip's instrument the audio of its insert's sample source when it changed (a new
|
||
/// sample, a cleared one, or a new instance). Missing media plays nothing.
|
||
fn sync_sample(strip: &mut Strip, want: Option<&soundcraft_model::Insert>, s: &Session) {
|
||
let audio = want.and_then(|i| i.sample).and_then(|id| s.pool.get(id));
|
||
let same = match (strip.sample.as_ref(), audio) {
|
||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||
(None, None) => true,
|
||
_ => false,
|
||
};
|
||
if same {
|
||
return;
|
||
}
|
||
if let Some(inst) = strip.instrument.as_mut() {
|
||
let shared = audio.map(|a| Arc::new(PoolSample(Arc::clone(a))) as Arc<dyn SampleSource>);
|
||
inst.plugin.set_sample(shared);
|
||
}
|
||
strip.sample = audio.cloned();
|
||
}
|
||
|
||
/// Record insert latency without processing audio, so this block can compensate for it.
|
||
fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) {
|
||
let mut latency = 0usize;
|
||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
|
||
if !ins.active || ins.bypass || slot.failed {
|
||
continue;
|
||
}
|
||
apply_params(slot, ins, t, i, pos);
|
||
latency = latency.saturating_add(slot.plugin.latency());
|
||
}
|
||
strip.latency = latency.min(MAX_LATENCY);
|
||
}
|
||
|
||
fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) {
|
||
// Automation lanes address insert slots; the instrument (`INSTRUMENT_SLOT`) has none, so it
|
||
// must not pick up slot A's lanes.
|
||
let Ok(slot_u8) = u8::try_from(i) else {
|
||
return apply_values(slot, ins, |_, v| v);
|
||
};
|
||
let automated = t.mixer.automation_mode.reads()
|
||
&& t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty());
|
||
apply_values(slot, ins, |k, val| {
|
||
if automated
|
||
&& let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
|
||
&& !l.points.is_empty()
|
||
{
|
||
return l.value_at(pos, val);
|
||
}
|
||
val
|
||
});
|
||
}
|
||
|
||
/// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time.
|
||
fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: impl Fn(&str, f32) -> f32) {
|
||
if slot.applied.len() != ins.params.len() {
|
||
slot.applied = vec![f32::NAN; ins.params.len()];
|
||
}
|
||
for ((k, v), last) in ins.params.iter().zip(slot.applied.iter_mut()) {
|
||
let val = value(k, *v);
|
||
if val.to_bits() != last.to_bits() {
|
||
slot.plugin.set_param(k, val);
|
||
*last = val;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn transport_at(s: &Session, pos: Samples, playing: bool, recording: bool) -> Transport {
|
||
let sr = s.sample_rate;
|
||
let ticks = Some(s.tempo.samples_to_ticks_f(pos, sr)).filter(|t| t.is_finite()).unwrap_or(0.0);
|
||
let tick = ticks.floor() as i64;
|
||
let meter = s.tempo.meter_at_tick(tick);
|
||
let bar = s.tempo.bar_beat_at_tick(tick).bar;
|
||
let q = TICKS_PER_QUARTER as f64;
|
||
Transport {
|
||
playing,
|
||
recording,
|
||
tempo: s.tempo.tempo_at_tick(tick),
|
||
numerator: meter.numerator,
|
||
denominator: meter.denominator,
|
||
samples: pos,
|
||
seconds: pos as f64 / sr.as_f64().max(1.0),
|
||
quarters: ticks / q,
|
||
bar_start: s.tempo.bar_start_tick(bar) as f64 / q,
|
||
bar: bar - 1,
|
||
}
|
||
}
|
||
|
||
fn volume_db_at(t: &Track, pos: Samples) -> f32 {
|
||
if t.mixer.automation_mode.reads()
|
||
&& t.mixer.automation_mode != soundcraft_model::AutomationMode::Write
|
||
&& let Some(l) = t.lane(&AutoParam::Volume)
|
||
&& !l.points.is_empty()
|
||
{
|
||
return l.value_at(pos, t.mixer.volume_db);
|
||
}
|
||
t.mixer.volume_db
|
||
}
|
||
|
||
fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 {
|
||
let base = t.mixer.pan.get(idx).copied().unwrap_or(if idx == 0 { 0.0 } else { 1.0 });
|
||
if t.mixer.automation_mode.reads()
|
||
&& let Some(l) = t.lane(&AutoParam::Pan(u8::try_from(idx).unwrap_or(0)))
|
||
&& !l.points.is_empty()
|
||
{
|
||
return l.value_at(pos, base);
|
||
}
|
||
base
|
||
}
|
||
|
||
/// Pan automation is read on a grid of [`PAN_STEP`] timeline samples rather than once per block,
|
||
/// so playback (callback-sized blocks) and bounces (1024-frame blocks) pan identically.
|
||
const PAN_STEP: Samples = 64;
|
||
|
||
/// The position to read the pan at for the frames from `start` of the block at `pos`, and the end
|
||
/// of the frames that share it. Without pan automation the whole block shares one pan.
|
||
fn pan_segment(t: &Track, pos: Samples, start: usize, frames: usize) -> (Samples, usize) {
|
||
let automated = t.mixer.automation_mode.reads() && t.automation.iter().any(|l| matches!(l.param, AutoParam::Pan(_)) && !l.points.is_empty());
|
||
if !automated {
|
||
return (pos, frames);
|
||
}
|
||
let here = pos.saturating_add(start as i64);
|
||
let at = here.div_euclid(PAN_STEP).saturating_mul(PAN_STEP);
|
||
let left = usize::try_from(at.saturating_add(PAN_STEP).saturating_sub(here)).unwrap_or(1).max(1);
|
||
(at, start.saturating_add(left).min(frames))
|
||
}
|
||
|
||
fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
|
||
if t.mixer.automation_mode.reads()
|
||
&& let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0)))
|
||
&& !l.points.is_empty()
|
||
{
|
||
return l.value_at(pos, base);
|
||
}
|
||
base
|
||
}
|
||
|
||
fn lerp_gain(db0: f32, db1: f32, i: usize, n: usize) -> f32 {
|
||
let g0 = db_to_gain(db0);
|
||
let g1 = db_to_gain(db1);
|
||
if n == 0 { g0 } else { g0 + (g1 - g0) * (i as f32 / n as f32) }
|
||
}
|
||
|
||
fn peak(x: &[f32]) -> f32 {
|
||
x.iter().fold(0.0f32, |m, v| if v.is_finite() { m.max(v.abs()) } else { m })
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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(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) {
|
||
apply_matrix_span(src, 0..frames, dst, m, gain);
|
||
}
|
||
|
||
/// [`apply_matrix`] over the frames in `span` only.
|
||
fn apply_matrix_span(src: &[Vec<f32>], span: std::ops::Range<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(span.end).skip(span.start) {
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Ceiling on one in-memory 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;
|
||
/// [`RangeRender`] streams any length.
|
||
pub const MAX_RENDER_SAMPLES: usize = 1 << 29;
|
||
|
||
/// 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>> {
|
||
render_in_memory(s, None, range, block)
|
||
}
|
||
|
||
/// Offline render of one bus over `range` (planar, the bus format's channel count).
|
||
/// The compensation delay written onto the bus is removed (track inserts, and the aux returns
|
||
/// when an aux returns onto it), so the bus lines up with the timeline. An unknown bus is silent.
|
||
pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec<Vec<f32>> {
|
||
render_in_memory(s, Some(bus), range, block)
|
||
}
|
||
|
||
fn render_in_memory(s: &Session, bus: Option<BusId>, range: Range, block: usize) -> Vec<Vec<f32>> {
|
||
let nch = source_channels(s, bus);
|
||
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch.max(1));
|
||
let mut out: Vec<Vec<f32>> = (0..nch).map(|_| Vec::with_capacity(len)).collect();
|
||
let mut render = RangeRender::new(s, bus, Range::new(range.start, range.start + len as i64), block);
|
||
while let Some((chunk, n)) = render.next_block(s) {
|
||
for (o, c) in out.iter_mut().zip(chunk) {
|
||
o.extend_from_slice(c.get(..n).unwrap_or_default());
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Channels of the main mix, or of `bus` (none for an unknown bus).
|
||
pub fn source_channels(s: &Session, bus: Option<BusId>) -> usize {
|
||
match bus {
|
||
Some(id) => s.bus(id).map_or(0, |b| b.format.channels()),
|
||
None => main_channels(s),
|
||
}
|
||
}
|
||
|
||
/// [`render_range`] or [`render_bus`] of any length, a block at a time.
|
||
pub struct RangeRender {
|
||
eng: MixEngine,
|
||
bus: Option<BusId>,
|
||
tmp: Vec<Vec<f32>>,
|
||
start: Samples,
|
||
done: usize,
|
||
total: usize,
|
||
latency: usize,
|
||
}
|
||
|
||
impl RangeRender {
|
||
/// Renders the main mix over `range`, or `bus` when one is named.
|
||
pub fn new(s: &Session, bus: Option<BusId>, range: Range, block: usize) -> Self {
|
||
let nch = source_channels(s, bus);
|
||
let eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||
let tmp = vec![vec![0.0f32; eng.max_block()]; nch];
|
||
let len = if nch == 0 { 0 } else { usize::try_from(range.len().max(0)).unwrap_or(0) };
|
||
RangeRender { eng, bus, tmp, start: range.start, done: 0, total: len, latency: 0 }
|
||
}
|
||
|
||
fn render(&mut self, s: &Session, pos: Samples, n: usize) {
|
||
match self.bus {
|
||
Some(id) => {
|
||
self.eng.render(s, pos, n, &mut []);
|
||
self.eng.copy_bus(id, n, &mut self.tmp);
|
||
}
|
||
None => self.eng.render(s, pos, n, &mut self.tmp),
|
||
}
|
||
}
|
||
|
||
/// Renders one more block of the mix of `s` (the session given to [`RangeRender::new`]) and
|
||
/// returns it as `(channels, n)`: the next `n` samples of the range lead each channel (none
|
||
/// while the mix's latency is still being skipped). `None` once the range is done.
|
||
pub fn next_block(&mut self, s: &Session) -> Option<(&[Vec<f32>], usize)> {
|
||
if self.done >= self.total {
|
||
return None;
|
||
}
|
||
let n = (self.total - self.done).min(self.eng.max_block());
|
||
self.render(s, self.start + self.done as i64, n);
|
||
if self.done == 0 {
|
||
// The first block tells the mix's latency: render that much more and drop the head.
|
||
self.latency = match self.bus {
|
||
Some(b) => self.eng.bus_latency(b),
|
||
None => self.eng.latency,
|
||
};
|
||
self.total = self.total.saturating_add(self.latency);
|
||
}
|
||
let skip = self.latency.saturating_sub(self.done).min(n);
|
||
self.done += n;
|
||
if skip > 0 && skip < n {
|
||
for c in &mut self.tmp {
|
||
c.copy_within(skip..n, 0);
|
||
}
|
||
}
|
||
Some((&self.tmp, n - skip))
|
||
}
|
||
}
|
||
|
||
/// 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).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().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
|
||
match clip_fx_params(s, c.id.0) {
|
||
None => render_clip(s, c, range.start, len, &mut out, &mut scratch),
|
||
Some(params) => {
|
||
let mut tmp = vec![vec![0.0f32; len]; out.len()];
|
||
render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
|
||
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, tmp.len());
|
||
fx.configure(params);
|
||
let mut done = 0;
|
||
while done < len {
|
||
let n = (len - done).min(4096);
|
||
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch.get(done..done + n).map(<[f32]>::to_vec).unwrap_or_default()).collect();
|
||
fx.process(&mut block, n);
|
||
for (o, b) in out.iter_mut().zip(block.iter()) {
|
||
if let Some(dst) = o.get_mut(done..done + n) {
|
||
for (x, y) in dst.iter_mut().zip(b.iter()) {
|
||
*x += *y;
|
||
}
|
||
}
|
||
}
|
||
done += n;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 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;
|
||
t.mixer.mute = false;
|
||
t.mixer.solo = false;
|
||
t.mixer.output = Route::Main;
|
||
t.mixer.pan = match t.channels() {
|
||
1 => vec![0.0],
|
||
_ => vec![-1.0, 1.0],
|
||
};
|
||
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;
|
||
}
|
||
}
|
||
let mut out = render_range(&solo, range, 1024);
|
||
// Mono tracks: fold back to one channel (undo the centre pan law).
|
||
if s.track(track).is_some_and(|t| t.channels() == 1)
|
||
&& let Some(l) = out.first().cloned()
|
||
{
|
||
let g = std::f32::consts::SQRT_2;
|
||
out = vec![l.iter().map(|x| x * g).collect()];
|
||
}
|
||
out
|
||
}
|
||
|
||
/// A built-in plugin, else a hosted CLAP plugin (`clap:<id>`).
|
||
fn create_plugin(id: &str) -> Option<Box<dyn Plugin>> {
|
||
// Tests never load real plug-ins; adopt a fake instead. Scanning the machine's plug-in folders
|
||
// loads every installed plug-in in-process, and some never unload, so a test that reached the
|
||
// real hosts would hang on a developer machine (CI has none installed).
|
||
#[cfg(test)]
|
||
if is_third_party(id) {
|
||
return None;
|
||
}
|
||
soundcraft_dsp::create(id)
|
||
.or_else(|| soundcraft_clap_host::create(id))
|
||
.or_else(|| soundcraft_vst3_host::create(id))
|
||
.or_else(|| soundcraft_au_host::create(id))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod audio_thread_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn only_a_marked_thread_is_the_audio_thread() {
|
||
assert!(!on_audio_thread());
|
||
let marked = std::thread::spawn(|| {
|
||
let before = on_audio_thread();
|
||
mark_audio_thread();
|
||
(before, on_audio_thread())
|
||
})
|
||
.join()
|
||
.unwrap();
|
||
assert_eq!(marked, (false, true));
|
||
assert!(!on_audio_thread(), "marking another thread leaves this one alone");
|
||
}
|
||
|
||
/// What `on_audio_thread` says inside each strip job, for strips run from a thread that is
|
||
/// (or is not) the audio thread.
|
||
fn seen_by_strips(audio: bool) -> Vec<bool> {
|
||
std::thread::spawn(move || {
|
||
if audio {
|
||
mark_audio_thread();
|
||
}
|
||
let mut work = vec![None; 64];
|
||
run_strips(&mut work, |w| {
|
||
// Long enough that the pool's workers take part.
|
||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||
*w = Some(on_audio_thread());
|
||
});
|
||
work.into_iter().map(|w| w.unwrap()).collect()
|
||
})
|
||
.join()
|
||
.unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn strips_rendered_for_the_audio_thread_count_as_the_audio_thread_and_only_then() {
|
||
assert!(seen_by_strips(true).iter().all(|&rt| rt), "a worker rendering for the audio thread is on it");
|
||
assert!(seen_by_strips(false).iter().all(|&rt| !rt), "the workers don't stay marked afterwards");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|