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
use crate::synthesis::wavetable::WAVETABLE;
/// FM (Frequency Modulation) Synthesis Parameters
///
/// FM synthesis works by using one oscillator (the modulator) to modulate
/// the frequency of another oscillator (the carrier). This creates complex,
/// harmonically rich timbres that are impossible with basic subtractive synthesis.
///
/// Famous for: DX7 sounds, electric pianos, bells, brass, metallic tones
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FMParams {
/// Ratio of modulator frequency to carrier frequency
/// Common ratios: 1.0 (harmonic), 2.0 (octave up), 0.5 (octave down), 3.5 (inharmonic)
pub mod_ratio: f32,
/// Modulation index - controls the brightness/complexity of the sound
/// Higher values = more harmonics = brighter/harsher
/// Typical range: 0.0 to 10.0
pub mod_index: f32,
/// Envelope for modulation index (controls how brightness changes over time)
/// 0.0 to 1.0, multiplied by mod_index
pub index_envelope_attack: f32,
pub index_envelope_decay: f32,
pub index_envelope_sustain: f32,
pub index_envelope_release: f32,
/// Modulator envelope depth (0.0 = no envelope, 1.0 = full envelope effect)
pub index_env_amount: f32,
}
impl FMParams {
/// Create new FM synthesis parameters
///
/// # Arguments
/// * `mod_ratio` - Modulator to carrier frequency ratio
/// * `mod_index` - Modulation index (brightness)
pub fn new(mod_ratio: f32, mod_index: f32) -> Self {
Self {
mod_ratio: mod_ratio.max(0.01),
mod_index: mod_index.max(0.0),
index_envelope_attack: 0.01,
index_envelope_decay: 0.1,
index_envelope_sustain: 0.7,
index_envelope_release: 0.2,
index_env_amount: 0.0,
}
}
/// Create FM params with modulation index envelope
pub fn with_index_envelope(
mod_ratio: f32,
mod_index: f32,
attack: f32,
decay: f32,
sustain: f32,
release: f32,
amount: f32,
) -> Self {
Self {
mod_ratio: mod_ratio.max(0.01),
mod_index: mod_index.max(0.0),
index_envelope_attack: attack.max(0.001),
index_envelope_decay: decay.max(0.001),
index_envelope_sustain: sustain.clamp(0.0, 1.0),
index_envelope_release: release.max(0.001),
index_env_amount: amount.clamp(0.0, 1.0),
}
}
/// Classic electric piano sound (DX7-style)
///
/// Modulator ratio slightly detuned from harmonic for warmth
pub fn electric_piano() -> Self {
Self::with_index_envelope(1.0, 2.5, 0.001, 0.8, 0.2, 0.5, 0.9)
}
/// Bright bell sound
///
/// Inharmonic ratio creates bell-like metallic timbre
pub fn bell() -> Self {
Self::with_index_envelope(3.5, 8.0, 0.001, 1.2, 0.1, 0.8, 0.95)
}
/// Brass-like sound
///
/// High modulation index with envelope for expressive brass
pub fn brass() -> Self {
Self::with_index_envelope(1.0, 5.0, 0.05, 0.2, 0.8, 0.3, 0.8)
}
/// Bass sound with harmonics
///
/// Low modulation for fundamental-heavy bass with subtle harmonics
pub fn bass() -> Self {
Self::with_index_envelope(1.0, 1.2, 0.001, 0.15, 0.6, 0.2, 0.7)
}
/// Metallic pad (shimmer effect)
///
/// Irrational ratio for slowly evolving inharmonic texture
pub fn metallic_pad() -> Self {
Self::with_index_envelope(2.414, 4.0, 0.8, 0.5, 0.7, 1.0, 0.6)
}
/// Growling bass
///
/// Octave-down modulator with high index for aggressive bass
pub fn growl() -> Self {
Self::with_index_envelope(0.5, 6.0, 0.001, 0.3, 0.5, 0.2, 0.85)
}
/// Disable FM (bypass)
pub fn none() -> Self {
Self::new(1.0, 0.0)
}
/// Calculate the modulation index at a given time using the index envelope
///
/// # Arguments
/// * `time_in_note` - Time since note started (seconds)
/// * `note_duration` - Total note duration (seconds)
pub fn index_at(&self, time_in_note: f32, note_duration: f32) -> f32 {
if self.index_env_amount == 0.0 {
return self.mod_index;
}
let env_value = self.envelope_value_at(time_in_note, note_duration);
// Interpolate between base index and zero based on envelope
// When envelope is 1.0, use full mod_index
// When envelope is 0.0, reduce mod_index
self.mod_index * (1.0 - self.index_env_amount + env_value * self.index_env_amount)
}
/// Get envelope value (0.0 to 1.0) at a given time
/// This is a simple linear ADSR similar to the main Envelope but optimized for FM
#[inline]
fn envelope_value_at(&self, time: f32, note_duration: f32) -> f32 {
if time < 0.0 {
return 0.0;
}
// Attack phase
if time < self.index_envelope_attack {
return time / self.index_envelope_attack;
}
// Decay phase
let decay_time = time - self.index_envelope_attack;
if decay_time < self.index_envelope_decay {
let decay_progress = decay_time / self.index_envelope_decay;
// Linear interpolation from 1.0 to sustain using FMA
return 1.0 + (self.index_envelope_sustain - 1.0) * decay_progress;
}
// Sustain phase
if time < note_duration {
return self.index_envelope_sustain;
}
// Release phase
let release_time = time - note_duration;
if release_time >= self.index_envelope_release {
return 0.0;
}
let release_progress = release_time / self.index_envelope_release;
self.index_envelope_sustain * (1.0 - release_progress)
}
/// Generate an FM synthesis sample
///
/// # Arguments
/// * `carrier_freq` - Carrier oscillator frequency (Hz)
/// * `time_in_note` - Time within the note (seconds)
/// * `note_duration` - Total note duration (seconds)
///
/// # Returns
/// Sample value between -1.0 and 1.0
#[inline]
pub fn sample(&self, carrier_freq: f32, time_in_note: f32, note_duration: f32) -> f32 {
if self.mod_index < 0.0001 {
// Bypass - just return carrier sine wave (using fast wavetable)
let phase = time_in_note * carrier_freq;
return WAVETABLE.sample(phase);
}
// Calculate modulator frequency
let modulator_freq = carrier_freq * self.mod_ratio;
// Get current modulation index (with envelope)
let current_index = self.index_at(time_in_note, note_duration);
// Generate modulator signal (using fast wavetable)
let mod_phase = time_in_note * modulator_freq;
let modulator = WAVETABLE.sample(mod_phase);
// Classic FM synthesis: modulate the PHASE, not the frequency
// The modulation index controls the depth of phase modulation
// This keeps the output bounded to [-1, 1]
let carrier_phase = time_in_note * carrier_freq;
let phase_modulation = modulator * current_index;
// Sample carrier with phase modulation applied
WAVETABLE.sample(carrier_phase + phase_modulation)
}
}
impl Default for FMParams {
fn default() -> Self {
Self::none()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fm_params_creation() {
let fm = FMParams::new(2.0, 3.0);
assert_eq!(fm.mod_ratio, 2.0);
assert_eq!(fm.mod_index, 3.0);
}
#[test]
fn test_fm_bypass() {
let fm = FMParams::none();
assert_eq!(fm.mod_index, 0.0);
// Should produce a sine wave
let sample = fm.sample(440.0, 0.0, 1.0);
assert!(sample.abs() < 0.1); // At t=0, sin(0) ≈ 0
}
#[test]
fn test_fm_synthesis() {
let fm = FMParams::new(1.0, 5.0);
// Sample should be between -1 and 1
for i in 0..100 {
let t = i as f32 / 100.0;
let sample = fm.sample(440.0, t, 1.0);
assert!(
sample >= -1.0 && sample <= 1.0,
"Sample {} out of range at t={}",
sample,
t
);
}
}
#[test]
fn test_index_envelope() {
let fm = FMParams::with_index_envelope(1.0, 10.0, 0.1, 0.1, 0.5, 0.2, 1.0);
// At t=0, should start at low index
let start_index = fm.index_at(0.0, 1.0);
assert!(start_index < 1.0, "Should start with low index");
// At peak of attack, should be at full index
let peak_index = fm.index_at(0.1, 1.0);
assert!(peak_index > 8.0, "Should reach high index at attack peak");
// During sustain, should be at sustain level
let sustain_index = fm.index_at(0.5, 1.0);
assert!(
sustain_index > start_index && sustain_index < peak_index,
"Sustain should be between start and peak"
);
}
#[test]
fn test_presets() {
let _ep = FMParams::electric_piano();
let _bell = FMParams::bell();
let _brass = FMParams::brass();
let _bass = FMParams::bass();
let _pad = FMParams::metallic_pad();
let _growl = FMParams::growl();
}
}