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 {
48 phase: 0.0,
49 tuning: None,
50 }
51 }
52
53 /// Load a tuning table into this oscillator.
54 /// After loading, param 3 (midi_note) drives the frequency.
55 pub fn set_tuning(&mut self, frequencies: [f32; 128]) {
56 self.tuning = Some(Box::new(frequencies));
57 }
58
59 pub fn clear_tuning(&mut self) {
60 self.tuning = None;
61 }
62}
63
64/// Polynomial Band-Limited Step (BLEP) correction.
65/// Reduces aliasing at waveform discontinuities.
66/// `t` = current phase, `dt` = phase increment per sample.
67#[inline(always)]
68fn blep(t: f32, dt: f32) -> f32 {
69 if t < dt {
70 let t = t / dt;
71 2.0 * t - t * t - 1.0
72 } else if t > 1.0 - dt {
73 let t = (t - 1.0) / dt;
74 t * t + 2.0 * t + 1.0
75 } else {
76 0.0
77 }
78}
79
80impl Default for Oscillator {
81 fn default() -> Self {
82 Self::new()
83 }
84}
85
86impl DspNode for Oscillator {
87 fn process(
88 &mut self,
89 _inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
90 output: &mut [f32; BUFFER_SIZE],
91 params: &mut ParamBlock,
92 sample_rate: f32,
93 ) {
94 // Snapshot params once — they are stable or slowly ramping.
95 // Reading inside the loop would prevent auto-vectorization.
96 let amp = params.get(1).current.clamp(0.0, 1.0);
97 let wave = params.get(2).current as u32;
98
99 let freq = if let Some(ref tuning) = self.tuning {
100 let midi = params.get(3).current;
101 if midi >= 0.0 {
102 let note = (midi as usize).min(127);
103 tuning[note].max(0.01)
104 } else {
105 params.get(0).current.max(0.01)
106 }
107 } else {
108 params.get(0).current.max(0.01)
109 };
110
111 let phase_inc = freq / sample_rate;
112
113 match wave {
114 0 => {
115 // ── Sine: vectorizable loop ───────────────────────────────
116 // LLVM will auto-vectorize this into 4-wide SSE/AVX lanes
117 // because fast_sin_norm is a pure polynomial with no branches.
118 let mut phase = self.phase;
119 for out in output.iter_mut() {
120 *out = fast_sin_norm(phase) * amp;
121 phase = (phase + phase_inc).fract();
122 }
123 self.phase = phase;
124 }
125 1 => {
126 // ── Sawtooth with BLEP ────────────────────────────────────
127 let mut phase = self.phase;
128 for out in output.iter_mut() {
129 let mut saw = 2.0 * phase - 1.0;
130 saw -= blep(phase, phase_inc);
131 *out = saw * amp;
132 phase = (phase + phase_inc).fract();
133 }
134 self.phase = phase;
135 }
136 2 => {
137 // ── Square with BLEP ──────────────────────────────────────
138 let mut phase = self.phase;
139 for out in output.iter_mut() {
140 let mut sq = if phase < 0.5 { 1.0f32 } else { -1.0f32 };
141 sq += blep(phase, phase_inc);
142 sq -= blep((phase + 0.5).fract(), phase_inc);
143 *out = sq * amp;
144 phase = (phase + phase_inc).fract();
145 }
146 self.phase = phase;
147 }
148 _ => {
149 // ── Triangle: vectorizable (no discontinuities) ───────────
150 let mut phase = self.phase;
151 for out in output.iter_mut() {
152 let tri = if phase < 0.5 {
153 4.0 * phase - 1.0
154 } else {
155 3.0 - 4.0 * phase
156 };
157 *out = tri * amp;
158 phase = (phase + phase_inc).fract();
159 }
160 self.phase = phase;
161 }
162 }
163
164 // Advance params once per buffer (not per sample) when not ramping.
165 // This is correct because we snapshotted the values above.
166 // If params are ramping, tick them per-sample for accuracy.
167 if params.params[..params.count].iter().any(|p| p.step != 0.0) {
168 for _ in 0..BUFFER_SIZE {
169 params.tick_all();
170 }
171 }
172 }
173
174 fn capture_state(&self) -> StateBlob {
175 StateBlob::from_value(&OscState { phase: self.phase })
176 }
177
178 fn restore_state(&mut self, state: StateBlob) {
179 let s: OscState = state.to_value();
180 self.phase = s.phase;
181 }
182
183 fn type_name(&self) -> &'static str {
184 "Oscillator"
185 }
186}