Merge pull request #108 from scottcampbelldata/sampler-instrument

Add a built-in Sampler instrument
This commit is contained in:
Brandon Thomas 2026-10-11 21:06:08 -04:00 committed by GitHub
commit acb08ff5f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1362 additions and 25 deletions

View File

@ -117,7 +117,8 @@ where things are.
- **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer,
channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi,
rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither,
a subtractive synth and a drum synth, all original. **CLAP plugins** load too. Any processor
a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG across the
keyboard), all original. **CLAP plugins** load too. Any processor
runs offline as AudioSuite, and user presets save per plugin.
- **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event
list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files,

View File

@ -1,7 +1,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! SoundCraft DSP.
//!
//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and two
//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and three
//! instruments), metering (peak, RMS, ITU-R BS.1770 loudness), spectrum analysis and offline
//! AudioSuite-style whole-buffer processing.
//!
@ -28,6 +28,8 @@ pub use offline::FadeShape;
pub use osc::Waveform;
pub use plugins::eq::{eq1_response, eq7_response};
use std::sync::Arc;
/// Plugin category, used to group the insert menu.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum Category {
@ -197,6 +199,13 @@ pub trait Plugin: Send {
fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {}
fn note_off(&mut self, _offset: usize, _note: u8) {}
fn all_notes_off(&mut self) {}
/// Sample-playing instruments (the Sampler): the recorded audio to play, `None` to clear it.
/// `false` when the plugin does not play samples. Does not allocate; sounding voices stop.
/// The previous sample is dropped here, so hosts keep their own reference to it (the
/// session's source pool does) to keep deallocation off the audio thread.
fn set_sample(&mut self, _sample: Option<Arc<dyn SampleSource>>) -> bool {
false
}
/// The plugin's complete internal state as an opaque blob (third-party plugins), for saving
/// in the session. `None` when the plugin has no state beyond its parameters (built-ins) or
/// the plugin refused. May allocate: never call it in steady-state audio processing.
@ -240,6 +249,40 @@ pub trait PluginEditor: Send {
fn set_param(&mut self, _id: &str, _value: f32) {}
}
/// Recorded audio a sample-playing instrument reads: planar `f32` at its own sample rate (the
/// instrument converts rates as it plays). Implementations must be cheap to read from the audio
/// thread: no locks, no I/O.
pub trait SampleSource: Send + Sync {
/// Sample rate of the audio in Hz.
fn sample_rate(&self) -> f32;
fn num_channels(&self) -> usize;
/// One channel's frames (`None` past the last channel).
fn channel(&self, ch: usize) -> Option<&[f32]>;
/// Frames per channel (the shortest channel, so every index below it is readable).
fn frames(&self) -> usize {
(0..self.num_channels()).filter_map(|c| self.channel(c)).map(<[f32]>::len).min().unwrap_or(0)
}
}
/// A sample held in memory (tests, generated audio).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SampleBuffer {
pub sample_rate: f32,
pub channels: Vec<Vec<f32>>,
}
impl SampleSource for SampleBuffer {
fn sample_rate(&self) -> f32 {
self.sample_rate
}
fn num_channels(&self) -> usize {
self.channels.len()
}
fn channel(&self, ch: usize) -> Option<&[f32]> {
self.channels.get(ch).map(Vec::as_slice)
}
}
/// The plugin registry.
pub fn plugins() -> &'static [&'static PluginInfo] {
plugins::REGISTRY
@ -298,4 +341,21 @@ mod trait_default_tests {
assert!(p.editor().is_none(), "{}", info.id);
}
}
#[test]
fn only_the_sampler_takes_samples() {
for info in plugins() {
let mut p = create(info.id).unwrap();
let s: Arc<dyn SampleSource> = Arc::new(SampleBuffer { sample_rate: 48_000.0, channels: vec![vec![0.0; 16]] });
assert_eq!(p.set_sample(Some(s)), info.id == "sampler", "{}", info.id);
assert_eq!(p.set_sample(None), info.id == "sampler", "{}", info.id);
}
}
#[test]
fn sample_buffer_frames_is_the_shortest_channel() {
let s = SampleBuffer { sample_rate: 44_100.0, channels: vec![vec![0.0; 10], vec![0.0; 7]] };
assert_eq!(s.frames(), 7);
assert_eq!(SampleBuffer::default().frames(), 0);
}
}

View File

@ -7,6 +7,7 @@ pub mod harmonic;
pub mod modulation;
pub mod pitch;
pub mod reverb;
pub mod sampler;
pub mod strip;
pub mod synth;
pub mod utility;
@ -41,6 +42,7 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
&utility::DITHER_INFO,
&synth::SYNTH_INFO,
&drums::DRUM_INFO,
&sampler::SAMPLER_INFO,
];
/// Constructs an (unprepared) plugin by id.
@ -72,6 +74,7 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
"dither" => Box::new(utility::Dither::new()),
"subtractive_synth" => Box::new(synth::SubtractiveSynth::new()),
"drum_synth" => Box::new(drums::DrumSynth::new()),
"sampler" => Box::new(sampler::Sampler::new()),
_ => return None,
})
}

View File

