Files
soundcraft/crates/playback/src/lib.rs
T
Limestudio90andClaude Opus 5.5 acaa91008c mix: a newly adopted instance replaces a matching one in its slot
When a third-party plugin was removed and re-added (same id, same channel
count) and both session updates reached the audio thread in one drain, sync
saw an unchanged slot, kept the old instance and retired the new one as an
unclaimed leftover. The host-side editor belonged to the new, retired
instance, so reopening it failed with "the plugin is gone", and the old
instance kept running with no editor.

The host only hands over an instance when it no longer tracks the one in
the slot, so an instance waiting for a slot now always replaces it (inserts
and instruments). Debug logs record instance creation and release on the
UI thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:27:12 +02:00

982 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Realtime playback through the system audio device (cpal: CoreAudio, WASAPI, ALSA/Pulse/
//! PipeWire via ALSA, JACK, WebAudio).
//!
//! The UI thread owns a [`Player`]; the audio callback owns the [`MixEngine`] and a snapshot of
//! the session (`Arc<Session>`) that the UI swaps whenever the document changes. Commands go to
//! the callback through a channel; position and meters come back through atomics and a
//! `try_lock`ed snapshot, so the callback never blocks. When no device is available the player
//! falls back to a silent clock thread so the transport, meters and automation still run.
//!
//! Surround: the device is opened with as many output channels as it offers, up to the width of
//! the session's main format. Main channels (SMPTE/WAV order) go to device channels in order; when
//! the device has fewer channels than the main mix, the mix is folded down to stereo (ITU-R
//! BS.775) on the first two device channels.
//!
//! Plugins and threads: hosted third-party plugins (CLAP/VST3) are created, given their stored
//! state and prepared on the thread that calls [`Player::update_session`] (the UI thread), and
//! travel to the audio thread together with the session snapshot that needs them; the mix
//! engine adopts them (see `soundcraft_mix`'s crate docs). Instances the engine retires come back
//! on a return channel and are dropped on the UI thread ([`Player::idle`]). Plugin states for
//! saving are read on request ([`Player::capture_states`]: a command with a reply; the audio
//! thread answers between two blocks, never blocking). Plugin editors are driven on the UI thread
//! through handles taken from each instance before it leaves ([`Player::open_editor`]).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod record;
use soundcraft_dsp::{Plugin, PluginEditor};
use soundcraft_mix::{MAX_CHANNELS, MixEngine, PreparedInstance, StripMeter, itu_stereo_fold, main_channels};
use soundcraft_model::{Session, TrackId};
use soundcraft_time::{Range, Samples};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Mutex, PoisonError};
const BLOCK: usize = 512;
/// The realtime audio thread marker lives in the mix engine, which carries it into the strips it
/// renders on worker threads; the cpal callbacks here set it.
pub use soundcraft_mix::{mark_audio_thread, on_audio_thread};
/// Meter values for the UI.
#[derive(Debug, Clone, Default)]
pub struct MeterSnapshot {
pub tracks: HashMap<TrackId, StripMeter>,
pub main: StripMeter,
}
/// Plugin states read from the live instances: `(track, slot or INSTRUMENT_SLOT, state)`.
pub type PluginStates = Vec<(TrackId, usize, Vec<u8>)>;
/// A parameter edit made in a plugin's own editor: `(track, slot, param id, value)`.
pub type EditorEdit = (TrackId, usize, String, f32);
enum Cmd {
/// A new snapshot plus the third-party instances it needs (created on the UI thread).
Session(Arc<Session>, Vec<PreparedInstance>),
/// Read every plugin state and reply.
CaptureStates(Sender<PluginStates>),
Input(Arc<record::InputRing>),
Recording(bool),
Play {
from: Samples,
end: Option<Samples>,
looped: Option<Range>,
},
Stop,
Locate(Samples),
}
struct Shared {
position: AtomicI64,
playing: AtomicBool,
/// Speed × 1000.
speed: AtomicU32,
meters: Mutex<MeterSnapshot>,
}
/// State owned by the audio callback.
struct AudioState {
rx: Receiver<Cmd>,
shared: Arc<Shared>,
session: Arc<Session>,
mix: MixEngine,
pos: Samples,
playing: bool,
end: Option<Samples>,
looped: Option<Range>,
/// Rendered frames waiting to be consumed (planar, main channels) and the fractional read index.
pending: Vec<Vec<f32>>,
/// Stereo fold-down gains per main channel (used when the device has fewer channels).
fold: [(f32, f32); MAX_CHANNELS],
pending_len: usize,
read: f64,
device_rate: f64,
input: Option<Arc<record::InputRing>>,
recording: bool,
/// Retired plugin instances go back to the UI thread to be dropped there.
garbage: Sender<Vec<Box<dyn Plugin>>>,
}
fn new_engine(sr: f32) -> MixEngine {
let mut m = MixEngine::new(sr, BLOCK);
m.set_external_instances(true);
m.metronome = true;
m
}
impl AudioState {
fn new(rx: Receiver<Cmd>, shared: Arc<Shared>, session: Arc<Session>, device_rate: f64, garbage: Sender<Vec<Box<dyn Plugin>>>) -> Self {
let sr = session.sample_rate.as_f64() as f32;
let nch = main_channels(&session);
AudioState {
garbage,
fold: fold_gains(&session),
rx,
shared,
mix: new_engine(sr),
session,
pos: 0,
playing: false,
end: None,
looped: None,
pending: vec![vec![0.0; BLOCK]; nch],
pending_len: 0,
read: 0.0,
device_rate,
input: None,
recording: false,
}
}
fn drain(&mut self) {
let mut changed = false;
while let Ok(c) = self.rx.try_recv() {
match c {
Cmd::Input(r) => self.input = Some(r),
Cmd::Recording(r) => self.recording = r,
Cmd::CaptureStates(reply) => {
self.mix.ensure_synced(&self.session);
let _ = reply.send(self.mix.capture_states());
}
Cmd::Session(s, instances) => {
changed = true;
if (s.sample_rate.as_f64() - self.session.sample_rate.as_f64()).abs() > 0.5 {
let mut old = std::mem::replace(&mut self.mix, new_engine(s.sample_rate.as_f64() as f32));
let _ = self.garbage.send(old.drain_plugins());
}
self.mix.adopt(instances);
// The main format changed (a document edit, not steady state): resize.
let nch = main_channels(&s);
if self.pending.len() != nch {
self.pending = vec![vec![0.0; BLOCK]; nch];
self.pending_len = 0;
self.read = 0.0;
}
self.fold = fold_gains(&s);
self.session = s;
}
Cmd::Play { from, end, looped } => {
self.pos = from;
self.end = end;
self.looped = looped.filter(|r| r.len() > 64);
self.playing = true;
self.pending_len = 0;
self.read = 0.0;
self.mix.reset();
}
Cmd::Stop => {
self.playing = false;
self.end = None;
self.looped = None;
self.mix.reset();
}
Cmd::Locate(at) => {
self.pos = at;
self.pending_len = 0;
self.read = 0.0;
}
}
}
if changed {
// Place adopted instances now (not only once playback starts), so state captures and
// editors see them; `sync` creates no third-party plugins here.
self.mix.ensure_synced(&self.session);
}
self.send_garbage();
self.shared.playing.store(self.playing, Ordering::Relaxed);
}
fn send_garbage(&mut self) {
let g = self.mix.take_retired();
if !g.is_empty() {
let _ = self.garbage.send(g);
}
}
/// Render the next block at the session rate into `pending`.
fn render_next(&mut self, quantum: usize) {
let mut n = quantum.clamp(1, BLOCK);
if let Some(l) = self.looped
&& self.pos >= l.end
{
self.pos = l.start;
}
if let Some(l) = self.looped {
n = n.min(usize::try_from((l.end - self.pos).max(1)).unwrap_or(BLOCK));
} else if let Some(e) = self.end {
if self.pos >= e {
self.playing = false;
self.end = None;
self.shared.playing.store(false, Ordering::Relaxed);
n = 0;
} else {
n = n.min(usize::try_from(e - self.pos).unwrap_or(BLOCK));
}
}
if n == 0 {
for c in &mut self.pending {
c.iter_mut().for_each(|x| *x = 0.0);
}
self.pending_len = BLOCK;
return;
}
if let Some(ring) = &self.input {
let ch = ring.channels.load(Ordering::Relaxed).max(1);
if self.mix.input.len() != ch {
self.mix.input = vec![vec![0.0; BLOCK]; ch];
}
ring.pop_into(&mut self.mix.input, n, self.session.sample_rate.hz());
}
self.mix.monitor_only = !self.playing;
self.mix.recording = self.recording;
self.mix.render(&self.session, self.pos, n, &mut self.pending);
self.send_garbage();
self.pending_len = n;
if let Ok(mut m) = self.shared.meters.try_lock() {
m.main.copy_from(&self.mix.main_meter);
for (k, v) in &self.mix.meters {
match m.tracks.get_mut(k) {
Some(d) => d.copy_from(v),
None => {
m.tracks.insert(*k, v.clone());
}
}
}
}
if self.playing {
self.pos += n as i64;
self.shared.position.store(self.pos, Ordering::Relaxed);
}
}
/// Fill an interleaved device buffer.
fn fill(&mut self, out: &mut [f32], channels: usize) {
self.drain();
let channels = channels.max(1);
let monitoring = self.input.is_some() && self.session.tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm);
if !self.playing && !monitoring {
out.iter_mut().for_each(|x| *x = 0.0);
if let Ok(mut m) = self.shared.meters.try_lock() {
m.main = StripMeter::default();
m.tracks.clear();
}
return;
}
let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0;
let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0);
let callback_frames = out.len() / channels;
let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, BLOCK);
for frame in out.chunks_mut(channels) {
while self.read >= self.pending_len as f64 {
self.read -= self.pending_len as f64;
if !self.playing && !monitoring {
break;
}
self.render_next(quantum);
if self.pending_len == 0 {
break;
}
}
let i0 = self.read.floor() as usize;
let fr = (self.read - i0 as f64) as f32;
let sounding = self.playing || monitoring;
let nch = self.pending.len();
let sample = |c: usize| {
let ch = self.pending.get(c);
let a = ch.and_then(|v| v.get(i0)).copied().unwrap_or(0.0);
let b = ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a);
a + (b - a) * fr
};
if nch > 2 && channels < nch {
// Fewer device channels than the main mix: ITU fold-down to stereo.
let (mut l, mut r) = (0.0f32, 0.0f32);
if sounding {
for (c, (gl, gr)) in self.fold.iter().enumerate().take(nch) {
let v = sample(c);
l += v * gl;
r += v * gr;
}
}
for (c, o) in frame.iter_mut().enumerate() {
*o = match c {
0 => l.clamp(-1.0, 1.0),
1 => r.clamp(-1.0, 1.0),
_ => 0.0,
};
}
} else {
for (c, o) in frame.iter_mut().enumerate() {
// Stereo mixes on a mono device play the left channel (as before).
*o = if c < nch.max(1) && sounding { sample(c).clamp(-1.0, 1.0) } else { 0.0 };
}
}
self.read += step;
}
}
}
/// UI-thread bookkeeping of the third-party instances the audio thread holds.
struct Side {
/// What was last created for each slot: (plugin id, channels, created ok).
shadow: HashMap<(TrackId, usize), (String, usize, bool)>,
/// Session sample rate the instances were prepared for.
rate: f32,
/// Editor handles of the live instances.
editors: HashMap<(TrackId, usize), Box<dyn PluginEditor>>,
garbage: Receiver<Vec<Box<dyn Plugin>>>,
}
impl Side {
/// Drops retired instances (closing their editors first, inside the plugin's drop).
fn collect_garbage(&mut self) {
while let Ok(g) = self.garbage.try_recv() {
for p in g {
log::debug!("plugin instance released: {}", p.info().name);
drop(p);
}
}
}
fn forget(&mut self, key: (TrackId, usize)) {
self.shadow.remove(&key);
if let Some(mut e) = self.editors.remove(&key) {
e.close();
}
}
}
/// The transport and audio device.
pub struct Player {
shared: Arc<Shared>,
tx: Sender<Cmd>,
side: Mutex<Side>,
#[cfg(not(target_arch = "wasm32"))]
_stream: Option<cpal::Stream>,
#[cfg(target_arch = "wasm32")]
_stream: Option<cpal::Stream>,
pub device_name: String,
pub device_rate: u32,
/// Output channels the device was opened with (0 for the silent clock).
pub device_channels: u16,
/// True when no audio device could be opened (silent clock).
pub silent: bool,
}
impl Player {
/// Open the default output device. Never fails: without a device it runs a silent clock.
pub fn new(session: Arc<Session>) -> Player {
let p = Player::open(Arc::clone(&session));
// Hand the initial session's third-party plugins over.
p.update_session(session);
p
}
fn side(&self) -> std::sync::MutexGuard<'_, Side> {
self.side.lock().unwrap_or_else(PoisonError::into_inner)
}
fn open(session: Arc<Session>) -> Player {
let (gtx, grx) = channel();
let side = Mutex::new(Side { shadow: HashMap::new(), rate: session.sample_rate.as_f64() as f32, editors: HashMap::new(), garbage: grx });
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let want = session.sample_rate.hz();
let want_ch = u16::try_from(main_channels(&session)).unwrap_or(2);
match open_device(want, want_ch) {
Ok((device, config, name)) => {
let rate = config.sample_rate.0;
let channels = usize::from(config.channels);
let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx);
// cpal may call this on the audio thread (on ALSA in a loop while the device
// stays broken): marked, so the logger hands the record off instead of writing it.
let err_fn = |e: cpal::StreamError| {
mark_audio_thread();
log::warn!("audio stream error: {e}");
};
use cpal::traits::{DeviceTrait, StreamTrait};
match device.build_output_stream(
&config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
mark_audio_thread();
state.fill(data, channels);
},
err_fn,
None,
) {
Ok(stream) => {
if let Err(e) = stream.play() {
log::warn!("audio stream failed to start: {e}");
}
log::info!("audio device: {name} @ {rate} Hz, {channels} ch");
Player {
shared,
tx,
side,
_stream: Some(stream),
device_name: name,
device_rate: rate,
device_channels: config.channels,
silent: false,
}
}
Err(e) => {
log::warn!("cannot open audio stream: {e}; using a silent clock");
let (tx2, rx2) = channel();
let (gtx2, grx2) = channel();
// The state (and its receivers) moved into the failed closure; rebuild.
let mut side = side;
side.get_mut().unwrap_or_else(PoisonError::into_inner).garbage = grx2;
Player::silent_clock(shared, tx2, rx2, want, side, gtx2)
}
}
}
Err(e) => {
log::warn!("no audio output: {e}; using a silent clock");
let s2 = Arc::clone(&shared);
Player::silent_clock_with(s2, tx, rx, session, side, gtx)
}
}
}
fn silent_clock(
shared: Arc<Shared>,
tx: Sender<Cmd>,
rx: Receiver<Cmd>,
rate: u32,
side: Mutex<Side>,
gtx: Sender<Vec<Box<dyn Plugin>>>,
) -> Player {
let s = Arc::new(Session::new("Untitled", soundcraft_time::SampleRate::new(rate).unwrap_or_default()));
Player::silent_clock_with(shared, tx, rx, s, side, gtx)
}
#[cfg(not(target_arch = "wasm32"))]
fn silent_clock_with(
shared: Arc<Shared>,
tx: Sender<Cmd>,
rx: Receiver<Cmd>,
session: Arc<Session>,
side: Mutex<Side>,
gtx: Sender<Vec<Box<dyn Plugin>>>,
) -> Player {
let rate = session.sample_rate.hz();
let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx);
let spawn = std::thread::Builder::new().name("soundcraft-clock".into()).spawn(move || {
let mut buf = vec![0.0f32; 4096];
let mut last = std::time::Instant::now();
loop {
std::thread::sleep(std::time::Duration::from_millis(10));
let now = std::time::Instant::now();
let frames = ((now - last).as_secs_f64() * state.device_rate) as usize;
last = now;
let n = (frames * 2).min(buf.len());
if let Some(b) = buf.get_mut(..n) {
state.fill(b, 2);
}
if Arc::strong_count(&state.shared) <= 1 {
break;
}
}
});
if let Err(e) = spawn {
log::warn!("clock thread failed: {e}");
}
Player {
shared,
tx,
side,
_stream: None,
device_name: "No audio device (silent)".into(),
device_rate: rate,
device_channels: 0,
silent: true,
}
}
#[cfg(target_arch = "wasm32")]
fn silent_clock_with(
shared: Arc<Shared>,
tx: Sender<Cmd>,
_rx: Receiver<Cmd>,
session: Arc<Session>,
side: Mutex<Side>,
_gtx: Sender<Vec<Box<dyn Plugin>>>,
) -> Player {
Player {
shared,
tx,
side,
_stream: None,
device_name: "No audio device".into(),
device_rate: session.sample_rate.hz(),
device_channels: 0,
silent: true,
}
}
/// Sends a new session snapshot to the audio thread, with freshly created (state-restored,
/// prepared) instances of any third-party plugin it newly needs. Call on the UI thread.
pub fn update_session(&self, s: Arc<Session>) {
let instances = self.side().prepare(&s);
let _ = self.tx.send(Cmd::Session(s, instances));
}
/// Reads the state of every live third-party plugin (for saving the session). Waits briefly
/// for the audio thread to answer between two blocks; empty when it does not answer.
pub fn capture_states(&self) -> PluginStates {
self.side().collect_garbage();
#[cfg(not(target_arch = "wasm32"))]
{
let (tx, rx) = channel();
if self.tx.send(Cmd::CaptureStates(tx)).is_err() {
return Vec::new();
}
rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap_or_default()
}
#[cfg(target_arch = "wasm32")]
{
Vec::new()
}
}
/// Per-UI-frame housekeeping: drops retired instances and services plugin editors. Returns
/// the parameter edits made in open editors since the last call.
pub fn idle(&self) -> Vec<EditorEdit> {
let mut side = self.side();
side.collect_garbage();
let mut out = Vec::new();
for (&(t, slot), e) in side.editors.iter_mut() {
for (id, v) in e.idle() {
out.push((t, slot, id, v));
}
}
out
}
/// True when the plugin in this slot has its own editor.
pub fn has_editor(&self, track: TrackId, slot: usize) -> bool {
self.side().editors.contains_key(&(track, slot))
}
/// Opens the plugin's own editor window. Main thread only.
pub fn open_editor(&self, track: TrackId, slot: usize) -> Result<(), String> {
match self.side().editors.get_mut(&(track, slot)) {
Some(e) => e.open(),
None => Err("this plugin has no editor (or it is not loaded)".into()),
}
}
pub fn close_editor(&self, track: TrackId, slot: usize) {
if let Some(e) = self.side().editors.get_mut(&(track, slot)) {
e.close();
}
}
pub fn editor_open(&self, track: TrackId, slot: usize) -> bool {
self.side().editors.get(&(track, slot)).is_some_and(|e| e.is_open())
}
pub fn any_editor_open(&self) -> bool {
self.side().editors.values().any(|e| e.is_open())
}
/// Shows a parameter change made elsewhere in the plugin's open editor.
pub fn editor_set_param(&self, track: TrackId, slot: usize, id: &str, value: f32) {
if let Some(e) = self.side().editors.get_mut(&(track, slot)) {
e.set_param(id, value);
}
}
/// Start playback at `from`, stopping at `end`, or looping `looped`.
pub fn play(&self, from: Samples, end: Option<Samples>, looped: Option<Range>) {
self.shared.position.store(from, Ordering::Relaxed);
self.shared.playing.store(true, Ordering::Relaxed);
let _ = self.tx.send(Cmd::Play { from, end, looped });
}
/// Feed live input (from a `record::Recorder`) into the mix for input monitoring.
pub fn set_input(&self, ring: Arc<record::InputRing>) {
let _ = self.tx.send(Cmd::Input(ring));
}
pub fn set_recording(&self, on: bool) {
let _ = self.tx.send(Cmd::Recording(on));
}
pub fn stop(&self) {
self.shared.playing.store(false, Ordering::Relaxed);
let _ = self.tx.send(Cmd::Stop);
}
pub fn locate(&self, at: Samples) {
self.shared.position.store(at, Ordering::Relaxed);
let _ = self.tx.send(Cmd::Locate(at));
}
pub fn set_speed(&self, speed: f32) {
let v = if speed.is_finite() { (speed.clamp(0.1, 4.0) * 1000.0) as u32 } else { 1000 };
self.shared.speed.store(v, Ordering::Relaxed);
}
/// Playback rate last requested, 1.0 at full speed.
pub fn speed(&self) -> f32 {
self.shared.speed.load(Ordering::Relaxed) as f32 / 1000.0
}
pub fn position(&self) -> Samples {
self.shared.position.load(Ordering::Relaxed)
}
pub fn is_playing(&self) -> bool {
self.shared.playing.load(Ordering::Relaxed)
}
pub fn meters(&self) -> MeterSnapshot {
self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
/// Copy meters into reusable UI storage. Once track/channel counts are stable this performs
/// no allocation, unlike cloning a new snapshot every video frame.
pub fn copy_meters(&self, out: &mut MeterSnapshot) {
let source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner);
copy_meter_snapshot(&source, out);
}
}
fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) {
out.tracks.retain(|id, _| source.tracks.contains_key(id));
for (id, meter) in &source.tracks {
out.tracks.entry(*id).or_default().copy_from(meter);
}
out.main.copy_from(&source.main);
}
impl Side {
/// Creates the third-party instances `s` needs that the audio thread does not have yet.
fn prepare(&mut self, s: &Session) -> Vec<PreparedInstance> {
self.collect_garbage();
let sr = s.sample_rate.as_f64() as f32;
if (sr - self.rate).abs() > 0.5 {
// The audio thread rebuilds its engine: everything is created anew.
let keys: Vec<(TrackId, usize)> = self.shadow.keys().copied().collect();
for k in keys {
self.forget(k);
}
self.rate = sr;
}
let specs = soundcraft_mix::instance_specs(s);
let stale: Vec<(TrackId, usize)> = self.shadow.keys().filter(|k| !specs.iter().any(|x| (x.track, x.slot) == **k)).copied().collect();
for k in stale {
self.forget(k);
}
let mut out = Vec::new();
for spec in specs {
let key = (spec.track, spec.slot);
if self.shadow.get(&key).is_some_and(|(id, ch, _)| *id == spec.id && *ch == spec.ch) {
continue;
}
self.forget(key);
let made = soundcraft_mix::create_instance(&spec.id, spec.state.as_deref(), sr, BLOCK, spec.ch);
log::debug!("plugin instance for {:?} slot {}: {} ({} ch, created: {})", spec.track, spec.slot, spec.id, spec.ch, made.is_some());
self.shadow.insert(key, (spec.id.clone(), spec.ch, made.is_some()));
if let Some(mut plugin) = made {
if let Some(e) = plugin.editor() {
self.editors.insert(key, e);
}
out.push(PreparedInstance { track: spec.track, slot: spec.slot, id: spec.id, ch: spec.ch, plugin });
}
}
out
}
}
/// ITU stereo fold-down gains for each main channel of a session.
fn fold_gains(s: &Session) -> [(f32, f32); MAX_CHANNELS] {
let mut g = [(0.0, 0.0); MAX_CHANNELS];
let f = s.main_format();
let sp = f.speakers();
if sp.len() == f.channels() {
for (o, x) in g.iter_mut().zip(sp.iter()) {
*o = itu_stereo_fold(*x);
}
} else {
// Ambisonics: W only, at -3 dB per side.
g[0] = (std::f32::consts::FRAC_1_SQRT_2, std::f32::consts::FRAC_1_SQRT_2);
}
g
}
/// Open the default output device: f32 at the session rate if possible, with as many channels as
/// it offers up to `want_ch` (at least stereo when available).
fn open_device(want_rate: u32, want_ch: u16) -> Result<(cpal::Device, cpal::StreamConfig, String), String> {
use cpal::traits::{DeviceTrait, HostTrait};
let host = cpal::default_host();
let device = host.default_output_device().ok_or_else(|| "no default output device".to_string())?;
#[allow(deprecated)]
let name = device.name().unwrap_or_else(|_| "Audio device".into());
// Prefer an f32 config at the session rate; else the device default (we resample).
let want_ch = want_ch.max(2);
let mut chosen: Option<cpal::StreamConfig> = None;
if let Ok(configs) = device.supported_output_configs() {
for c in configs {
if c.sample_format() == cpal::SampleFormat::F32
&& c.min_sample_rate().0 <= want_rate
&& c.max_sample_rate().0 >= want_rate
&& c.channels() >= 2
&& c.channels() <= want_ch
&& chosen.as_ref().is_none_or(|x| c.channels() > x.channels)
{
chosen = Some(c.with_sample_rate(cpal::SampleRate(want_rate)).config());
}
}
}
let config = match chosen {
Some(c) => c,
None => {
let d = device.default_output_config().map_err(|e| e.to_string())?;
if d.sample_format() != cpal::SampleFormat::F32 {
return Err(format!("unsupported device sample format {:?}", d.sample_format()));
}
d.config()
}
};
Ok((device, config, name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reusable_meter_snapshot_updates_and_drops_stale_tracks() {
let keep = TrackId(1);
let stale = TrackId(2);
let mut source = MeterSnapshot::default();
source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0 });
source.main.peaks = vec![0.75, 0.5];
let mut out = MeterSnapshot::default();
out.tracks.insert(stale, StripMeter::default());
copy_meter_snapshot(&source, &mut out);
assert_eq!(out.tracks.len(), 1);
assert_eq!(out.tracks[&keep], source.tracks[&keep]);
assert_eq!(out.main, source.main);
}
#[test]
fn audio_state_plays_and_stops_at_end() {
let s = Arc::new(soundcraft_model::Session::default());
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let mut st = AudioState::new(rx, Arc::clone(&shared), s, 48_000.0, channel().0);
tx.send(Cmd::Play { from: 0, end: Some(1000), looped: None }).unwrap();
let mut out = vec![0.0f32; 4096];
st.fill(&mut out, 2);
assert!(shared.position.load(Ordering::Relaxed) >= 1000);
assert!(!shared.playing.load(Ordering::Relaxed));
}
#[test]
fn short_device_callbacks_render_short_mix_blocks() {
let s = Arc::new(soundcraft_model::Session::default());
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let mut st = AudioState::new(rx, Arc::clone(&shared), s, 48_000.0, channel().0);
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
let mut out = vec![0.0f32; 2 * 64];
st.fill(&mut out, 2);
assert_eq!(shared.position.load(Ordering::Relaxed), 64);
}
#[test]
fn surround_main_maps_to_device_channels_or_folds_down() {
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
let mut s = soundcraft_model::Session::default();
s.outputs[0].format = ChannelFormat::Surround51;
let t = s.add_track(TrackKind::Audio, ChannelFormat::Surround51, None);
let chans: Vec<Vec<f32>> = (0..6).map(|c| vec![0.1 * (c + 1) as f32; 48_000]).collect();
s.pool.insert(SourceId(5), Arc::new(SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: chans })));
let id = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "x", SourceId(5), 0, 0, 48_000));
let s = Arc::new(s);
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::clone(&s), 48_000.0, channel().0);
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
// An 8-channel device: channels in order, the rest silent.
let mut out = vec![0.0f32; 8 * 256];
st.fill(&mut out, 8);
let f = &out[8 * 100..8 * 101];
for c in 0..6 {
assert!((f[c] - 0.1 * (c + 1) as f32).abs() < 1e-5, "{f:?}");
}
assert!(f[6] == 0.0 && f[7] == 0.0);
// A stereo device: ITU fold-down.
let mut out = vec![0.0f32; 2 * 256];
st.fill(&mut out, 2);
let h = std::f32::consts::FRAC_1_SQRT_2;
let l = (0.1 + h * 0.3 + h * 0.5f32).min(1.0);
assert!((out[200] - l).abs() < 1e-4, "{} vs {l}", out[200]);
assert_eq!(shared.meters.lock().unwrap().main.peaks.len(), 6);
}
struct Stateful(Box<dyn Plugin>);
impl Plugin for Stateful {
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
self.0.info()
}
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
self.0.prepare(sr, mb, ch);
}
fn reset(&mut self) {
self.0.reset();
}
fn set_param(&mut self, id: &str, v: f32) -> bool {
self.0.set_param(id, v)
}
fn param(&self, id: &str) -> Option<f32> {
self.0.param(id)
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
self.0.process(io, frames);
}
fn save_state(&mut self) -> Option<Vec<u8>> {
Some(b"blob".to_vec())
}
}
#[test]
fn instances_arrive_with_the_session_and_leave_through_the_return_channel() {
use soundcraft_model::{ChannelFormat, Insert, TrackKind};
let mut s = soundcraft_model::Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
s.track_mut(t).unwrap().mixer.inserts[1] = Some(Insert::new("clap:test.fake"));
let s = Arc::new(s);
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let (gtx, grx) = channel();
let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(soundcraft_model::Session::default()), 48_000.0, gtx);
let plugin: Box<dyn Plugin> = Box::new(Stateful(soundcraft_dsp::create("gain").unwrap()));
tx.send(Cmd::Session(Arc::clone(&s), vec![PreparedInstance { track: t, slot: 1, id: "clap:test.fake".into(), ch: 2, plugin }])).unwrap();
let (rtx, rrx) = channel();
tx.send(Cmd::CaptureStates(rtx)).unwrap();
// Stopped: the callback only drains commands (adopting the instance) and answers.
let mut out = vec![0.0f32; 256];
st.fill(&mut out, 2);
assert_eq!(rrx.try_recv().unwrap(), vec![(t, 1, b"blob".to_vec())]);
assert!(grx.try_recv().is_err(), "nothing retired yet");
// A session without the insert retires the instance to the return channel.
tx.send(Cmd::Session(Arc::new(soundcraft_model::Session::default()), Vec::new())).unwrap();
st.fill(&mut out, 2);
assert_eq!(grx.try_recv().unwrap().len(), 1);
// A sample-rate change rebuilds the engine without dropping plugins on this thread.
tx.send(Cmd::Session(
Arc::clone(&s),
vec![PreparedInstance {
track: t,
slot: 1,
id: "clap:test.fake".into(),
ch: 2,
plugin: Box::new(Stateful(soundcraft_dsp::create("gain").unwrap())),
}],
))
.unwrap();
st.fill(&mut out, 2);
let mut s44 = (*s).clone();
s44.sample_rate = soundcraft_time::SampleRate::new(44_100).unwrap();
tx.send(Cmd::Session(Arc::new(s44), Vec::new())).unwrap();
st.fill(&mut out, 2);
assert_eq!(grx.try_recv().unwrap().len(), 1);
}
#[test]
fn side_creates_each_missing_instance_once() {
use soundcraft_model::{ChannelFormat, Insert, TrackKind};
let (_gtx, grx) = channel();
let mut side = Side { shadow: HashMap::new(), rate: 48_000.0, editors: HashMap::new(), garbage: grx };
let mut s = soundcraft_model::Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:does.not.exist"));
s.track_mut(t).unwrap().mixer.inserts[1] = Some(Insert::new("gain"));
assert!(side.prepare(&s).is_empty(), "the plugin is not installed; built-ins stay with the engine");
assert_eq!(side.shadow.get(&(t, 0)), Some(&("clap:does.not.exist".to_string(), 1, false)));
assert!(side.prepare(&s).is_empty());
assert_eq!(side.shadow.len(), 1, "failures are remembered, not retried every update");
s.track_mut(t).unwrap().mixer.inserts[0] = None;
side.prepare(&s);
assert!(side.shadow.is_empty());
}
#[test]
fn looping_wraps() {
let s = Arc::new(soundcraft_model::Session::default());
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let mut st = AudioState::new(rx, Arc::clone(&shared), s, 48_000.0, channel().0);
tx.send(Cmd::Play { from: 0, end: None, looped: Some(Range::new(0, 1000)) }).unwrap();
let mut out = vec![0.0f32; 20_000];
st.fill(&mut out, 2);
let p = shared.position.load(Ordering::Relaxed);
assert!(p <= 1000, "position {p} should stay inside the loop");
assert!(shared.playing.load(Ordering::Relaxed));
}
#[test]
fn armed_tracks_are_heard_and_metered_after_playback_stops() {
use soundcraft_model::{ChannelFormat, TrackKind};
let mut s = soundcraft_model::Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.track_mut(t).unwrap().mixer.record_arm = true;
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0);
let ring = record::InputRing::new(1, 48_000);
tx.send(Cmd::Input(Arc::clone(&ring))).unwrap();
tx.send(Cmd::Play { from: 0, end: Some(1000), looped: None }).unwrap();
let mut out = vec![0.0f32; 4096];
st.fill(&mut out, 2);
assert!(!shared.playing.load(Ordering::Relaxed));
ring.push(&[0.5; 4096]);
st.fill(&mut out, 2);
assert!(out.iter().any(|x| x.abs() > 0.1), "the input is heard");
assert!(shared.meters.lock().unwrap().tracks.get(&t).is_some_and(|m| m.peak[0] > 0.1), "and metered");
}
}