mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-14 14:26:46 -04:00
2968 lines
126 KiB
Rust
2968 lines
126 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).
|
||
//! [`MixEngine::capture_states`] reads every live instance's state back (for saving the session);
|
||
//! it calls into the plugins and allocates, so a realtime host runs it only on request.
|
||
//!
|
||
//! 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`], [`PreparedInstance`] and
|
||
/// [`MixEngine::capture_states`] (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
|
||
}
|
||
|
||
/// 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<Box<dyn Plugin>> {
|
||
let mut p = create_plugin(id)?;
|
||
if let Some(st) = state
|
||
&& !p.load_state(st)
|
||
{
|
||
log::warn!("{id}: the plugin did not accept its stored state");
|
||
}
|
||
p.prepare(sample_rate, max_block, ch);
|
||
Some(p)
|
||
}
|
||
|
||
/// Peak meter values for one strip (linear, per channel, max over the last 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,
|
||
}
|
||
|
||
impl StripMeter {
|
||
/// Copy another meter without reallocating once the channel count is stable.
|
||
pub fn copy_from(&mut self, o: &StripMeter) {
|
||
self.peak = o.peak;
|
||
self.peaks.clone_from(&o.peaks);
|
||
self.gain_reduction_db = o.gain_reduction_db;
|
||
}
|
||
|
||
fn measure(&mut self, bufs: &[Vec<f32>], frames: usize, gr: f32) {
|
||
if self.peaks.len() != bufs.len() {
|
||
self.peaks.resize(bufs.len(), 0.0);
|
||
}
|
||
for (p, b) in self.peaks.iter_mut().zip(bufs.iter()) {
|
||
*p = peak(b.get(..frames).unwrap_or(&[]));
|
||
}
|
||
let last = self.peaks.len().saturating_sub(1);
|
||
self.peak = [self.peaks.first().copied().unwrap_or(0.0), self.peaks.get(1.min(last)).copied().unwrap_or(0.0)];
|
||
self.gain_reduction_db = gr;
|
||
}
|
||
}
|
||
|
||
/// A bus: its format and one buffer per channel.
|
||
struct BusBuf {
|
||
fmt: ChannelFormat,
|
||
buf: Vec<Vec<f32>>,
|
||
}
|
||
|
||
struct PluginSlot {
|
||
id: String,
|
||
/// Channel count the plugin was prepared for.
|
||
ch: usize,
|
||
plugin: Box<dyn Plugin>,
|
||
/// Last values pushed to the plugin, in the insert's parameter-map order.
|
||
applied: Vec<f32>,
|
||
}
|
||
|
||
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>>,
|
||
}
|
||
|
||
/// A multichannel delay line (samples), resized only when the delay changes.
|
||
#[derive(Default)]
|
||
struct DelayLine {
|
||
bufs: Vec<Vec<f32>>,
|
||
idx: usize,
|
||
len: usize,
|
||
}
|
||
|
||
impl DelayLine {
|
||
fn set(&mut self, len: usize, ch: usize) {
|
||
let len = len.min(1 << 20);
|
||
if len != self.len || self.bufs.len() != ch {
|
||
self.len = len;
|
||
self.idx = 0;
|
||
self.bufs = vec![vec![0.0; len]; ch];
|
||
}
|
||
}
|
||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||
if self.len == 0 {
|
||
return;
|
||
}
|
||
let start = self.idx;
|
||
for (c, ch) in io.iter_mut().enumerate() {
|
||
let Some(ring) = self.bufs.get_mut(c) else { continue };
|
||
let mut i = start;
|
||
for x in ch.iter_mut().take(frames) {
|
||
if let Some(r) = ring.get_mut(i) {
|
||
std::mem::swap(r, x);
|
||
}
|
||
i += 1;
|
||
if i >= self.len {
|
||
i = 0;
|
||
}
|
||
}
|
||
}
|
||
self.idx = (start + frames) % self.len.max(1);
|
||
}
|
||
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(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 mix.
|
||
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,
|
||
/// 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)
|
||
}?;
|
||
Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new() })
|
||
}
|
||
}
|
||
|
||
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,
|
||
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
|
||
}
|
||
|
||
/// The state of every live plugin that has one (third-party plugins), as
|
||
/// `(track, slot or INSTRUMENT_SLOT, state)`. Calls into the plugins and allocates.
|
||
pub fn capture_states(&mut self) -> Vec<(TrackId, usize, Vec<u8>)> {
|
||
let mut out = Vec::new();
|
||
for (tid, st) in self.strips.iter_mut() {
|
||
for (i, slot) in st.plugins.iter_mut().enumerate() {
|
||
if let Some(data) = slot.as_mut().and_then(|s| s.plugin.save_state()) {
|
||
out.push((*tid, i, data));
|
||
}
|
||
}
|
||
if let Some(data) = st.instrument.as_mut().and_then(|s| s.plugin.save_state()) {
|
||
out.push((*tid, INSTRUMENT_SLOT, data));
|
||
}
|
||
}
|
||
out.sort_by_key(|(t, s, _)| (*t, *s));
|
||
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();
|
||
}
|
||
if let Some(i) = &mut s.instrument {
|
||
i.plugin.all_notes_off();
|
||
i.plugin.reset();
|
||
}
|
||
s.held.clear();
|
||
s.chase = true;
|
||
s.align.clear();
|
||
s.align_pre.clear();
|
||
s.out_extra.clear();
|
||
}
|
||
self.meters.clear();
|
||
self.main_meter = StripMeter::default();
|
||
}
|
||
|
||
/// 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 {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
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);
|
||
}
|
||
st.out_extra.set(if t.mixer.output == Route::Main { 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
|
||
{
|
||
apply_params(slot, ins, t, i, pos);
|
||
slot.plugin.set_transport(&transport);
|
||
slot.plugin.process(&mut strip.buf, frames);
|
||
}
|
||
}
|
||
let v0 = volume_db_at(t, pos);
|
||
let v1 = volume_db_at(t, pos + frames as i64);
|
||
for (ch, src) in strip.buf.iter().enumerate().take(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);
|
||
if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) {
|
||
*d = x * g;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
self.meters.entry(t.id).or_default().measure(&self.main, frames, 0.0);
|
||
}
|
||
}
|
||
// 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);
|
||
}
|
||
|
||
/// 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) {
|
||
self.meters.entry(t.id).or_default().measure(&strip.buf, frames, strip.gr);
|
||
let src_fmt = strip_format(t, strip.buf.len());
|
||
let law = self.pan_law;
|
||
for (i, snd) in t.mixer.sends.iter().enumerate() {
|
||
let Some(snd) = snd else { continue };
|
||
if snd.mute || strip.muted {
|
||
continue;
|
||
}
|
||
let lvl = send_level(t, i, snd.level_db, pos);
|
||
let gain = db_to_gain(lvl);
|
||
let src = if snd.pre_fader { &strip.pre } else { &strip.buf };
|
||
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 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) = &mut strip.instrument {
|
||
// #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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
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 {
|
||
continue;
|
||
}
|
||
apply_params(slot, ins, t, i, pos);
|
||
slot.plugin.set_transport(&transport);
|
||
slot.plugin.process(&mut strip.buf, frames);
|
||
latency = latency.saturating_add(slot.plugin.latency());
|
||
if gr == 0.0 {
|
||
gr = slot.plugin.gain_reduction_db();
|
||
}
|
||
}
|
||
strip.gr = gr;
|
||
strip.latency = latency;
|
||
let auto_mute = t.mixer.automation_mode.reads() && t.lane(&AutoParam::Mute).is_some_and(|l| l.value_at(pos, 0.0) >= 0.5);
|
||
let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t);
|
||
strip.muted = t.mixer.mute || auto_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));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 }
|
||
}
|
||
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();
|
||
// Depth = how many aux hops feed this aux's input bus (bounded; cycles cap out).
|
||
let depth = |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
|
||
};
|
||
auxes.sort_by_key(|&i| depth(i));
|
||
order.extend(auxes);
|
||
order
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
/// 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::*;
|
||
|
||
/// 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 as usize).saturating_add(1)).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 {
|
||
continue;
|
||
}
|
||
apply_params(slot, ins, t, i, pos);
|
||
latency = latency.saturating_add(slot.plugin.latency());
|
||
}
|
||
strip.latency = 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 offline render (samples summed over channels): 2^29 f32 = 2 GiB, about 93
|
||
/// minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory.
|
||
pub const MAX_RENDER_SAMPLES: usize = 1 << 29;
|
||
|
||
/// Offline render of the main mix over `range` (planar, [`main_channels`] channels in SMPTE/WAV
|
||
/// order). The mix's delay-compensation latency is removed, so the result lines up with the
|
||
/// timeline.
|
||
pub fn render_range(s: &Session, range: Range, block: usize) -> Vec<Vec<f32>> {
|
||
let nch = main_channels(s);
|
||
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch.max(1));
|
||
// Probe the latency with one block, then render `len + latency` and drop the head.
|
||
let latency = {
|
||
let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||
let b = probe.max_block();
|
||
let mut tmp = vec![vec![0.0f32; b]; nch];
|
||
probe.render(s, range.start, b.min(len.max(1)), &mut tmp);
|
||
probe.latency
|
||
};
|
||
let total = len.saturating_add(latency);
|
||
let mut out = vec![vec![0.0f32; total]; nch];
|
||
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||
let b = eng.max_block();
|
||
let mut tmp = vec![vec![0.0f32; b]; nch];
|
||
let mut done = 0usize;
|
||
while done < total {
|
||
let n = (total - done).min(b);
|
||
eng.render(s, range.start + done as i64, n, &mut tmp);
|
||
for (o, t) in out.iter_mut().zip(tmp.iter()) {
|
||
if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) {
|
||
d.copy_from_slice(sr);
|
||
}
|
||
}
|
||
done += n;
|
||
}
|
||
if latency > 0 {
|
||
for c in &mut out {
|
||
c.drain(..latency.min(c.len()));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Offline render of one bus over `range` (planar, the bus format's channel count).
|
||
/// Track-insert compensation is removed, so a bus fed by tracks 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>> {
|
||
let nch = s.bus(bus).map(|b| b.format.channels()).unwrap_or(0);
|
||
if nch == 0 {
|
||
return Vec::new();
|
||
}
|
||
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch);
|
||
let latency = {
|
||
let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||
let b = probe.max_block();
|
||
probe.render(s, range.start, b.min(len.max(1)), &mut []);
|
||
probe.track_latency
|
||
};
|
||
let total = len.saturating_add(latency);
|
||
let mut out = vec![vec![0.0f32; total]; nch];
|
||
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||
let b = eng.max_block();
|
||
let mut tmp = vec![vec![0.0f32; b]; nch];
|
||
let mut done = 0usize;
|
||
while done < total {
|
||
let n = (total - done).min(b);
|
||
eng.render(s, range.start + done as i64, n, &mut []);
|
||
eng.copy_bus(bus, n, &mut tmp);
|
||
for (o, t) in out.iter_mut().zip(tmp.iter()) {
|
||
if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) {
|
||
d.copy_from_slice(sr);
|
||
}
|
||
}
|
||
done += n;
|
||
}
|
||
if latency > 0 {
|
||
for c in &mut out {
|
||
c.drain(..latency.min(c.len()));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped;
|
||
/// Ambisonics play their W channel). Mono/stereo input is returned unchanged.
|
||
pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec<f32>]) -> Vec<Vec<f32>> {
|
||
if ch.len() <= 2 {
|
||
return ch.to_vec();
|
||
}
|
||
let len = ch.iter().map(Vec::len).max().unwrap_or(0);
|
||
let mut out = vec![vec![0.0f32; len]; 2];
|
||
let sp = fmt.speakers();
|
||
let h = std::f32::consts::FRAC_1_SQRT_2;
|
||
for (c, src) in ch.iter().enumerate() {
|
||
let (gl, gr) = match sp.get(c) {
|
||
Some(x) if sp.len() == ch.len() => itu_stereo_fold(*x),
|
||
_ if c == 0 => (h, h),
|
||
_ => (0.0, 0.0),
|
||
};
|
||
for (g, dst) in [(gl, 0usize), (gr, 1)] {
|
||
if g == 0.0 {
|
||
continue;
|
||
}
|
||
if let Some(d) = out.get_mut(dst) {
|
||
for (o, x) in d.iter_mut().zip(src.iter()) {
|
||
*o += x * g;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Render only one track's clips (no mixer processing) — Consolidate.
|
||
pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
|
||
let Some(t) = s.track(track) else { return Vec::new() };
|
||
let len = usize::try_from(range.len().max(0)).unwrap_or(0).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>> {
|
||
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 {
|
||
use super::*;
|
||
use soundcraft_audio_io::AudioBuffer;
|
||
use soundcraft_model::{Insert, SourceAudio, SourceId};
|
||
use std::sync::Arc;
|
||
|
||
fn synth_session(level_db: f32) -> Session {
|
||
use soundcraft_midi::{Note, Sequence};
|
||
let mut s = Session::default();
|
||
let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
|
||
let mut seq = Sequence::default();
|
||
seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 });
|
||
let cid = s.new_clip_id();
|
||
let tr = s.track_mut(t).unwrap();
|
||
tr.playlist_mut().unwrap().clips.push(Clip::midi(cid, "n", 0, 24_000, seq));
|
||
let mut ins = Insert::new("subtractive_synth");
|
||
ins.params.insert("level".into(), level_db);
|
||
tr.instrument = Some(ins);
|
||
s
|
||
}
|
||
|
||
#[test]
|
||
fn stored_instrument_params_reach_the_plugin() {
|
||
// #152: a saved instrument level of -12 dB must render quieter than -6 dB.
|
||
// RMS over the held part of the note, past any parameter smoothing.
|
||
let rms = |db: f32| {
|
||
let out = render_range(&synth_session(db), Range::new(0, 12_000), 512);
|
||
let tail = out[0].get(6_000..).unwrap_or_default();
|
||
(tail.iter().map(|x| x * x).sum::<f32>() / tail.len().max(1) as f32).sqrt()
|
||
};
|
||
let (loud, quiet) = (rms(-6.0), rms(-12.0));
|
||
assert!(loud > 0.0, "the synth should sound");
|
||
assert!((quiet / loud - 0.5).abs() < 0.05, "-12 dB vs -6 dB should halve the level: {quiet} / {loud}");
|
||
}
|
||
|
||
#[test]
|
||
fn insert_slot_a_automation_does_not_drive_the_instrument() {
|
||
let mut s = synth_session(-6.0);
|
||
let reference = render_range(&s, Range::new(0, 12_000), 512);
|
||
let tr = &mut s.tracks[0];
|
||
let mut lane = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: 0, param: "level".into() });
|
||
lane.set_point(0, -48.0);
|
||
tr.automation.push(lane);
|
||
assert_eq!(render_range(&s, Range::new(0, 12_000), 512), reference);
|
||
}
|
||
|
||
fn session_with_dc(level: f32, frames: usize) -> (Session, TrackId) {
|
||
let mut s = Session::default();
|
||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; frames]] };
|
||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(buf)));
|
||
let id = s.new_clip_id();
|
||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||
pl.clips.push(Clip::audio(id, "dc", SourceId(900), 0, 0, frames as i64));
|
||
}
|
||
(s, t)
|
||
}
|
||
|
||
#[test]
|
||
fn mono_center_pan_is_minus_3db() {
|
||
let (s, _) = session_with_dc(0.5, 4800);
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
let expect = 0.5 * std::f32::consts::FRAC_1_SQRT_2;
|
||
assert!((out[0][1000] - expect).abs() < 1e-3, "{}", out[0][1000]);
|
||
assert!((out[1][1000] - expect).abs() < 1e-3);
|
||
}
|
||
|
||
#[test]
|
||
fn fader_mute_and_solo() {
|
||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||
s.track_mut(t).unwrap().mixer.volume_db = -6.0;
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
let expect = 0.5 * std::f32::consts::FRAC_1_SQRT_2 * db_to_gain(-6.0);
|
||
assert!((out[0][2000] - expect).abs() < 1e-3);
|
||
s.track_mut(t).unwrap().mixer.mute = true;
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
assert!(out[0][2000].abs() < 1e-6);
|
||
// Soloing another track mutes this one.
|
||
s.track_mut(t).unwrap().mixer.mute = false;
|
||
let other = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||
s.track_mut(other).unwrap().mixer.solo = true;
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
assert!(out[0][2000].abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn hard_pans() {
|
||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||
s.track_mut(t).unwrap().mixer.pan = vec![-1.0];
|
||
let out = render_range(&s, Range::new(0, 4800), 256);
|
||
assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4);
|
||
}
|
||
|
||
#[test]
|
||
fn pan_automation_does_not_depend_on_the_block_size() {
|
||
// Bounces render in 1024-frame blocks, playback in whatever its callback asks for (#244).
|
||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||
{
|
||
let l = s.track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0));
|
||
l.set_point(0, -1.0);
|
||
l.set_point(480, 1.0);
|
||
}
|
||
let bounce = render_range(&s, Range::new(0, 4800), 1024);
|
||
assert!(bounce[0][600].abs() < 1e-4 && bounce[1][600] > 0.49, "{} {}", bounce[0][600], bounce[1][600]);
|
||
for block in [512, 100, 37] {
|
||
assert_eq!(render_range(&s, Range::new(0, 4800), block), bounce, "block {block}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fades_and_clip_gain_apply() {
|
||
let (mut s, t) = session_with_dc(1.0, 48_000);
|
||
{
|
||
let c = &mut s.track_mut(t).unwrap().playlist_mut().unwrap().clips[0];
|
||
c.fade_in = soundcraft_model::Fade { len: 1000, shape: soundcraft_model::FadeShape::Linear };
|
||
c.gain_db = -6.0;
|
||
}
|
||
let out = render_range(&s, Range::new(0, 2000), 512);
|
||
let g = std::f32::consts::FRAC_1_SQRT_2 * db_to_gain(-6.0);
|
||
assert!(out[0][0].abs() < 1e-3);
|
||
assert!((out[0][500] - 0.5 * g).abs() < 0.02, "{}", out[0][500]);
|
||
assert!((out[0][1500] - g).abs() < 1e-3);
|
||
}
|
||
|
||
#[test]
|
||
fn sends_feed_aux_via_bus() {
|
||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||
let bus = s.add_bus("Verb", ChannelFormat::Stereo);
|
||
let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Verb"));
|
||
s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus);
|
||
{
|
||
let tr = s.track_mut(t).unwrap();
|
||
tr.mixer.output = Route::None;
|
||
let mut snd = soundcraft_model::SendSlot::new(Route::Bus(bus));
|
||
snd.level_db = 0.0;
|
||
tr.mixer.sends[0] = Some(snd);
|
||
}
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]);
|
||
}
|
||
|
||
#[test]
|
||
fn render_bus_is_the_bus_not_the_main_mix() {
|
||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||
let stem = s.add_bus("Stem", ChannelFormat::Stereo);
|
||
let empty = s.add_bus("Empty", ChannelFormat::Stereo);
|
||
s.track_mut(t).unwrap().mixer.output = Route::Bus(stem);
|
||
let main = render_range(&s, Range::new(0, 4800), 512);
|
||
let bus = render_bus(&s, stem, Range::new(0, 4800), 512);
|
||
let silent = render_bus(&s, empty, Range::new(0, 4800), 512);
|
||
assert!(main[0][1000].abs() < 1e-4, "main should not carry a track routed to the bus: {}", main[0][1000]);
|
||
assert!(bus[0][1000].abs() > 0.1, "bus should carry the track: {}", bus[0][1000]);
|
||
assert!(silent.iter().all(|c| c.iter().all(|x| x.abs() < 1e-6)));
|
||
}
|
||
|
||
#[test]
|
||
fn volume_automation_ramps() {
|
||
let (mut s, t) = session_with_dc(0.5, 48_000);
|
||
{
|
||
let tr = s.track_mut(t).unwrap();
|
||
let l = tr.lane_mut(&AutoParam::Volume);
|
||
l.set_point(0, 0.0);
|
||
l.set_point(24_000, -144.0);
|
||
}
|
||
let out = render_range(&s, Range::new(0, 48_000), 512);
|
||
assert!(out[0][100] > 0.3);
|
||
assert!(out[0][30_000].abs() < 1e-4);
|
||
}
|
||
|
||
#[test]
|
||
fn inserts_process_and_survive_unknown_plugins() {
|
||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("does-not-exist"));
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
assert!(out[0][100] > 0.3);
|
||
let id = soundcraft_dsp::plugins().iter().find(|p| p.id.contains("gain")).map(|p| p.id).unwrap_or("gain");
|
||
let mut ins = Insert::new(id);
|
||
ins.params.insert("gain".into(), -6.0);
|
||
s.track_mut(t).unwrap().mixer.inserts[0] = Some(ins);
|
||
let _ = render_range(&s, Range::new(0, 4800), 512);
|
||
}
|
||
|
||
/// A built-in gain behind a third-party id, with a state blob and a log of parameter sets.
|
||
struct Stateful {
|
||
inner: Box<dyn Plugin>,
|
||
blob: Vec<u8>,
|
||
sets: Arc<std::sync::atomic::AtomicUsize>,
|
||
}
|
||
|
||
impl Plugin for Stateful {
|
||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||
self.inner.info()
|
||
}
|
||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||
self.inner.prepare(sr, mb, ch);
|
||
}
|
||
fn reset(&mut self) {
|
||
self.inner.reset();
|
||
}
|
||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||
self.sets.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||
self.inner.set_param(id, v)
|
||
}
|
||
fn param(&self, id: &str) -> Option<f32> {
|
||
self.inner.param(id)
|
||
}
|
||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||
self.inner.process(io, frames);
|
||
}
|
||
fn save_state(&mut self) -> Option<Vec<u8>> {
|
||
Some(self.blob.clone())
|
||
}
|
||
}
|
||
|
||
struct Clock {
|
||
inner: Box<dyn Plugin>,
|
||
seen: Arc<std::sync::Mutex<Vec<Transport>>>,
|
||
}
|
||
|
||
impl Plugin for Clock {
|
||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||
self.inner.info()
|
||
}
|
||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||
self.inner.prepare(sr, mb, ch);
|
||
}
|
||
fn reset(&mut self) {
|
||
self.inner.reset();
|
||
}
|
||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||
self.inner.set_param(id, v)
|
||
}
|
||
fn param(&self, id: &str) -> Option<f32> {
|
||
self.inner.param(id)
|
||
}
|
||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||
self.inner.process(io, frames);
|
||
}
|
||
fn set_transport(&mut self, t: &Transport) {
|
||
self.seen.lock().unwrap().push(*t);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn plugins_get_the_session_transport() {
|
||
let (mut s, t) = session_with_dc(0.5, 48_000 * 4);
|
||
s.tempo.set_tempo(0, 90.0).unwrap();
|
||
s.tempo.set_meter(0, 3, 4).unwrap();
|
||
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.clock"));
|
||
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||
let clock = Clock { inner: soundcraft_dsp::create("gain").unwrap(), seen: Arc::clone(&seen) };
|
||
let mut eng = MixEngine::new(48_000.0, 512);
|
||
eng.set_external_instances(true);
|
||
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.clock".into(), ch: 1, plugin: Box::new(clock) }]);
|
||
render_block(&mut eng, &s, 96_000);
|
||
eng.monitor_only = true;
|
||
render_block(&mut eng, &s, 96_000);
|
||
let seen = seen.lock().unwrap();
|
||
let t0 = seen[0];
|
||
assert!(t0.playing && !t0.recording);
|
||
assert_eq!((t0.tempo, t0.numerator, t0.denominator, t0.samples, t0.bar), (90.0, 3, 4, 96_000, 1));
|
||
assert!((t0.seconds - 2.0).abs() < 1e-9 && (t0.quarters - 3.0).abs() < 1e-9 && (t0.bar_start - 3.0).abs() < 1e-9);
|
||
assert!(!seen[1].playing);
|
||
}
|
||
|
||
struct Ears {
|
||
inner: Box<dyn Plugin>,
|
||
heard: Arc<std::sync::Mutex<Vec<(usize, [u8; 3])>>>,
|
||
}
|
||
|
||
impl Plugin for Ears {
|
||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||
self.inner.info()
|
||
}
|
||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||
self.inner.prepare(sr, mb, ch);
|
||
}
|
||
fn reset(&mut self) {
|
||
self.inner.reset();
|
||
}
|
||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||
self.inner.set_param(id, v)
|
||
}
|
||
fn param(&self, id: &str) -> Option<f32> {
|
||
self.inner.param(id)
|
||
}
|
||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||
self.inner.process(io, frames);
|
||
}
|
||
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
|
||
self.heard.lock().unwrap().push((offset, msg));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn controllers_play_at_their_offsets_and_are_chased_after_a_seek() {
|
||
let mut s = Session::default();
|
||
let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
|
||
s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.ears"));
|
||
let cc = |tick, kind, value| soundcraft_midi::CtrlEvent { tick, channel: 0, kind, value };
|
||
let ctrls = vec![cc(4, CtrlKind::Cc(64), 127), cc(960, CtrlKind::PitchBend, -8192), cc(1920, CtrlKind::Cc(64), 0)];
|
||
let seq = soundcraft_midi::Sequence { ctrls, ..Default::default() };
|
||
let cid = s.new_clip_id();
|
||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, 48_000 * 4, seq));
|
||
let heard = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||
let ears = Ears { inner: soundcraft_dsp::create("gain").unwrap(), heard: Arc::clone(&heard) };
|
||
let mut eng = MixEngine::new(48_000.0, 512);
|
||
eng.set_external_instances(true);
|
||
eng.adopt(vec![PreparedInstance { track: t, slot: INSTRUMENT_SLOT, id: "clap:test.ears".into(), ch: 2, plugin: Box::new(ears) }]);
|
||
render_block(&mut eng, &s, 0);
|
||
assert_eq!(std::mem::take(&mut *heard.lock().unwrap()), vec![(0, [0xB0, 121, 0]), (100, [0xB0, 64, 127])]);
|
||
render_block(&mut eng, &s, 36_000);
|
||
assert_eq!(std::mem::take(&mut *heard.lock().unwrap()), vec![(0, [0xB0, 121, 0]), (0, [0xB0, 64, 127]), (0, [0xE0, 0, 0])]);
|
||
render_block(&mut eng, &s, 36_512);
|
||
assert!(heard.lock().unwrap().is_empty());
|
||
}
|
||
|
||
struct Keys {
|
||
inner: Box<dyn Plugin>,
|
||
heard: Arc<std::sync::Mutex<Vec<(u8, u8)>>>,
|
||
}
|
||
|
||
impl Plugin for Keys {
|
||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||
self.inner.info()
|
||
}
|
||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||
self.inner.prepare(sr, mb, ch);
|
||
}
|
||
fn reset(&mut self) {
|
||
self.inner.reset();
|
||
}
|
||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||
self.inner.set_param(id, v)
|
||
}
|
||
fn param(&self, id: &str) -> Option<f32> {
|
||
self.inner.param(id)
|
||
}
|
||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||
self.inner.process(io, frames);
|
||
}
|
||
fn note_on(&mut self, _offset: usize, note: u8, velocity: u8) {
|
||
self.heard.lock().unwrap().push((note, velocity));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn real_time_properties_reach_the_instrument() {
|
||
let mut s = Session::default();
|
||
let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
|
||
s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.keys"));
|
||
let mut seq = soundcraft_midi::Sequence::default();
|
||
seq.notes.push(soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 });
|
||
let cid = s.new_clip_id();
|
||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, 48_000, seq));
|
||
s.edit.values.insert(format!("rtp.{}.transpose", t.0), 12.0);
|
||
s.edit.values.insert(format!("rtp.{}.velocity", t.0), -20.0);
|
||
let heard = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||
let keys = Keys { inner: soundcraft_dsp::create("gain").unwrap(), heard: Arc::clone(&heard) };
|
||
let mut eng = MixEngine::new(48_000.0, 512);
|
||
eng.set_external_instances(true);
|
||
eng.adopt(vec![PreparedInstance { track: t, slot: INSTRUMENT_SLOT, id: "clap:test.keys".into(), ch: 2, plugin: Box::new(keys) }]);
|
||
render_block(&mut eng, &s, 0);
|
||
assert_eq!(*heard.lock().unwrap(), vec![(72, 80)]);
|
||
}
|
||
|
||
fn render_block(eng: &mut MixEngine, s: &Session, pos: i64) -> f32 {
|
||
let mut out = vec![vec![0.0f32; 512]; 2];
|
||
eng.render(s, pos, 512, &mut out);
|
||
out[0][500]
|
||
}
|
||
|
||
#[test]
|
||
fn external_instances_are_adopted_retired_and_captured() {
|
||
let (mut s, t) = session_with_dc(0.5, 48_000);
|
||
let mut ins = Insert::new("clap:test.fake");
|
||
ins.params.insert("gain".into(), -6.0);
|
||
s.track_mut(t).unwrap().mixer.inserts[0] = Some(ins);
|
||
let specs = instance_specs(&s);
|
||
assert_eq!(specs, vec![InstanceSpec { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, state: None }]);
|
||
let mut eng = MixEngine::new(48_000.0, 512);
|
||
eng.set_external_instances(true);
|
||
// Nothing to adopt yet: the slot passes audio through (and sync never tried to load a
|
||
// CLAP plugin on this thread).
|
||
let dry = render_block(&mut eng, &s, 0);
|
||
assert!(dry > 0.3, "{dry}");
|
||
let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||
let mut gain = soundcraft_dsp::create("gain").unwrap();
|
||
gain.prepare(48_000.0, 512, 1);
|
||
let fake = Stateful { inner: gain, blob: b"live state".to_vec(), sets: Arc::clone(&sets) };
|
||
// An instance for the wrong channel count is retired, not used.
|
||
let wrong = Stateful { inner: soundcraft_dsp::create("gain").unwrap(), blob: Vec::new(), sets: Arc::clone(&sets) };
|
||
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 2, plugin: Box::new(wrong) }]);
|
||
assert!(render_block(&mut eng, &s, 512) > 0.3);
|
||
assert_eq!(eng.take_retired().len(), 1);
|
||
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: Box::new(fake) }]);
|
||
let mut wet = 0.0;
|
||
for b in 2..20 {
|
||
wet = render_block(&mut eng, &s, b * 512);
|
||
}
|
||
assert!((wet / dry - 0.501).abs() < 0.01, "{wet} vs {dry}");
|
||
assert!(sets.load(std::sync::atomic::Ordering::Relaxed) > 0, "no stored state: session params are pushed");
|
||
assert_eq!(eng.capture_states(), vec![(t, 0, b"live state".to_vec())]);
|
||
assert!(eng.take_retired().is_empty());
|
||
// Removing the insert retires the instance instead of dropping it here.
|
||
s.track_mut(t).unwrap().mixer.inserts[0] = None;
|
||
render_block(&mut eng, &s, 20 * 512);
|
||
assert_eq!(eng.take_retired().len(), 1);
|
||
assert!(eng.capture_states().is_empty());
|
||
// Instances nobody claims are retired too; drain_plugins empties the engine.
|
||
eng.adopt(vec![PreparedInstance { track: t, slot: 3, id: "clap:x".into(), ch: 1, plugin: soundcraft_dsp::create("gain").unwrap() }]);
|
||
render_block(&mut eng, &s, 21 * 512);
|
||
assert_eq!(eng.take_retired().len(), 1);
|
||
assert!(eng.drain_plugins().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn a_new_instance_replaces_a_matching_one_in_its_slot() {
|
||
// The host removed and re-added the plugin, and both session updates reached the audio
|
||
// thread in one go: the slot still holds the old instance (same id, same channels), but
|
||
// the host made a new one and drives its editor. The new one must win, or the editor
|
||
// talks to an instance that was retired ("the plugin is gone").
|
||
let (mut s, t) = session_with_dc(0.5, 48_000);
|
||
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.fake"));
|
||
let mut eng = MixEngine::new(48_000.0, 512);
|
||
eng.set_external_instances(true);
|
||
let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||
let make = |blob: &[u8]| {
|
||
let mut gain = soundcraft_dsp::create("gain").unwrap();
|
||
gain.prepare(48_000.0, 512, 1);
|
||
Box::new(Stateful { inner: gain, blob: blob.to_vec(), sets: Arc::clone(&sets) })
|
||
};
|
||
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"old") }]);
|
||
render_block(&mut eng, &s, 0);
|
||
assert_eq!(eng.capture_states(), vec![(t, 0, b"old".to_vec())]);
|
||
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"new") }]);
|
||
render_block(&mut eng, &s, 512);
|
||
assert_eq!(eng.capture_states(), vec![(t, 0, b"new".to_vec())]);
|
||
assert_eq!(eng.take_retired().len(), 1, "the old instance is retired");
|
||
}
|
||
|
||
#[test]
|
||
fn stored_state_comes_first_then_session_params() {
|
||
let (mut s, t) = session_with_dc(0.5, 48_000);
|
||
let mut ins = Insert::new("vst3:00000000000000000000000000000001");
|
||
ins.params.insert("gain".into(), -6.0);
|
||
ins.set_state_bytes(b"stored");
|
||
s.track_mut(t).unwrap().mixer.inserts[0] = Some(ins);
|
||
assert_eq!(instance_specs(&s)[0].state.as_deref(), Some(&b"stored"[..]));
|
||
let mut eng = MixEngine::new(48_000.0, 512);
|
||
eng.set_external_instances(true);
|
||
let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||
let fake = Stateful { inner: soundcraft_dsp::create("gain").unwrap(), blob: Vec::new(), sets: Arc::clone(&sets) };
|
||
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "vst3:00000000000000000000000000000001".into(), ch: 1, plugin: Box::new(fake) }]);
|
||
render_block(&mut eng, &s, 0);
|
||
assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 1, "session params apply once");
|
||
render_block(&mut eng, &s, 512);
|
||
assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||
s.track_mut(t).unwrap().mixer.inserts[0].as_mut().unwrap().params.insert("gain".into(), -12.0);
|
||
render_block(&mut eng, &s, 1024);
|
||
assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 2, "later session edits apply");
|
||
// Offline (not external): unknown third-party plugins are simply absent.
|
||
let out = render_range(&s, Range::new(0, 1024), 512);
|
||
assert!(out[0][100] > 0.3);
|
||
}
|
||
|
||
#[test]
|
||
fn clip_effects_gain_and_trim_automation_apply() {
|
||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||
let cid = s.track(t).unwrap().clips()[0].id.0;
|
||
s.edit.values.insert(format!("clip_fx.{cid}.gain"), -6.0);
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
let expect = 0.5 * std::f32::consts::FRAC_1_SQRT_2 * db_to_gain(-6.0);
|
||
assert!((out[0][2000] - expect).abs() < 0.01, "{}", out[0][2000]);
|
||
s.edit.set_flag(&format!("clip_fx.bypass.{cid}"), true);
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
assert!((out[0][2000] - 0.5 * std::f32::consts::FRAC_1_SQRT_2).abs() < 0.01);
|
||
s.track_mut(t).unwrap().lane_mut(&AutoParam::Plugin { slot: u8::MAX, param: "trim".into() }).set_point(0, -12.0);
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
assert!(out[0][2000] < 0.2, "{}", out[0][2000]);
|
||
}
|
||
|
||
#[test]
|
||
fn delay_compensation_aligns_latent_tracks() {
|
||
let mut s = Session::default();
|
||
let mut imp = vec![0.0f32; 48_000];
|
||
imp[1000] = 1.0;
|
||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![imp] })));
|
||
let mut ids = Vec::new();
|
||
for (pan, fx) in [(-1.0f32, true), (1.0, false)] {
|
||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||
let cid = s.new_clip_id();
|
||
let tr = s.track_mut(t).unwrap();
|
||
tr.playlist_mut().unwrap().clips.push(Clip::audio(cid, "i", SourceId(900), 0, 0, 48_000));
|
||
tr.mixer.pan = vec![pan];
|
||
if fx {
|
||
tr.mixer.inserts[0] = Some(Insert::new("maximizer"));
|
||
}
|
||
ids.push(t);
|
||
}
|
||
let argmax = |c: &[f32]| c.iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).map(|(i, _)| i).unwrap();
|
||
let lat = soundcraft_dsp::create("maximizer").unwrap().latency();
|
||
assert!(lat > 0, "test needs a latent plugin");
|
||
let out = render_range(&s, Range::new(0, 8000), 512);
|
||
assert_eq!(argmax(&out[0]), argmax(&out[1]), "PDC should align L and R");
|
||
s.edit.delay_compensation = false;
|
||
let out = render_range(&s, Range::new(0, 8000), 512);
|
||
assert_ne!(argmax(&out[0]), argmax(&out[1]));
|
||
}
|
||
|
||
/// A reset (stop, locate) must not leave the previous position's audio in the alignment delay.
|
||
#[test]
|
||
fn reset_clears_delay_compensation_history() {
|
||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||
s.edit.click = false;
|
||
s.track_mut(t).unwrap().mixer.pan = vec![1.0];
|
||
let latent = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||
let mut comp = Insert::new("compressor");
|
||
comp.params.insert("lookahead".into(), 5.0);
|
||
s.track_mut(latent).unwrap().mixer.inserts[0] = Some(comp);
|
||
let mut eng = MixEngine::new(48_000.0, 512);
|
||
let mut out = vec![vec![0.0f32; 512]; 2];
|
||
for b in 0..9 {
|
||
eng.render(&s, b * 512, 512, &mut out);
|
||
}
|
||
assert!(eng.latency > 0 && out[1][500] > 0.3, "the fixture plays through the alignment delay");
|
||
eng.reset();
|
||
eng.render(&s, 1_000_000, 512, &mut out);
|
||
assert!(out.iter().flatten().all(|x| *x == 0.0), "old aligned audio after a reset");
|
||
}
|
||
|
||
fn impulse_at(ch: &[f32]) -> Vec<usize> {
|
||
ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect()
|
||
}
|
||
|
||
/// An aux insert's lookahead is part of the mix latency on the first block, not the block after.
|
||
#[test]
|
||
fn delay_compensation_aligns_an_aux_insert() {
|
||
let mut s = Session::default();
|
||
let n = 8192;
|
||
let mut left = vec![0.0f32; n];
|
||
for at in [0usize, 2048, 8000] {
|
||
left[at] = 0.25;
|
||
}
|
||
s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![left.clone(), left] })));
|
||
let bus = s.add_bus("Test Bus", ChannelFormat::Stereo);
|
||
let source = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Source"));
|
||
let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Return"));
|
||
let cid = s.new_clip_id();
|
||
s.track_mut(source).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "impulses", SourceId(901), 0, 0, n as i64));
|
||
s.track_mut(source).unwrap().mixer.output = Route::Bus(bus);
|
||
s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus);
|
||
let render = |s: &Session, start: i64, end: i64| render_range(s, Range::new(start, end), 1024);
|
||
let control = render(&s, 0, n as i64);
|
||
assert_eq!(impulse_at(&control[0]), vec![0, 2048, 8000]);
|
||
assert_eq!(impulse_at(&control[1]), impulse_at(&control[0]));
|
||
|
||
let mut comp = Insert::new("compressor");
|
||
comp.params.insert("lookahead".into(), 5.0);
|
||
comp.params.insert("ratio".into(), 1.0);
|
||
s.track_mut(source).unwrap().mixer.inserts[0] = Some(comp.clone());
|
||
let on_source = render(&s, 0, n as i64);
|
||
assert_eq!(impulse_at(&on_source[0]), impulse_at(&control[0]));
|
||
|
||
s.track_mut(source).unwrap().mixer.inserts[0] = None;
|
||
s.track_mut(aux).unwrap().mixer.inserts[0] = Some(comp);
|
||
let on_aux = render(&s, 0, n as i64);
|
||
assert_eq!(impulse_at(&on_aux[0]), vec![0, 2048, 8000], "aux lookahead must stay inside the bounce");
|
||
assert_eq!(impulse_at(&on_aux[1]), impulse_at(&on_aux[0]));
|
||
assert!((on_aux[0][8000] - 0.25).abs() < 1e-3, "{}", on_aux[0][8000]);
|
||
let partial = render(&s, 2048, 3072);
|
||
assert_eq!(impulse_at(&partial[0]), vec![0]);
|
||
|
||
s.track_mut(aux).unwrap().mixer.inserts[0].as_mut().unwrap().params.insert("lookahead".into(), 0.0);
|
||
let zero = render(&s, 0, n as i64);
|
||
assert_eq!(impulse_at(&zero[0]), impulse_at(&control[0]));
|
||
}
|
||
|
||
#[test]
|
||
fn input_monitoring_replaces_clips() {
|
||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||
let mut eng = MixEngine::new(48_000.0, 512);
|
||
eng.input = vec![vec![0.25; 512]];
|
||
let mut out = vec![vec![0.0; 512]; 2];
|
||
// Not monitored: the clip plays.
|
||
eng.render(&s, 0, 512, &mut out);
|
||
assert!((out[0][100] - 0.5 * std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-3);
|
||
// Monitored: the input replaces it, and it still sounds when stopped.
|
||
s.track_mut(t).unwrap().mixer.input_monitor = true;
|
||
eng.render(&s, 512, 512, &mut out);
|
||
assert!((out[0][100] - 0.25 * std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-3, "{}", out[0][100]);
|
||
eng.monitor_only = true;
|
||
eng.render(&s, 1024, 512, &mut out);
|
||
assert!(out[0][100] > 0.1);
|
||
s.track_mut(t).unwrap().mixer.input_monitor = false;
|
||
eng.render(&s, 1024, 512, &mut out);
|
||
assert!(out[0][100].abs() < 1e-6, "stopped, unmonitored: silence");
|
||
}
|
||
|
||
#[test]
|
||
fn empty_session_is_silent() {
|
||
let s = Session::default();
|
||
let out = render_range(&s, Range::new(0, 1000), 128);
|
||
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn strip_workspace_keeps_its_allocation_between_blocks() {
|
||
let mut s = Session::default();
|
||
for _ in 0..6 {
|
||
s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||
}
|
||
let mut eng = MixEngine::new(48_000.0, 512);
|
||
let mut out = vec![vec![0.0; 512]; 2];
|
||
eng.render(&s, 0, 512, &mut out);
|
||
let capacity = eng.work.capacity();
|
||
assert!(capacity >= 6);
|
||
eng.render(&s, 512, 512, &mut out);
|
||
assert_eq!(eng.work.capacity(), capacity);
|
||
}
|
||
|
||
#[test]
|
||
fn stopped_monitoring_does_not_click() {
|
||
let mut s = Session::default();
|
||
s.edit.click = true;
|
||
let mut eng = MixEngine::new(48_000.0, 512);
|
||
eng.metronome = true;
|
||
eng.monitor_only = true;
|
||
let mut out = vec![vec![0.0f32; 512]; 2];
|
||
for _ in 0..4 {
|
||
eng.render(&s, 0, 512, &mut out);
|
||
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
|
||
}
|
||
}
|
||
|
||
/// Renders `len` frames from zero the way playback does (metronome on), in blocks of `block`.
|
||
fn render_playback(s: &Session, len: usize, block: usize, recording: bool) -> Vec<Vec<f32>> {
|
||
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||
eng.metronome = true;
|
||
eng.recording = recording;
|
||
let mut out = vec![vec![0.0f32; len]; 2];
|
||
let mut tmp = vec![vec![0.0f32; block]; 2];
|
||
let mut done = 0usize;
|
||
while done < len {
|
||
let n = (len - done).min(block);
|
||
eng.render(s, done as i64, n, &mut tmp);
|
||
for (o, t) in out.iter_mut().zip(tmp.iter()) {
|
||
if let (Some(d), Some(src)) = (o.get_mut(done..done + n), t.get(..n)) {
|
||
d.copy_from_slice(src);
|
||
}
|
||
}
|
||
done += n;
|
||
}
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn click_sounds_on_every_beat_only_in_playback() {
|
||
let mut s = Session::default();
|
||
let beat = s.sample_rate.as_f64() as usize * 60 / 120; // 120 BPM quarter notes
|
||
let len = beat * 8;
|
||
s.edit.click = true;
|
||
// Offline renders (bounces, stems) never print the metronome.
|
||
let offline = render_range(&s, Range::new(0, len as i64), 512);
|
||
assert!(offline.iter().all(|c| c.iter().all(|x| *x == 0.0)));
|
||
|
||
let out = render_playback(&s, len, 512, false);
|
||
for b in 0..8usize {
|
||
let at = b * beat;
|
||
let on: f32 = out[0][at..at + 200].iter().map(|x| x.abs()).fold(0.0, f32::max);
|
||
let off: f32 = out[0][at + beat / 2..at + beat / 2 + 200].iter().map(|x| x.abs()).fold(0.0, f32::max);
|
||
assert!(on > 0.1, "beat {b} should click, peak {on}");
|
||
assert_eq!(off, 0.0, "between beats must stay silent");
|
||
assert_eq!(out[0][at], out[1][at], "both channels get the click");
|
||
}
|
||
// Blocks that start mid-beat render the same clicks as a single pass.
|
||
let split = render_playback(&s, len, 97, false);
|
||
assert_eq!(out, split);
|
||
}
|
||
|
||
#[test]
|
||
fn click_only_during_record_follows_recording() {
|
||
let mut s = Session::default();
|
||
s.edit.click = true;
|
||
s.edit.set_flag("click.only_during_record", true);
|
||
let idle = render_playback(&s, 48_000, 512, false);
|
||
assert!(idle.iter().all(|c| c.iter().all(|x| *x == 0.0)));
|
||
let rec = render_playback(&s, 48_000, 512, true);
|
||
assert!(rec.iter().any(|c| c.iter().any(|x| x.abs() > 0.1)));
|
||
}
|
||
|
||
/// A session with one track of `fmt` playing a distinct DC level per channel (0.1, 0.2, …).
|
||
fn multichannel_session(fmt: ChannelFormat, main: ChannelFormat, frames: usize) -> (Session, TrackId) {
|
||
let mut s = Session::default();
|
||
s.outputs[0].format = main;
|
||
let t = s.add_track(TrackKind::Audio, fmt, None);
|
||
let chans: Vec<Vec<f32>> = (0..fmt.channels()).map(|c| (0..frames).map(|i| 0.1 * (c + 1) as f32 + (i % 7) as f32 * 1e-3).collect()).collect();
|
||
s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: chans })));
|
||
let id = s.new_clip_id();
|
||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "mc", SourceId(901), 0, 0, frames as i64));
|
||
(s, t)
|
||
}
|
||
|
||
#[test]
|
||
fn surround_track_passes_through_bit_exact() {
|
||
let (s, _) = multichannel_session(ChannelFormat::Surround51, ChannelFormat::Surround51, 4800);
|
||
let src = s.pool.get(SourceId(901)).unwrap().buffer.channels.clone();
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
assert_eq!(out.len(), 6);
|
||
for (o, x) in out.iter().zip(src.iter()) {
|
||
assert_eq!(o, x, "5.1 → 5.1 must be bit-exact");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn surround_to_stereo_folds_down_itu() {
|
||
let (s, _) = multichannel_session(ChannelFormat::Surround51, ChannelFormat::Stereo, 4800);
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
assert_eq!(out.len(), 2);
|
||
let h = std::f32::consts::FRAC_1_SQRT_2;
|
||
let x = |c: usize| 0.1 * (c + 1) as f32 + (1000 % 7) as f32 * 1e-3;
|
||
// L R C LFE Ls Rs.
|
||
let l = x(0) + h * x(2) + h * x(4);
|
||
let r = x(1) + h * x(2) + h * x(5);
|
||
assert!((out[0][1000] - l).abs() < 1e-5, "{} vs {l}", out[0][1000]);
|
||
assert!((out[1][1000] - r).abs() < 1e-5, "{} vs {r}", out[1][1000]);
|
||
}
|
||
|
||
#[test]
|
||
fn mono_track_on_surround_main_pans() {
|
||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||
s.outputs[0].format = ChannelFormat::Surround51;
|
||
// No surround pan: stereo behaviour on L/R.
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
assert_eq!(out.len(), 6);
|
||
let h = 0.5 * gains(0.0, PanLaw::Minus3).0;
|
||
assert!((out[0][100] - h).abs() < 1e-6 && (out[1][100] - h).abs() < 1e-6);
|
||
assert!(out[2..].iter().all(|c| c[100].abs() < 1e-7));
|
||
// Front centre: the centre speaker only.
|
||
s.track_mut(t).unwrap().mixer.surround = Some(SurroundPan::default());
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
assert!((out[2][100] - 0.5).abs() < 1e-4, "{}", out[2][100]);
|
||
assert!(out.iter().enumerate().all(|(c, x)| c == 2 || x[100].abs() < 1e-4));
|
||
// Rear right with LFE: Rs plus the LFE send.
|
||
s.track_mut(t).unwrap().mixer.surround = Some(SurroundPan { x: 1.0, y: -1.0, lfe_db: -6.0, ..SurroundPan::default() });
|
||
let mut eng = MixEngine::new(48_000.0, 512);
|
||
let mut buf = vec![vec![0.0; 512]; 6];
|
||
eng.render(&s, 0, 512, &mut buf);
|
||
assert!((buf[5][100] - 0.5).abs() < 1e-4 && (buf[3][100] - 0.5 * db_to_gain(-6.0)).abs() < 1e-4);
|
||
assert_eq!(eng.main_meter.peaks.len(), 6);
|
||
assert_eq!(eng.meters.get(&t).map(|m| m.peaks.len()), Some(1));
|
||
// A stereo device buffer gets the first two channels.
|
||
let mut two = vec![vec![1.0; 512]; 2];
|
||
eng.render(&s, 512, 512, &mut two);
|
||
assert!(two[0][10].abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn surround_busses_and_master_fader() {
|
||
let (mut s, t) = multichannel_session(ChannelFormat::Surround51, ChannelFormat::Surround51, 4800);
|
||
let bus = s.add_bus("Stem", ChannelFormat::Surround51);
|
||
let aux = s.add_track(TrackKind::Aux, ChannelFormat::Surround51, Some("Stem"));
|
||
s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus);
|
||
s.track_mut(t).unwrap().mixer.output = Route::Bus(bus);
|
||
let m = s.add_track(TrackKind::Master, ChannelFormat::Surround51, None);
|
||
s.track_mut(m).unwrap().mixer.volume_db = -6.0;
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
let g = db_to_gain(-6.0);
|
||
for (c, ch) in out.iter().enumerate() {
|
||
let x = 0.1 * (c + 1) as f32 + (1000 % 7) as f32 * 1e-3;
|
||
assert!((ch[1000] - x * g).abs() < 1e-5, "channel {c}: {} vs {}", ch[1000], x * g);
|
||
}
|
||
// A mono bus sums a stereo source like the old stereo bus did.
|
||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||
let bus = s.add_bus("Mono", ChannelFormat::Mono);
|
||
let aux = s.add_track(TrackKind::Aux, ChannelFormat::Mono, Some("M"));
|
||
s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus);
|
||
s.track_mut(aux).unwrap().mixer.pan = vec![-1.0];
|
||
s.track_mut(t).unwrap().mixer.output = Route::Bus(bus);
|
||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||
assert!((out[0][1000] - 0.5 * gains(0.0, PanLaw::Minus3).0).abs() < 1e-6, "{}", out[0][1000]);
|
||
}
|
||
|
||
#[test]
|
||
fn surround_upmix_and_downmix_between_layouts() {
|
||
// 5.1 → 7.1: shared speakers map 1:1; 5.x surrounds pan between the 7.1 sides and rears.
|
||
let (s, _) = multichannel_session(ChannelFormat::Surround51, ChannelFormat::Surround71, 2000);
|
||
let out = render_range(&s, Range::new(0, 2000), 512);
|
||
assert_eq!(out.len(), 8);
|
||
let x = |c: usize| 0.1 * (c + 1) as f32 + (100 % 7) as f32 * 1e-3;
|
||
for (c, ch) in out.iter().enumerate().take(4) {
|
||
assert!((ch[100] - x(c)).abs() < 1e-6);
|
||
}
|
||
let e_ls = out[4][100].powi(2) + out[6][100].powi(2);
|
||
assert!((e_ls - x(4).powi(2)).abs() < 1e-5, "Ls energy is preserved");
|
||
}
|
||
}
|