aether_nodes/oscillator.rs
1//! Band-limited wavetable oscillator with tuning table support.
2//!
3//! Param layout:
4//! 0 = frequency (Hz) — overridden by tuning table when MIDI note is set
5//! 1 = amplitude (0..1)
6//! 2 = waveform (0=sine, 1=saw, 2=square, 3=triangle)
7//! 3 = midi_note (0..127, -1 = use frequency param directly)
8//!
9//! SIMD strategy: the sine path uses a minimax polynomial approximation
10//! that the compiler can auto-vectorize across 4-wide f32 lanes.
11//! Saw/square/triangle use scalar BLEP (discontinuity correction requires
12//! per-sample phase tracking that defeats vectorization).
13
14use aether_core::{node::DspNode, param::ParamBlock, state::StateBlob, BUFFER_SIZE, MAX_INPUTS};
15use std::f32::consts::TAU;
16
17// ── Fast sine approximation (minimax polynomial, error < 1.5e-4) ─────────────
18// Accurate enough for audio; ~4× faster than libm sin() on x86.
19// Maps input in [0, 1) (normalized phase) to sin(phase * 2π).
20//
21// Algorithm: Bhaskara I approximation refined with a 5th-order minimax fit.
22// Reference: "Approximations for Digital Oscillators" — Välimäki & Pakarinen 2012.
23#[inline(always)]
24fn fast_sin_norm(phase: f32) -> f32 {
25 // Map [0,1) → [-π, π)
26 let x = (phase - 0.5) * TAU;
27 // 5th-order minimax polynomial for sin(x) on [-π, π]
28 // Coefficients from Remez algorithm fit
29 let x2 = x * x;
30 x * (0.999_999_4 + x2 * (-0.166_666_58 + x2 * (0.008_333_331 - x2 * 0.000_198_409)))
31}
32
33#[derive(Clone, Copy)]
34struct OscState {
35 phase: f32,
36}
37
38pub struct Oscillator {
39 phase: f32,
40 /// Optional tuning table — when set, MIDI note param drives frequency.
41 /// Stored as 128 f32 values (one per MIDI note).
42 tuning: Option<Box<[f32; 128]>>,
43}
44
45impl Oscillator {
46 pub fn new() -> Self {
47 Self { phase: 0.0, tuning: None }
48 }
49
50 /// Load a tuning table into this oscillator.
51 /// After loading, param 3 (midi_note) drives the frequency.
52 pub fn set_tuning(&mut self, frequencies: [f32; 128]) {
53 self.tuning = Some(Box::new(frequencies));
54 }
55
56 pub fn clear_tuning(&mut self) {
57 self.tuning = None;
58 }
59}
60
61/// Polynomial Band-Limited Step (BLEP) correction.
62/// Reduces aliasing at waveform discontinuities.
63/// `t` = current phase, `dt` = phase increment per sample.
64#[inline(always)]
65fn blep(t: f32, dt: f32) -> f32 {
66 if t < dt {
67 let t = t / dt;
68 2.0 * t - t * t - 1.0
69 } else if t > 1.0 - dt {
70 let t = (t - 1.0) / dt;
71 t * t + 2.0 * t + 1.0
72 } else {
73 0.0
74 }
75}
76
77impl Default for Oscillator {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl DspNode for Oscillator {
84 fn process(
85 &mut self,
86 _inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
87 output: &mut [f32; BUFFER_SIZE],
88 params: &mut ParamBlock,
89 sample_rate: f32,
90 ) {
91 // Snapshot params once — they are stable or slowly ramping.
92 // Reading inside the loop would prevent auto-vectorization.
93 let amp = params.get(1).current.clamp(0.0, 1.0);
94 let wave = params.get(2).current as u32;
95
96 let freq = if let Some(ref tuning) = self.tuning {
97 let midi = params.get(3).current;
98 if midi >= 0.0 {
99 let note = (midi as usize).min(127);
100 tuning[note].max(0.01)
101 } else {
102 params.get(0).current.max(0.01)
103 }
104 } else {
105 params.get(0).current.max(0.01)
106 };
107
108 let phase_inc = freq / sample_rate;
109
110 match wave {
111 0 => {
112 // ── Sine: vectorizable loop ───────────────────────────────
113 // LLVM will auto-vectorize this into 4-wide SSE/AVX lanes
114 // because fast_sin_norm is a pure polynomial with no branches.
115 let mut phase = self.phase;
116 for out in output.iter_mut() {
117 *out = fast_sin_norm(phase) * amp;
118 phase = (phase + phase_inc).fract();
119 }
120 self.phase = phase;
121 }
122 1 => {
123 // ── Sawtooth with BLEP ────────────────────────────────────
124 let mut phase = self.phase;
125 for out in output.iter_mut() {
126 let mut saw = 2.0 * phase - 1.0;
127 saw -= blep(phase, phase_inc);
128 *out = saw * amp;
129 phase = (phase + phase_inc).fract();
130 }
131 self.phase = phase;
132 }
133 2 => {
134 // ── Square with BLEP ──────────────────────────────────────
135 let mut phase = self.phase;
136 for out in output.iter_mut() {
137 let mut sq = if phase < 0.5 { 1.0f32 } else { -1.0f32 };
138 sq += blep(phase, phase_inc);
139 sq -= blep((phase + 0.5).fract(), phase_inc);
140 *out = sq * amp;
141 phase = (phase + phase_inc).fract();
142 }
143 self.phase = phase;
144 }
145 _ => {
146 // ── Triangle: vectorizable (no discontinuities) ───────────
147 let mut phase = self.phase;
148 for out in output.iter_mut() {
149 let tri = if phase < 0.5 {
150 4.0 * phase - 1.0
151 } else {
152 3.0 - 4.0 * phase
153 };
154 *out = tri * amp;
155 phase = (phase + phase_inc).fract();
156 }
157 self.phase = phase;
158 }
159 }
160
161 // Advance params once per buffer (not per sample) when not ramping.
162 // This is correct because we snapshotted the values above.
163 // If params are ramping, tick them per-sample for accuracy.
164 if params.params[..params.count].iter().any(|p| p.step != 0.0) {
165 for _ in 0..BUFFER_SIZE {
166 params.tick_all();
167 }
168 }
169 }
170
171 fn capture_state(&self) -> StateBlob {
172 StateBlob::from_value(&OscState { phase: self.phase })
173 }
174
175 fn restore_state(&mut self, state: StateBlob) {
176 let s: OscState = state.to_value();
177 self.phase = s.phase;
178 }
179
180 fn type_name(&self) -> &'static str {
181 "Oscillator"
182 }
183}