@ -0,0 +1,817 @@
//! Sampler: plays one recorded sample across the keyboard, 32 voices.
//!
//! Each note plays the sample pitched from its root note (an octave up plays twice as fast) with
//! 4-point cubic interpolation, which also converts the sample's own rate to the host's. Key and
//! velocity ranges, an amp ADSR with velocity sensitivity, a sample start point, forward or
//! ping-pong loops, and gate or one-shot playback. The host hands the audio over with
//! [`Plugin::set_sample`]; without one the Sampler is silent.
use std::sync::Arc;
use super::synth::{Adsr, AdsrRates, Event, EventKind, EventQueue, MAX_EVENTS, Stage};
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
use crate::util::{Smoother, frames_in, ms_to_samples, sane_channels, sane_sr};
use crate::{Category, ParamInfo, Plugin, PluginInfo, SampleSource, Unit, db_to_gain};
const PLAY_MODES: &[&str] = &["Gate", "One Shot"];
const LOOP_MODES: &[&str] = &["Off", "Forward", "Ping-Pong"];
const ROOT: usize = 0;
const TRANSPOSE: usize = 1;
const FINE: usize = 2;
const KEY_TRACK: usize = 3;
const KEY_LOW: usize = 4;
const KEY_HIGH: usize = 5;
const VEL_LOW: usize = 6;
const VEL_HIGH: usize = 7;
const VEL_SENS: usize = 8;
const ATTACK: usize = 9;
const DECAY: usize = 10;
const SUSTAIN: usize = 11;
const RELEASE: usize = 12;
const PLAY_MODE: usize = 13;
const START: usize = 14;
const LOOP_MODE: usize = 15;
const LOOP_START: usize = 16;
const LOOP_END: usize = 17;
const LEVEL: usize = 18;
static SAMPLER_PARAMS: [ParamInfo; 19] = [
lin("root_note", "Root Note", 0.0, 127.0, 60.0, Unit::None),
lin("transpose", "Transpose", -48.0, 48.0, 0.0, Unit::Semitones),
lin("fine_tune", "Fine Tune", -100.0, 100.0, 0.0, Unit::Cents),
toggle("key_track", "Key Tracking", true),
lin("key_low", "Lowest Key", 0.0, 127.0, 0.0, Unit::None),
lin("key_high", "Highest Key", 0.0, 127.0, 127.0, Unit::None),
lin("vel_low", "Lowest Velocity", 1.0, 127.0, 1.0, Unit::None),
lin("vel_high", "Highest Velocity", 1.0, 127.0, 127.0, Unit::None),
lin("velocity", "Velocity Sens", 0.0, 100.0, 70.0, Unit::Percent),
log("attack", "Attack", 0.1, 5000.0, 1.0, Unit::Ms),
log("decay", "Decay", 1.0, 10000.0, 300.0, Unit::Ms),
lin("sustain", "Sustain", 0.0, 100.0, 100.0, Unit::Percent),
log("release", "Release", 1.0, 10000.0, 150.0, Unit::Ms),
choice("play_mode", "Play Mode", PLAY_MODES, 0),
lin("start", "Sample Start", 0.0, 100.0, 0.0, Unit::Percent),
choice("loop_mode", "Loop", LOOP_MODES, 0),
lin("loop_start", "Loop Start", 0.0, 100.0, 0.0, Unit::Percent),
lin("loop_end", "Loop End", 0.0, 100.0, 100.0, Unit::Percent),
lin("level", "Level", -48.0, 12.0, 0.0, Unit::Db),
];
pub static SAMPLER_INFO: PluginInfo = PluginInfo {
id: "sampler",
name: "Sampler",
short_name: "Sampler",
category: Category::Instrument,
params: &SAMPLER_PARAMS,
is_instrument: true,
audiosuite: false,
};
const VOICES: usize = 32;
/// Fastest playback step in sample frames per output frame (keeps hostile pitch bounded).
const MAX_STEP: f64 = 256.0;
#[derive(Debug, Clone, Copy, PartialEq)]
enum LoopMode {
Off,
Forward,
PingPong,
}
#[derive(Debug, Clone, Copy)]
struct Voice {
note: u8,
/// Key still down (gate mode only; one-shot voices are never held).
held: bool,
gain: f32,
/// Read position in sample frames.
pos: f64,
step: f64,
forward: bool,
looping: bool,
env: Adsr,
}
impl Voice {
const fn new() -> Self {
Self { note: 0, held: false, gain: 0.0, pos: 0.0, step: 1.0, forward: true, looping: false, env: Adsr::new() }
}
fn active(&self) -> bool {
self.env.stage != Stage::Idle
}
fn stop(&mut self) {
*self = Voice::new();
}
}
/// Values derived from the parameters (refreshed on every change).
#[derive(Debug, Clone, Copy)]
struct Derived {
root: f64,
/// Transpose plus fine tune, in semitones.
offset: f64,
key_track: bool,
keys: (u8, u8),
vels: (u8, u8),
sens: f32,
one_shot: bool,
start: f64,
loop_mode: LoopMode,
/// Loop start and end as fractions of the sample (start <= end).
loop_span: (f64, f64),
}
/// A MIDI key or velocity parameter as a whole number in `lo..=127`.
fn whole(v: f32, lo: u8) -> u8 {
if v.is_finite() { v.round().clamp(f32::from(lo), 127.0) as u8 } else { lo }
}
fn ordered(a: u8, b: u8) -> (u8, u8) {
(a.min(b), a.max(b))
}
/// 4-point cubic (Catmull-Rom) interpolation of `x` at fractional frame `pos` (>= 0). Reads
/// outside the slice are silence.
#[inline]
fn hermite(x: &[f32], pos: f64) -> f32 {
let i = pos as usize;
let t = (pos - i as f64) as f32;
let at = |k: usize| x.get(k).copied().unwrap_or(0.0);
let x0 = at(i);
if t == 0.0 {
return if x0.is_finite() { x0 } else { 0.0 };
}
let xm1 = at(i.saturating_sub(1));
let x1 = at(i.saturating_add(1));
let x2 = at(i.saturating_add(2));
let c1 = 0.5 * (x1 - xm1);
let c2 = xm1 - 2.5 * x0 + 2.0 * x1 - 0.5 * x2;
let c3 = 0.5 * (x2 - xm1) + 1.5 * (x0 - x1);
let y = ((c3 * t + c2) * t + c1) * t + x0;
// A float file can hold NaN or infinities; they play as silence.
if y.is_finite() { y } else { 0.0 }
}
/// Plays a sample across the keyboard.
pub struct Sampler {
p: Params,
d: Derived,
sr: f32,
ch: usize,
sample: Option<Arc<dyn SampleSource>>,
/// Frames in the sample (0 = nothing to play).
len: usize,
/// The sample's own rate.
sample_sr: f32,
voices: [Voice; VOICES],
events: EventQueue,
rates: AdsrRates,
level: Smoother,
}
impl Default for Sampler {
fn default() -> Self {
Self::new()
}
}
impl Sampler {
pub fn new() -> Self {
let mut s = Self {
p: Params::new(&SAMPLER_PARAMS),
d: Derived {
root: 60.0,
offset: 0.0,
key_track: true,
keys: (0, 127),
vels: (1, 127),
sens: 0.7,
one_shot: false,
start: 0.0,
loop_mode: LoopMode::Off,
loop_span: (0.0, 1.0),
},
sr: 48_000.0,
ch: 0,
sample: None,
len: 0,
sample_sr: 48_000.0,
voices: [Voice::new(); VOICES],
events: EventQueue::new(),
rates: AdsrRates::default(),
level: Smoother::new(1.0),
};
s.update();
s
}
fn update(&mut self) {
let v = |i| self.p.v(i);
let pct = |i| f64::from(v(i) / 100.0).clamp(0.0, 1.0);
let (ls, le) = (pct(LOOP_START), pct(LOOP_END));
self.d = Derived {
root: f64::from(whole(v(ROOT), 0)),
offset: f64::from(v(TRANSPOSE)) + f64::from(v(FINE)) / 100.0,
key_track: self.p.on(KEY_TRACK),
keys: ordered(whole(v(KEY_LOW), 0), whole(v(KEY_HIGH), 0)),
vels: ordered(whole(v(VEL_LOW), 1), whole(v(VEL_HIGH), 1)),
sens: (v(VEL_SENS) / 100.0).clamp(0.0, 1.0),
one_shot: self.p.choice(PLAY_MODE) == 1,
start: pct(START),
loop_mode: match self.p.choice(LOOP_MODE) {
1 => LoopMode::Forward,
2 => LoopMode::PingPong,
_ => LoopMode::Off,
},
loop_span: (ls.min(le), ls.max(le)),
};
self.rates = AdsrRates::new(v(ATTACK), v(DECAY), v(SUSTAIN) / 100.0, v(RELEASE), self.sr);
self.level.set(db_to_gain(v(LEVEL)));
}
/// The loop region in sample frames, when it is long enough to loop.
fn loop_frames(&self) -> Option<(f64, f64)> {
if self.d.loop_mode == LoopMode::Off {
return None;
}
let len = self.len as f64;
let (a, b) = (self.d.loop_span.0 * len, self.d.loop_span.1 * len);
(b - a >= 1.0).then_some((a, b))
}
fn start_note(&mut self, note: u8, vel: u8) {
if vel == 0 {
self.stop_note(note);
return;
}
let d = self.d;
let (note, vel) = (note.min(127), vel.min(127));
if self.len == 0 || note < d.keys.0 || note > d.keys.1 || vel < d.vels.0 || vel > d.vels.1 {
return;
}
let semis = if d.key_track { f64::from(note) - d.root } else { 0.0 } + d.offset;
let step = f64::from(self.sample_sr) / f64::from(self.sr) * 2f64.powf(semis / 12.0);
if !step.is_finite() || step <= 0.0 {
return;
}
if !d.one_shot {
// Retriggering a held key releases the earlier voice instead of stacking it.
self.stop_note(note);
}
let idx = self
.voices
.iter()
.position(|v| !v.active())
.or_else(|| {
self.voices
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| (a.held, a.env.level).partial_cmp(&(b.held, b.env.level)).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
})
.unwrap_or(0);
let start = d.start * self.len.saturating_sub(1) as f64;
let looping = self.loop_frames().is_some_and(|(_, end)| start < end);
let gain = (1.0 - d.sens) + d.sens * f32::from(vel) / 127.0;
if let Some(v) = self.voices.get_mut(idx) {
*v = Voice { note, held: !d.one_shot, gain, pos: start, step: step.min(MAX_STEP), forward: true, looping, env: Adsr::new() };
v.env.gate_on();
}
}
fn stop_note(&mut self, note: u8) {
for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) {
v.held = false;
v.env.gate_off();
}
}
fn apply(&mut self, kind: EventKind) {
match kind {
EventKind::On(n, v) => self.start_note(n, v),
EventKind::Off(n) => self.stop_note(n),
EventKind::AllOff => {
for v in &mut self.voices {
v.held = false;
v.env.gate_off();
}
}
}
}
/// Adds every voice's output for frame `n` into `io` and advances the voices.
#[inline]
fn render_frame(&mut self, io: &mut [Vec<f32>], ch: usize, n: usize, level: f32) {
let Some(sample) = self.sample.as_deref() else { return };
let nch = sample.num_channels();
let len = self.len as f64;
let mode = self.d.loop_mode;
let region = self.loop_frames();
for v in &mut self.voices {
if !v.active() {
continue;
}
let a = v.env.next(&self.rates) * v.gain * level;
for (c, b) in io.iter_mut().take(ch).enumerate() {
let src = if nch == 1 { 0 } else { c };
if let (Some(x), Some(data)) = (b.get_mut(n), sample.channel(src)) {
*x += hermite(data, v.pos) * a;
}
}
let r = if v.looping { region } else { None };
advance(v, len, r, mode);
}
}
}
/// Moves a voice's read position one output frame on, looping or ending it.
#[inline]
fn advance(v: &mut Voice, len: f64, region: Option<(f64, f64)>, mode: LoopMode) {
v.pos = if v.forward { v.pos + v.step } else { v.pos - v.step };
match region {
Some((ls, le)) if mode == LoopMode::PingPong => {
if v.forward && v.pos >= le {
v.pos = (le - (v.pos - le)).max(ls);
v.forward = false;
} else if !v.forward && v.pos <= ls {
v.pos = (ls + (ls - v.pos)).min(le);
v.forward = true;
}
}
Some((ls, le)) => {
if v.pos >= le {
v.pos = ls + (v.pos - ls) % (le - ls);
}
}
None => {
if v.pos >= len || !v.pos.is_finite() {
v.stop();
}
}
}
if !v.pos.is_finite() || v.pos < 0.0 {
v.stop();
}
}
impl Plugin for Sampler {
param_plumbing!(SAMPLER_INFO);
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
self.sr = sane_sr(sample_rate);
self.ch = sane_channels(channels);
self.level.set_time(20.0, self.sr);
self.update();
self.reset();
}
fn reset(&mut self) {
for v in &mut self.voices {
v.stop();
}
self.events.clear();
self.level.snap();
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
let ch = self.ch.min(io.len());
let frames = frames_in(io, frames, ch);
for b in io.iter_mut().take(ch) {
b.iter_mut().take(frames).for_each(|x| *x = 0.0);
}
let mut evs = [Event { offset: 0, kind: EventKind::AllOff }; MAX_EVENTS];
let count = {
let sorted = self.events.sorted();
for (d, s) in evs.iter_mut().zip(sorted) {
*d = *s;
}
sorted.len().min(MAX_EVENTS)
};
self.events.clear();
let mut next = 0;
for n in 0..frames {
while let Some(e) = evs.get(next).filter(|_| next < count) {
if e.offset > n && n + 1 < frames {
break;
}
let kind = e.kind;
self.apply(kind);
next += 1;
}
let level = self.level.next();
self.render_frame(io, ch, n, level);
}
// Events for an empty block take effect immediately.
for e in evs.iter().take(count).skip(next) {
self.apply(e.kind);
}
}
fn tail_samples(&self) -> usize {
let release = ms_to_samples(self.p.v(RELEASE), self.sr);
// A one-shot keeps playing its sample after the note ends.
let rest = if self.d.one_shot { self.len as f32 * self.sr / self.sample_sr } else { 0.0 };
let t = release + rest;
if t.is_finite() { t.min(1.0e9) as usize } else { 0 }
}
fn note_on(&mut self, offset: usize, note: u8, velocity: u8) {
self.events.push(offset, EventKind::On(note, velocity));
}
fn note_off(&mut self, offset: usize, note: u8) {
self.events.push(offset, EventKind::Off(note));
}
fn all_notes_off(&mut self) {
self.events.push(0, EventKind::AllOff);
}
fn set_sample(&mut self, sample: Option<Arc<dyn SampleSource>>) -> bool {
for v in &mut self.voices {
v.stop();
}
self.len = sample.as_deref().map_or(0, |s| if s.num_channels() == 0 { 0 } else { s.frames() });
let rate = sample.as_deref().map_or(self.sr, |s| s.sample_rate());
// An unusable rate plays at the host rate rather than not at all.
self.sample_sr = if rate.is_finite() && rate > 0.0 { rate.min(1_536_000.0) } else { self.sr };
self.sample = sample;
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SampleBuffer;
use std::f32::consts::TAU;
const SR: f32 = 48_000.0;
fn sampler(sample: SampleBuffer) -> Sampler {
let mut s = Sampler::new();
s.prepare(SR, 512, 2);
assert!(s.set_sample(Some(Arc::new(sample))));
s
}
fn mono(sample_rate: f32, data: Vec<f32>) -> SampleBuffer {
SampleBuffer { sample_rate, channels: vec![data] }
}
fn sine(freq: f32, sr: f32, len: usize) -> Vec<f32> {
(0..len).map(|i| 0.5 * (TAU * freq * i as f32 / sr).sin()).collect()
}
/// Renders `len` frames in 512-frame blocks; returns both channels.
fn run(s: &mut Sampler, len: usize) -> [Vec<f32>; 2] {
let mut out = [Vec::new(), Vec::new()];
let mut buf = vec![vec![1.0f32; 512]; 2];
let mut done = 0;
while done < len {
let n = 512.min(len - done);
s.process(&mut buf, n);
out[0].extend_from_slice(&buf[0][..n]);
out[1].extend_from_slice(&buf[1][..n]);
done += n;
}
out
}
fn peak(x: &[f32]) -> f32 {
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
}
/// Frequency from upward zero crossings.
fn freq(x: &[f32], sr: f32) -> f32 {
let ups: Vec<usize> = x.windows(2).enumerate().filter(|(_, w)| w[0] < 0.0 && w[1] >= 0.0).map(|(i, _)| i).collect();
let (Some(a), Some(b)) = (ups.first(), ups.last()) else { return 0.0 };
(ups.len() - 1) as f32 * sr / (b - a) as f32
}
#[test]
fn silent_without_a_sample_and_replaces_the_buffer() {
let mut s = Sampler::new();
s.prepare(SR, 512, 2);
s.note_on(0, 60, 127);
let out = run(&mut s, 4800);
assert_eq!(peak(&out[0]), 0.0);
assert_eq!(peak(&out[1]), 0.0);
}
#[test]
fn root_note_plays_the_sample_verbatim() {
let data = sine(440.0, SR, 9600);
let mut s = sampler(mono(SR, data.clone()));
s.set_param("velocity", 0.0);
s.note_on(0, 60, 100);
let out = run(&mut s, 9600);
// Past the 0.1-ms-ish attack the output is the sample itself, on both sides.
for i in 100..9600 {
assert!((out[0][i] - data[i]).abs() < 1e-6, "frame {i}: {} vs {}", out[0][i], data[i]);
}
assert_eq!(out[0], out[1], "a mono sample feeds both channels");
}
#[test]
fn keys_transpose_from_the_root_note() {
let mut s = sampler(mono(SR, sine(400.0, SR, 96_000)));
s.note_on(0, 72, 127);
let up = run(&mut s, 24_000);
assert!((freq(&up[0][480..], SR) - 800.0).abs() < 2.0, "an octave up: {}", freq(&up[0][480..], SR));
s.reset();
s.note_on(0, 53, 127);
let down = run(&mut s, 24_000);
let want = 400.0 * 2f32.powf(-7.0 / 12.0);
assert!((freq(&down[0][480..], SR) - want).abs() < 2.0);
// Root note 48 makes key 60 play an octave up; transpose and fine tune add on top.
s.reset();
s.set_param("root_note", 48.0);
s.set_param("transpose", -12.0);
s.set_param("fine_tune", 100.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 24_000);
let want = 400.0 * 2f32.powf(1.0 / 12.0);
assert!((freq(&out[0][480..], SR) - want).abs() < 2.0, "{}", freq(&out[0][480..], SR));
}
#[test]
fn key_tracking_off_plays_every_key_at_the_sample_pitch() {
let mut s = sampler(mono(SR, sine(400.0, SR, 48_000)));
s.set_param("key_track", 0.0);
s.note_on(0, 90, 127);
let out = run(&mut s, 24_000);
assert!((freq(&out[0][480..], SR) - 400.0).abs() < 2.0);
}
#[test]
fn converts_the_sample_rate() {
// A 300 Hz tone recorded at 22.05 kHz still plays at 300 Hz on a 48 kHz host.
let mut s = sampler(mono(22_050.0, sine(300.0, 22_050.0, 22_050)));
s.note_on(0, 60, 127);
let out = run(&mut s, 60_000);
assert!((freq(&out[0][480..], SR) - 300.0).abs() < 1.0, "{}", freq(&out[0][480..], SR));
// And it lasts as long as the recording: one second.
assert!(peak(&out[0][46_000..47_500]) > 0.1);
assert_eq!(peak(&out[0][48_200..]), 0.0, "the sample ended");
// A 96 kHz recording is read faster, not cut short.
let mut s = sampler(mono(96_000.0, sine(1000.0, 96_000.0, 96_000)));
s.note_on(0, 60, 127);
let out = run(&mut s, 48_000);
assert!((freq(&out[0][480..], SR) - 1000.0).abs() < 2.0);
}
#[test]
fn key_and_velocity_ranges_gate_notes() {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.set_param("key_low", 48.0);
s.set_param("key_high", 60.0);
s.set_param("vel_low", 40.0);
s.set_param("vel_high", 100.0);
for (note, vel, sounds) in
[(47, 80, false), (61, 80, false), (48, 80, true), (60, 80, true), (55, 39, false), (55, 101, false), (55, 40, true)]
{
s.reset();
s.note_on(0, note, vel);
let out = run(&mut s, 960);
assert_eq!(peak(&out[0]) > 0.0, sounds, "note {note} velocity {vel}");
}
// Reversed ranges are read in order rather than muting everything.
s.set_param("key_low", 60.0);
s.set_param("key_high", 48.0);
s.reset();
s.note_on(0, 50, 80);
assert!(peak(&run(&mut s, 960)[0]) > 0.0);
}
#[test]
fn velocity_sensitivity_scales_level() {
let level = |sens: f32, vel: u8| {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.set_param("velocity", sens);
s.note_on(0, 60, vel);
peak(&run(&mut s, 2400)[0][1000..])
};
assert!((level(100.0, 127) - 0.5).abs() < 1e-4);
assert!((level(100.0, 64) - 0.5 * 64.0 / 127.0).abs() < 1e-4);
assert!((level(0.0, 1) - 0.5).abs() < 1e-4, "no sensitivity: every velocity is full level");
assert!(level(70.0, 30) < level(70.0, 127));
}
#[test]
fn adsr_shapes_the_note() {
let mut s = sampler(mono(SR, vec![0.5; 96_000]));
s.set_param("velocity", 0.0);
s.set_param("attack", 100.0);
s.set_param("decay", 100.0);
s.set_param("sustain", 50.0);
s.set_param("release", 100.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 24_000);
// Linear attack: halfway up at 50 ms, full level at 100 ms, then decay to the sustain level.
assert!((out[0][2400] - 0.25).abs() < 0.01, "attack midpoint {}", out[0][2400]);
assert!(peak(&out[0][4700..4900]) > 0.49);
assert!((out[0][20_000] - 0.25).abs() < 0.005, "sustain {}", out[0][20_000]);
s.note_off(0, 60);
let out = run(&mut s, 24_000);
assert!(out[0][100] > 0.2, "release starts from the sustain level");
assert!(peak(&out[0][5000..]) < 1e-3, "released after 100 ms");
assert_eq!(peak(&out[0][12_000..]), 0.0, "the voice ended");
}
#[test]
fn gate_mode_stops_on_note_off_and_one_shot_plays_through() {
let mut s = sampler(mono(SR, vec![0.5; 24_000]));
s.set_param("release", 1.0);
s.note_on(0, 60, 127);
s.note_off(1000, 60);
let out = run(&mut s, 24_000);
assert!(peak(&out[0][..900]) > 0.1);
assert_eq!(peak(&out[0][2000..]), 0.0, "gate: released at the note-off");
s.set_param("play_mode", 1.0);
s.reset();
s.note_on(0, 60, 127);
s.note_off(1000, 60);
let out = run(&mut s, 30_000);
assert!(peak(&out[0][20_000..23_900]) > 0.1, "one shot: ignores the note-off");
assert_eq!(peak(&out[0][24_100..]), 0.0, "and stops at the end of the sample");
assert!(s.tail_samples() >= 24_000, "a one-shot's tail covers the sample");
// Stopping the transport still silences one-shots.
s.reset();
s.note_on(0, 60, 127);
run(&mut s, 512);
s.all_notes_off();
assert_eq!(peak(&run(&mut s, 4800)[0][2400..]), 0.0);
}
#[test]
fn retriggering_a_held_key_releases_the_old_voice() {
let mut s = sampler(mono(SR, vec![0.25; 48_000]));
s.set_param("velocity", 0.0);
s.set_param("release", 1.0);
s.note_on(0, 60, 127);
s.note_on(256, 60, 127);
let out = run(&mut s, 4800);
assert!(peak(&out[0][2400..]) < 0.26, "voices did not stack: {}", peak(&out[0][2400..]));
// One-shots overlap.
s.set_param("play_mode", 1.0);
s.reset();
s.note_on(0, 60, 127);
s.note_on(256, 60, 127);
let out = run(&mut s, 4800);
assert!((peak(&out[0][2400..]) - 0.5).abs() < 1e-3);
}
#[test]
fn forward_loop_sustains_past_the_end() {
let data = sine(480.0, SR, 4800);
let mut s = sampler(mono(SR, data.clone()));
s.set_param("velocity", 0.0);
s.set_param("loop_mode", 1.0);
s.set_param("loop_start", 50.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 48_000);
assert!(peak(&out[0][40_000..]) > 0.4, "still sounding long after the 0.1 s sample");
// After the first pass it repeats the loop region (2400 frames, whole cycles of 480 Hz).
for i in 0..2400 {
assert!((out[0][4800 + i] - data[2400 + i]).abs() < 1e-4, "frame {i}");
}
assert!((freq(&out[0][4800..], SR) - 480.0).abs() < 1.0);
// Without the loop the note ends with the sample.
s.set_param("loop_mode", 0.0);
s.reset();
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 9600)[0][4900..]), 0.0);
}
#[test]
fn ping_pong_loop_reverses_direction() {
// A rising ramp, looped ping-pong over the whole sample, rises then falls.
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
let mut s = sampler(mono(SR, data));
s.set_param("velocity", 0.0);
s.set_param("loop_mode", 2.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 3000);
assert!(out[0][900] > out[0][500], "rising");
assert!(out[0][1500] < out[0][1100], "falling after the loop end");
assert!(out[0][2500] > out[0][2100], "rising again after the loop start");
assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0));
}
#[test]
fn a_loop_too_short_to_loop_plays_once() {
let mut s = sampler(mono(SR, vec![0.5; 1000]));
s.set_param("loop_mode", 1.0);
s.set_param("loop_start", 40.0);
s.set_param("loop_end", 40.0);
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 4800)[0][1100..]), 0.0);
}
#[test]
fn sample_start_skips_into_the_sample() {
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
let mut s = sampler(mono(SR, data));
s.set_param("velocity", 0.0);
s.set_param("attack", 0.1);
s.set_param("start", 50.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 2000);
assert!((out[0][10] - 0.509).abs() < 0.002, "{}", out[0][10]);
assert_eq!(peak(&out[0][520..]), 0.0, "half the sample left");
}
#[test]
fn stereo_samples_keep_their_sides() {
let sample = SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 4800], vec![-0.25; 4800]] };
let mut s = sampler(sample);
s.set_param("velocity", 0.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 2400);
assert!((out[0][1000] - 0.5).abs() < 1e-6);
assert!((out[1][1000] + 0.25).abs() < 1e-6);
}
#[test]
fn note_offsets_are_sample_accurate() {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.note_on(300, 60, 127);
let out = run(&mut s, 512);
assert!(out[0][..300].iter().all(|&v| v == 0.0));
assert!(out[0][301..].iter().any(|&v| v != 0.0));
}
#[test]
fn many_notes_steal_voices_and_stay_bounded() {
let mut s = sampler(mono(SR, sine(220.0, SR, 48_000)));
s.set_param("level", 12.0);
for n in 0..100u8 {
s.note_on(usize::from(n), n, 127);
}
let out = run(&mut s, 9600);
assert!(out[0].iter().all(|v| v.is_finite()));
assert!(s.voices.iter().filter(|v| v.active()).count() <= VOICES);
}
#[test]
fn a_new_sample_stops_the_old_voices() {
let mut s = sampler(mono(SR, vec![0.5; 48_000]));
s.note_on(0, 60, 127);
run(&mut s, 512);
assert!(s.set_sample(Some(Arc::new(mono(SR, vec![0.1; 10])))));
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
assert!(s.set_sample(None));
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
}
#[test]
fn hostile_samples_and_params_never_panic() {
let samples = [
SampleBuffer { sample_rate: f32::NAN, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: 0.0, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: f32::INFINITY, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: 1.0e-30, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: SR, channels: vec![] },
SampleBuffer { sample_rate: SR, channels: vec![vec![]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 100], vec![0.5; 3]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 1]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![f32::NAN; 100]; 9] },
];
let params: Vec<(&str, f32)> = SAMPLER_PARAMS.iter().flat_map(|p| [(p.id, f32::NAN), (p.id, f32::INFINITY), (p.id, -1.0e30)]).collect();
for sample in samples {
let mut s = Sampler::new();
s.prepare(f32::NAN, 0, 99);
s.set_sample(Some(Arc::new(sample)));
for (id, v) in &params {
assert!(s.set_param(id, *v));
for n in [0u8, 60, 127, 255] {
s.note_on(0, n, 255);
s.note_on(7, n, 1);
}
let mut io = vec![vec![0.0f32; 64]; 3];
s.process(&mut io, 1000);
assert!(io.iter().flatten().all(|v| v.is_finite()), "{id} = {v}");
s.process(&mut [], 64);
let _ = s.tail_samples();
}
}
// Loops with every mode at the extremes of pitch.
let mut s = sampler(mono(SR, sine(100.0, SR, 1000)));
for mode in [0.0, 1.0, 2.0] {
s.set_param("loop_mode", mode);
for (t, root) in [(48.0, 0.0), (-48.0, 127.0)] {
s.set_param("transpose", t);
s.set_param("root_note", root);
s.note_on(0, 127, 127);
s.note_on(0, 0, 127);
let out = run(&mut s, 4096);
assert!(out[0].iter().all(|v| v.is_finite() && v.abs() < 4.0), "mode {mode} transpose {t}");
}
}
}
}

