mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-11 16:35:42 -06:00
1226 lines
50 KiB
Rust
1226 lines
50 KiB
Rust
//! 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, and the
|
||
//! session snapshots the callback replaces, come back on return channels and are dropped on the UI
|
||
//! thread ([`Player::idle`]). Plugin editors are driven, and plugin states for saving read
|
||
//! ([`Player::capture_states`]), 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, PluginStateReader};
|
||
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::{BTreeMap, HashMap, HashSet};
|
||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
|
||
use std::sync::mpsc::{Receiver, Sender, SyncSender, channel, sync_channel};
|
||
use std::sync::{Arc, Mutex, PoisonError};
|
||
|
||
const BLOCK: usize = 512;
|
||
|
||
/// Room for replaced snapshots on their way back; `update_session` empties it before each send.
|
||
const OLD_SESSIONS: usize = 16;
|
||
|
||
/// 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>),
|
||
/// Where the callback sends the snapshots it replaces (freeing one takes thousands of frees).
|
||
OldSessions(SyncSender<Arc<Session>>),
|
||
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>,
|
||
}
|
||
|
||
/// What stopped the audio output.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum OutputTrouble {
|
||
/// A block panicked; the output stays silent.
|
||
Bug,
|
||
/// The device went away.
|
||
Gone,
|
||
/// The device stopped asking for audio while playing.
|
||
Stalled,
|
||
}
|
||
|
||
/// How the audio output is doing, for the UI.
|
||
#[derive(Default)]
|
||
struct Fault {
|
||
/// A block panicked: output stays silent from then on.
|
||
failed: AtomicBool,
|
||
gone: AtomicBool,
|
||
/// Blocks so far, to tell a device that stopped asking for audio.
|
||
beats: std::sync::atomic::AtomicU64,
|
||
/// The UI has been told.
|
||
reported: AtomicBool,
|
||
}
|
||
|
||
impl Fault {
|
||
/// What stopped the output, once.
|
||
fn trouble(&self, stalled: bool) -> Option<OutputTrouble> {
|
||
let trouble = if self.failed.load(Ordering::Relaxed) {
|
||
OutputTrouble::Bug
|
||
} else if self.gone.load(Ordering::Relaxed) {
|
||
OutputTrouble::Gone
|
||
} else if stalled {
|
||
OutputTrouble::Stalled
|
||
} else {
|
||
return None;
|
||
};
|
||
(!self.reported.swap(true, Ordering::Relaxed)).then_some(trouble)
|
||
}
|
||
}
|
||
|
||
/// How long a playing output may go without a block before it counts as stalled.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
const STALL: std::time::Duration = std::time::Duration::from_secs(2);
|
||
|
||
/// True once the block count `beats` has stood still for `STALL` while playing; `seen` is the
|
||
/// count last seen and since when.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn stalled(seen: &mut Option<(u64, std::time::Instant)>, beats: u64, playing: bool) -> bool {
|
||
match *seen {
|
||
Some((b, since)) if playing && b == beats => since.elapsed() >= STALL,
|
||
_ => {
|
||
*seen = Some((beats, std::time::Instant::now()));
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Fill one block with `fill`. If it panics, the block and every later one are silent and the
|
||
/// transport stops, rather than the panic taking the app down.
|
||
fn guarded_block(fault: &Fault, shared: &Shared, out: &mut [f32], fill: impl FnOnce(&mut [f32])) {
|
||
fault.beats.fetch_add(1, Ordering::Relaxed);
|
||
if fault.failed.load(Ordering::Relaxed) || std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fill(out))).is_err() {
|
||
fault.failed.store(true, Ordering::Relaxed);
|
||
shared.playing.store(false, Ordering::Relaxed);
|
||
out.fill(0.0);
|
||
}
|
||
}
|
||
|
||
/// 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>>>,
|
||
old_sessions: Option<SyncSender<Arc<Session>>>,
|
||
}
|
||
|
||
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,
|
||
old_sessions: None,
|
||
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::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);
|
||
let old = std::mem::replace(&mut self.session, s);
|
||
if let Some(tx) = &self.old_sessions {
|
||
let _ = tx.try_send(old);
|
||
}
|
||
}
|
||
Cmd::OldSessions(tx) => self.old_sessions = Some(tx),
|
||
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;
|
||
self.mix.reset();
|
||
}
|
||
}
|
||
}
|
||
if changed {
|
||
// Place adopted instances now (not only once playback starts), so 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>>,
|
||
/// State readers of the live instances.
|
||
states: BTreeMap<(TrackId, usize), Box<dyn PluginStateReader>>,
|
||
/// Instances that refused their stored state: saves keep that state until the plugin's editor
|
||
/// is opened.
|
||
refused: HashSet<(TrackId, usize)>,
|
||
garbage: Receiver<Vec<Box<dyn Plugin>>>,
|
||
old_sessions: Option<Receiver<Arc<Session>>>,
|
||
}
|
||
|
||
impl Side {
|
||
fn new(rate: f32, garbage: Receiver<Vec<Box<dyn Plugin>>>) -> Side {
|
||
Side { shadow: HashMap::new(), rate, editors: HashMap::new(), states: BTreeMap::new(), refused: HashSet::new(), garbage, old_sessions: None }
|
||
}
|
||
|
||
/// Drops retired instances (closing their editors first, inside the plugin's drop) and
|
||
/// replaced session snapshots.
|
||
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);
|
||
}
|
||
}
|
||
if let Some(rx) = &self.old_sessions {
|
||
rx.try_iter().for_each(drop);
|
||
}
|
||
}
|
||
|
||
fn return_old_sessions(&mut self, tx: &Sender<Cmd>) {
|
||
let (otx, orx) = sync_channel(OLD_SESSIONS);
|
||
self.old_sessions = Some(orx);
|
||
let _ = tx.send(Cmd::OldSessions(otx));
|
||
}
|
||
|
||
fn forget(&mut self, key: (TrackId, usize)) {
|
||
self.shadow.remove(&key);
|
||
self.states.remove(&key);
|
||
self.refused.remove(&key);
|
||
if let Some(mut e) = self.editors.remove(&key) {
|
||
e.close();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The transport and audio device.
|
||
pub struct Player {
|
||
shared: Arc<Shared>,
|
||
fault: Arc<Fault>,
|
||
/// The block count last seen while playing, and since when.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
watch: Mutex<Option<(u64, std::time::Instant)>>,
|
||
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));
|
||
p.side().return_old_sessions(&p.tx);
|
||
// Hand the initial session's third-party plugins over.
|
||
p.update_session(session);
|
||
p
|
||
}
|
||
|
||
/// What stopped the audio output, once: a bug in the engine, or the device went away or
|
||
/// stopped asking for audio while playing.
|
||
pub fn trouble(&self) -> Option<OutputTrouble> {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let stalled = {
|
||
let playing = self.shared.playing.load(Ordering::Relaxed) && !self.silent;
|
||
stalled(&mut self.watch.lock().unwrap_or_else(PoisonError::into_inner), self.fault.beats.load(Ordering::Relaxed), playing)
|
||
};
|
||
#[cfg(target_arch = "wasm32")]
|
||
let stalled = false;
|
||
self.fault.trouble(stalled)
|
||
}
|
||
|
||
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::new(session.sample_rate.as_f64() as f32, 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);
|
||
let fault = Arc::new(Fault::default());
|
||
let (failed, guard) = (Arc::clone(&fault), Arc::clone(&shared));
|
||
// 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 gone = Arc::clone(&fault);
|
||
let err_fn = move |e: cpal::StreamError| {
|
||
mark_audio_thread();
|
||
if matches!(e, cpal::StreamError::DeviceNotAvailable) {
|
||
gone.gone.store(true, Ordering::Relaxed);
|
||
}
|
||
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();
|
||
guarded_block(&failed, &guard, data, |d| state.fill(d, 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,
|
||
fault,
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
watch: Mutex::new(None),
|
||
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 fault = Arc::new(Fault::default());
|
||
let failed = Arc::clone(&fault);
|
||
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) {
|
||
let shared = Arc::clone(&state.shared);
|
||
guarded_block(&failed, &shared, b, |b| 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,
|
||
fault,
|
||
watch: Mutex::new(None),
|
||
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,
|
||
fault: Arc::default(),
|
||
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.
|
||
pub fn capture_states(&self) -> PluginStates {
|
||
self.side().capture_states()
|
||
}
|
||
|
||
/// 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> {
|
||
self.side().open_editor((track, slot))
|
||
}
|
||
|
||
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 {
|
||
fn capture_states(&mut self) -> PluginStates {
|
||
self.states.iter_mut().filter(|(k, _)| !self.refused.contains(*k)).filter_map(|(&(t, slot), r)| Some((t, slot, r.save_state()?))).collect()
|
||
}
|
||
|
||
fn open_editor(&mut self, key: (TrackId, usize)) -> Result<(), String> {
|
||
let e = self.editors.get_mut(&key).ok_or_else(|| "this plugin has no editor (or it is not loaded)".to_string())?;
|
||
e.open()?;
|
||
self.refused.remove(&key);
|
||
Ok(())
|
||
}
|
||
|
||
/// 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(soundcraft_mix::NewInstance { mut plugin, refused_state }) = made {
|
||
if let Some(e) = plugin.editor() {
|
||
self.editors.insert(key, e);
|
||
}
|
||
if let Some(r) = plugin.state_reader() {
|
||
self.states.insert(key, r);
|
||
}
|
||
if refused_state {
|
||
self.refused.insert(key);
|
||
}
|
||
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, ..StripMeter::default() });
|
||
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 a_block_that_panics_goes_silent_and_is_reported_once() {
|
||
let shared = Shared {
|
||
position: AtomicI64::new(0),
|
||
playing: AtomicBool::new(true),
|
||
speed: AtomicU32::new(1000),
|
||
meters: Mutex::new(MeterSnapshot::default()),
|
||
};
|
||
let fault = Fault::default();
|
||
let mut out = [0.5f32; 8];
|
||
guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25));
|
||
assert_eq!((out, fault.trouble(false)), ([0.25; 8], None));
|
||
guarded_block(&fault, &shared, &mut out, |_| std::panic::panic_any("a bug in the mix"));
|
||
guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25));
|
||
assert_eq!(out, [0.0; 8]);
|
||
assert!(!shared.playing.load(Ordering::Relaxed));
|
||
assert_eq!((fault.trouble(false), fault.trouble(false)), (Some(OutputTrouble::Bug), None));
|
||
assert_eq!(fault.beats.load(Ordering::Relaxed), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn a_lost_or_stalled_device_is_reported_once() {
|
||
let ago = |seen: &mut Option<(u64, std::time::Instant)>| {
|
||
if let Some(s) = seen {
|
||
s.1 = std::time::Instant::now().checked_sub(STALL).unwrap();
|
||
}
|
||
};
|
||
let mut seen = None;
|
||
assert!(!stalled(&mut seen, 5, true));
|
||
ago(&mut seen);
|
||
assert!(stalled(&mut seen, 5, true));
|
||
assert!(!stalled(&mut seen, 6, true), "it asked again");
|
||
ago(&mut seen);
|
||
assert!(!stalled(&mut seen, 6, false), "stopped, so nothing to ask for");
|
||
assert_eq!(Fault::default().trouble(true), Some(OutputTrouble::Stalled));
|
||
let fault = Fault::default();
|
||
fault.gone.store(true, Ordering::Relaxed);
|
||
assert_eq!((fault.trouble(false), fault.trouble(true)), (Some(OutputTrouble::Gone), None));
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
#[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 = 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 mut out = vec![0.0f32; 256];
|
||
st.fill(&mut out, 2);
|
||
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: 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 plugin_states_are_read_without_the_audio_thread() {
|
||
let mut side = Side::new(48_000.0, channel().1);
|
||
let (a, b) = (TrackId(1), TrackId(2));
|
||
side.states.insert((b, 0), Box::new(|| Some(b"b0".to_vec())));
|
||
side.states.insert((a, soundcraft_mix::INSTRUMENT_SLOT), Box::new(|| Some(b"synth".to_vec())));
|
||
side.states.insert((a, 3), Box::new(|| Some(b"a3".to_vec())));
|
||
side.states.insert((a, 4), Box::new(|| None));
|
||
let want = vec![(a, 3, b"a3".to_vec()), (a, soundcraft_mix::INSTRUMENT_SLOT, b"synth".to_vec()), (b, 0, b"b0".to_vec())];
|
||
assert_eq!(side.capture_states(), want);
|
||
side.forget((a, 3));
|
||
assert_eq!(side.capture_states(), want[1..]);
|
||
}
|
||
|
||
struct Window;
|
||
|
||
impl PluginEditor for Window {
|
||
fn open(&mut self) -> Result<(), String> {
|
||
Ok(())
|
||
}
|
||
fn close(&mut self) {}
|
||
fn is_open(&self) -> bool {
|
||
false
|
||
}
|
||
fn idle(&mut self) -> Vec<(String, f32)> {
|
||
Vec::new()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_refused_state_is_kept_until_the_editor_opens() {
|
||
let mut side = Side::new(48_000.0, channel().1);
|
||
let k = (TrackId(1), 0);
|
||
side.states.insert(k, Box::new(|| Some(b"defaults".to_vec())));
|
||
side.editors.insert(k, Box::new(Window));
|
||
side.refused.insert(k);
|
||
assert!(side.capture_states().is_empty());
|
||
side.open_editor(k).unwrap();
|
||
assert_eq!(side.capture_states(), vec![(TrackId(1), 0, b"defaults".to_vec())]);
|
||
}
|
||
|
||
#[test]
|
||
fn side_creates_each_missing_instance_once() {
|
||
use soundcraft_model::{ChannelFormat, Insert, TrackKind};
|
||
let mut side = Side::new(48_000.0, channel().1);
|
||
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 replaced_sessions_are_freed_on_the_ui_thread() {
|
||
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, shared, Arc::new(soundcraft_model::Session::default()), 48_000.0, gtx);
|
||
let mut side = Side::new(48_000.0, grx);
|
||
side.return_old_sessions(&tx);
|
||
let mut out = vec![0.0f32; 256];
|
||
let mut watched = Vec::new();
|
||
for _ in 0..3 * OLD_SESSIONS {
|
||
let s = Arc::new(soundcraft_model::Session::default());
|
||
watched.push(Arc::downgrade(&s));
|
||
side.collect_garbage();
|
||
tx.send(Cmd::Session(s, Vec::new())).unwrap();
|
||
st.fill(&mut out, 2);
|
||
let replaced = watched.len().checked_sub(2).and_then(|i| watched.get(i));
|
||
assert!(replaced.is_none_or(|w| w.strong_count() == 1), "freed on the audio thread");
|
||
}
|
||
side.collect_garbage();
|
||
assert_eq!(watched.iter().filter(|w| w.strong_count() > 0).count(), 1, "only the live snapshot is left");
|
||
}
|
||
|
||
#[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 locate_while_playing_drops_the_old_position_audio() {
|
||
use soundcraft_model::{ChannelFormat, Clip, Insert, SourceAudio, SourceId, TrackKind};
|
||
for (plugin, params) in [("compressor", &[("lookahead", 5.0), ("ratio", 1.0)][..]), ("room_reverb", &[][..])] {
|
||
let mut s = soundcraft_model::Session::default();
|
||
s.edit.click = false;
|
||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||
s.pool.insert(
|
||
SourceId(5),
|
||
Arc::new(SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })),
|
||
);
|
||
let id = s.new_clip_id();
|
||
let tr = s.track_mut(t).unwrap();
|
||
tr.playlist_mut().unwrap().clips.push(Clip::audio(id, "x", SourceId(5), 0, 0, 4800));
|
||
let mut ins = Insert::new(plugin);
|
||
for (k, v) in params {
|
||
ins.params.insert((*k).into(), *v);
|
||
}
|
||
tr.mixer.inserts[0] = Some(ins);
|
||
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);
|
||
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
|
||
let mut out = vec![0.0f32; 2 * 512];
|
||
let mut before = 0.0f32;
|
||
for _ in 0..9 {
|
||
st.fill(&mut out, 2);
|
||
before = out.iter().fold(before, |m, x| m.max(x.abs()));
|
||
}
|
||
assert!(before > 0.1, "{plugin}: the clip plays before the locate");
|
||
tx.send(Cmd::Locate(1_000_000)).unwrap();
|
||
for _ in 0..8 {
|
||
st.fill(&mut out, 2);
|
||
assert!(out.iter().all(|x| x.abs() < 1e-6), "{plugin}: audio from before the locate");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|