Skip to main content

rill_digital_effects/
read_head.rs

1use rill_core::{
2    buffer::{ResourceRegistry, TapeReader},
3    math::Transcendental,
4    traits::{Node, NodeCategory, NodeMetadata, NodeState, Source},
5    ClockTick, NodeId, ParamValue, ParameterId, Port, ProcessError, ProcessResult, RenderContext,
6};
7
8/// Read head — pure tape reader. Reads from the shared [`TapeLoop`] at a
9/// fixed delay. Mono output. Level and pan are handled by a downstream
10/// SumNode with per-channel gains.
11///
12/// The tape loop is obtained through the graph's resource registry during
13/// node initialization.
14///
15/// # Signal ports
16/// - 1 audio output (mono), no inputs
17///
18/// # Parameters
19/// - `delay` (0.01 – 2.0 s)
20pub struct ReadHead<T: Transcendental, const BUF_SIZE: usize> {
21    id: NodeId,
22    metadata: NodeMetadata,
23    outputs: Vec<Port<T, BUF_SIZE>>,
24    state: NodeState<T, BUF_SIZE>,
25    tape: Option<TapeReader<T>>,
26    resource_name: String,
27    delay: f32,
28    sample_rate: f32,
29    /// Smoothed read position in fractional samples. Glides toward the target
30    /// (`delay * sample_rate`) to avoid zipper noise when `delay` is modulated,
31    /// which also produces the pitch glide of tape wow/flutter.
32    current_delay_samples: f64,
33    /// Per-sample one-pole glide coefficient toward the target delay.
34    delay_smoothing: f64,
35}
36
37/// Time constant of the delay glide, in seconds.
38const DELAY_SMOOTH_SECONDS: f64 = 0.008;
39
40/// Per-sample one-pole coefficient that reaches ~63% of a step in
41/// [`DELAY_SMOOTH_SECONDS`].
42fn delay_smoothing_coeff(sample_rate: f64) -> f64 {
43    1.0 - (-1.0 / (DELAY_SMOOTH_SECONDS * sample_rate)).exp()
44}
45
46// Holds an `Rc`-based tape handle — the graph is single-threaded and moved
47// to the signal thread once; `Send`/`Sync` are asserted for that pattern.
48#[allow(unsafe_code)]
49unsafe impl<T: Transcendental, const B: usize> Send for ReadHead<T, B> {}
50#[allow(unsafe_code)]
51unsafe impl<T: Transcendental, const B: usize> Sync for ReadHead<T, B> {}
52
53impl<T: Transcendental, const BUF_SIZE: usize> Default for ReadHead<T, BUF_SIZE> {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl<T: Transcendental, const BUF_SIZE: usize> ReadHead<T, BUF_SIZE> {
60    /// Create a new `ReadHead` with default delay of 0.5 seconds.
61    ///
62    /// `resource_name` is the name of the shared tape loop in the buffer registry.
63    /// Defaults to `"tape_0"`.
64    pub fn new() -> Self {
65        Self::with_resource("tape_0")
66    }
67
68    /// Create a new `ReadHead` with an explicit resource name.
69    pub fn with_resource(resource_name: &str) -> Self {
70        let mut metadata = NodeMetadata::new("ReadHead", NodeCategory::Source);
71        metadata.parameters = vec![rill_core::ParamMetadata::new(
72            "delay",
73            rill_core::ParamType::Float,
74            ParamValue::Float(0.5),
75        )
76        .with_range(0.01, 2.0, 0.01)];
77        let outputs = vec![Port::output(NodeId(0), 0, "out")];
78        Self {
79            id: NodeId(0),
80            metadata,
81            outputs,
82            state: NodeState::new(44100.0),
83            tape: None,
84            resource_name: resource_name.to_string(),
85            delay: 0.5,
86            sample_rate: 44100.0,
87            current_delay_samples: 0.5 * 44100.0,
88            delay_smoothing: delay_smoothing_coeff(44100.0),
89        }
90    }
91
92    /// Set the tape read handle (used by tests; the graph uses
93    /// [`resolve_resources`](Node::resolve_resources)).
94    pub fn set_reader(&mut self, reader: TapeReader<T>) {
95        self.tape = Some(reader);
96    }
97}
98
99impl<T: Transcendental, const BUF_SIZE: usize> Source<T, BUF_SIZE> for ReadHead<T, BUF_SIZE> {
100    #[allow(clippy::needless_range_loop)]
101    fn generate(
102        &mut self,
103        _ctx: &RenderContext,
104        _control_inputs: &[T],
105        _clock_inputs: &[RenderContext],
106        _tick: &ClockTick,
107    ) -> ProcessResult<()> {
108        let Some(tape) = self.tape.as_ref() else {
109            debug_assert!(false, "ReadHead: tape not set");
110            return Ok(());
111        };
112        let target = (self.delay as f64) * (self.sample_rate as f64);
113        let glide = self.delay_smoothing;
114        let mut current = self.current_delay_samples;
115        let out = self.outputs[0].write();
116        let n = BUF_SIZE;
117        for i in 0..n {
118            // Earlier samples in the block sit further back on the tape.
119            let d = current + (n - 1 - i) as f64;
120            out[i] = tape.read_interpolated(d.max(0.0));
121            // Glide the read position toward the target for the next (newer) sample.
122            current += (target - current) * glide;
123        }
124        self.current_delay_samples = current;
125        self.state.advance();
126        Ok(())
127    }
128
129    fn num_signal_outputs(&self) -> usize {
130        1
131    }
132}
133
134impl<T: Transcendental, const BUF_SIZE: usize> Node<T, BUF_SIZE> for ReadHead<T, BUF_SIZE> {
135    fn node_type_id(&self) -> rill_core::NodeTypeId
136    where
137        Self: 'static + Sized,
138    {
139        rill_core::NodeTypeId::of::<Self>()
140    }
141    fn id(&self) -> NodeId {
142        self.id
143    }
144    fn set_id(&mut self, id: NodeId) {
145        self.id = id;
146    }
147    fn metadata(&self) -> NodeMetadata {
148        self.metadata.clone()
149    }
150    fn init(&mut self, sr: f32) {
151        self.sample_rate = sr;
152        self.state.sample_rate = sr;
153        self.current_delay_samples = (self.delay as f64) * (sr as f64);
154        self.delay_smoothing = delay_smoothing_coeff(sr as f64);
155    }
156    fn reset(&mut self) {
157        self.state.sample_pos = 0;
158        self.state.blocks_processed = 0;
159        self.current_delay_samples = (self.delay as f64) * (self.sample_rate as f64);
160    }
161    fn resolve_resources(&mut self, resources: &mut ResourceRegistry<T>) {
162        if self.tape.is_some() {
163            return;
164        }
165        self.tape = resources.reader(&self.resource_name);
166    }
167    fn get_parameter(&self, id: &ParameterId) -> Option<ParamValue> {
168        match id.as_str() {
169            "delay" => Some(ParamValue::Float(self.delay)),
170            _ => None,
171        }
172    }
173    fn set_parameter(&mut self, id: &ParameterId, value: ParamValue) -> ProcessResult<()> {
174        let name = id.as_str();
175        if let Some(v) = value.as_f32() {
176            match name {
177                "delay" => {
178                    self.delay = v.clamp(0.01, 2.0);
179                    Ok(())
180                }
181                _ => Err(ProcessError::parameter(format!(
182                    "Unknown parameter: {}",
183                    name
184                ))),
185            }
186        } else {
187            Err(ProcessError::parameter("Expected float value"))
188        }
189    }
190    fn input_port(&self, _: usize) -> Option<&Port<T, BUF_SIZE>> {
191        None
192    }
193    fn input_port_mut(&mut self, _: usize) -> Option<&mut Port<T, BUF_SIZE>> {
194        None
195    }
196    fn output_port(&self, i: usize) -> Option<&Port<T, BUF_SIZE>> {
197        self.outputs.get(i)
198    }
199    fn output_port_mut(&mut self, i: usize) -> Option<&mut Port<T, BUF_SIZE>> {
200        self.outputs.get_mut(i)
201    }
202    fn control_port(&self, _: usize) -> Option<&Port<T, BUF_SIZE>> {
203        None
204    }
205    fn control_port_mut(&mut self, _: usize) -> Option<&mut Port<T, BUF_SIZE>> {
206        None
207    }
208    fn num_signal_inputs(&self) -> usize {
209        0
210    }
211    fn num_signal_outputs(&self) -> usize {
212        1
213    }
214    fn state(&self) -> &NodeState<T, BUF_SIZE> {
215        &self.state
216    }
217    fn state_mut(&mut self) -> &mut NodeState<T, BUF_SIZE> {
218        &mut self.state
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use rill_core::buffer::{tape_handles, TapeLoop};
226
227    /// Build a tape pre-filled with a rising ramp `0.0, 1.0, .. (n-1)`.
228    /// After writing, `read(0)` returns `n-1`, `read(d)` returns `n-1-d`.
229    fn ramp_tape(n: usize) -> TapeLoop<f32> {
230        let mut tape = TapeLoop::<f32>::new(1024).unwrap();
231        for i in 0..n {
232            tape.write(i as f32);
233        }
234        tape
235    }
236
237    fn delay_param(v: f32) -> (ParameterId, ParamValue) {
238        (ParameterId::new("delay").unwrap(), ParamValue::Float(v))
239    }
240
241    #[test]
242    fn test_read_head_creation() {
243        let rh = ReadHead::<f32, 64>::new();
244        assert!((rh.delay - 0.5).abs() < 1e-6);
245        assert_eq!(rh.outputs.len(), 1);
246    }
247
248    /// Regression: an integer sample delay must read exact tape samples.
249    /// sr=100, delay=0.1s → 10 samples. base = 10 + 4 - 1 = 13.
250    /// out[i] = read(13 - i) = [26, 27, 28, 29].
251    #[test]
252    fn read_head_integer_delay_reads_exact_samples() {
253        let tape = ramp_tape(40);
254        let mut rh = ReadHead::<f32, 4>::new();
255        let (id, v) = delay_param(0.1);
256        rh.set_parameter(&id, v).unwrap();
257        rh.init(100.0);
258        let (_writer, reader) = tape_handles(tape);
259        rh.set_reader(reader);
260
261        let ctx = RenderContext::new(0, 4, 100.0);
262        let tick = ClockTick::new(0, 4, 100.0, String::new());
263        rh.generate(&ctx, &[], &[], &tick).unwrap();
264
265        let out = rh.outputs[0].read();
266        assert_eq!(out[0], 26.0);
267        assert_eq!(out[3], 29.0);
268    }
269
270    /// A fractional sample delay must be linearly interpolated, not truncated.
271    /// sr=100, delay=0.105s → 10.5 samples. out[0] = read_interp(13.5),
272    /// between read(13)=26 and read(14)=25 → 25.5.
273    #[test]
274    fn read_head_fractional_delay_interpolates() {
275        let tape = ramp_tape(40);
276        let mut rh = ReadHead::<f32, 4>::new();
277        let (id, v) = delay_param(0.105);
278        rh.set_parameter(&id, v).unwrap();
279        rh.init(100.0);
280        let (_writer, reader) = tape_handles(tape);
281        rh.set_reader(reader);
282
283        let ctx = RenderContext::new(0, 4, 100.0);
284        let tick = ClockTick::new(0, 4, 100.0, String::new());
285        rh.generate(&ctx, &[], &[], &tick).unwrap();
286
287        let out = rh.outputs[0].read();
288        assert!(
289            out[0] > 25.0 && out[0] < 26.0,
290            "expected interpolated ~25.5, got {}",
291            out[0]
292        );
293        assert!((out[0] - 25.5).abs() < 0.01, "got {}", out[0]);
294    }
295
296    /// An abrupt delay change must glide, not jump instantly. After snapping the
297    /// current delay to 10 samples (delay=0.1) we request 30 samples (delay=0.3).
298    /// The first output must still reflect the old delay (~10 → ~26), not an
299    /// instant jump to 30 samples (~6).
300    #[test]
301    fn read_head_delay_change_glides_not_jumps() {
302        let tape = ramp_tape(40);
303        let mut rh = ReadHead::<f32, 4>::new();
304        let (id, v) = delay_param(0.1);
305        rh.set_parameter(&id, v).unwrap();
306        rh.init(100.0);
307        let (_writer, reader) = tape_handles(tape);
308        rh.set_reader(reader);
309
310        let (id2, v2) = delay_param(0.3);
311        rh.set_parameter(&id2, v2).unwrap();
312
313        let ctx = RenderContext::new(0, 4, 100.0);
314        let tick = ClockTick::new(0, 4, 100.0, String::new());
315        rh.generate(&ctx, &[], &[], &tick).unwrap();
316
317        let out = rh.outputs[0].read();
318        assert!(
319            out[0] > 20.0,
320            "delay jumped instead of gliding: out[0]={}",
321            out[0]
322        );
323    }
324}