View File

@ -97,7 +97,7 @@ const VOICES: usize = 16;
const SILENT: f32 = 1.0e-5;
#[derive(Debug, Clone, Copy, PartialEq)]
enum Stage {
pub(crate) enum Stage {
Idle,
Attack,
Decay,
@ -107,13 +107,13 @@ enum Stage {
/// Linear-attack, exponential decay/release ADSR. Times are "to -60 dB" for decay/release.
#[derive(Debug, Clone, Copy)]
struct Adsr {
stage: Stage,
level: f32,
pub(crate) struct Adsr {
pub(crate) stage: Stage,
pub(crate) level: f32,
}
#[derive(Debug, Clone, Copy, Default)]
struct AdsrRates {
pub(crate) struct AdsrRates {
attack: f32,
decay: f32,
sustain: f32,
@ -121,29 +121,29 @@ struct AdsrRates {
}
impl AdsrRates {
fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self {
pub(crate) fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self {
let exp60 = |ms: f32| (0.001f32.ln() / ms_to_samples(ms, sr).max(1.0)).exp();
Self { attack: 1.0 / ms_to_samples(a_ms, sr).max(1.0), decay: exp60(d_ms), sustain: s.clamp(0.0, 1.0), release: exp60(r_ms) }
}
}
impl Adsr {
const fn new() -> Self {
pub(crate) const fn new() -> Self {
Self { stage: Stage::Idle, level: 0.0 }
}
fn gate_on(&mut self) {
pub(crate) fn gate_on(&mut self) {
self.stage = Stage::Attack;
}
fn gate_off(&mut self) {
pub(crate) fn gate_off(&mut self) {
if self.stage != Stage::Idle {
self.stage = Stage::Release;
}
}
#[inline]
fn next(&mut self, r: &AdsrRates) -> f32 {
pub(crate) fn next(&mut self, r: &AdsrRates) -> f32 {
match self.stage {
Stage::Idle => self.level = 0.0,
Stage::Attack => {

View File

@ -8,15 +8,26 @@ use serde_json::json;
use soundcraft_model::{Insert, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"mix.instrument",
"Set Instrument",
[],
None,
"{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id",
has_selection,
set_instrument
)]
vec![
cmd!(
"mix.instrument",
"Set Instrument",
[],
None,
"{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id",
has_selection,
set_instrument
),
cmd!(
"mix.instrument_param",
"Set Instrument Parameter",
[],
None,
"{track?, param, value}: one parameter of an instrument track's built-in instrument (clamped into range)",
has_selection,
set_instrument_param
),
]
}
fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
@ -39,6 +50,20 @@ fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"track": t, "plugin": info.id}))
}
fn set_instrument_param(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "mix.instrument_param";
let param = str_param(p, "param").ok_or_else(|| bad(id, "`param` required"))?.to_string();
let value = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(id, "`value` must be a number"))? as f32;
let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?;
let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).ok_or_else(|| bad(id, "the track has no instrument"))?;
// Hosted instruments keep their parameters in their own state.
let info = soundcraft_dsp::plugin_info(&ins.plugin).ok_or_else(|| bad(id, format!("`{}` is not a built-in instrument", ins.plugin)))?;
let pi = info.param(&param).ok_or_else(|| bad(id, format!("`{}` has no parameter `{param}`", info.id)))?;
let v = pi.clamp(value);
ins.params.insert(param.clone(), v);
Ok(json!({"track": t, "param": param, "value": v}))
}
#[cfg(test)]
mod tests {
use crate::Engine;
@ -68,6 +93,24 @@ mod tests {
assert_eq!(instrument_of(&e, t).as_deref(), Some("subtractive_synth"));
}
#[test]
fn sets_a_built_in_instruments_parameters() {
let mut e = Engine::default();
let t = track(&mut e, "instrument");
let r = e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 5000})).unwrap();
assert_eq!(r["value"], 5000.0);
let r = e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 1.0e9})).unwrap();
assert_eq!(r["value"], 20000.0, "clamped");
let v = e.session().track(t).and_then(|tr| tr.instrument.as_ref()).and_then(|i| i.params.get("cutoff").copied());
assert_eq!(v, Some(20000.0));
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "nope", "value": 1})).is_err());
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff"})).is_err());
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": "loud"})).is_err());
let audio = track(&mut e, "audio");
assert!(e.execute("mix.instrument_param", &json!({"track": audio.0, "param": "cutoff", "value": 1})).is_err());
e.execute("edit.undo", &json!({})).unwrap();
}
#[test]
fn refuses_effects_unknown_plugins_and_other_track_kinds() {
let mut e = Engine::default();

View File

@ -25,6 +25,7 @@ mod more_util;
mod options;
mod plugin_state;
mod query;
mod sampler;
mod setup_more;
mod surround;
mod track;
@ -125,6 +126,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(surround::specs());
v.extend(plugin_state::specs());
v.extend(instrument::specs());
v.extend(sampler::specs());
v.extend(video::specs());
v
})

