Skip to main content

devalang_wasm/engine/audio/
generator.rs

1use super::lfo::{LfoParams, apply_lfo_modulation, generate_lfo_value};
2use super::synth::types::{SynthType, get_synth_type};
3use super::synth::{adsr_envelope, midi_to_frequency, oscillator_sample, time_to_samples};
4/// Note generator - creates audio samples for synthesized notes
5use anyhow::Result;
6use std::collections::HashMap;
7
8#[cfg(feature = "cli")]
9use crate::engine::plugin::{loader::load_plugin, runner::WasmPluginRunner};
10
11/// Filter definition
12#[derive(Debug, Clone)]
13pub struct FilterDef {
14    pub filter_type: String, // lowpass, highpass, bandpass
15    pub cutoff: f32,         // Hz
16    pub resonance: f32,      // 0.0 - 1.0
17}
18
19/// Parameters for synthesizing a note
20#[derive(Debug, Clone)]
21pub struct SynthParams {
22    pub waveform: String,
23    pub attack: f32,                   // seconds
24    pub decay: f32,                    // seconds
25    pub sustain: f32,                  // level (0.0 - 1.0)
26    pub release: f32,                  // seconds
27    pub synth_type: Option<String>,    // pluck, arp, pad, bass, lead, keys
28    pub filters: Vec<FilterDef>,       // Chain of filters
29    pub options: HashMap<String, f32>, // Configurable options for synth types
30    pub lfo: Option<LfoParams>,        // Low-Frequency Oscillator modulation
31    pub bpm: f32,                      // Tempo context for beat-synced modulation
32    // Plugin support
33    pub plugin_author: Option<String>,
34    pub plugin_name: Option<String>,
35    pub plugin_export: Option<String>,
36}
37
38impl Default for SynthParams {
39    fn default() -> Self {
40        Self {
41            waveform: "sine".to_string(),
42            attack: 0.01,
43            decay: 0.1,
44            sustain: 0.7,
45            release: 0.2,
46            synth_type: None,
47            filters: Vec::new(),
48            options: HashMap::new(),
49            lfo: None,
50            bpm: 120.0,
51            plugin_author: None,
52            plugin_name: None,
53            plugin_export: None,
54        }
55    }
56}
57
58/// Generate stereo audio samples for a single note
59pub fn generate_note(
60    midi_note: u8,
61    duration_ms: f32,
62    velocity: f32,
63    params: &SynthParams,
64    sample_rate: u32,
65) -> Result<Vec<f32>> {
66    generate_note_with_options(
67        midi_note,
68        duration_ms,
69        velocity,
70        params,
71        sample_rate,
72        0.0,
73        0.0,
74    )
75}
76
77/// Generate stereo audio samples for a single note with pan and detune options
78///
79/// If params contains plugin information, it will use the WASM plugin instead of built-in synth
80pub fn generate_note_with_options(
81    midi_note: u8,
82    duration_ms: f32,
83    velocity: f32,
84    params: &SynthParams,
85    sample_rate: u32,
86    pan: f32,    // -1.0 (left) to 1.0 (right), 0.0 = center
87    detune: f32, // cents, -100 to 100
88) -> Result<Vec<f32>> {
89    // Generating note (diagnostics suppressed)
90
91    // Check if we should use a WASM plugin
92    #[cfg(feature = "cli")]
93    {
94        if let Some(ref author) = params.plugin_author {
95            if let Some(ref name) = params.plugin_name {
96                // Using plugin path
97                return generate_note_with_plugin(
98                    midi_note,
99                    duration_ms,
100                    velocity,
101                    params,
102                    sample_rate,
103                    pan,
104                    detune,
105                    author,
106                    name,
107                    params.plugin_export.as_deref(),
108                );
109            }
110        }
111    }
112
113    // Using classic synth path
114
115    let base_frequency = midi_to_frequency(midi_note);
116
117    // Apply detune (cents to frequency ratio: 2^(cents/1200))
118    let frequency = if detune.abs() > 0.01 {
119        base_frequency * 2.0_f32.powf(detune / 1200.0)
120    } else {
121        base_frequency
122    };
123
124    let velocity = velocity.clamp(0.0, 1.0);
125
126    // Clone params to allow modification by synth type
127    let mut modified_params = params.clone();
128
129    // Apply synth type modifications
130    let synth_type: Option<Box<dyn SynthType>> = if let Some(ref type_name) = params.synth_type {
131        get_synth_type(type_name)
132    } else {
133        None
134    };
135
136    if let Some(ref stype) = synth_type {
137        stype.modify_params(&mut modified_params);
138    }
139
140    // Convert ADSR times to samples
141    let attack_samples = time_to_samples(modified_params.attack, sample_rate);
142    let decay_samples = time_to_samples(modified_params.decay, sample_rate);
143    let release_samples = time_to_samples(modified_params.release, sample_rate);
144
145    // Calculate sustain duration
146    let duration_seconds = duration_ms / 1000.0;
147    let total_samples = time_to_samples(duration_seconds, sample_rate);
148    let envelope_samples = attack_samples + decay_samples + release_samples;
149    let sustain_samples = total_samples.saturating_sub(envelope_samples);
150
151    let mut samples = Vec::with_capacity(total_samples * 2); // stereo
152
153    // Calculate pan gains (constant power panning)
154    let pan = pan.clamp(-1.0, 1.0);
155    let pan_angle = (pan + 1.0) * 0.25 * std::f32::consts::PI; // 0 to PI/2
156    let left_gain = pan_angle.cos();
157    let right_gain = pan_angle.sin();
158
159    // Beat-synced LFO rates need the tempo captured when the note was scheduled.
160    let bpm = modified_params.bpm;
161
162    for i in 0..total_samples {
163        let time = i as f32 / sample_rate as f32;
164
165        // Generate oscillator sample
166        let mut osc_frequency = frequency;
167
168        // Apply pitch LFO if configured
169        if let Some(ref lfo) = modified_params.lfo {
170            use crate::engine::audio::lfo::LfoTarget;
171            if lfo.target == LfoTarget::Pitch {
172                let lfo_cents = generate_lfo_value(lfo, time, bpm) * 100.0; // ±100 cents
173                osc_frequency = frequency * 2.0_f32.powf(lfo_cents / 1200.0);
174            }
175        }
176
177        let osc_sample = oscillator_sample(&modified_params.waveform, osc_frequency, time);
178
179        // Apply ADSR envelope
180        let envelope = adsr_envelope(
181            i,
182            attack_samples,
183            decay_samples,
184            sustain_samples,
185            release_samples,
186            modified_params.sustain,
187        );
188
189        // Apply velocity and envelope
190        let mut amplitude = osc_sample * envelope * velocity * 0.3; // 0.3 for headroom
191
192        // Apply volume LFO if configured
193        if let Some(ref lfo) = modified_params.lfo {
194            use crate::engine::audio::lfo::LfoTarget;
195            if lfo.target == LfoTarget::Volume {
196                let lfo_value = generate_lfo_value(lfo, time, bpm);
197                amplitude *= 1.0 + lfo_value; // Range: 0.0 to 2.0 with default depth
198            }
199        }
200
201        // Stereo output with panning
202        samples.push(amplitude * left_gain);
203        samples.push(amplitude * right_gain);
204    }
205
206    // Apply filters if any (with LFO cutoff modulation)
207    for filter in &modified_params.filters {
208        let mut modulated_filter = filter.clone();
209
210        // Apply cutoff LFO if configured
211        if let Some(ref lfo) = modified_params.lfo {
212            use crate::engine::audio::lfo::LfoTarget;
213            if lfo.target == LfoTarget::FilterCutoff {
214                // Modulate cutoff around its center value
215                let cutoff_range = filter.cutoff * 0.5; // ±50% of cutoff
216                modulated_filter.cutoff = apply_lfo_modulation(
217                    lfo,
218                    0.0, // Use average LFO value for filter
219                    bpm,
220                    filter.cutoff,
221                    cutoff_range,
222                );
223            }
224        }
225
226        apply_filter(&mut samples, &modulated_filter, sample_rate)?;
227    }
228
229    // Apply synth type post-processing
230    if let Some(stype) = synth_type {
231        stype.post_process(&mut samples, sample_rate, &modified_params.options)?;
232    }
233
234    Ok(samples)
235}
236
237/// Apply a filter to audio samples
238fn apply_filter(samples: &mut [f32], filter: &FilterDef, sample_rate: u32) -> Result<()> {
239    match filter.filter_type.to_lowercase().as_str() {
240        "lowpass" => apply_lowpass(samples, filter.cutoff, sample_rate),
241        "highpass" => apply_highpass(samples, filter.cutoff, sample_rate),
242        "bandpass" => apply_bandpass(samples, filter.cutoff, sample_rate),
243        _ => Ok(()),
244    }
245}
246
247/// Simple one-pole lowpass filter
248fn apply_lowpass(samples: &mut [f32], cutoff: f32, sample_rate: u32) -> Result<()> {
249    let dt = 1.0 / sample_rate as f32;
250    let rc = 1.0 / (2.0 * std::f32::consts::PI * cutoff);
251    let alpha = dt / (rc + dt);
252
253    let mut prev = 0.0f32;
254    for i in (0..samples.len()).step_by(2) {
255        // Process left channel
256        let filtered = prev + alpha * (samples[i] - prev);
257        prev = filtered;
258        samples[i] = filtered;
259
260        // Copy to right channel
261        if i + 1 < samples.len() {
262            samples[i + 1] = filtered;
263        }
264    }
265
266    Ok(())
267}
268
269/// Simple one-pole highpass filter
270fn apply_highpass(samples: &mut [f32], cutoff: f32, sample_rate: u32) -> Result<()> {
271    let dt = 1.0 / sample_rate as f32;
272    let rc = 1.0 / (2.0 * std::f32::consts::PI * cutoff);
273    let alpha = rc / (rc + dt);
274
275    let mut prev_input = 0.0f32;
276    let mut prev_output = 0.0f32;
277
278    for i in (0..samples.len()).step_by(2) {
279        let current = samples[i];
280        let filtered = alpha * (prev_output + current - prev_input);
281
282        prev_input = current;
283        prev_output = filtered;
284
285        samples[i] = filtered;
286        if i + 1 < samples.len() {
287            samples[i + 1] = filtered;
288        }
289    }
290
291    Ok(())
292}
293
294/// Simple bandpass filter (combination of lowpass and highpass)
295fn apply_bandpass(samples: &mut [f32], center: f32, sample_rate: u32) -> Result<()> {
296    // Bandpass = highpass below center, then lowpass above center
297    let bandwidth = center * 0.5; // 50% bandwidth
298
299    apply_highpass(samples, center - bandwidth, sample_rate)?;
300    apply_lowpass(samples, center + bandwidth, sample_rate)?;
301
302    Ok(())
303}
304
305/// Generate stereo audio samples for a chord (multiple notes)
306pub fn generate_chord(
307    midi_notes: &[u8],
308    duration_ms: f32,
309    velocity: f32,
310    params: &SynthParams,
311    sample_rate: u32,
312) -> Result<Vec<f32>> {
313    generate_chord_with_options(
314        midi_notes,
315        duration_ms,
316        velocity,
317        params,
318        sample_rate,
319        0.0,
320        0.0,
321        0.0,
322    )
323}
324
325/// Generate stereo audio samples for a chord with pan, detune and spread options
326pub fn generate_chord_with_options(
327    midi_notes: &[u8],
328    duration_ms: f32,
329    velocity: f32,
330    params: &SynthParams,
331    sample_rate: u32,
332    pan: f32,    // -1.0 (left) to 1.0 (right), 0.0 = center
333    detune: f32, // cents, -100 to 100
334    spread: f32, // stereo spread 0.0-1.0 for chord notes
335) -> Result<Vec<f32>> {
336    if midi_notes.is_empty() {
337        return Ok(Vec::new());
338    }
339
340    let num_notes = midi_notes.len();
341    let spread = spread.clamp(0.0, 1.0);
342
343    // Calculate pan position for each note if spread is enabled
344    let mut result: Option<Vec<f32>> = None;
345
346    for (i, &midi_note) in midi_notes.iter().enumerate() {
347        // Calculate individual pan for each note based on spread
348        let note_pan = if num_notes > 1 && spread > 0.0 {
349            // Distribute notes across stereo field
350            let position = i as f32 / (num_notes - 1) as f32; // 0.0 to 1.0
351            let spread_amount = (position - 0.5) * 2.0 * spread; // -spread to +spread
352            (pan + spread_amount).clamp(-1.0, 1.0)
353        } else {
354            pan
355        };
356
357        // Generate note with individual pan
358        let note_samples = generate_note_with_options(
359            midi_note,
360            duration_ms,
361            velocity,
362            params,
363            sample_rate,
364            note_pan,
365            detune,
366        )?;
367
368        // Mix notes together
369        match result {
370            None => {
371                result = Some(note_samples);
372            }
373            Some(ref mut buffer) => {
374                // Mix by averaging (to avoid clipping)
375                for (j, sample) in note_samples.iter().enumerate() {
376                    if j < buffer.len() {
377                        buffer[j] = (buffer[j] + sample) / 2.0;
378                    }
379                }
380            }
381        }
382    }
383
384    Ok(result.unwrap_or_default())
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_generate_note() {
393        let params = SynthParams::default();
394        let samples = generate_note(60, 500.0, 0.8, &params, 44100).unwrap();
395
396        // Should have samples (stereo)
397        assert!(samples.len() > 0);
398        assert_eq!(samples.len() % 2, 0); // Must be even (stereo)
399
400        // Check that some samples are non-zero
401        let has_audio = samples.iter().any(|&s| s.abs() > 0.001);
402        assert!(has_audio);
403    }
404
405    #[test]
406    fn test_generate_chord() {
407        let params = SynthParams::default();
408        let samples = generate_chord(&[60, 64, 67], 500.0, 0.8, &params, 44100).unwrap();
409
410        // Should have samples
411        assert!(samples.len() > 0);
412
413        // Check that some samples are non-zero
414        let has_audio = samples.iter().any(|&s| s.abs() > 0.001);
415        assert!(has_audio);
416    }
417}
418
419/// Generate audio using a WASM plugin
420#[cfg(feature = "cli")]
421fn generate_note_with_plugin(
422    midi_note: u8,
423    duration_ms: f32,
424    velocity: f32,
425    params: &SynthParams,
426    sample_rate: u32,
427    pan: f32,
428    detune: f32,
429    plugin_author: &str,
430    plugin_name: &str,
431    plugin_export: Option<&str>,
432) -> Result<Vec<f32>> {
433    use once_cell::sync::Lazy;
434    use std::sync::Mutex;
435
436    // Global plugin runner (cached)
437    static PLUGIN_RUNNER: Lazy<Mutex<WasmPluginRunner>> =
438        Lazy::new(|| Mutex::new(WasmPluginRunner::new()));
439
440    // Global plugin cache
441    static PLUGIN_CACHE: Lazy<Mutex<HashMap<String, Vec<u8>>>> =
442        Lazy::new(|| Mutex::new(HashMap::new()));
443
444    // Get or load plugin
445    let plugin_key = format!("{}.{}", plugin_author, plugin_name);
446    let mut cache = PLUGIN_CACHE
447        .lock()
448        .map_err(|_| anyhow::anyhow!("Plugin cache lock poisoned"))?;
449
450    let wasm_bytes = if let Some(bytes) = cache.get(&plugin_key) {
451        // Using cached plugin
452        bytes.clone()
453    } else {
454        // Load plugin
455        let (_info, bytes) = load_plugin(plugin_author, plugin_name)
456            .map_err(|e| anyhow::anyhow!("Failed to load plugin: {}", e))?;
457
458        cache.insert(plugin_key.clone(), bytes.clone());
459        bytes
460    };
461    drop(cache);
462
463    // Calculate buffer size
464    let base_frequency = midi_to_frequency(midi_note);
465    let frequency = if detune.abs() > 0.01 {
466        base_frequency * 2.0_f32.powf(detune / 1200.0)
467    } else {
468        base_frequency
469    };
470
471    let duration_seconds = duration_ms / 1000.0;
472    let total_samples = (duration_seconds * sample_rate as f32) as usize;
473    let mut buffer = vec![0.0f32; total_samples * 2]; // stereo
474
475    // Prepare options for plugin (merge waveform + custom options)
476    let mut plugin_options = params.options.clone();
477
478    // Remove audio parameters and internal flags (these are NOT plugin parameters)
479    let audio_params = ["pan", "gain", "volume", "detune", "spread", "decay_mode"];
480    for param in &audio_params {
481        plugin_options.remove(*param);
482    }
483
484    // Add waveform if not already in options (only for non-plugin synths or if explicitly set)
485    // For plugins, waveform should come from options if needed, not be auto-added
486    if !plugin_options.contains_key("waveform") && params.waveform != "plugin" {
487        // Try to convert waveform string to numeric value if possible
488        let waveform_value = match params.waveform.as_str() {
489            "sine" => 0.0,
490            "saw" => 1.0,
491            "square" => 2.0,
492            "triangle" => 3.0,
493            _ => 0.0, // default to sine
494        };
495        plugin_options.insert("waveform".to_string(), waveform_value);
496    }
497
498    // Call plugin
499    let runner = PLUGIN_RUNNER
500        .lock()
501        .map_err(|_| anyhow::anyhow!("Plugin runner lock poisoned"))?;
502    let synth_id = format!("{}_{}", plugin_key, plugin_export.unwrap_or("default"));
503
504    runner
505        .render_note_in_place(
506            &wasm_bytes,
507            &mut buffer,
508            Some(&synth_id), // Reuse cached instance with proper reset between notes
509            plugin_export,
510            frequency,
511            velocity,
512            duration_ms as i32,
513            sample_rate as i32,
514            2, // stereo
515            Some(&plugin_options),
516        )
517        .map_err(|e| anyhow::anyhow!("Plugin render error: {}", e))?;
518
519    // Apply panning if needed
520    if pan.abs() > 0.01 {
521        let pan = pan.clamp(-1.0, 1.0);
522        let pan_angle = (pan + 1.0) * 0.25 * std::f32::consts::PI;
523        let left_gain = pan_angle.cos();
524        let right_gain = pan_angle.sin();
525
526        for i in (0..buffer.len()).step_by(2) {
527            if i + 1 < buffer.len() {
528                buffer[i] *= left_gain;
529                buffer[i + 1] *= right_gain;
530            }
531        }
532    }
533
534    // Apply filters if any
535    for filter in &params.filters {
536        apply_filter(&mut buffer, filter, sample_rate)?;
537    }
538
539    Ok(buffer)
540}