1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
use std::f32::consts::TAU;
use std::sync::atomic::{AtomicU64, Ordering};
/// Unique seed counter — each Oscillator instance gets a distinct noise seed so
/// simultaneous Noise-shape voices don't produce correlated (identical) bursts.
static OSC_COUNTER: AtomicU64 = AtomicU64::new(1);
/// Waveform shape for a band-limited oscillator.
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub enum OscShape {
/// Pure sinusoidal wave (no harmonics).
#[default]
Sine,
/// Band-limited triangle wave (−12 dB/oct harmonic rolloff).
Triangle,
/// Band-limited sawtooth wave via PolyBLEP anti-aliasing.
Saw,
/// Band-limited square wave via PolyBLEP anti-aliasing.
Square,
/// White noise via xorshift64.
Noise,
}
/// Band-limited oscillator with configurable waveform and per-sample output.
///
/// Uses PolyBLEP anti-aliasing for Saw, Square, and Triangle waveforms to reduce
/// aliasing artifacts at high frequencies.
pub struct Oscillator {
phase: f32,
pub freq: f32,
pub shape: OscShape,
sample_rate: f32,
// Leaky integrator state for band-limited triangle generation
tri_state: f32,
// DC-blocking state for square wave input to triangle integrator
sq_dc: f32,
// xorshift64 state for noise generation
noise_seed: u64,
}
/// PolyBLEP residual — removes the aliasing step artifact at a phase discontinuity.
/// `t` : normalized phase in [0, 1)
/// `dt` : normalized frequency (freq / sample_rate)
///
/// Returns the correction term to subtract (for saw) or use in integration (for square/tri).
/// Based on Valimaki & Pakarinen (2007) and common DAW synth implementations.
#[inline(always)]
fn poly_blep(t: f32, dt: f32) -> f32 {
if t < dt {
// Just past the discontinuity: ramp correction from 0→0
let u = t / dt;
2.0 * u - u * u - 1.0 // = -(1-u)²
} else if t > 1.0 - dt {
// Approaching the discontinuity: ramp correction back to 0
let u = (t - 1.0) / dt;
u * u + 2.0 * u + 1.0 // = (1+u)²
} else {
0.0
}
}
impl Oscillator {
/// Creates a new oscillator at the specified frequency and waveform shape.
///
/// # Parameters
/// - `freq`: Oscillator frequency in Hz.
/// - `shape`: Waveform shape (`Sine`, `Saw`, `Square`, `Triangle`, or `Noise`).
/// - `sample_rate`: Audio sample rate in Hz (e.g. 44100.0).
///
/// # Returns
/// An `Oscillator` instance with phase initialized to zero.
pub fn new(freq: f32, shape: OscShape, sample_rate: f32) -> Self {
let mut osc = Self {
phase: 0.0,
freq,
shape,
sample_rate,
tri_state: 0.0,
sq_dc: 0.0,
noise_seed: OSC_COUNTER
.fetch_add(1, Ordering::Relaxed)
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(0x517C_C1B7_2722_0A95),
};
// Analytically initialize both triangle integrator state and DC-blocker state to
// their steady-state values at phase=0 (start of positive half-cycle) so the
// waveform produces correct amplitude from the very first sample without any
// startup transient.
//
// Both the DC blocker (sq_dc) and leaky integrator (tri_state) are 1-pole filters
// driven by the ±1 square wave. At phase=0 they are both at their respective
// troughs. Solving the symmetric fixed-point equations (see inline math):
//
// sq_dc_trough = -(1-p)/(1+p) [from LP fixed-point, ±1 input]
// tri_trough = -(4·dt/leak)·(1-p)/(1+p) [same shape, scaled by 4·dt/leak]
//
// where p = (1-α)^{T/2} and α = 0.001 (same coefficient for both filters)
if shape == OscShape::Triangle {
let alpha = 1e-3_f32; // coefficient shared by DC blocker and leaky integrator
let dt = freq / sample_rate;
let half_samples = sample_rate / (2.0 * freq.max(1.0));
let p = (1.0 - alpha).powf(half_samples);
let factor = (1.0 - p) / (1.0 + p);
osc.sq_dc = -factor; // DC-blocker trough at phase=0
osc.tri_state = -(4.0 * dt / alpha) * factor; // integrator trough at phase=0
}
osc
}
/// Advances the oscillator by one sample and returns the output value in `[-1, 1]`.
///
/// # Returns
/// The next audio sample as an `f32` in the range `[-1, 1]`.
pub fn next_sample(&mut self) -> f32 {
let t = self.phase / TAU;
let dt = (self.freq / self.sample_rate).clamp(0.0, 0.5);
let out = match self.shape {
OscShape::Sine => self.phase.sin(),
OscShape::Saw => {
// Band-limited sawtooth via PolyBLEP.
// Naive: 2t - 1, with a step discontinuity at t=0.
// Correction subtracts the blep residual at the wrap point.
(2.0 * t - 1.0) - poly_blep(t, dt)
}
OscShape::Square => {
// Band-limited square via PolyBLEP.
let sq_naive = if t < 0.5 { 1.0f32 } else { -1.0f32 };
sq_naive + poly_blep(t, dt) - poly_blep((t + 0.5) % 1.0, dt)
}
OscShape::Noise => {
// White noise via xorshift64, mapped to [-1, 1]
let mut s = self.noise_seed;
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
self.noise_seed = s;
(s as f32 / u64::MAX as f32) * 2.0 - 1.0
}
OscShape::Triangle => {
// Band-limited triangle via leaky integration of a PolyBLEP square wave.
// The square has discontinuities at t=0 and t=0.5; blep corrects both.
// A leaky integrator then shapes the square into a smooth triangle with
// naturally high-frequency rolloff (−12 dB/oct vs saw's −6 dB/oct).
let sq_naive = if t < 0.5 { 1.0f32 } else { -1.0f32 };
let sq = sq_naive + poly_blep(t, dt) - poly_blep((t + 0.5) % 1.0, dt);
// DC-block the square before integrating (prevents sub-bass accumulation).
// α=0.001 gives a ~1000-sample (23ms at 44.1kHz) time constant — fast
// enough to track any DC offset without affecting the audio band.
self.sq_dc += 0.001 * (sq - self.sq_dc);
let sq_ac = sq - self.sq_dc;
// Integrate: step size = 4*dt to get correct ±1 amplitude
self.tri_state += 4.0 * dt * sq_ac;
// Leaky integrator — leak of 1e-3 gives a ~1000-sample time constant
// so the waveform reaches steady-state amplitude within ~23 ms.
// The prior value (2e-5) had a 50 000-sample constant, causing a multi-
// second transient where the output overshot ±1 before settling.
self.tri_state *= 1.0 - 1e-3;
self.tri_state
}
};
self.phase = (self.phase + TAU * self.freq / self.sample_rate).rem_euclid(TAU);
out
}
}
/// Exponential smoothing for audio parameters (frequency glide, amplitude, etc.).
/// Eliminates zipper noise when parameters change between control frames.
pub struct SmoothParam {
current: f32,
target: f32,
rate: f32, // lerp coefficient per sample
}
impl SmoothParam {
/// Creates a new smoothed parameter with the given initial value and glide time.
///
/// # Parameters
/// - `initial`: Starting value of the parameter.
/// - `smoothing_ms`: One-pole filter time constant in milliseconds.
/// - `sample_rate`: Audio sample rate in Hz used to convert the time constant.
///
/// # Returns
/// A `SmoothParam` with `current` and `target` both set to `initial`.
pub fn new(initial: f32, smoothing_ms: f32, sample_rate: f32) -> Self {
let samples = smoothing_ms * 0.001 * sample_rate;
Self {
current: initial,
target: initial,
rate: 1.0 / samples.max(1.0),
}
}
/// Sets the target value that the parameter will glide toward.
///
/// # Parameters
/// - `t`: The new target value.
pub fn set_target(&mut self, t: f32) {
self.target = t;
}
/// Advances the smoothed parameter by one sample and returns the current interpolated value.
///
/// # Returns
/// The current value after one step of exponential smoothing toward the target.
pub fn next(&mut self) -> f32 {
self.current += self.rate * (self.target - self.current);
self.current
}
pub fn current(&self) -> f32 {
self.current
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_oscillator_frequency_above_zero() {
// A sine oscillator at 440 Hz must produce non-zero output eventually
// (initial phase is 0 so sample 0 is 0.0; check after a few samples).
let mut osc = Oscillator::new(440.0, OscShape::Sine, 44100.0);
// Skip the first sample (sin(0) == 0) and check that subsequent ones are non-zero
osc.next_sample(); // sample at phase 0
let s = osc.next_sample();
assert!(
s.abs() > 1e-6,
"Sine oscillator at 440 Hz should produce non-zero output, got {}",
s
);
}
#[test]
fn test_oscillator_amplitude_clamp() {
// The oscillator output for Sine should always be in [-1, 1]
let mut osc = Oscillator::new(440.0, OscShape::Sine, 44100.0);
for _ in 0..4410 {
let s = osc.next_sample();
assert!(
(-1.0..=1.0).contains(&s),
"Sine sample out of [-1, 1]: {}",
s
);
}
}
#[test]
fn test_oscillator_silence_when_amplitude_zero() {
// A zero-frequency oscillator has dt=0 so phase never advances;
// sin(0) = 0 for every sample — effectively silence.
let mut osc = Oscillator::new(0.0, OscShape::Sine, 44100.0);
for _ in 0..100 {
let s = osc.next_sample();
assert!(
s.abs() < 1e-10,
"Expected silence from zero-freq oscillator, got {}",
s
);
}
}
#[test]
fn test_sine_at_phase_zero_is_zero() {
// sin(0) = 0; the very first sample must be 0.
let mut osc = Oscillator::new(440.0, OscShape::Sine, 44100.0);
let s = osc.next_sample();
assert!(s.abs() < 1e-10, "sin(0) should be 0, got {}", s);
}
#[test]
fn test_sine_at_quarter_period_is_near_one() {
// After exactly 1/4 of one period (sample_rate / freq / 4 samples)
// the sine should be very close to 1.0.
let freq = 1000.0_f32;
let sr = 44100.0_f32;
let quarter_samples = (sr / freq / 4.0).round() as usize;
let mut osc = Oscillator::new(freq, OscShape::Sine, sr);
let mut last = 0.0_f32;
for _ in 0..quarter_samples {
last = osc.next_sample();
}
assert!(
last > 0.9,
"Sine at ~quarter period should be near 1.0, got {}",
last
);
}
#[test]
fn test_square_wave_is_plus_or_minus_one() {
// A band-limited square should be very close to +1 or -1 away from
// the discontinuities. Check 100 samples from the middle of each half-cycle.
let freq = 440.0_f32;
let sr = 44100.0_f32;
let mut osc = Oscillator::new(freq, OscShape::Square, sr);
// Skip to 10% into the first half-cycle to avoid the PolyBLEP transition region.
let skip = (sr / freq * 0.1) as usize;
for _ in 0..skip {
let _ = osc.next_sample();
}
for _ in 0..20 {
let s = osc.next_sample();
assert!(s.abs() > 0.5, "Square wave sample not near ±1: {}", s);
}
}
#[test]
fn test_saw_wave_in_range() {
// Sawtooth output should stay in [-1.5, 1.5] (PolyBLEP can briefly overshoot).
let mut osc = Oscillator::new(440.0, OscShape::Saw, 44100.0);
for _ in 0..4410 {
let s = osc.next_sample();
assert!(s.abs() < 1.5, "Saw sample out of expected range: {}", s);
assert!(s.is_finite(), "Saw sample is non-finite");
}
}
#[test]
fn test_higher_frequency_shorter_period() {
// A 1000 Hz sine completes one cycle in 44.1 samples; a 500 Hz sine
// needs 88.2 samples. Verify that doubling frequency halves the zero-crossing period.
let sr = 44100.0_f32;
let count_crossings = |freq: f32| -> usize {
let mut osc = Oscillator::new(freq, OscShape::Sine, sr);
let mut prev = 0.0_f32;
let mut crossings = 0;
for _ in 0..4410 {
let s = osc.next_sample();
if prev < 0.0 && s >= 0.0 {
crossings += 1;
}
prev = s;
}
crossings
};
let c1000 = count_crossings(1000.0);
let c500 = count_crossings(500.0);
// 1000 Hz has roughly twice the zero-crossings of 500 Hz over the same window.
assert!(
c1000 > c500,
"1000 Hz should have more zero-crossings than 500 Hz ({} vs {})",
c1000,
c500
);
}
#[test]
fn test_amplitude_scaling_via_level() {
// If we scale the oscillator output by 0.5 we should get half the amplitude.
let mut osc1 = Oscillator::new(440.0, OscShape::Sine, 44100.0);
let mut osc2 = Oscillator::new(440.0, OscShape::Sine, 44100.0);
// Skip first sample (it is 0).
let _ = osc1.next_sample();
let _ = osc2.next_sample();
let s1 = osc1.next_sample();
let s2 = osc2.next_sample() * 0.5;
assert!(
(s1 * 0.5 - s2).abs() < 1e-6,
"Amplitude scaling mismatch: {} vs {}",
s1 * 0.5,
s2
);
}
}