Files
soundcraft/crates/mix/src/lib.rs
T

2504 lines
103 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;