View File

@ -0,0 +1,321 @@
//! The built-in Sampler's sample: `mix.sampler_load` decodes an audio file (WAV, AIFF, FLAC, MP3,
//! OGG, anything `file.import_audio` reads) into a session source, resampled to the session rate,
//! and makes it the sample an instrument track's Sampler plays; `mix.sampler_clear` removes it.
//! The source is saved in `Audio Files/` with the session like any other audio, so a session that
//! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set
//! with `mix.instrument_param`, or all at once with `params` here.
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{Insert, TrackKind};
use std::path::Path;
const SAMPLER: &str = "sampler";
/// Largest sample accepted (frames × channels): ten minutes of 192 kHz stereo.
const MAX_SAMPLE_VALUES: usize = 192_000 * 600 * 2;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"mix.sampler_load",
"Load Sampler Sample",
[],
None,
"{track?, path, params?: {root_note, key_low, key_high, attack, loop_mode, …}}: an instrument track plays the file through the built-in Sampler (made its instrument if it is not already) → {track, source, name, frames, channels, sample_rate}",
has_selection,
load
),
cmd!("mix.sampler_clear", "Clear Sampler Sample", [], None, "{track?}: the track's Sampler stops playing its sample", has_selection, clear),
]
}
/// The instrument track a sampler command acts on.
fn instrument_track(e: &Engine, id: &str, p: &Value) -> Result<TrackId> {
let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?;
if e.session().track(t).map(|tr| tr.kind) != Some(TrackKind::Instrument) {
return Err(bad(id, "only instrument tracks have a sampler"));
}
Ok(t)
}
fn load(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "mix.sampler_load";
let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string();
let t = instrument_track(e, id, p)?;
let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?;
// Validate the parameters before touching the session.
let mut values = Vec::new();
if let Some(v) = p.get("params") {
let obj = v.as_object().ok_or_else(|| bad(id, "`params` must be an object"))?;
for (k, v) in obj {
let pi = info.param(k).ok_or_else(|| bad(id, format!("the sampler has no parameter `{k}`")))?;
let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad(id, format!("`{k}` must be a number")))?;
values.push((k.clone(), pi.clamp(x as f32)));
}
}
let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string();
let ext = Path::new(&name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
let (format, buf) = soundcraft_audio_io::decode(&bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
if buf.frames() == 0 || buf.num_channels() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
}
if buf.frames().saturating_mul(buf.num_channels()) > MAX_SAMPLE_VALUES {
return Err(EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")));
}
let channels = buf.num_channels();
let s = e.session_mut();
let src = crate::io::add_source(s, &name, buf, Some(&path), format.format);
let (frames, rate, stem) = s.source(src).map_or((0, 0, String::new()), |x| (x.frames, x.sample_rate, x.name.clone()));
let tr = s.track_mut(t).ok_or_else(|| bad(id, "no track"))?;
let keep = tr.instrument.as_ref().is_some_and(|i| i.plugin == SAMPLER);
if !keep {
let mut ins = Insert::new(SAMPLER);
for pi in info.params {
ins.params.insert(pi.id.to_string(), pi.default);
}
tr.instrument = Some(ins);
}
if let Some(ins) = tr.instrument.as_mut() {
ins.sample = Some(src);
for (k, v) in values {
ins.params.insert(k, v);
}
}
Ok(json!({"track": t, "source": src, "name": stem, "frames": frames, "channels": channels, "sample_rate": rate}))
}
fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "mix.sampler_clear";
let t = instrument_track(e, id, p)?;
let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).filter(|i| i.plugin == SAMPLER);
let ins = ins.ok_or_else(|| bad(id, "the track's instrument is not the sampler"))?;
let had = ins.sample.take();
Ok(json!({"track": t, "cleared": had}))
}
#[cfg(test)]
mod tests {
use crate::Engine;
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
use soundcraft_midi::{Note, Sequence};
use soundcraft_model::{Clip, TrackId};
use soundcraft_time::{Range, TICKS_PER_QUARTER};
use std::path::PathBuf;
/// A scratch folder for one test.
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("sc-sampler-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Writes a generated tone (no recorded or third-party audio) as a 16-bit WAV.
fn tone_wav(path: &std::path::Path, freq: f32, rate: u32, secs: f32, channels: usize) {
let n = (rate as f32 * secs) as usize;
let one: Vec<f32> = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * freq * i as f32 / rate as f32).sin()).collect();
let buf = AudioBuffer { sample_rate: rate, channels: vec![one; channels] };
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
}
fn instrument_track(e: &mut Engine) -> TrackId {
e.execute("track.new", &json!({"kind": "instrument", "format": "stereo"})).unwrap();
e.session().tracks.last().unwrap().id
}
/// One MIDI note (pitch, start in quarter notes, length in quarters) on the track at 120 bpm.
fn add_notes(e: &mut Engine, t: TrackId, notes: &[(u8, i64, i64)]) {
let q = TICKS_PER_QUARTER;
let mut seq = Sequence::default();
for &(pitch, start, len) in notes {
seq.notes.push(Note { pitch, velocity: 127, release_velocity: 64, channel: 0, start: start * q, length: len * q });
}
let s = e.session_mut();
let cid = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::midi(cid, "notes", 0, 48_000 * 8, seq));
}
fn render(e: &Engine, secs: i64) -> Vec<Vec<f32>> {
soundcraft_mix::render_range(e.session(), Range::new(0, 48_000 * secs), 512)
}
fn peak(x: &[f32]) -> f32 {
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
}
fn crossings(x: &[f32]) -> usize {
x.windows(2).filter(|w| w[0] < 0.0 && w[1] >= 0.0).count()
}
#[test]
fn loads_a_sample_plays_it_and_undoes() {
let d = dir("play");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 44_100, 2.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1), (72, 2, 1)]);
assert!(peak(&render(&e, 2)[0]) > 0.0, "the default synth plays the notes first");
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
assert_eq!(r["frames"], 96_000, "resampled to the 48 kHz session: {r}");
assert_eq!(r["sample_rate"], 48_000);
assert_eq!(r["channels"], 1);
assert_eq!(r["name"], "tone");
let ins = e.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(ins.plugin, "sampler");
assert_eq!(ins.sample.map(|s| s.0), r["source"].as_u64());
let out = render(&e, 2);
// Middle C plays the tone at its pitch for the first quarter (0.5 s), key 72 an octave up.
let first = &out[0][2400..21_600];
let second = &out[0][50_400..69_600];
assert!(peak(first) > 0.05, "the sample plays");
let (a, b) = (crossings(first), crossings(second));
assert!((a as f32 - 176.0).abs() <= 2.0, "440 Hz over 0.4 s: {a}");
assert!((b as f32 - 352.0).abs() <= 3.0, "an octave up: {b}");
assert!(peak(&out[0][36_000..48_000]) < 1e-3, "released between the notes");
e.execute("edit.undo", &json!({})).unwrap();
let back = e.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(back.plugin, "subtractive_synth");
assert!(back.sample.is_none());
assert!(e.session().sources.is_empty(), "undo removes the source too");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn loads_flac_and_aiff_too() {
let d = dir("formats");
let n = 4800;
let tone: Vec<f32> = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * 440.0 * i as f32 / 96_000.0).sin()).collect();
let buf = AudioBuffer { sample_rate: 96_000, channels: vec![tone.clone(), tone] };
let mut e = Engine::default();
let t = instrument_track(&mut e);
for (ext, format) in [("flac", FileFormat::Flac), ("aif", FileFormat::Aiff)] {
let path = d.join(format!("tone.{ext}"));
let opts = EncodeOptions { format, bit_depth: BitDepth::Int24, dither: false, bwf: None };
std::fs::write(&path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": path.to_string_lossy()})).unwrap();
assert_eq!(r["channels"], 2, "{ext}");
assert_eq!(r["frames"], 2400, "{ext}: 96 kHz resampled to the 48 kHz session");
}
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn instrument_params_reach_the_sampler() {
let d = dir("params");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 48_000, 2.0, 2);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1)]);
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"key_low": 61}})).unwrap();
assert_eq!(peak(&render(&e, 1)[0]), 0.0, "key 60 is below the key range");
e.execute("mix.instrument_param", &json!({"track": t.0, "param": "key_low", "value": 0})).unwrap();
e.execute("mix.instrument_param", &json!({"track": t.0, "param": "root_note", "value": 48})).unwrap();
let out = render(&e, 1);
assert!((crossings(&out[0][2400..21_600]) as f32 - 352.0).abs() <= 3.0, "root note 48: key 60 plays an octave up");
assert!(peak(&out[1]) > 0.05, "a stereo sample plays on both sides");
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"nope": 1}})).is_err());
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"root_note": "C3"}})).is_err());
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": 3})).is_err());
assert_eq!(e.session().sources.len(), 1, "rejected loads add nothing");
// Reloading keeps the sampler's settings and swaps the sample.
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
let ins = e.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(ins.params.get("root_note"), Some(&48.0));
assert_eq!(ins.sample.map(|s| s.0), r["source"].as_u64());
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn clear_silences_the_sampler() {
let d = dir("clear");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 48_000, 1.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1)]);
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
assert!(peak(&render(&e, 1)[0]) > 0.05);
let r = e.execute("mix.sampler_clear", &json!({"track": t.0})).unwrap();
assert!(r["cleared"].is_u64());
assert_eq!(peak(&render(&e, 1)[0]), 0.0);
e.execute("mix.instrument", &json!({"track": t.0, "plugin": "drum_synth"})).unwrap();
assert!(e.execute("mix.sampler_clear", &json!({"track": t.0})).is_err(), "not a sampler");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_sampled_session_saves_and_reopens_with_its_sample() {
let d = dir("save");
let wav = d.join("pluck.wav");
tone_wav(&wav, 330.0, 22_050, 1.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(64, 0, 1)]);
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"root_note": 64, "loop_mode": 1}})).unwrap();
let before = render(&e, 1);
let path = d.join("song.scraft");
e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
std::fs::remove_file(&wav).unwrap();
let mut e2 = Engine::default();
e2.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
let ins = e2.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(ins.plugin, "sampler");
assert_eq!(ins.params.get("loop_mode"), Some(&1.0));
let after = render(&e2, 1);
assert!(peak(&after[0]) > 0.05, "the saved copy in Audio Files plays");
let diff = before[0].iter().zip(&after[0]).fold(0.0f32, |m, (a, b)| m.max((a - b).abs()));
assert!(diff < 1e-5, "same audio after reopening: {diff}");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn refuses_bad_input() {
let d = dir("bad");
let junk = d.join("junk.wav");
std::fs::write(&junk, b"definitely not audio").unwrap();
let empty = d.join("empty.wav");
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(&empty, soundcraft_audio_io::encode(&AudioBuffer { sample_rate: 48_000, channels: vec![vec![]] }, &opts).unwrap()).unwrap();
let wav = d.join("ok.wav");
tone_wav(&wav, 440.0, 48_000, 0.1, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
e.execute("track.new", &json!({"kind": "audio"})).unwrap();
let audio = e.session().tracks.last().unwrap().id;
let load = |e: &mut Engine, track: u64, path: &std::path::Path| {
e.execute("mix.sampler_load", &json!({"track": track, "path": path.to_string_lossy()}))
};
assert!(load(&mut e, t.0, &junk).is_err(), "not audio");
assert!(load(&mut e, t.0, &empty).is_err(), "no frames");
assert!(load(&mut e, t.0, &d.join("missing.wav")).is_err(), "missing file");
assert!(load(&mut e, audio.0, &wav).is_err(), "an audio track");
assert!(load(&mut e, 999_999, &wav).is_err(), "no such track");
assert!(e.execute("mix.sampler_load", &json!({"track": t.0})).is_err(), "no path");
assert!(e.execute("mix.sampler_clear", &json!({"track": audio.0})).is_err());
assert!(e.session().sources.is_empty());
assert_eq!(e.session().track(t).unwrap().instrument.as_ref().map(|i| i.plugin.as_str()), Some("subtractive_synth"));
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn missing_media_plays_silence() {
let d = dir("missing");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 48_000, 1.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1)]);
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
let src = soundcraft_model::SourceId(r["source"].as_u64().unwrap());
e.session_mut().pool.remove(src);
assert_eq!(peak(&render(&e, 1)[0]), 0.0);
let _ = std::fs::remove_dir_all(d);
}
}

