1use rill_core::{
2 math::Transcendental,
3 traits::algorithm::{Algorithm, AlgorithmMetadata},
4 ProcessError, ProcessResult,
5};
6
7use crate::{Capacitor, Inductor, WdfElement};
8
9#[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 pub tape_speed: T,
23 pub bias_level: T,
25 pub saturation: T,
27 pub hysteresis: T,
29
30 tape_position: T,
31}
32
33impl<T: Transcendental> RecordHead<T> {
34 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 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#[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 pub tape_speed: T,
112 pub tape_width: T,
114 pub noise_floor: T,
116 pub wow_flutter: T,
118 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 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 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 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 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 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 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}