use std::sync::Arc;
use crossbeam_channel::{Receiver, Sender};
use phosphor_midi::message::MidiMessage;
use phosphor_plugin::{MidiEvent, Plugin};
use crate::clip::{ClipEvent, ClipSnapshot, MidiClip, RecordBuffer};
use crate::engine::VuLevels;
use crate::metronome::Metronome;
use crate::project::{TrackHandle, TrackKind};
use crate::transport::Transport;
pub enum MixerCommand {
AddTrack {
kind: TrackKind,
handle: Arc<TrackHandle>,
},
SetInstrument {
track_id: usize,
instrument: Box<dyn Plugin + Send>,
},
RemoveTrack {
track_id: usize,
},
SetParameter {
track_id: usize,
param_index: usize,
value: f32,
},
CreateClip {
track_id: usize,
start_tick: i64,
length_ticks: i64,
},
UpdateClip {
track_id: usize,
clip_index: usize,
events: Vec<ClipEvent>,
},
UpdateClipPosition {
track_id: usize,
clip_index: usize,
start_tick: i64,
length_ticks: i64,
},
RemoveClip {
track_id: usize,
clip_index: usize,
},
}
const HEAVY_COMMAND: u32 = 16;
fn command_cost(cmd: &MixerCommand) -> u32 {
match cmd {
MixerCommand::SetParameter { .. } | MixerCommand::UpdateClipPosition { .. } => 1,
MixerCommand::AddTrack { .. }
| MixerCommand::SetInstrument { .. }
| MixerCommand::RemoveTrack { .. }
| MixerCommand::CreateClip { .. }
| MixerCommand::UpdateClip { .. }
| MixerCommand::RemoveClip { .. } => HEAVY_COMMAND,
}
}
const COMMAND_BUDGET: u32 = 64;
const TRACK_CAPACITY: usize = 64;
const LIMITER_CEILING: f32 = 0.891;
const LIMITER_RELEASE_SECONDS: f32 = 0.050;
struct MasterLimiter {
gain: f32,
release_coeff: f32,
}
impl MasterLimiter {
fn new(sample_rate: u32) -> Self {
let sr = (sample_rate as f32).max(1.0);
Self {
gain: 1.0,
release_coeff: 1.0 - (-1.0 / (LIMITER_RELEASE_SECONDS * sr)).exp(),
}
}
fn reset(&mut self) {
self.gain = 1.0;
}
fn process(&mut self, output: &mut [f32]) {
let mut frames = output.chunks_exact_mut(2);
for frame in frames.by_ref() {
let l = if frame[0].is_finite() { frame[0] } else { 0.0 };
let r = if frame[1].is_finite() { frame[1] } else { 0.0 };
let peak = l.abs().max(r.abs());
let target = if peak > LIMITER_CEILING {
(LIMITER_CEILING / peak) * (1.0 - 2.0 * f32::EPSILON)
} else {
1.0
};
if target < self.gain {
self.gain = target;
} else {
self.gain += (target - self.gain) * self.release_coeff;
}
frame[0] = (l * self.gain).clamp(-1.0, 1.0);
frame[1] = (r * self.gain).clamp(-1.0, 1.0);
}
for tail in frames.into_remainder() {
let s = if tail.is_finite() { *tail } else { 0.0 };
*tail = (s * self.gain).clamp(-LIMITER_CEILING, LIMITER_CEILING);
}
}
}
pub struct AudioTrack {
pub id: usize,
pub kind: TrackKind,
pub handle: Arc<TrackHandle>,
pub instrument: Option<Box<dyn Plugin>>,
pub clips: Vec<MidiClip>,
record_buf: RecordBuffer,
was_recording: bool,
last_record_tick: i64,
last_playback_tick: i64,
buf_l: Vec<f32>,
buf_r: Vec<f32>,
plugin_events: Vec<MidiEvent>,
}
impl AudioTrack {
pub fn new(handle: Arc<TrackHandle>, max_buffer_size: usize) -> Self {
Self {
id: handle.id,
kind: handle.kind,
handle,
instrument: None,
clips: Vec::new(),
record_buf: RecordBuffer::new(),
was_recording: false,
last_record_tick: -1,
last_playback_tick: -1,
buf_l: vec![0.0; max_buffer_size],
buf_r: vec![0.0; max_buffer_size],
plugin_events: Vec::with_capacity(256),
}
}
}
pub struct Mixer {
tracks: Vec<AudioTrack>,
master_vu: Arc<VuLevels>,
command_rx: Receiver<MixerCommand>,
clip_tx: Sender<ClipSnapshot>,
metronome: Metronome,
sample_rate: u32,
max_buffer_size: usize,
scratch_l: Vec<f32>,
scratch_r: Vec<f32>,
live_events: Vec<MidiEvent>,
limiter: MasterLimiter,
}
impl Mixer {
pub fn new(
command_rx: Receiver<MixerCommand>,
master_vu: Arc<VuLevels>,
clip_tx: Sender<ClipSnapshot>,
sample_rate: u32,
max_buffer_size: usize,
) -> Self {
Self {
tracks: Vec::with_capacity(TRACK_CAPACITY),
master_vu,
command_rx,
clip_tx,
metronome: Metronome::new(sample_rate as f64),
sample_rate,
max_buffer_size,
scratch_l: vec![0.0; max_buffer_size],
scratch_r: vec![0.0; max_buffer_size],
live_events: Vec::with_capacity(256),
limiter: MasterLimiter::new(sample_rate),
}
}
pub fn process(&mut self, output: &mut [f32], midi_messages: &[MidiMessage], transport: &Transport) {
let _ = self.drain_commands();
let num_frames = output.len() / 2;
let playing = transport.is_playing();
let recording = transport.is_recording();
let looping = transport.is_looping();
let current_tick = transport.position_ticks();
let bpm = transport.tempo_bpm();
let ticks_per_sample = (bpm * Transport::PPQ as f64) / (60.0 * self.sample_rate as f64);
let buffer_ticks = (num_frames as f64 * ticks_per_sample) as i64;
let loop_end = transport.loop_end();
self.live_events.clear();
for msg in midi_messages {
if let Some(ev) = midi_to_plugin_event(msg) {
self.live_events.push(ev);
}
}
let any_solo = self.tracks.iter().any(|t| t.handle.config.is_soloed());
let mut master_l = std::mem::take(&mut self.scratch_l);
let mut master_r = std::mem::take(&mut self.scratch_r);
let live_events = std::mem::take(&mut self.live_events);
if master_l.len() < num_frames {
master_l.resize(num_frames, 0.0);
master_r.resize(num_frames, 0.0);
}
master_l[..num_frames].fill(0.0);
master_r[..num_frames].fill(0.0);
let clip_tx = &self.clip_tx;
for track in &mut self.tracks {
if track.buf_l.len() < num_frames {
track.buf_l.resize(num_frames, 0.0);
track.buf_r.resize(num_frames, 0.0);
}
track.buf_l[..num_frames].fill(0.0);
track.buf_r[..num_frames].fill(0.0);
track.plugin_events.clear();
let is_midi_active = track.kind == TrackKind::Instrument
&& track.handle.config.is_midi_active();
let is_armed = track.handle.config.is_armed();
let should_record = playing && recording && is_armed && is_midi_active;
if should_record && !track.was_recording {
let rec_start = if looping { transport.loop_start() } else { current_tick };
track.record_buf.start(rec_start);
tracing::debug!("rec start track={} tick={}", track.id, current_tick);
}
if should_record && track.was_recording && looping
&& track.record_buf.is_active() && track.last_record_tick >= 0
&& current_tick < track.last_record_tick
{
commit_recording(track, loop_end, clip_tx);
track.record_buf.start(transport.loop_start());
}
if should_record {
track.last_record_tick = current_tick;
}
if !should_record && track.was_recording {
commit_recording(track, current_tick, clip_tx);
}
track.was_recording = should_record;
if is_midi_active {
for ev in &live_events {
track.plugin_events.push(*ev);
if should_record {
let event_tick = current_tick
+ (ev.sample_offset as f64 * ticks_per_sample) as i64;
track.record_buf.record(event_tick, ev.status, ev.data1, ev.data2);
}
}
}
if playing && !track.clips.is_empty() {
let from = current_tick;
let to = current_tick + buffer_ticks;
let just_wrapped = looping && track.last_playback_tick >= 0
&& current_tick < track.last_playback_tick;
track.last_playback_tick = current_tick;
if just_wrapped {
let wrap_start = transport.loop_start();
for clip in &track.clips {
for (tick_offset, event) in clip.events_in_range(wrap_start, to) {
let sample_offset = (tick_offset as f64 / ticks_per_sample) as u32;
track.plugin_events.push(MidiEvent {
sample_offset: sample_offset.min(num_frames as u32 - 1),
status: event.status,
data1: event.data1,
data2: event.data2,
});
}
}
} else {
for clip in &track.clips {
for (tick_offset, event) in clip.events_in_range(from, to) {
let sample_offset = (tick_offset as f64 / ticks_per_sample) as u32;
track.plugin_events.push(MidiEvent {
sample_offset: sample_offset.min(num_frames as u32 - 1),
status: event.status,
data1: event.data1,
data2: event.data2,
});
}
}
}
track.plugin_events.sort_by_key(|e| e.sample_offset);
}
if playing {
track.last_record_tick = current_tick;
}
if let Some(ref mut instrument) = track.instrument {
let out_l = &mut track.buf_l[..num_frames];
let out_r = &mut track.buf_r[..num_frames];
let mut out_slices: [&mut [f32]; 2] = [out_l, out_r];
instrument.process(&[], &mut out_slices, &track.plugin_events);
}
let muted = track.handle.config.is_muted();
let soloed = track.handle.config.is_soloed();
let audible = !muted && (!any_solo || soloed);
let volume = track.handle.config.get_volume();
let mut peak_l = 0.0f32;
let mut peak_r = 0.0f32;
for i in 0..num_frames {
peak_l = peak_l.max(track.buf_l[i].abs());
peak_r = peak_r.max(track.buf_r[i].abs());
}
let (old_l, old_r) = track.handle.vu.get();
let decay = 0.85f32;
track.handle.vu.set(
if peak_l > old_l { peak_l } else { old_l * decay },
if peak_r > old_r { peak_r } else { old_r * decay },
);
if audible {
for i in 0..num_frames {
master_l[i] += track.buf_l[i] * volume;
master_r[i] += track.buf_r[i] * volume;
}
}
}
for i in 0..num_frames {
output[i * 2] = master_l[i];
output[i * 2 + 1] = master_r[i];
}
self.scratch_l = master_l;
self.scratch_r = master_r;
self.live_events = live_events;
self.metronome.process(output, transport);
self.limiter.process(output);
let mut mp_l = 0.0f32;
let mut mp_r = 0.0f32;
for i in 0..num_frames {
mp_l = mp_l.max(output[i * 2].abs());
mp_r = mp_r.max(output[i * 2 + 1].abs());
}
let (old_l, old_r) = self.master_vu.get();
let decay = 0.85f32;
self.master_vu.set(
if mp_l > old_l { mp_l } else { old_l * decay },
if mp_r > old_r { mp_r } else { old_r * decay },
);
}
pub fn reset_all(&mut self) {
let clip_tx = &self.clip_tx;
for track in &mut self.tracks {
if let Some(ref mut inst) = track.instrument {
inst.reset();
}
track.handle.vu.set(0.0, 0.0);
if track.record_buf.is_active() && track.was_recording {
let end_tick = track.last_record_tick.max(0);
commit_recording(track, end_tick, clip_tx);
} else if track.record_buf.is_active() {
track.record_buf.discard();
}
track.was_recording = false;
track.last_playback_tick = -1;
}
self.metronome.reset();
self.limiter.reset();
}
fn drain_commands(&mut self) -> u32 {
let mut spent = 0;
while spent < COMMAND_BUDGET {
let Ok(cmd) = self.command_rx.try_recv() else { break };
spent += command_cost(&cmd);
self.apply_command(cmd);
}
spent
}
fn apply_command(&mut self, cmd: MixerCommand) {
match cmd {
MixerCommand::AddTrack { kind: _, handle } => {
let track = AudioTrack::new(handle, self.max_buffer_size);
self.tracks.push(track);
}
MixerCommand::SetInstrument { track_id, mut instrument } => {
if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
instrument.init(self.sample_rate as f64, self.max_buffer_size);
track.instrument = Some(instrument);
}
}
MixerCommand::RemoveTrack { track_id } => {
self.tracks.retain(|t| t.id != track_id);
}
MixerCommand::SetParameter { track_id, param_index, value } => {
if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
if let Some(ref mut inst) = track.instrument {
inst.set_parameter(param_index, value);
}
}
}
MixerCommand::CreateClip { track_id, start_tick, length_ticks } => {
if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
track.clips.push(MidiClip::new(start_tick, length_ticks, Vec::new()));
}
}
MixerCommand::UpdateClip { track_id, clip_index, events } => {
if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
if let Some(clip) = track.clips.get_mut(clip_index) {
clip.events = events;
clip.events.sort_by_key(|e| e.tick);
}
}
}
MixerCommand::UpdateClipPosition { track_id, clip_index, start_tick, length_ticks } => {
if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
if let Some(clip) = track.clips.get_mut(clip_index) {
clip.start_tick = start_tick;
clip.length_ticks = length_ticks;
}
}
}
MixerCommand::RemoveClip { track_id, clip_index } => {
if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
if clip_index < track.clips.len() {
track.clips.remove(clip_index);
}
}
}
}
}
}
fn commit_recording(track: &mut AudioTrack, end_tick: i64, clip_tx: &Sender<ClipSnapshot>) {
if let Some(clip) = track.record_buf.commit(end_tick) {
let idx = track.clips.len();
tracing::debug!(
"rec commit track={}: {} events, ticks {}..{}",
track.id, clip.events.len(), clip.start_tick, clip.end_tick()
);
let snapshot = ClipSnapshot::from_clip(track.id, idx, &clip);
track.clips.push(clip);
let _ = clip_tx.send(snapshot);
}
}
pub fn midi_to_plugin_event(msg: &MidiMessage) -> Option<MidiEvent> {
use phosphor_midi::message::MidiMessageType;
match msg.message_type {
MidiMessageType::NoteOn { .. }
| MidiMessageType::NoteOff { .. }
| MidiMessageType::ControlChange { .. }
| MidiMessageType::PitchBend { .. } => Some(MidiEvent {
sample_offset: 0,
status: msg.raw[0],
data1: msg.raw[1],
data2: msg.raw[2],
}),
_ => None,
}
}
pub fn mixer_command_channel() -> (Sender<MixerCommand>, Receiver<MixerCommand>) {
crossbeam_channel::unbounded()
}
pub fn clip_snapshot_channel() -> (Sender<ClipSnapshot>, Receiver<ClipSnapshot>) {
crossbeam_channel::unbounded()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::TrackConfig;
use phosphor_dsp::synth::PhosphorSynth;
use phosphor_midi::message::{MidiMessage, MidiMessageType};
fn make_note_on(note: u8, vel: u8) -> MidiMessage {
MidiMessage {
timestamp: Some(0),
message_type: MidiMessageType::NoteOn { channel: 0, note, velocity: vel },
raw: [0x90, note, vel],
len: 3,
}
}
fn make_note_off(note: u8) -> MidiMessage {
MidiMessage {
timestamp: Some(0),
message_type: MidiMessageType::NoteOff { channel: 0, note, velocity: 0 },
raw: [0x80, note, 0],
len: 3,
}
}
fn setup_mixer() -> (Mixer, Sender<MixerCommand>, Receiver<ClipSnapshot>, Arc<Transport>) {
let (tx, rx) = mixer_command_channel();
let (clip_tx, clip_rx) = clip_snapshot_channel();
let master_vu = Arc::new(VuLevels::new());
let transport = Arc::new(Transport::new(120.0));
let mixer = Mixer::new(rx, master_vu, clip_tx, 44100, 256);
(mixer, tx, clip_rx, transport)
}
fn add_armed_synth(tx: &Sender<MixerCommand>, id: usize) -> Arc<TrackHandle> {
let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
handle.config.armed.store(true, std::sync::atomic::Ordering::Relaxed);
tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle: handle.clone() }).unwrap();
tx.send(MixerCommand::SetInstrument { track_id: id, instrument: Box::new(PhosphorSynth::new()) }).unwrap();
handle
}
#[test]
fn mixer_empty_output() {
let (mut mixer, _tx, _clip_rx, transport) = setup_mixer();
let mut output = vec![0.0f32; 128];
mixer.process(&mut output, &[], &transport);
assert!(output.iter().all(|&s| s == 0.0));
}
#[test]
fn mixer_live_midi_produces_sound() {
let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
let _handle = add_armed_synth(&tx, 0);
transport.play();
let midi = vec![make_note_on(60, 100)];
let mut output = vec![0.0f32; 512];
mixer.process(&mut output, &midi, &transport);
let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
assert!(peak > 0.001, "Should produce sound, peak={peak}");
}
#[test]
fn mixer_records_midi_clip() {
let (mut mixer, tx, clip_rx, transport) = setup_mixer();
let _handle = add_armed_synth(&tx, 0);
transport.play();
transport.toggle_record();
let midi = vec![make_note_on(60, 100)];
let mut output = vec![0.0f32; 512];
mixer.process(&mut output, &midi, &transport);
let midi = vec![make_note_off(60)];
mixer.process(&mut output, &midi, &transport);
transport.toggle_record();
mixer.process(&mut output, &[], &transport);
let snap = clip_rx.try_recv().expect("Should receive clip snapshot");
assert_eq!(snap.track_id, 0);
assert!(snap.event_count >= 2, "Should have note on + off, got {}", snap.event_count);
assert!(!snap.notes.is_empty(), "Should have parsed notes");
}
#[test]
fn mixer_plays_back_recorded_clip() {
let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
let _handle = add_armed_synth(&tx, 0);
transport.play();
transport.toggle_record();
let midi = vec![make_note_on(60, 100)];
let mut output = vec![0.0f32; 512];
mixer.process(&mut output, &midi, &transport);
let midi = vec![make_note_off(60)];
mixer.process(&mut output, &midi, &transport);
transport.toggle_record();
mixer.process(&mut output, &[], &transport);
transport.stop();
transport.play();
output.fill(0.0);
mixer.process(&mut output, &[], &transport);
let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
assert!(peak > 0.001, "Playback should produce sound, peak={peak}");
}
#[test]
fn mixer_mute_silences() {
let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
let handle = add_armed_synth(&tx, 0);
handle.config.muted.store(true, std::sync::atomic::Ordering::Relaxed);
transport.play();
let midi = vec![make_note_on(60, 100)];
let mut output = vec![0.0f32; 512];
mixer.process(&mut output, &midi, &transport);
let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
assert!(peak == 0.0, "Muted track should be silent, peak={peak}");
}
#[test]
fn mixer_no_record_when_not_armed() {
let (mut mixer, tx, clip_rx, transport) = setup_mixer();
let handle = add_armed_synth(&tx, 0);
handle.config.armed.store(false, std::sync::atomic::Ordering::Relaxed);
transport.play();
transport.toggle_record();
let midi = vec![make_note_on(60, 100)];
let mut output = vec![0.0f32; 512];
mixer.process(&mut output, &midi, &transport);
transport.toggle_record();
mixer.process(&mut output, &[], &transport);
assert!(clip_rx.try_recv().is_err(), "Should not record when not armed");
}
#[test]
fn mixer_reset_commits_recording() {
let (mut mixer, tx, clip_rx, transport) = setup_mixer();
let _handle = add_armed_synth(&tx, 0);
transport.play();
transport.toggle_record();
let midi = vec![make_note_on(60, 100)];
let mut output = vec![0.0f32; 512];
mixer.process(&mut output, &midi, &transport);
mixer.reset_all();
assert!(clip_rx.try_recv().is_ok(), "Reset should commit active recording");
}
#[test]
fn end_to_end_record_and_playback() {
let (mut mixer, tx, clip_rx, transport) = setup_mixer();
let _handle = add_armed_synth(&tx, 0);
let sr = 44100u32;
let buf_frames = 256;
let buf_samples = buf_frames * 2;
transport.toggle_record();
transport.play();
let mut output = vec![0.0f32; buf_samples];
for _ in 0..4 {
mixer.process(&mut output, &[], &transport);
transport.advance(buf_frames as u32, sr);
}
let midi = vec![make_note_on(60, 100)];
mixer.process(&mut output, &midi, &transport);
let peak_during = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
assert!(peak_during > 0.001, "Should hear note during recording (monitoring)");
transport.advance(buf_frames as u32, sr);
for _ in 0..8 {
output.fill(0.0);
mixer.process(&mut output, &[], &transport);
transport.advance(buf_frames as u32, sr);
}
let midi = vec![make_note_off(60)];
mixer.process(&mut output, &midi, &transport);
transport.advance(buf_frames as u32, sr);
for _ in 0..4 {
output.fill(0.0);
mixer.process(&mut output, &[], &transport);
transport.advance(buf_frames as u32, sr);
}
transport.toggle_record();
mixer.process(&mut output, &[], &transport);
transport.advance(buf_frames as u32, sr);
let snap = clip_rx.try_recv().expect("Should receive clip snapshot after stopping record");
assert!(snap.event_count >= 2, "Clip should have note on + off");
assert!(!snap.notes.is_empty(), "Clip should have parsed notes");
transport.stop();
transport.play();
for _ in 0..4 {
output.fill(0.0);
mixer.process(&mut output, &[], &transport);
transport.advance(buf_frames as u32, sr);
}
output.fill(0.0);
mixer.process(&mut output, &[], &transport);
let peak_playback = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
assert!(peak_playback > 0.001, "Playback should produce sound at the recorded position, peak={peak_playback}");
}
#[test]
fn loop_record_commits_on_wrap() {
let (mut mixer, tx, clip_rx, transport) = setup_mixer();
let _handle = add_armed_synth(&tx, 0);
let sr = 44100u32;
let buf_frames = 256u32;
transport.set_loop_bars(1, 1);
transport.start_loop_record();
let mut output = vec![0.0f32; buf_frames as usize * 2];
let midi = vec![make_note_on(60, 100)];
mixer.process(&mut output, &midi, &transport);
transport.advance(buf_frames, sr);
for _ in 0..5 {
mixer.process(&mut output, &[], &transport);
transport.advance(buf_frames, sr);
}
let midi = vec![make_note_off(60)];
mixer.process(&mut output, &midi, &transport);
transport.advance(buf_frames, sr);
for _ in 0..400 {
mixer.process(&mut output, &[], &transport);
transport.advance(buf_frames, sr);
if let Ok(snap) = clip_rx.try_recv() {
assert!(snap.event_count >= 2, "Clip should have events, got {}", snap.event_count);
assert!(!snap.notes.is_empty(), "Clip should have notes");
transport.stop_loop_record();
return;
}
}
panic!("Recording should have committed when the loop wrapped");
}
#[test]
fn loop_playback_after_record() {
let (mut mixer, tx, clip_rx, transport) = setup_mixer();
let _handle = add_armed_synth(&tx, 0);
let sr = 44100u32;
let bf = 256u32;
transport.set_loop_bars(1, 1);
transport.start_loop_record();
let mut output = vec![0.0f32; bf as usize * 2];
mixer.process(&mut output, &[make_note_on(60, 100)], &transport);
transport.advance(bf, sr);
for _ in 0..3 {
mixer.process(&mut output, &[], &transport);
transport.advance(bf, sr);
}
mixer.process(&mut output, &[make_note_off(60)], &transport);
transport.advance(bf, sr);
for _ in 0..200 {
mixer.process(&mut output, &[], &transport);
transport.advance(bf, sr);
if clip_rx.try_recv().is_ok() { break; }
}
transport.stop_loop_record();
transport.set_position(0);
transport.toggle_loop(); transport.play();
output.fill(0.0);
mixer.process(&mut output, &[], &transport);
let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
assert!(peak > 0.001, "Should hear playback, peak={peak}");
}
const WORST_CALLBACK: u32 = COMMAND_BUDGET - 1 + HEAVY_COMMAND;
#[derive(Clone)]
struct ParamLog(Arc<std::sync::Mutex<Vec<(usize, f32)>>>);
impl ParamLog {
fn new() -> Self {
Self(Arc::new(std::sync::Mutex::new(Vec::new())))
}
fn seen(&self) -> Vec<(usize, f32)> {
self.0.lock().unwrap().clone()
}
}
impl Plugin for ParamLog {
fn info(&self) -> phosphor_plugin::PluginInfo {
phosphor_plugin::PluginInfo {
name: "ParamLog".into(),
version: "0".into(),
author: "test".into(),
category: phosphor_plugin::PluginCategory::Instrument,
}
}
fn init(&mut self, _sample_rate: f64, _max_buffer_size: usize) {}
fn process(&mut self, _inputs: &[&[f32]], _outputs: &mut [&mut [f32]], _midi: &[MidiEvent]) {}
fn parameter_count(&self) -> usize { 8 }
fn parameter_info(&self, _index: usize) -> Option<phosphor_plugin::ParameterInfo> { None }
fn get_parameter(&self, _index: usize) -> f32 { 0.0 }
fn set_parameter(&mut self, index: usize, value: f32) {
self.0.lock().unwrap().push((index, value));
}
fn reset(&mut self) {}
}
fn add_logging_track(mixer: &mut Mixer, tx: &Sender<MixerCommand>, id: usize) -> ParamLog {
let log = ParamLog::new();
let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
tx.send(MixerCommand::SetInstrument {
track_id: id,
instrument: Box::new(log.clone()),
}).unwrap();
mixer.drain_commands();
log
}
#[test]
fn one_callback_applies_a_bounded_amount_of_work() {
let (mut mixer, tx, _clip_rx, _transport) = setup_mixer();
let log = add_logging_track(&mut mixer, &tx, 0);
for i in 0..500 {
tx.send(MixerCommand::SetParameter {
track_id: 0,
param_index: i % 8,
value: i as f32,
}).unwrap();
}
let spent = mixer.drain_commands();
assert!(
spent <= WORST_CALLBACK,
"one callback spent {spent} units, over the {WORST_CALLBACK} bound"
);
assert_eq!(
log.seen().len(),
COMMAND_BUDGET as usize,
"a parameter costs one unit, so a full budget is exactly that many"
);
assert!(!mixer.command_rx.is_empty(), "the rest has to still be queued");
}
#[test]
fn nothing_is_lost_or_reordered_across_callbacks() {
let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
let log = add_logging_track(&mut mixer, &tx, 0);
let sent: Vec<(usize, f32)> = (0..500).map(|i| (i % 8, i as f32)).collect();
for &(param_index, value) in &sent {
tx.send(MixerCommand::SetParameter { track_id: 0, param_index, value }).unwrap();
}
let mut output = vec![0.0f32; 128];
let mut callbacks = 0;
while !mixer.command_rx.is_empty() {
mixer.process(&mut output, &[], &transport);
callbacks += 1;
assert!(callbacks < 100, "the drain is not making progress");
}
assert!(
callbacks >= 500 / COMMAND_BUDGET as usize,
"500 commands went through in {callbacks} callbacks, so the budget did not hold"
);
assert_eq!(log.seen(), sent, "the audio thread saw a different sequence");
}
#[test]
fn a_track_and_its_instrument_survive_a_budget_boundary() {
let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
let log = ParamLog::new();
for _ in 0..COMMAND_BUDGET {
tx.send(MixerCommand::SetParameter { track_id: 99, param_index: 0, value: 0.0 })
.unwrap();
}
let handle = Arc::new(TrackHandle::new(7, TrackKind::Instrument));
tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
tx.send(MixerCommand::SetInstrument {
track_id: 7,
instrument: Box::new(log.clone()),
}).unwrap();
tx.send(MixerCommand::SetParameter { track_id: 7, param_index: 3, value: 0.5 }).unwrap();
let mut output = vec![0.0f32; 128];
mixer.process(&mut output, &[], &transport);
assert!(mixer.tracks.is_empty(), "the budget did not stop at the parameters");
while !mixer.command_rx.is_empty() {
mixer.process(&mut output, &[], &transport);
}
assert_eq!(mixer.tracks.len(), 1);
assert!(mixer.tracks[0].instrument.is_some(), "the instrument never arrived");
assert_eq!(
log.seen(),
vec![(3, 0.5)],
"the parameter that follows the instrument did not reach it"
);
}
#[test]
fn an_instrument_load_costs_more_than_a_parameter() {
let param = MixerCommand::SetParameter { track_id: 0, param_index: 0, value: 0.0 };
let load = MixerCommand::SetInstrument {
track_id: 0,
instrument: Box::new(FixedOutput(0.0)),
};
assert!(command_cost(&load) > command_cost(¶m));
let (mut mixer, tx, _clip_rx, _transport) = setup_mixer();
for id in 0..8 {
let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
}
while !mixer.command_rx.is_empty() {
mixer.drain_commands();
}
for id in 0..8 {
tx.send(MixerCommand::SetInstrument {
track_id: id,
instrument: Box::new(FixedOutput(0.25)),
}).unwrap();
}
mixer.drain_commands();
let loaded = mixer.tracks.iter().filter(|t| t.instrument.is_some()).count();
assert_eq!(loaded, (COMMAND_BUDGET / HEAVY_COMMAND) as usize);
}
#[test]
fn adding_tracks_does_not_grow_the_track_list() {
let (mut mixer, tx, _clip_rx, _transport) = setup_mixer();
let capacity = mixer.tracks.capacity();
assert!(capacity >= TRACK_CAPACITY);
for id in 0..TRACK_CAPACITY {
let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
}
while !mixer.command_rx.is_empty() {
mixer.drain_commands();
}
assert_eq!(mixer.tracks.len(), TRACK_CAPACITY);
assert_eq!(
mixer.tracks.capacity(), capacity,
"the track list reallocated on the audio thread"
);
}
struct FixedOutput(f32);
impl Plugin for FixedOutput {
fn info(&self) -> phosphor_plugin::PluginInfo {
phosphor_plugin::PluginInfo {
name: "Fixed".into(),
version: "0".into(),
author: "test".into(),
category: phosphor_plugin::PluginCategory::Instrument,
}
}
fn init(&mut self, _sample_rate: f64, _max_buffer_size: usize) {}
fn process(&mut self, _inputs: &[&[f32]], outputs: &mut [&mut [f32]], _midi: &[MidiEvent]) {
for ch in outputs.iter_mut() {
ch.fill(self.0);
}
}
fn parameter_count(&self) -> usize { 0 }
fn parameter_info(&self, _index: usize) -> Option<phosphor_plugin::ParameterInfo> { None }
fn get_parameter(&self, _index: usize) -> f32 { 0.0 }
fn set_parameter(&mut self, _index: usize, _value: f32) {}
fn reset(&mut self) {}
}
fn add_fixed_track(tx: &Sender<MixerCommand>, id: usize, value: f32) -> Arc<TrackHandle> {
let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
handle.config.set_volume(1.0);
tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle: handle.clone() }).unwrap();
tx.send(MixerCommand::SetInstrument {
track_id: id,
instrument: Box::new(FixedOutput(value)),
}).unwrap();
handle
}
#[test]
fn master_limiter_bounds_many_loud_tracks() {
let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
for id in 0..6 {
add_fixed_track(&tx, id, 0.75);
}
transport.play();
let mut output = vec![0.0f32; 512];
for _ in 0..8 {
mixer.process(&mut output, &[], &transport);
for (i, &s) in output.iter().enumerate() {
assert!(s.is_finite(), "non-finite sample at {i}");
assert!(s.abs() <= 1.0, "sample {i} left the mixer at {s}");
}
}
let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
assert!(peak > 0.8, "limiter over-attenuated, peak={peak}");
}
#[test]
fn non_finite_track_output_becomes_silence() {
let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
add_fixed_track(&tx, 0, f32::NAN);
transport.play();
let mut output = vec![0.0f32; 512];
mixer.process(&mut output, &[], &transport);
assert!(output.iter().all(|s| *s == 0.0), "NaN track should render as silence");
tx.send(MixerCommand::RemoveTrack { track_id: 0 }).unwrap();
add_fixed_track(&tx, 1, 0.5);
mixer.process(&mut output, &[], &transport);
let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
assert!((peak - 0.5).abs() < 1.0e-6, "mixer did not recover, peak={peak}");
}
#[test]
fn infinite_track_output_becomes_silence() {
let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
add_fixed_track(&tx, 0, f32::INFINITY);
transport.play();
let mut output = vec![0.0f32; 512];
mixer.process(&mut output, &[], &transport);
assert!(output.iter().all(|s| *s == 0.0), "infinite track should render as silence");
}
#[test]
fn limiter_is_bit_identical_below_the_ceiling() {
let mut limiter = MasterLimiter::new(44_100);
let mut input: Vec<f32> = Vec::new();
for i in 0..20_000u32 {
let phase = i as f32 * 0.01;
let amp = LIMITER_CEILING * (i as f32 / 20_000.0);
input.push(phase.sin() * amp);
input.push(phase.cos() * amp);
}
input.push(LIMITER_CEILING);
input.push(-LIMITER_CEILING);
input.push(0.0);
input.push(-0.0);
input.push(f32::MIN_POSITIVE);
input.push(-f32::MIN_POSITIVE);
let mut output = input.clone();
limiter.process(&mut output);
for (i, (a, b)) in input.iter().zip(output.iter()).enumerate() {
assert_eq!(a.to_bits(), b.to_bits(), "limiter altered sample {i}: {a} -> {b}");
}
}
#[test]
fn limiter_holds_the_ceiling_under_abuse() {
let mut limiter = MasterLimiter::new(44_100);
for amplitude in [1.0f32, 2.0, 10.0, 1.0e3, 1.0e6, 1.0e30] {
let mut buf: Vec<f32> = (0..4_096)
.map(|i| (i as f32 * 0.05).sin() * amplitude)
.collect();
limiter.process(&mut buf);
for (i, &s) in buf.iter().enumerate() {
assert!(s.is_finite(), "amplitude {amplitude}: sample {i} is {s}");
assert!(
s.abs() <= LIMITER_CEILING,
"amplitude {amplitude}: sample {i} reached {s}, above the ceiling"
);
}
}
}
#[test]
fn limiter_attack_has_no_overshoot() {
let mut limiter = MasterLimiter::new(44_100);
let mut buf = vec![0.0f32; 64];
limiter.process(&mut buf);
let mut step = vec![4.0f32; 64];
limiter.process(&mut step);
assert!(
step[0].abs() <= LIMITER_CEILING,
"first sample of the step overshot to {}",
step[0]
);
}
#[test]
fn limiter_release_is_gradual() {
let mut limiter = MasterLimiter::new(44_100);
let mut loud = vec![4.0f32; 64];
limiter.process(&mut loud);
let reduced = limiter.gain;
assert!(reduced < 0.5, "limiter did not engage, gain={reduced}");
let mut quiet = vec![0.1f32; 441 * 2];
limiter.process(&mut quiet);
assert!(limiter.gain > reduced, "gain did not recover at all");
assert!(
limiter.gain < 1.0,
"gain snapped back to unity within 10 ms, which is a click"
);
let mut long = vec![0.1f32; 22_050 * 2];
limiter.process(&mut long);
assert!(
(limiter.gain - 1.0).abs() < 1.0e-4,
"gain never returned to unity: {}",
limiter.gain
);
}
#[test]
fn limiter_does_not_shift_the_stereo_image() {
let mut limiter = MasterLimiter::new(44_100);
let mut buf: Vec<f32> = Vec::new();
for i in 0..1_024 {
let phase = i as f32 * 0.05;
buf.push(phase.sin() * 3.0);
buf.push(phase.sin() * 1.5);
}
limiter.process(&mut buf);
for frame in buf.chunks_exact(2) {
if frame[1].abs() > 1.0e-4 {
let ratio = frame[0] / frame[1];
assert!(
(ratio - 2.0).abs() < 1.0e-3,
"channel balance moved: L/R = {ratio}"
);
}
}
}
fn loudest_dx7_voice() -> phosphor_dsp::dx7::Dx7Synth {
use phosphor_dsp::dx7;
let mut synth = dx7::Dx7Synth::new();
let (bank, patch) = dx7::voice_knobs(147);
synth.set_parameter(dx7::P_BANK, bank);
synth.set_parameter(dx7::P_PATCH, patch);
debug_assert_eq!(dx7::voice_name(147), "TIMPANI");
synth
}
#[test]
fn master_limiter_bounds_four_loud_instrument_tracks() {
let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
for id in 0..4 {
let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
handle.config.set_volume(1.0);
let synth = loudest_dx7_voice();
tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
tx.send(MixerCommand::SetInstrument {
track_id: id,
instrument: Box::new(synth),
}).unwrap();
}
transport.play();
let chord: Vec<MidiMessage> = [36u8, 43, 48, 55, 60, 64, 67, 72]
.iter()
.map(|¬e| make_note_on(note, 127))
.collect();
let mut output = vec![0.0f32; 512];
let mut peak = 0.0f32;
for block in 0..200 {
output.fill(0.0);
if block == 0 {
mixer.process(&mut output, &chord, &transport);
} else {
mixer.process(&mut output, &[], &transport);
}
for (i, &s) in output.iter().enumerate() {
assert!(s.is_finite(), "block {block} sample {i} is {s}");
assert!(s.abs() <= 1.0, "block {block} sample {i} left the mixer at {s}");
peak = peak.max(s.abs());
}
}
assert!(peak > 0.5, "four loud tracks should be loud, peak={peak}");
}
#[test]
fn limiter_idle_for_the_worst_single_track() {
let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
let handle = Arc::new(TrackHandle::new(0, TrackKind::Instrument));
handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
handle.config.set_volume(1.0);
let synth = loudest_dx7_voice();
tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
tx.send(MixerCommand::SetInstrument { track_id: 0, instrument: Box::new(synth) }).unwrap();
transport.play();
let chord: Vec<MidiMessage> = [36u8, 43, 48, 55, 60, 64, 67, 72]
.iter()
.map(|¬e| make_note_on(note, 127))
.collect();
let mut output = vec![0.0f32; 512];
let mut peak = 0.0f32;
for block in 0..200 {
output.fill(0.0);
if block == 0 {
mixer.process(&mut output, &chord, &transport);
} else {
mixer.process(&mut output, &[], &transport);
}
peak = peak.max(output.iter().map(|s| s.abs()).fold(0.0f32, f32::max));
assert_eq!(
mixer.limiter.gain, 1.0,
"limiter engaged at block {block}, peak {peak}"
);
}
assert!(peak > 0.3, "expected a loud chord, peak={peak}");
}
fn worst_track_through_the_mixer(volume: f32) -> (f32, f32) {
let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
let handle = Arc::new(TrackHandle::new(0, TrackKind::Instrument));
handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
handle.config.set_volume(volume);
let synth = loudest_dx7_voice();
tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
tx.send(MixerCommand::SetInstrument { track_id: 0, instrument: Box::new(synth) }).unwrap();
transport.play();
let chord: Vec<MidiMessage> = [36u8, 43, 48, 55, 60, 64, 67, 72]
.iter()
.map(|¬e| make_note_on(note, 127))
.collect();
let mut output = vec![0.0f32; 512];
let mut peak = 0.0f32;
let mut min_gain = 1.0f32;
for block in 0..200 {
output.fill(0.0);
if block == 0 {
mixer.process(&mut output, &chord, &transport);
} else {
mixer.process(&mut output, &[], &transport);
}
for &s in output.iter() {
assert!(s.is_finite(), "block {block}: non-finite sample");
assert!(s.abs() <= 1.0, "block {block}: sample left the mixer at {s}");
peak = peak.max(s.abs());
}
min_gain = min_gain.min(mixer.limiter.gain);
}
(peak, min_gain)
}
#[test]
fn fader_below_unity_never_engages_the_limiter() {
for volume in [
0.25,
TrackConfig::DEFAULT_VOLUME,
TrackConfig::UNITY_VOLUME,
] {
let (peak, min_gain) = worst_track_through_the_mixer(volume);
assert_eq!(
min_gain, 1.0,
"limiter reduced by {:.2} dB at fader {volume} (peak {peak:.4})",
20.0 * min_gain.log10()
);
}
}
#[test]
fn fader_makeup_gain_is_bounded_not_wasted() {
let (unity_peak, _) = worst_track_through_the_mixer(TrackConfig::UNITY_VOLUME);
let (max_peak, min_gain) = worst_track_through_the_mixer(TrackConfig::MAX_VOLUME);
assert!(
max_peak <= LIMITER_CEILING,
"fader at maximum let {max_peak:.4} through, above the ceiling"
);
assert!(
max_peak >= unity_peak,
"turning the fader up made the track quieter: {unity_peak:.4} -> {max_peak:.4}"
);
let reduction_db = -20.0 * min_gain.log10();
let boost_db = 20.0 * (TrackConfig::MAX_VOLUME / TrackConfig::UNITY_VOLUME).log10();
assert!(
reduction_db <= boost_db,
"limiter took {reduction_db:.2} dB off a {boost_db:.2} dB boost"
);
}
#[test]
fn metronome_click_sits_with_the_music() {
use phosphor_dsp::dx7;
fn render(with_track: bool, metronome: bool) -> f32 {
let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
let chord: Vec<MidiMessage> = if with_track {
let handle = Arc::new(TrackHandle::new(0, TrackKind::Instrument));
handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
tx.send(MixerCommand::AddTrack {
kind: TrackKind::Instrument,
handle,
})
.unwrap();
tx.send(MixerCommand::SetInstrument {
track_id: 0,
instrument: Box::new(dx7::Dx7Synth::new()),
})
.unwrap();
[60u8, 64, 67].iter().map(|&n| make_note_on(n, 100)).collect()
} else {
Vec::new()
};
if metronome {
transport.toggle_metronome();
}
transport.play();
let mut output = vec![0.0f32; 512];
let mut peak = 0.0f32;
for block in 0..200 {
output.fill(0.0);
if block == 0 {
mixer.process(&mut output, &chord, &transport);
} else {
mixer.process(&mut output, &[], &transport);
}
peak = peak.max(output.iter().map(|s| s.abs()).fold(0.0f32, f32::max));
transport.advance(256, 44_100);
}
peak
}
let music = render(true, false);
let click = render(false, true);
assert!(music > 0.0 && click > 0.0, "music {music}, click {click}");
let relative_db = 20.0 * (click / music).log10();
assert!(
(-12.0..=0.0).contains(&relative_db),
"the click is {relative_db:.1} dB against a triad (click {click:.4}, \
music {music:.4}); it has to be audible over the music without \
being the loudest thing in the mix"
);
}
#[test]
fn fader_scales_the_track() {
let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
let handle = add_fixed_track(&tx, 0, 0.25);
transport.play();
let mut output = vec![0.0f32; 512];
for (volume, expected) in [(0.0f32, 0.0f32), (0.5, 0.125), (1.0, 0.25), (2.0, 0.5)] {
handle.config.set_volume(volume);
output.fill(0.0);
mixer.process(&mut output, &[], &transport);
let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
assert!(
(peak - expected).abs() < 1.0e-6,
"fader at {volume} gave {peak}, expected {expected}"
);
}
}
}