View File

@ -436,6 +436,8 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
}
let mut needed: Vec<SourceId> =
picked.iter().flat_map(|t| t.playlists.iter().flat_map(|pl| pl.clips.iter().filter_map(|c| c.source()))).collect();
// A Sampler's sample is session audio too.
needed.extend(picked.iter().filter_map(|t| t.instrument.as_ref().and_then(|i| i.sample)));
needed.sort_by_key(|x| x.0);
needed.dedup();
let sr = e.session().sample_rate.hz();
@ -481,6 +483,10 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
}
// The donor's source ids mean nothing here: remap the sample, or drop it when it didn't come along.
if let Some(ins) = tr.instrument.as_mut() {
ins.sample = ins.sample.and_then(|sid| map.get(&sid.0).copied());
}
// Busses are matched by name (created when missing).
let remap = |s: &mut Session, r: &Route| -> Route {
match r {
@ -603,4 +609,26 @@ mod tests {
assert!(e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Nope"]})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_session_data_brings_a_samplers_sample() {
let dir = std::env::temp_dir().join(format!("sc-import-sampler-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = dir.join("hit.wav");
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4_800]] };
std::fs::write(&wav, soundcraft_audio_io::encode(&buf, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap();
let mut donor = Engine::default();
donor.execute("track.new", &json!({"count": 1, "name": "Pads", "kind": "instrument"})).unwrap();
donor.execute("mix.sampler_load", &json!({"track": "Pads", "path": wav.to_string_lossy()})).unwrap();
let path = dir.join("donor.scraft").to_string_lossy().into_owned();
crate::io::save_session(&mut donor, &path).unwrap();
let mut e = crate::demo::demo_engine();
e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Pads"]})).unwrap();
let sample = e.session().track_by_name("Pads").and_then(|t| t.instrument.as_ref()).and_then(|i| i.sample).unwrap();
let src = e.session().source(sample).unwrap();
assert_eq!(src.name, "hit");
assert!(e.session().pool.get(sample).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -43,14 +43,22 @@
//! [`MixEngine::capture_states`] reads every live instance's state back (for saving the session);
//! it calls into the plugins and allocates, so a realtime host runs it only on request.
//!
//! A built-in instrument's parameter values are applied the same way. A sample-playing
//! instrument (the Sampler) is handed the decoded audio of its insert's
//! [`Insert::sample`] source straight from the session's pool, shared and never copied.
//!
//! [`Insert::state`]: soundcraft_model::Insert::state
//! [`Insert::sample`]: soundcraft_model::Insert::sample
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains};
use soundcraft_dsp::{Plugin, db_to_gain};
use soundcraft_model::{AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, Speaker, SurroundPan, Track, TrackId, TrackKind};
use soundcraft_dsp::{Plugin, SampleSource, db_to_gain};
use soundcraft_model::{
AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind,
};
use soundcraft_time::{Range, Samples};
use std::collections::HashMap;
use std::sync::Arc;
/// Default (stereo) main mix width. The actual width is [`main_channels`].
pub const MAIN_CHANNELS: usize = 2;
@ -187,6 +195,8 @@ struct Strip {
scratch: Vec<f32>,
plugins: Vec<Option<PluginSlot>>,
instrument: Option<PluginSlot>,
/// The pool audio handed to the instrument with `set_sample` (sample-playing instruments).
sample: Option<Arc<SourceAudio>>,
/// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek.
held: Vec<u8>,
muted: bool,
@ -261,6 +271,7 @@ impl Strip {
scratch: Vec::new(),
plugins: Vec::new(),
instrument: None,
sample: None,
held: Vec::new(),
muted: false,
gr: 0.0,
@ -518,7 +529,9 @@ impl MixEngine {
let old = strip.instrument.take();
cx.retire(old);
strip.instrument = want.and_then(|i| cx.make(t.id, INSTRUMENT_SLOT, i, ich));
strip.sample = None;
}
sync_sample(strip, want, s);
}
// Instances nobody claimed (the session changed again before they arrived).
let leftovers: Vec<(TrackId, usize)> = cx.adopt.keys().copied().collect();
@ -1060,6 +1073,10 @@ fn structure_fingerprint(s: &Session) -> usize {
snd.target.hash(&mut h);
}
t.instrument.as_ref().map(|x| x.plugin.as_str()).hash(&mut h);
let sample = t.instrument.as_ref().and_then(|x| x.sample);
sample.hash(&mut h);
// Which decoded audio it is, so new audio under the same id (another session) re-syncs.
sample.and_then(|id| s.pool.get(id)).map(|a| Arc::as_ptr(a) as usize).hash(&mut h);
}
for b in &s.busses {
b.id.hash(&mut h);
@ -1338,6 +1355,40 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu
}
}
/// Session audio shared with a sample-playing instrument.
struct PoolSample(Arc<SourceAudio>);
impl SampleSource for PoolSample {
fn sample_rate(&self) -> f32 {
self.0.buffer.sample_rate as f32
}
fn num_channels(&self) -> usize {
self.0.buffer.num_channels()
}
fn channel(&self, ch: usize) -> Option<&[f32]> {
self.0.channel(ch)
}
}
/// Hands a strip's instrument the audio of its insert's sample source when it changed (a new
/// sample, a cleared one, or a new instance). Missing media plays nothing.
fn sync_sample(strip: &mut Strip, want: Option<&soundcraft_model::Insert>, s: &Session) {
let audio = want.and_then(|i| i.sample).and_then(|id| s.pool.get(id));
let same = match (strip.sample.as_ref(), audio) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
};
if same {
return;
}
if let Some(inst) = strip.instrument.as_mut() {
let shared = audio.map(|a| Arc::new(PoolSample(Arc::clone(a))) as Arc<dyn SampleSource>);
inst.plugin.set_sample(shared);
}
strip.sample = audio.cloned();
}
/// Record insert latency without processing audio, so this block can compensate for it.
fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) {
let mut latency = 0usize;

View File

@ -1,6 +1,6 @@
//! Per-track mixer state: inserts, sends, routing, fader, pan, mute/solo.
use crate::{AutomationMode, BusId};
use crate::{AutomationMode, BusId, SourceId};
use std::collections::BTreeMap;
pub const INSERT_SLOTS: usize = 10;
@ -438,6 +438,9 @@ pub struct Insert {
/// state), standard base64. Restored into a new instance before it first processes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
/// The session audio source a sample-playing instrument (the built-in Sampler) plays.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sample: Option<SourceId>,
}
fn yes() -> bool {
@ -446,7 +449,15 @@ fn yes() -> bool {
impl Insert {
pub fn new(plugin: impl Into<String>) -> Self {
Insert { plugin: plugin.into(), params: BTreeMap::new(), bypass: false, active: true, preset: String::from("<factory default>"), state: None }
Insert {
plugin: plugin.into(),
params: BTreeMap::new(),
bypass: false,
active: true,
preset: String::from("<factory default>"),
state: None,
sample: None,
}
}
/// The decoded plugin state (`None` when absent or malformed).