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
//! Monophonic voice pipeline.
//!
//! `Voice` connects oscillator, envelope, filter, and LFO in a single
//! sample-by-sample render path.
use crate::audio::{
crusher::Bitcrusher,
env::{EnvStage, Envelope},
filter::SvFilter,
osc::{Lfo, Oscillator, detune_hz, midi_to_hz},
};
use crate::params::{LfoTarget, MidiNote, SynthParams};
/// Monophonic synthesiser voice combining oscillator, envelope, filter, and LFO.
pub struct Voice {
/// Whether the voice is currently producing sound.
pub active: bool,
/// MIDI note number of the current target pitch.
pub target_note: MidiNote,
/// Target frequency in Hz (includes detune).
pub target_freq: f32,
/// Current glide-smoothed frequency in Hz.
pub current_freq: f32,
/// Waveform generator.
pub osc: Oscillator,
/// Second oscillator for unison/detune/hard-sync effects.
pub osc2: Oscillator,
/// Bitcrusher (bit depth and sample rate reduction).
pub crusher: Bitcrusher,
/// Amplitude envelope.
pub env: Envelope,
/// State-variable filter.
pub filter: SvFilter,
/// Low-frequency oscillator.
pub lfo: Lfo,
/// Per-sample portamento smoothing coefficient (0 = instant, ~1 = very slow).
pub glide_coeff: f32,
}
impl Default for Voice {
fn default() -> Self {
Self::new()
}
}
impl Voice {
/// Construct a voice with default state at A4 (440 Hz).
#[must_use]
pub fn new() -> Self {
Self {
active: false,
target_note: MidiNote::A4,
target_freq: 440.0,
current_freq: 440.0,
osc: Oscillator::default(),
osc2: Oscillator::default(),
crusher: Bitcrusher::default(),
env: Envelope::default(),
filter: SvFilter::default(),
lfo: Lfo::default(),
glide_coeff: 0.0,
}
}
/// Recompute the portamento smoothing coefficient from glide time and sample rate.
pub fn update_glide(&mut self, glide_time: f32, sample_rate: f32) {
self.glide_coeff = if glide_time < 1e-4 {
0.0
} else {
(-1.0_f32 / (glide_time * sample_rate)).exp()
};
}
/// Start a new note (or retrigger legato if the voice is already active).
pub fn note_on(&mut self, note: impl Into<MidiNote>, params: &SynthParams, sample_rate: f32) {
let note = note.into();
let legato = self.active;
self.target_note = note;
let base = midi_to_hz(note);
self.target_freq = detune_hz(base, params.osc.detune);
if !legato {
// No glide on fresh attacks – snap to pitch immediately.
self.current_freq = self.target_freq;
self.crusher.reset();
}
self.active = true;
self.update_glide(params.global.glide_time, sample_rate);
self.env.note_on(legato);
}
/// Begin the envelope release phase.
pub fn note_off(&mut self) {
self.env.note_off();
}
/// Immediately silence the voice and reset DSP state (all-notes-off / panic).
pub fn panic(&mut self) {
self.active = false;
self.env.reset();
self.filter.reset();
self.crusher.reset();
}
/// Render one sample. Called from the audio callback – no allocation.
pub fn process(&mut self, params: &SynthParams, sample_rate: f32) -> f32 {
if !self.active && !self.env.is_active() {
return 0.0;
}
// Deactivate once envelope reaches Idle.
if self.env.stage == EnvStage::Idle && !self.env.is_active() {
self.active = false;
}
// LFO
let lfo_val = self.lfo.next(params.lfo.lfo_rate, sample_rate); // -1..1
let lfo_depth = params.lfo.lfo_depth;
// Glide (portamento)
let gc = self.glide_coeff;
self.current_freq = self.target_freq + (self.current_freq - self.target_freq) * gc;
let freq = self.current_freq;
// Pitch modulation (vibrato)
let modded_freq = match params.lfo.lfo_target {
LfoTarget::Pitch => freq * 2.0_f32.powf(lfo_val * lfo_depth * 0.1),
_ => freq,
};
// Detune (re-apply in case params changed)
let final_freq = detune_hz(modded_freq, 0.0); // detune already baked into target_freq
// Pulse width modulation
let pw = match params.lfo.lfo_target {
LfoTarget::PulseWidth => {
(params.osc.pulse_width + lfo_val * lfo_depth * 0.4).clamp(0.05, 0.95)
}
_ => params.osc.pulse_width,
};
// Oscillator
let osc_out = self.osc.next_sample(
final_freq,
sample_rate,
params.osc.waveform,
pw,
params.osc.noise_mix,
);
let osc_out = if params.osc2.osc2_mix > 0.001 {
if params.osc2.hard_sync && self.osc.just_wrapped() {
self.osc2.reset();
}
let osc2_freq = detune_hz(final_freq, params.osc2.detune);
let secondary =
self.osc2
.next_sample(osc2_freq, sample_rate, params.osc2.waveform, pw, 0.0);
osc_out * (1.0 - params.osc2.osc2_mix) + secondary * params.osc2.osc2_mix
} else {
osc_out
};
// Bitcrusher (pre-filter: quantization harmonics shaped by filter resonance)
let osc_out = self
.crusher
.process(osc_out, params.crusher.bits, params.crusher.rate);
// Envelope
let env_val = self.env.process(
params.env.attack,
params.env.decay,
params.env.sustain,
params.env.release,
params.env.env_reverse,
sample_rate,
);
// Volume LFO (tremolo)
let vol_mod = match params.lfo.lfo_target {
LfoTarget::Volume => 1.0 - lfo_val * lfo_depth * 0.5,
_ => 1.0,
};
// Filter cutoff LFO
let cutoff_mod = match params.lfo.lfo_target {
LfoTarget::Cutoff => (params.filter.cutoff * 2.0_f32.powf(lfo_val * lfo_depth * 2.0))
.clamp(20.0, 18000.0),
_ => params.filter.cutoff,
};
// Filter stage
let filtered = self.filter.process(
osc_out * env_val,
params.filter.filter_mode,
cutoff_mod,
params.filter.resonance,
params.filter.drive,
sample_rate,
);
// Volume & tremolo (dry; post-mix reverb is handled in engine)
filtered * env_val * vol_mod * params.global.volume
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::params::{MidiNote, SynthParams, Waveform};
#[test]
fn voice_osc2_mix_zero_is_finite() {
let mut voice = Voice::new();
let params = SynthParams::default(); // osc2_mix = 0.0
voice.note_on(MidiNote::A4, ¶ms, 44100.0);
for _ in 0..1000 {
let s = voice.process(¶ms, 44100.0);
assert!(s.is_finite(), "non-finite sample with osc2 off: {s}");
}
}
#[test]
fn voice_osc2_hard_sync_is_finite() {
let mut voice = Voice::new();
let mut params = SynthParams::default();
params.osc2.osc2_mix = 0.5;
params.osc2.hard_sync = true;
params.osc2.waveform = Waveform::Sawtooth;
params.osc2.detune = 7.0;
voice.note_on(MidiNote::A4, ¶ms, 44100.0);
for _ in 0..1000 {
let s = voice.process(¶ms, 44100.0);
assert!(s.is_finite(), "non-finite sample with hard sync on: {s}");
}
}
#[test]
fn voice_hard_sync_resets_osc2_phase() {
// With hard sync, OSC2 resets to phase 0 each time OSC1 wraps.
// OSC2 (sawtooth) immediately after reset starts at phase ≈ 0, giving
// output ≈ -1. This is observable even through the envelope and filter
// because OSC2 is the sole source (osc2_mix = 1.0) and the filter is
// transparent at low resonance. Without sync the same OSC2 would be at
// an arbitrary phase after 5+ free cycles, frequently yielding positive
// values; with sync the output must be strongly negative at every wrap.
use crate::params::{EnvParams, FilterMode, FilterParams, GlobalParams};
let mut params = SynthParams::default();
params.osc.waveform = Waveform::Sawtooth;
params.osc2.waveform = Waveform::Sawtooth;
params.osc2.detune = 700.0; // OSC2 ~5.3× faster than OSC1
params.osc2.osc2_mix = 1.0; // 100% OSC2 so the reset is the sole signal
params.osc2.hard_sync = true;
params.env = EnvParams {
attack: 0.0,
decay: 0.0,
sustain: 1.0,
release: 0.0,
env_reverse: false,
};
params.filter = FilterParams {
filter_mode: FilterMode::LowPass,
cutoff: 20000.0,
resonance: 0.0,
drive: 0.0,
};
params.global = GlobalParams {
volume: 1.0,
glide_time: 0.0,
};
let mut voice = Voice::new();
voice.note_on(MidiNote::A4, ¶ms, 44100.0);
// 440 Hz at 44100 Hz → wrap every ~100 samples; 600 samples covers ≥5 wraps.
let mut wrap_samples: Vec<f32> = Vec::new();
for _ in 0..600 {
let s = voice.process(¶ms, 44100.0);
if voice.osc.just_wrapped() {
wrap_samples.push(s);
}
}
assert!(
wrap_samples.len() >= 4,
"expected ≥4 OSC1 wraps in 600 samples, got {}",
wrap_samples.len()
);
// After the first wrap (envelope may still settle), each wrap-point
// must produce strongly negative output (OSC2 sawtooth just off phase 0).
// Phase step ≈ 659 Hz / 44100 Hz ≈ 0.015 → sawtooth ≈ -0.97.
// Allow headroom for filter state and slight envelope drift.
for (i, &s) in wrap_samples[1..].iter().enumerate() {
assert!(
s < -0.5,
"wrap sample {}: expected output near -1 (OSC2 reset to phase 0), got {s}",
i + 1
);
}
// Contrast: without hard sync, OSC2 runs freely and after 5+ free
// cycles will often be at a positive phase. Collect and verify at least
// one wrap-point sample is clearly positive (proving the sync test is
// discriminating rather than vacuously satisfied).
let mut params_free = params.clone();
params_free.osc2.hard_sync = false;
let mut voice_free = Voice::new();
voice_free.note_on(MidiNote::A4, ¶ms_free, 44100.0);
let mut any_positive = false;
for _ in 0..600 {
let s = voice_free.process(¶ms_free, 44100.0);
if voice_free.osc.just_wrapped() && s > 0.0 {
any_positive = true;
break;
}
}
assert!(
any_positive,
"unsynced OSC2 should produce positive values at some OSC1 wrap-points"
);
}
}