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
use super::xorshift::XorShift32;
use crate::fixed::fixed::Q15;
use crate::fixed::tables::sine;
use crate::fixed::units::Phase;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, Eq, Hash, PartialEq)]
pub enum Waveform {
#[default]
TranslatedSine,
TranslatedSquare,
TranslatedRampUp,
TranslatedRampDown,
Sine,
RampDown,
Square,
Random,
}
impl Waveform {
/// Evaluate the waveform at a given cycle [`Phase`]. Returns
/// a [`Q15`]:
///
/// * Audio shapes ([`Self::Sine`], [`Self::RampDown`],
/// [`Self::Square`]) span the full `[-1, +1]` range.
/// * Translated shapes (used by envelope-style LFOs) span
/// only the positive half `[0, 1]`.
///
/// [`Self::Random`] returns [`Q15::ZERO`]; the actual
/// RNG-driven value lives on [`WaveformState`].
pub fn value_q15(&self, phase: Phase) -> Q15 {
match self {
// 0.5 + 0.5 · cos(τ · step)
// = (cos(τ · step) + 1) / 2
// `cos = sin(· + π/2)` → quarter-cycle phase shift.
// Halve the cosine, re-centre to `[0, 1]`.
Waveform::TranslatedSine => {
sine(phase.shifted(Phase::QUARTER)).halved() + Q15::HALF
}
// Half-cycle high then half-cycle low.
Waveform::TranslatedSquare => {
if phase.is_first_half() { Q15::ONE } else { Q15::ZERO }
}
// Sawtooth in `[0, 1]`. A half-cycle phase shift moves
// the discontinuity from the cycle boundary to the
// mid-point — that's the envelope LFO convention.
Waveform::TranslatedRampUp => {
phase.shifted(Phase::HALF).to_q15_unsigned()
}
// Mirror of `TranslatedRampUp` about `Q15::HALF`:
// `down(p) = 1 − up(p)`. (Off by 1 LSB at the
// saturation boundary, well below the quantisation
// floor — within the existing test tolerance.)
Waveform::TranslatedRampDown => {
Q15::ONE - Waveform::TranslatedRampUp.value_q15(phase)
}
// `−sin(τ · step)` — invert the LUT result. The
// saturating `Neg` impl on `Q15` clips the single
// problematic case (`−NEG_ONE`) to `ONE` for us.
Waveform::Sine => -sine(phase),
// Sawtooth in `[−1, +1]`. The `i16` reinterpretation
// of the cycle position followed by `wrapping_neg`
// produces:
// phase 0 → 0
// phase ≈ HALF → ≈ −1
// phase HALF → −1 (i16 wrap)
// phase ≈ 1 cycle → ≈ 0+
// — i.e. ramp 0 → −1 over the first half, then jump
// to +1 and ramp back to 0 over the second half.
Waveform::RampDown => Q15::from_raw(phase.raw_as_i16().wrapping_neg()),
Waveform::Square => {
if phase.is_first_half() { Q15::NEG_ONE } else { Q15::ONE }
}
Waveform::Random => Q15::ZERO,
}
}
}
#[derive(Default, Clone, Copy, Debug)]
pub struct WaveformState {
wf: Waveform,
rng: XorShift32,
}
impl WaveformState {
pub fn new(wf: Waveform) -> Self {
Self {
wf,
rng: XorShift32::default(),
}
}
/// Q-format LFO sampler. Returns the waveform amplitude at
/// the given [`Phase`]. The `Random` shape consults the
/// per-instance RNG and returns a value in `[0, ONE]`
/// (positive Q15 only).
pub fn value_q15(&mut self, phase: Phase) -> Q15 {
if let Waveform::Random = self.wf {
// Pure-integer RNG → unsigned Q15. Mask to the
// positive Q15 range.
let n = self.rng.next().unwrap() as i16;
Q15::from_raw(n & Q15::ONE.raw())
} else {
self.wf.value_q15(phase)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Q15-raw approximate equality (within `tol` LSBs).
fn approx_eq_q15(a: Q15, b: Q15, tol: i16) -> bool {
(a.raw() as i32 - b.raw() as i32).abs() <= tol as i32
}
#[test]
fn translated_sine_range() {
// Sweep a full cycle. 256 samples is more than enough to
// catch any negative excursion that would mean we broke
// the unsigned shape.
const SAMPLES_PER_CYCLE: u32 = 256;
const STEP: u16 = ((1u32 << 16) / SAMPLES_PER_CYCLE) as u16;
let wf = Waveform::TranslatedSine;
for k in 0..SAMPLES_PER_CYCLE {
let phase = Phase::from_raw((k as u16).wrapping_mul(STEP));
let v = wf.value_q15(phase);
assert!(
v.raw() >= 0,
"TranslatedSine should be unsigned, got {} at phase {:#x}",
v.raw(),
phase.raw()
);
}
}
#[test]
fn translated_sine_key_points() {
let wf = Waveform::TranslatedSine;
// Cosine tour: 0 → max ≈ ONE, 1/4 → centre, 1/2 → 0,
// 3/4 → centre.
assert!(approx_eq_q15(wf.value_q15(Phase::ZERO), Q15::ONE, 4));
assert!(approx_eq_q15(wf.value_q15(Phase::QUARTER), Q15::HALF, 4));
assert!(approx_eq_q15(wf.value_q15(Phase::HALF), Q15::ZERO, 4));
assert!(approx_eq_q15(wf.value_q15(Phase::THREE_QUARTERS), Q15::HALF, 4));
}
#[test]
fn translated_ramps_centre_at_phase_zero() {
// Both translated ramps start at the cycle midpoint (= 0.5).
assert!(approx_eq_q15(
Waveform::TranslatedRampUp.value_q15(Phase::ZERO),
Q15::HALF,
2
));
assert!(approx_eq_q15(
Waveform::TranslatedRampDown.value_q15(Phase::ZERO),
Q15::HALF,
2
));
}
#[test]
fn random_waveform_not_stuck() {
let mut ws = WaveformState::new(Waveform::Random);
let first = ws.value_q15(Phase::ZERO);
let second = ws.value_q15(Phase::ZERO);
// With a proper RNG, two consecutive values should differ.
assert_ne!(first.raw(), second.raw(), "Random waveform appears stuck");
}
}