Skip to main content

rill_core_model/
tape.rs

1use rill_core::{
2    math::Transcendental,
3    traits::algorithm::{Algorithm, AlgorithmMetadata},
4    ProcessError, ProcessResult,
5};
6
7use crate::{Capacitor, Inductor, WdfElement};
8
9/// Record head model for analog tape.
10///
11/// Applies bias oscillator mixing and magnetic tape nonlinearity
12/// (saturation + hysteresis). Output is the magnetized signal
13/// ready to be stored on tape. No op-amp stages — add those externally.
14#[derive(Debug, Clone)]
15pub struct RecordHead<T: Transcendental> {
16    sample_rate: T,
17    bias_oscillator: T,
18    bias_phase: T,
19    record_head: Inductor<T>,
20
21    /// Tape speed in cm/s.
22    pub tape_speed: T,
23    /// Bias level (0.0–1.0).
24    pub bias_level: T,
25    /// Magnetic saturation strength (0.0–1.0).
26    pub saturation: T,
27    /// Hysteresis effect strength.
28    pub hysteresis: T,
29
30    tape_position: T,
31}
32
33impl<T: Transcendental> RecordHead<T> {
34    /// Create a new record head model.
35    pub fn new(sample_rate: f32) -> Self {
36        let sr = T::from_f32(sample_rate);
37        Self {
38            sample_rate: sr,
39            bias_oscillator: T::from_f32(100_000.0),
40            bias_phase: T::ZERO,
41            record_head: Inductor::<T>::new(T::from_f64(100e-6), sr),
42            tape_speed: T::from_f64(4.76),
43            bias_level: T::from_f64(0.8),
44            saturation: T::from_f64(0.9),
45            hysteresis: T::from_f64(0.1),
46            tape_position: T::ZERO,
47        }
48    }
49
50    /// Process one sample through the record chain.
51    pub fn process_sample(&mut self, input: T) -> T {
52        let dt = T::ONE / self.sample_rate;
53
54        let two = T::from_f32(2.0);
55        let bias_phase_inc = two * T::PI * self.bias_oscillator * dt;
56        self.bias_phase += bias_phase_inc;
57        let bias_signal = self.bias_level * self.bias_phase.sin();
58
59        let record_signal = input + bias_signal;
60        let recorded = self.tape_nonlinearity(record_signal);
61
62        let _head_current = recorded / self.record_head.port_resistance();
63        self.tape_position += self.tape_speed * dt;
64
65        recorded
66    }
67
68    fn tape_nonlinearity(&self, signal: T) -> T {
69        let saturated = signal.tanh() * self.saturation;
70        let hysteresis_effect = self.hysteresis * signal.signum() * T::from_f64(0.01);
71        saturated + hysteresis_effect
72    }
73}
74
75impl<T: Transcendental> Algorithm<T> for RecordHead<T> {
76    fn process(&mut self, input: Option<&[T]>, output: &mut [T]) -> ProcessResult<()> {
77        let src = input.ok_or_else(|| ProcessError::processing("RecordHead requires input"))?;
78        let n = src.len().min(output.len());
79        for i in 0..n {
80            output[i] = self.process_sample(src[i]);
81        }
82        Ok(())
83    }
84
85    fn reset(&mut self) {
86        self.bias_phase = T::ZERO;
87        self.tape_position = T::ZERO;
88    }
89
90    fn metadata(&self) -> AlgorithmMetadata {
91        AlgorithmMetadata {
92            name: "Tape Record Head",
93            description: "Analog tape recording physics (bias + saturation)",
94            ..AlgorithmMetadata::empty()
95        }
96    }
97}
98
99/// Playback head model for analog tape.
100///
101/// Applies wow & flutter, head differentiator, gap loss, playback EQ,
102/// print-through, and tape noise. Output is the amplified audio signal.
103/// No op-amp stages — add those externally.
104#[derive(Debug, Clone)]
105pub struct PlaybackHead<T: Transcendental> {
106    sample_rate: T,
107    playback_head: Inductor<T>,
108    eq_filters: [Capacitor<T>; 2],
109
110    /// Tape speed in cm/s.
111    pub tape_speed: T,
112    /// Tape width in mm (affects noise floor).
113    pub tape_width: T,
114    /// Tape noise floor amplitude.
115    pub noise_floor: T,
116    /// Wow & flutter intensity factor.
117    pub wow_flutter: T,
118    /// Print-through crosstalk factor.
119    pub print_through: T,
120
121    wow_phase: T,
122    flutter_phase: T,
123    playback_head_flux: T,
124    playback_head_state: T,
125}
126
127impl<T: Transcendental> PlaybackHead<T> {
128    /// Create a new playback head model.
129    pub fn new(sample_rate: f32) -> Self {
130        let sr = T::from_f32(sample_rate);
131        Self {
132            sample_rate: sr,
133            playback_head: Inductor::<T>::new(T::from_f64(50e-6), sr),
134            eq_filters: [
135                Capacitor::<T>::new(T::from_f64(100e-9), sr),
136                Capacitor::<T>::new(T::from_f64(1e-6), sr),
137            ],
138            tape_speed: T::from_f64(4.76),
139            tape_width: T::from_f64(3.81),
140            noise_floor: T::from_f64(0.0001),
141            wow_flutter: T::from_f64(0.002),
142            print_through: T::from_f64(0.01),
143            wow_phase: T::ZERO,
144            flutter_phase: T::ZERO,
145            playback_head_flux: T::ZERO,
146            playback_head_state: T::ZERO,
147        }
148    }
149
150    /// Process one sample through the playback chain.
151    /// `recorded_signal` is the magnetized signal read from tape.
152    pub fn process_sample(&mut self, recorded_signal: T) -> T {
153        let dt = T::ONE / self.sample_rate;
154
155        let speed_variation = T::ONE + self.wow_and_flutter(dt);
156        let tape_signal = recorded_signal * speed_variation;
157
158        // Head differentiator: V ∝ dΦ/dt
159        let flux_change = tape_signal - self.playback_head_flux;
160        self.playback_head_flux = tape_signal;
161        let head_signal = flux_change / dt;
162
163        // Gap loss: first-order low-pass at ~18 kHz
164        let gap_freq = T::from_f32(18000.0) * (self.tape_speed / T::from_f64(4.76));
165        let two_pi = T::from_f32(2.0) * T::PI;
166        let gap_alpha = (two_pi * gap_freq * dt) / (T::ONE + two_pi * gap_freq * dt);
167        self.playback_head_state =
168            gap_alpha * head_signal + (T::ONE - gap_alpha) * self.playback_head_state;
169
170        // Head impedance voltage divider
171        let head_z = self.playback_head.port_resistance();
172        let head_output = self.playback_head_state * (head_z / (head_z + T::from_f32(1000.0)));
173
174        // Playback EQ: two-stage capacitive network
175        let mut eq_signal = head_output;
176        for filter in &mut self.eq_filters {
177            let alpha = T::ONE / (T::ONE + filter.port_resistance() * T::from_f32(1000.0));
178            eq_signal *= alpha;
179        }
180
181        let print_through_signal = self.print_through * tape_signal;
182        let noise = self.tape_noise();
183
184        eq_signal + print_through_signal + noise
185    }
186
187    fn wow_and_flutter(&mut self, dt: T) -> T {
188        let two_pi = T::from_f32(2.0) * T::PI;
189
190        let wow_freq = T::from_f32(2.0);
191        self.wow_phase += two_pi * wow_freq * dt;
192        let wow = T::from_f64(0.01) * self.wow_flutter * self.wow_phase.sin();
193
194        let flutter_freq = T::from_f32(30.0);
195        self.flutter_phase += two_pi * flutter_freq * dt;
196        let flutter = T::from_f64(0.005) * self.wow_flutter * self.flutter_phase.sin();
197
198        wow + flutter
199    }
200
201    fn tape_noise(&self) -> T {
202        let width_factor = (T::from_f64(3.81) / self.tape_width).sqrt();
203        let white_noise = T::random();
204        let pink_noise = white_noise * self.noise_floor * width_factor;
205
206        let click = if T::random().abs() < T::from_f64(0.0001) {
207            T::random() * T::from_f64(0.1)
208        } else {
209            T::ZERO
210        };
211
212        pink_noise + click
213    }
214}
215
216impl<T: Transcendental> Algorithm<T> for PlaybackHead<T> {
217    fn process(&mut self, input: Option<&[T]>, output: &mut [T]) -> ProcessResult<()> {
218        let src = input.ok_or_else(|| ProcessError::processing("PlaybackHead requires input"))?;
219        let n = src.len().min(output.len());
220        for i in 0..n {
221            output[i] = self.process_sample(src[i]);
222        }
223        Ok(())
224    }
225
226    fn reset(&mut self) {
227        self.wow_phase = T::ZERO;
228        self.flutter_phase = T::ZERO;
229        self.playback_head_flux = T::ZERO;
230        self.playback_head_state = T::ZERO;
231    }
232
233    fn metadata(&self) -> AlgorithmMetadata {
234        AlgorithmMetadata {
235            name: "Tape Playback Head",
236            description: "Analog tape playback physics (wow/flutter, EQ, noise)",
237            ..AlgorithmMetadata::empty()
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_record_head_produces_output() {
248        let mut head = RecordHead::<f64>::new(44100.0);
249        let s = head.process_sample(0.5);
250        assert!(s.abs() > 0.0, "record head should produce output");
251    }
252
253    #[test]
254    fn test_playback_head_passes_signal() {
255        let mut head = PlaybackHead::<f64>::new(44100.0);
256        let s = head.process_sample(0.5);
257        assert!(s.abs() > 0.0, "playback head should produce output");
258    }
259
260    #[test]
261    fn test_tanh_saturation_is_bounded() {
262        let mut head = RecordHead::<f64>::new(44100.0);
263        let s = head.process_sample(10.0);
264        assert!(s.abs() <= 1.0, "saturation should bound signal");
265    }
266
267    #[test]
268    fn test_reset_clears_state() {
269        let mut head = PlaybackHead::<f64>::new(44100.0);
270        head.process_sample(0.5);
271        head.reset();
272        assert_eq!(head.wow_phase, 0.0);
273        assert_eq!(head.flutter_phase, 0.0);
274        assert_eq!(head.playback_head_flux, 0.0);
275    }
276}