Skip to main content

quiver/modules/
sampler.rs

1//! Sample playback module (Q142).
2//!
3//! [`SamplePlayer`] plays a mono sample buffer with V/Oct pitch control, a
4//! selectable start position, one-shot / looping modes, trigger and gated
5//! playback, and an end-of-sample trigger output. Reads are cubic-interpolated
6//! (Catmull-Rom) and the audio-path `tick` is allocation-free; only the non-RT
7//! [`SamplePlayer::set_buffer`] setter allocates.
8//!
9//! Buffers are held as `alloc::vec::Vec<f64>`. Like the rest of `modules/`, this
10//! relies on the crate's unconditional `extern crate alloc`, so it compiles in
11//! pure `no_std` as well as `alloc`/`std`.
12
13use super::common::{EdgeDetector, GATE_HIGH_V, GATE_THRESHOLD_V};
14use crate::port::{
15    GraphModule, ModulatedParam, ParamRange, PortDef, PortSpec, PortValues, SignalKind,
16};
17use alloc::vec;
18use alloc::vec::Vec;
19use libm::Libm;
20
21/// Mono sample player with V/Oct pitch, start position, and looping.
22///
23/// # Parameter reads via [`ModulatedParam`] (Q147)
24///
25/// Pitch and start position are read through [`ModulatedParam`], making that type
26/// a live part of a real DSP path rather than an unused export:
27/// - `pitch` uses a [`ParamRange::VoltPerOctave`] mapping. Its `base` field carries
28///   the coarse V/Oct pitch from the `voct` input, and its value is `2^voct`, so
29///   0 V plays at unity rate and +1 V doubles the playback speed.
30/// - `start` uses a [`ParamRange::Linear`] `0..1` mapping. Its `base` is the panel
31///   start-position knob and its CV comes from the `start` input (normalized on the
32///   `ModulatedParam` ±5 V scale), combined into a normalized `0..1` position.
33pub struct SamplePlayer {
34    /// Mono sample data.
35    buffer: Vec<f64>,
36    /// Sample rate the buffer was recorded at.
37    buffer_sample_rate: f64,
38    /// Engine (graph) sample rate.
39    sample_rate: f64,
40    /// Current fractional read position, in buffer samples.
41    phase: f64,
42    /// Whether playback is currently active.
43    playing: bool,
44    /// True when the current playback was started by the gate input (so a gate
45    /// release stops it); false when started by the trigger input (gate ignored).
46    started_by_gate: bool,
47    /// Rising-edge detector for the trigger input.
48    trig_edge: EdgeDetector,
49    /// Rising-edge detector for the gate input.
50    gate_edge: EdgeDetector,
51    /// Pitch read path (V/Oct -> playback-rate multiplier).
52    pitch: ModulatedParam,
53    /// Start-position read path (normalized 0..1).
54    start: ModulatedParam,
55    spec: PortSpec,
56}
57
58impl SamplePlayer {
59    /// Create a player over `buffer` recorded at `buffer_sample_rate`, running in a
60    /// graph at `engine_sample_rate`.
61    pub fn new(buffer: Vec<f64>, buffer_sample_rate: f64, engine_sample_rate: f64) -> Self {
62        Self {
63            buffer,
64            buffer_sample_rate: if buffer_sample_rate > 0.0 {
65                buffer_sample_rate
66            } else {
67                44100.0
68            },
69            sample_rate: if engine_sample_rate > 0.0 {
70                engine_sample_rate
71            } else {
72                44100.0
73            },
74            phase: 0.0,
75            playing: false,
76            started_by_gate: false,
77            trig_edge: EdgeDetector::new(),
78            gate_edge: EdgeDetector::new(),
79            pitch: ModulatedParam::new(ParamRange::VoltPerOctave { base_freq: 1.0 }),
80            start: ModulatedParam::new(ParamRange::Linear { min: 0.0, max: 1.0 }).with_base(0.0),
81            spec: PortSpec {
82                inputs: vec![
83                    PortDef::new(0, "trig", SignalKind::Trigger),
84                    PortDef::new(1, "gate", SignalKind::Gate),
85                    PortDef::new(2, "voct", SignalKind::VoltPerOctave),
86                    PortDef::new(3, "start", SignalKind::CvUnipolar)
87                        .with_default(0.0)
88                        .with_attenuverter(),
89                    PortDef::new(4, "loop", SignalKind::Gate).with_default(0.0),
90                ],
91                outputs: vec![
92                    PortDef::new(10, "out", SignalKind::Audio),
93                    PortDef::new(11, "eos", SignalKind::Trigger),
94                ],
95            },
96        }
97    }
98
99    /// Create an empty player (silent until a buffer is assigned).
100    pub fn empty(engine_sample_rate: f64) -> Self {
101        Self::new(Vec::new(), engine_sample_rate, engine_sample_rate)
102    }
103
104    /// Replace the sample buffer (non-real-time; allocates/moves the `Vec`).
105    ///
106    /// Resets playback state so a stale read position cannot index past a shorter
107    /// new buffer.
108    pub fn set_buffer(&mut self, buffer: Vec<f64>, buffer_sample_rate: f64) {
109        self.buffer = buffer;
110        if buffer_sample_rate > 0.0 {
111            self.buffer_sample_rate = buffer_sample_rate;
112        }
113        self.phase = 0.0;
114        self.playing = false;
115        self.started_by_gate = false;
116    }
117
118    /// Number of samples in the loaded buffer.
119    pub fn len(&self) -> usize {
120        self.buffer.len()
121    }
122
123    /// Whether the loaded buffer is empty.
124    pub fn is_empty(&self) -> bool {
125        self.buffer.is_empty()
126    }
127
128    /// Set the panel start-position knob (0..1), the `base` of the start
129    /// [`ModulatedParam`].
130    pub fn set_start(&mut self, start: f64) {
131        self.start.base = start.clamp(0.0, 1.0);
132    }
133
134    /// Current start-position knob (0..1).
135    pub fn start_position(&self) -> f64 {
136        self.start.base
137    }
138
139    /// Cubic (Catmull-Rom) interpolated read at fractional `pos` (buffer samples),
140    /// with edge indices clamped into range.
141    fn read_cubic(&self, pos: f64) -> f64 {
142        let len = self.buffer.len();
143        if len == 0 {
144            return 0.0;
145        }
146        if len == 1 {
147            return self.buffer[0];
148        }
149        let i = Libm::<f64>::floor(pos) as isize;
150        let frac = pos - i as f64;
151        let last = (len - 1) as isize;
152        let sample = |k: isize| -> f64 {
153            let idx = (i + k).clamp(0, last) as usize;
154            self.buffer[idx]
155        };
156        let y0 = sample(-1);
157        let y1 = sample(0);
158        let y2 = sample(1);
159        let y3 = sample(2);
160        let a = -0.5 * y0 + 1.5 * y1 - 1.5 * y2 + 0.5 * y3;
161        let b = y0 - 2.5 * y1 + 2.0 * y2 - 0.5 * y3;
162        let c = -0.5 * y0 + 0.5 * y2;
163        let d = y1;
164        ((a * frac + b) * frac + c) * frac + d
165    }
166
167    /// Start-position in buffer samples, resolved from the start `ModulatedParam`.
168    fn start_sample(&self) -> f64 {
169        let len = self.buffer.len();
170        if len == 0 {
171            0.0
172        } else {
173            self.start.value().clamp(0.0, 1.0) * (len - 1) as f64
174        }
175    }
176}
177
178impl Default for SamplePlayer {
179    fn default() -> Self {
180        Self::empty(44100.0)
181    }
182}
183
184impl GraphModule for SamplePlayer {
185    fn port_spec(&self) -> &PortSpec {
186        &self.spec
187    }
188
189    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
190        let trig = inputs.get_or(0, 0.0);
191        let gate = inputs.get_or(1, 0.0);
192        let voct = inputs.get_or(2, 0.0);
193        let start_cv = inputs.get_or(3, 0.0);
194        let looping = inputs.get_or(4, 0.0) > GATE_THRESHOLD_V;
195
196        // Feed the start CV into its ModulatedParam so the resolved start position
197        // combines the panel knob (base) with incoming CV.
198        self.start.set_cv(start_cv);
199
200        // Coarse V/Oct pitch drives the base of the pitch ModulatedParam; its value
201        // is the playback-rate multiplier 2^voct.
202        self.pitch.base = voct;
203        let rate_mult = self.pitch.value();
204
205        let len = self.buffer.len();
206        let mut eos = 0.0;
207
208        // Retrigger handling: trigger and gate both (re)start from the start
209        // position; a trigger-started voice ignores the gate, a gate-started voice
210        // stops when the gate falls (gated one-shot / looper).
211        let trig_edge = self.trig_edge.rising(trig);
212        let gate_edge = self.gate_edge.rising(gate);
213        if trig_edge {
214            self.phase = self.start_sample();
215            self.playing = len > 0;
216            self.started_by_gate = false;
217        } else if gate_edge {
218            self.phase = self.start_sample();
219            self.playing = len > 0;
220            self.started_by_gate = true;
221        }
222
223        // Gated release: a voice started by the gate stops when the gate goes low.
224        if self.started_by_gate && gate <= GATE_THRESHOLD_V {
225            self.playing = false;
226        }
227
228        if len == 0 || !self.playing {
229            outputs.set(10, 0.0);
230            outputs.set(11, eos);
231            return;
232        }
233
234        // Read at the current position, then advance.
235        let out = self.read_cubic(self.phase);
236
237        // Playback rate in buffer-samples per engine-sample.
238        let rate = rate_mult * (self.buffer_sample_rate / self.sample_rate);
239        self.phase += rate;
240
241        let end = len as f64;
242        if self.phase >= end {
243            eos = GATE_HIGH_V;
244            if looping {
245                // Wrap back into the loop region [start, end) with a single
246                // bounded modulo instead of a data-dependent `while` loop: at a
247                // high playback rate over a short loop span the loop could
248                // otherwise iterate O(rate/span) times per tick (a variable-time
249                // algorithm in the RT path). `fmod(phase - start, span)` lands in
250                // [0, span) since span > 0, so `start + ..` is always in
251                // [start, end).
252                let start = self.start_sample();
253                let span = (end - start).max(1.0);
254                self.phase = start + Libm::<f64>::fmod(self.phase - start, span);
255            } else {
256                self.playing = false;
257                self.phase = end;
258            }
259        }
260
261        outputs.set(10, out);
262        outputs.set(11, eos);
263    }
264
265    fn reset(&mut self) {
266        self.phase = 0.0;
267        self.playing = false;
268        self.started_by_gate = false;
269        self.trig_edge.reset();
270        self.gate_edge.reset();
271    }
272
273    fn set_sample_rate(&mut self, sample_rate: f64) {
274        if sample_rate > 0.0 {
275            self.sample_rate = sample_rate;
276        }
277    }
278
279    fn type_id(&self) -> &'static str {
280        "sample_player"
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    /// A buffer with an impulse every `spacing` samples, `count` impulses long.
289    fn impulse_buffer(spacing: usize, count: usize) -> Vec<f64> {
290        let mut buf = vec![0.0; spacing * count];
291        for k in 0..count {
292            buf[k * spacing] = 1.0;
293        }
294        buf
295    }
296
297    fn trigger_once(player: &mut SamplePlayer, inputs: &mut PortValues, outputs: &mut PortValues) {
298        // Rising edge on the trigger port.
299        inputs.set(0, 0.0);
300        player.tick(inputs, outputs);
301        inputs.set(0, 5.0);
302        player.tick(inputs, outputs);
303    }
304
305    #[test]
306    fn test_unity_rate_impulse_spacing() {
307        // buffer_sr == engine_sr and 0 V => rate 1.0 => output spacing == buffer spacing.
308        let sr = 48000.0;
309        let mut player = SamplePlayer::new(impulse_buffer(4, 6), sr, sr);
310        let mut inputs = PortValues::new();
311        let mut outputs = PortValues::new();
312        inputs.set(2, 0.0); // 0 V
313
314        trigger_once(&mut player, &mut inputs, &mut outputs);
315        // First tick after the trigger already produced buffer[0] (an impulse).
316        let mut impulse_positions = Vec::new();
317        // The trigger's second tick is output index 0.
318        let first = outputs.get(10).unwrap();
319        if first > 0.5 {
320            impulse_positions.push(0);
321        }
322        for i in 1..20 {
323            player.tick(&inputs, &mut outputs);
324            if outputs.get(10).unwrap() > 0.5 {
325                impulse_positions.push(i);
326            }
327        }
328        // Impulses at 0, 4, 8, ...
329        assert!(impulse_positions.len() >= 3);
330        assert_eq!(impulse_positions[0], 0);
331        assert_eq!(impulse_positions[1], 4);
332        assert_eq!(impulse_positions[2], 8);
333    }
334
335    #[test]
336    fn test_plus_one_volt_doubles_speed() {
337        let sr = 48000.0;
338        let mut player = SamplePlayer::new(impulse_buffer(4, 6), sr, sr);
339        let mut inputs = PortValues::new();
340        let mut outputs = PortValues::new();
341        inputs.set(2, 1.0); // +1 V => 2x rate
342
343        trigger_once(&mut player, &mut inputs, &mut outputs);
344        let mut impulse_positions = Vec::new();
345        if outputs.get(10).unwrap() > 0.5 {
346            impulse_positions.push(0);
347        }
348        for i in 1..20 {
349            player.tick(&inputs, &mut outputs);
350            if outputs.get(10).unwrap() > 0.5 {
351                impulse_positions.push(i);
352            }
353        }
354        // At 2x rate impulses come out at half the spacing: 0, 2, 4, ...
355        assert!(impulse_positions.len() >= 3);
356        assert_eq!(impulse_positions[0], 0);
357        assert_eq!(impulse_positions[1], 2);
358        assert_eq!(impulse_positions[2], 4);
359    }
360
361    #[test]
362    fn test_loop_wraps() {
363        let sr = 48000.0;
364        // Short buffer, looping on.
365        let mut player = SamplePlayer::new(impulse_buffer(2, 3), sr, sr); // len 6
366        let mut inputs = PortValues::new();
367        let mut outputs = PortValues::new();
368        inputs.set(2, 0.0);
369        inputs.set(4, 5.0); // loop on
370
371        trigger_once(&mut player, &mut inputs, &mut outputs);
372        let mut impulses = 0;
373        for _ in 0..60 {
374            player.tick(&inputs, &mut outputs);
375            if outputs.get(10).unwrap() > 0.5 {
376                impulses += 1;
377            }
378        }
379        // Without looping there are only 3 impulses total; wrapping produces many more.
380        assert!(impulses > 6, "loop did not wrap: {impulses} impulses");
381    }
382
383    #[test]
384    fn test_loop_wrap_bounded_at_pathological_rate() {
385        // Regression: the loop wrap must be bounded modular arithmetic, not a
386        // data-dependent `while` that iterates O(rate/span) times per tick. With
387        // a huge playback rate over a 1-sample loop span the old loop would hang
388        // (at f64 magnitudes where `phase -= span` is a no-op it never
389        // terminates). The fix keeps every tick O(1) and phase inside the loop.
390        let sr = 48000.0;
391        let mut player = SamplePlayer::new(impulse_buffer(1, 8), sr, sr); // len 8
392        let mut inputs = PortValues::new();
393        let mut outputs = PortValues::new();
394        // Start knob at the very end so the loop span collapses to 1 sample.
395        player.set_start(1.0);
396        inputs.set(4, 5.0); // loop on
397        inputs.set(2, 60.0); // +60 V/oct: rate = 2^60, wildly overshoots each tick
398
399        trigger_once(&mut player, &mut inputs, &mut outputs);
400        // Each tick must terminate quickly and keep phase within [0, len).
401        for _ in 0..100 {
402            player.tick(&inputs, &mut outputs);
403            assert!(
404                player.phase.is_finite()
405                    && player.phase >= 0.0
406                    && player.phase < player.len() as f64,
407                "phase escaped the loop region: {}",
408                player.phase
409            );
410            assert!(outputs.get(10).unwrap().is_finite());
411        }
412    }
413
414    #[test]
415    fn test_eos_fires_once_at_end() {
416        let sr = 48000.0;
417        let mut player = SamplePlayer::new(impulse_buffer(1, 8), sr, sr); // len 8, loop off
418        let mut inputs = PortValues::new();
419        let mut outputs = PortValues::new();
420        inputs.set(2, 0.0);
421
422        trigger_once(&mut player, &mut inputs, &mut outputs);
423        let mut eos_count = 0;
424        for _ in 0..40 {
425            player.tick(&inputs, &mut outputs);
426            if outputs.get(11).unwrap() > GATE_THRESHOLD_V {
427                eos_count += 1;
428            }
429        }
430        assert_eq!(eos_count, 1, "eos should fire exactly once at end");
431    }
432
433    #[test]
434    fn test_empty_buffer_silent() {
435        let mut player = SamplePlayer::empty(48000.0);
436        let mut inputs = PortValues::new();
437        let mut outputs = PortValues::new();
438        assert!(player.is_empty());
439        assert_eq!(player.len(), 0);
440
441        trigger_once(&mut player, &mut inputs, &mut outputs);
442        for _ in 0..50 {
443            player.tick(&inputs, &mut outputs);
444            assert_eq!(outputs.get(10).unwrap(), 0.0);
445        }
446    }
447
448    #[test]
449    fn test_gated_playback_stops_on_release() {
450        let sr = 48000.0;
451        let mut player = SamplePlayer::new(impulse_buffer(1, 64), sr, sr);
452        let mut inputs = PortValues::new();
453        let mut outputs = PortValues::new();
454        inputs.set(2, 0.0);
455
456        // Gate on -> starts.
457        inputs.set(1, 0.0);
458        player.tick(&inputs, &mut outputs);
459        inputs.set(1, 5.0);
460        player.tick(&inputs, &mut outputs);
461        assert!(player.playing);
462
463        // Gate off -> gated voice stops.
464        inputs.set(1, 0.0);
465        player.tick(&inputs, &mut outputs);
466        assert!(!player.playing);
467        assert_eq!(outputs.get(10).unwrap(), 0.0);
468    }
469
470    #[test]
471    fn test_type_id_and_default() {
472        let player = SamplePlayer::default();
473        assert_eq!(player.type_id(), "sample_player");
474        assert!(player.is_empty());
475    }
476
477    #[test]
478    fn test_set_buffer_swaps() {
479        let mut player = SamplePlayer::empty(48000.0);
480        assert!(player.is_empty());
481        player.set_buffer(vec![0.5; 100], 48000.0);
482        assert_eq!(player.len(), 100);
483    }
484}