Merge pull request #108 from scottcampbelldata/sampler-instrument
Add a built-in Sampler instrument
This commit is contained in:
commit
acb08ff5f0
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ¶ms {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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(¶m).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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue