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