Skip to main content

mlua_pulse/
synthesis.rs

1//! Lua-facing synthesis algorithm options and conversion helpers.
2
3use crate::error::{PulseError, PulseResult};
4use std::collections::BTreeMap;
5use tunes::composition::TrackBuilder;
6use tunes::synthesis::fm_synthesis::FMParams;
7use tunes::synthesis::granular::GranularParams;
8use tunes::synthesis::karplus_strong::KarplusStrong;
9use tunes::synthesis::sample::Sample;
10
11/// One parsed synthesis option value.
12#[derive(Debug, Clone, PartialEq)]
13pub enum SynthOption {
14    /// Floating point synth option.
15    Number(f32),
16    /// Text synth option such as a sample path or preset selector.
17    Text(String),
18    /// Integer synth option, currently used by deterministic seeds.
19    Integer(i64),
20    /// List of floating point values, primarily additive harmonics.
21    Numbers(Vec<f32>),
22}
23
24/// Parsed synthesis options supplied by Lua or Rust callers.
25#[derive(Debug, Clone, Default, PartialEq)]
26pub enum SynthOptions {
27    /// Use the algorithm's default settings.
28    #[default]
29    Default,
30    /// Use a named preset for the selected algorithm.
31    Preset(String),
32    /// Use named parameter values.
33    Params(BTreeMap<String, SynthOption>),
34    /// Use a direct harmonic-amplitude list for additive synthesis.
35    Harmonics(Vec<f32>),
36}
37
38/// Metadata describing one Lua-visible synthesis algorithm.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct SynthInfo {
41    /// Canonical synth name.
42    pub name: &'static str,
43    /// Broad synthesis family.
44    pub category: &'static str,
45    /// Supported parameter names.
46    pub parameters: &'static [&'static str],
47    /// Supported preset names.
48    pub presets: &'static [&'static str],
49    /// Names accepted by `sequence:synth(...)`.
50    pub aliases: &'static [&'static str],
51}
52
53/// One synthesis algorithm selected for a sequence.
54#[derive(Debug, Clone, PartialEq)]
55pub enum PulseSynth {
56    /// Frequency modulation synthesis using `tunes::FMParams`.
57    Fm(FMParams),
58    /// Additive synthesis using harmonic amplitudes.
59    Additive(PulseAdditiveSynth),
60    /// Built-in `tunes` wavetable synthesis.
61    Wavetable,
62    /// Karplus-Strong physical modeling rendered as sample events.
63    KarplusStrong(PulseKarplusStrong),
64    /// Granular sample synthesis rendered as sample events.
65    Granular(PulseGranularSynth),
66}
67
68/// Additive synthesis parameters.
69#[derive(Debug, Clone, PartialEq)]
70pub struct PulseAdditiveSynth {
71    /// Harmonic amplitude multipliers, starting from the fundamental.
72    pub harmonics: Vec<f32>,
73}
74
75/// Karplus-Strong plucked-string parameters.
76#[derive(Debug, Clone, PartialEq)]
77pub struct PulseKarplusStrong {
78    /// Feedback decay amount in `0..=1`.
79    pub decay: f32,
80    /// Noise/filter brightness in `0..=1`.
81    pub brightness: f32,
82    /// Optional deterministic seed for repeatable pluck noise.
83    pub seed: Option<u64>,
84}
85
86/// Granular synthesis parameters backed by a source audio file.
87#[derive(Debug, Clone)]
88pub struct PulseGranularSynth {
89    /// Source audio path.
90    pub source: String,
91    /// `tunes` granular parameter set.
92    pub params: GranularParams,
93    /// Output duration in seconds.
94    pub duration: f32,
95}
96
97impl PartialEq for PulseGranularSynth {
98    fn eq(&self, other: &Self) -> bool {
99        self.source == other.source
100            && self.duration == other.duration
101            && self.params.grain_size_ms == other.params.grain_size_ms
102            && self.params.density == other.params.density
103            && self.params.position == other.params.position
104            && self.params.position_spread == other.params.position_spread
105            && self.params.pitch_variation == other.params.pitch_variation
106    }
107}
108
109/// Builds a synth algorithm from a Lua/Rust name and parsed options.
110///
111/// # Errors
112///
113/// Returns an error when the synth name, preset, or option values are invalid.
114pub fn synth_from_options(name: &str, options: SynthOptions) -> PulseResult<PulseSynth> {
115    match normalize_name(name).as_str() {
116        "fm" => build_fm(options).map(PulseSynth::Fm),
117        "additive" => build_additive(options).map(PulseSynth::Additive),
118        "wavetable" => build_wavetable(options),
119        "karplusstrong" | "karplus" | "pluck" => {
120            build_karplus_strong(options).map(PulseSynth::KarplusStrong)
121        }
122        "granular" | "grain" | "grains" => build_granular(options).map(PulseSynth::Granular),
123        _ => Err(PulseError::InvalidSynth {
124            name: name.to_string(),
125        }),
126    }
127}
128
129/// Returns metadata for all Lua-visible synth algorithms.
130#[must_use]
131pub fn synth_infos() -> &'static [SynthInfo] {
132    static INFOS: &[SynthInfo] = &[
133        SynthInfo {
134            name: "fm",
135            category: "frequency_modulation",
136            parameters: &[
137                "mod_ratio",
138                "mod_index",
139                "index_envelope_attack",
140                "index_envelope_decay",
141                "index_envelope_sustain",
142                "index_envelope_release",
143                "index_env_amount",
144            ],
145            presets: &[
146                "electric_piano",
147                "bell",
148                "brass",
149                "bass",
150                "metallic_pad",
151                "growl",
152                "none",
153            ],
154            aliases: &["fm"],
155        },
156        SynthInfo {
157            name: "additive",
158            category: "additive",
159            parameters: &["harmonics"],
160            presets: &["saw", "sawtooth", "organ", "bell", "pad"],
161            aliases: &["additive"],
162        },
163        SynthInfo {
164            name: "wavetable",
165            category: "wavetable",
166            parameters: &[],
167            presets: &["rich"],
168            aliases: &["wavetable"],
169        },
170        SynthInfo {
171            name: "karplus_strong",
172            category: "physical_modeling",
173            parameters: &["decay", "brightness", "seed"],
174            presets: &["guitar", "harp", "bass", "muted"],
175            aliases: &["karplus_strong", "karplus", "pluck"],
176        },
177        SynthInfo {
178            name: "granular",
179            category: "sample_granular",
180            parameters: &[
181                "source",
182                "duration",
183                "preset",
184                "grain_size_ms",
185                "density",
186                "position",
187                "position_spread",
188                "pitch_variation",
189            ],
190            presets: &[
191                "default",
192                "balanced",
193                "texture",
194                "time_stretch",
195                "stretch",
196                "freeze",
197                "frozen",
198                "glitch",
199                "stutter",
200                "cloud",
201                "swarm",
202            ],
203            aliases: &["granular", "grain", "grains"],
204        },
205    ];
206
207    INFOS
208}
209
210/// Returns canonical synth names in stable API order.
211#[must_use]
212pub fn synth_names() -> Vec<&'static str> {
213    synth_infos().iter().map(|info| info.name).collect()
214}
215
216/// Looks up synth metadata by canonical name or alias.
217///
218/// # Errors
219///
220/// Returns `PulseError::InvalidSynth` when `name` is unknown.
221pub fn synth_info(name: &str) -> PulseResult<&'static SynthInfo> {
222    let normalized = normalize_name(name);
223    synth_infos()
224        .iter()
225        .find(|info| {
226            normalize_name(info.name) == normalized
227                || info
228                    .aliases
229                    .iter()
230                    .any(|alias| normalize_name(alias) == normalized)
231        })
232        .ok_or_else(|| PulseError::InvalidSynth {
233            name: name.to_string(),
234        })
235}
236
237fn build_fm(options: SynthOptions) -> PulseResult<FMParams> {
238    match options {
239        SynthOptions::Default => Ok(FMParams::electric_piano()),
240        SynthOptions::Preset(preset) => fm_preset(&preset),
241        SynthOptions::Params(params) => {
242            let mut mod_ratio = 1.0;
243            let mut mod_index = 2.5;
244            let mut attack = 0.01;
245            let mut decay = 0.1;
246            let mut sustain = 0.7;
247            let mut release = 0.2;
248            let mut amount = 0.0;
249            let mut use_envelope = false;
250
251            for (key, value) in params {
252                match key.as_str() {
253                    "mod_ratio" => {
254                        mod_ratio = bounded_number("fm", "mod_ratio", &value, 0.01, f32::MAX)?;
255                    }
256                    "mod_index" => {
257                        mod_index = bounded_number("fm", "mod_index", &value, 0.0, f32::MAX)?;
258                    }
259                    "index_envelope_attack" => {
260                        attack =
261                            bounded_number("fm", "index_envelope_attack", &value, 0.001, 10.0)?;
262                        use_envelope = true;
263                    }
264                    "index_envelope_decay" => {
265                        decay = bounded_number("fm", "index_envelope_decay", &value, 0.001, 10.0)?;
266                        use_envelope = true;
267                    }
268                    "index_envelope_sustain" => {
269                        sustain = bounded_number("fm", "index_envelope_sustain", &value, 0.0, 1.0)?;
270                        use_envelope = true;
271                    }
272                    "index_envelope_release" => {
273                        release =
274                            bounded_number("fm", "index_envelope_release", &value, 0.001, 10.0)?;
275                        use_envelope = true;
276                    }
277                    "index_env_amount" => {
278                        amount = bounded_number("fm", "index_env_amount", &value, 0.0, 1.0)?;
279                        use_envelope = true;
280                    }
281                    _ => return Err(option_key_error("fm", &key)),
282                }
283            }
284
285            if use_envelope {
286                Ok(FMParams::with_index_envelope(
287                    mod_ratio, mod_index, attack, decay, sustain, release, amount,
288                ))
289            } else {
290                Ok(FMParams::new(mod_ratio, mod_index))
291            }
292        }
293        SynthOptions::Harmonics(_) => Err(option_value_error("fm", "options", "harmonics")),
294    }
295}
296
297fn fm_preset(preset: &str) -> PulseResult<FMParams> {
298    match normalize_name(preset).as_str() {
299        "electricpiano" => Ok(FMParams::electric_piano()),
300        "bell" => Ok(FMParams::bell()),
301        "brass" => Ok(FMParams::brass()),
302        "bass" => Ok(FMParams::bass()),
303        "metallicpad" => Ok(FMParams::metallic_pad()),
304        "growl" => Ok(FMParams::growl()),
305        "none" => Ok(FMParams::none()),
306        _ => Err(PulseError::InvalidSynthPreset {
307            synth: "fm".to_string(),
308            preset: preset.to_string(),
309        }),
310    }
311}
312
313fn build_additive(options: SynthOptions) -> PulseResult<PulseAdditiveSynth> {
314    let harmonics = match options {
315        SynthOptions::Default => vec![1.0, 0.5, 0.33, 0.25, 0.2],
316        SynthOptions::Preset(preset) => additive_preset(&preset)?,
317        SynthOptions::Harmonics(harmonics) => harmonics,
318        SynthOptions::Params(params) => additive_harmonics_from_params(params)?,
319    };
320
321    validate_harmonics(&harmonics)?;
322    Ok(PulseAdditiveSynth { harmonics })
323}
324
325fn additive_preset(preset: &str) -> PulseResult<Vec<f32>> {
326    match normalize_name(preset).as_str() {
327        "saw" | "sawtooth" => Ok(vec![1.0, 0.5, 0.33, 0.25, 0.2, 0.166]),
328        "organ" => Ok(vec![1.0, 0.0, 0.5, 0.0, 0.3, 0.0, 0.2]),
329        "bell" => Ok(vec![1.0, 0.65, 0.0, 0.35, 0.0, 0.2]),
330        "pad" => Ok(vec![1.0, 0.35, 0.25, 0.18, 0.12]),
331        _ => Err(PulseError::InvalidSynthPreset {
332            synth: "additive".to_string(),
333            preset: preset.to_string(),
334        }),
335    }
336}
337
338fn additive_harmonics_from_params(params: BTreeMap<String, SynthOption>) -> PulseResult<Vec<f32>> {
339    if params.is_empty() {
340        return Err(option_value_error("additive", "harmonics", "empty"));
341    }
342
343    let mut harmonics = None;
344    for (key, value) in params {
345        match key.as_str() {
346            "harmonics" => {
347                let SynthOption::Numbers(values) = value else {
348                    return Err(option_value_error(
349                        "additive",
350                        "harmonics",
351                        synth_option_label(&value),
352                    ));
353                };
354                harmonics = Some(values);
355            }
356            _ => return Err(option_key_error("additive", &key)),
357        }
358    }
359
360    Ok(harmonics.unwrap_or_default())
361}
362
363fn build_wavetable(options: SynthOptions) -> PulseResult<PulseSynth> {
364    match options {
365        SynthOptions::Default => Ok(PulseSynth::Wavetable),
366        SynthOptions::Preset(preset) if normalize_name(&preset) == "rich" => {
367            Ok(PulseSynth::Wavetable)
368        }
369        SynthOptions::Preset(preset) => Err(PulseError::InvalidSynthPreset {
370            synth: "wavetable".to_string(),
371            preset,
372        }),
373        SynthOptions::Params(params) if params.is_empty() => Ok(PulseSynth::Wavetable),
374        SynthOptions::Params(_) | SynthOptions::Harmonics(_) => {
375            Err(option_value_error("wavetable", "options", "unsupported"))
376        }
377    }
378}
379
380fn build_karplus_strong(options: SynthOptions) -> PulseResult<PulseKarplusStrong> {
381    let mut synth = PulseKarplusStrong::default();
382    match options {
383        SynthOptions::Default => {}
384        SynthOptions::Preset(preset) => synth = karplus_preset(&preset)?,
385        SynthOptions::Params(params) => {
386            for (key, value) in params {
387                match key.as_str() {
388                    "decay" => {
389                        synth.decay = bounded_number("karplus_strong", "decay", &value, 0.0, 1.0)?;
390                    }
391                    "brightness" => {
392                        synth.brightness =
393                            bounded_number("karplus_strong", "brightness", &value, 0.0, 1.0)?;
394                    }
395                    "seed" => {
396                        synth.seed =
397                            Some(unsigned_integer_option("karplus_strong", "seed", &value)?);
398                    }
399                    _ => return Err(option_key_error("karplus_strong", &key)),
400                }
401            }
402        }
403        SynthOptions::Harmonics(_) => {
404            return Err(option_value_error("karplus_strong", "options", "harmonics"));
405        }
406    }
407
408    Ok(synth)
409}
410
411fn karplus_preset(preset: &str) -> PulseResult<PulseKarplusStrong> {
412    match normalize_name(preset).as_str() {
413        "guitar" => Ok(PulseKarplusStrong {
414            decay: 0.996,
415            brightness: 0.7,
416            seed: None,
417        }),
418        "harp" => Ok(PulseKarplusStrong {
419            decay: 0.998,
420            brightness: 0.85,
421            seed: None,
422        }),
423        "bass" => Ok(PulseKarplusStrong {
424            decay: 0.998,
425            brightness: 0.35,
426            seed: None,
427        }),
428        "muted" => Ok(PulseKarplusStrong {
429            decay: 0.99,
430            brightness: 0.45,
431            seed: None,
432        }),
433        _ => Err(PulseError::InvalidSynthPreset {
434            synth: "karplus_strong".to_string(),
435            preset: preset.to_string(),
436        }),
437    }
438}
439
440fn build_granular(options: SynthOptions) -> PulseResult<PulseGranularSynth> {
441    let SynthOptions::Params(params) = options else {
442        return Err(option_value_error("granular", "options", "params"));
443    };
444
445    let mut source = None;
446    let mut duration = None;
447    let mut granular = GranularParams::default();
448
449    for (key, value) in &params {
450        match key.as_str() {
451            "source" | "path" | "sample" => {
452                let SynthOption::Text(value) = value else {
453                    return Err(option_value_error(
454                        "granular",
455                        "source",
456                        synth_option_label(value),
457                    ));
458                };
459                source = Some(value.clone());
460            }
461            "duration" => {
462                duration = Some(bounded_number(
463                    "granular",
464                    "duration",
465                    value,
466                    0.001,
467                    f32::MAX,
468                )?);
469            }
470            "preset" => {
471                let SynthOption::Text(preset) = value else {
472                    return Err(option_value_error(
473                        "granular",
474                        "preset",
475                        synth_option_label(value),
476                    ));
477                };
478                granular = granular_preset(preset)?;
479            }
480            "grain_size_ms" | "density" | "position" | "position_spread" | "pitch_variation" => {}
481            _ => return Err(option_key_error("granular", key)),
482        }
483    }
484
485    for (key, value) in params {
486        match key.as_str() {
487            "source" | "path" | "sample" | "duration" | "preset" => {}
488            "grain_size_ms" => {
489                granular.grain_size_ms =
490                    bounded_number("granular", "grain_size_ms", &value, 5.0, 500.0)?;
491            }
492            "density" => {
493                granular.density = bounded_number("granular", "density", &value, 0.0, 1.0)?;
494            }
495            "position" => {
496                granular.position = bounded_number("granular", "position", &value, 0.0, 1.0)?;
497            }
498            "position_spread" => {
499                granular.position_spread =
500                    bounded_number("granular", "position_spread", &value, 0.0, 1.0)?;
501            }
502            "pitch_variation" => {
503                granular.pitch_variation =
504                    bounded_number("granular", "pitch_variation", &value, 0.0, 1.0)?;
505            }
506            _ => unreachable!("granular option keys were validated before applying values"),
507        }
508    }
509
510    let source = source.ok_or_else(|| option_value_error("granular", "source", "missing"))?;
511    if source.trim().is_empty() {
512        return Err(option_value_error("granular", "source", "empty"));
513    }
514    let duration = duration.ok_or_else(|| option_value_error("granular", "duration", "missing"))?;
515
516    Ok(PulseGranularSynth {
517        source,
518        params: granular,
519        duration,
520    })
521}
522
523fn granular_preset(preset: &str) -> PulseResult<GranularParams> {
524    match normalize_name(preset).as_str() {
525        "default" | "balanced" => Ok(GranularParams::default()),
526        "texture" => Ok(GranularParams::texture()),
527        "timestretch" | "stretch" => Ok(GranularParams::time_stretch()),
528        "freeze" | "frozen" => Ok(GranularParams::freeze()),
529        "glitch" | "stutter" => Ok(GranularParams::glitch()),
530        "cloud" | "swarm" => Ok(GranularParams::cloud()),
531        _ => Err(PulseError::InvalidSynthPreset {
532            synth: "granular".to_string(),
533            preset: preset.to_string(),
534        }),
535    }
536}
537
538fn validate_harmonics(harmonics: &[f32]) -> PulseResult<()> {
539    if harmonics.is_empty() {
540        return Err(option_value_error("additive", "harmonics", "empty"));
541    }
542
543    let mut has_audible_harmonic = false;
544    for &harmonic in harmonics {
545        if !harmonic.is_finite() || harmonic < 0.0 {
546            return Err(option_value_error("additive", "harmonics", harmonic));
547        }
548        has_audible_harmonic |= harmonic > 0.0;
549    }
550
551    if !has_audible_harmonic {
552        return Err(option_value_error("additive", "harmonics", "silent"));
553    }
554
555    Ok(())
556}
557
558fn normalize_name(value: &str) -> String {
559    value
560        .trim()
561        .to_ascii_lowercase()
562        .replace(['_', '-', ' '], "")
563}
564
565fn number_option(synth: &str, option: &str, value: &SynthOption) -> PulseResult<f32> {
566    match value {
567        SynthOption::Number(value) if value.is_finite() => Ok(*value),
568        SynthOption::Integer(value) => Ok(*value as f32),
569        SynthOption::Number(value) => Err(option_value_error(synth, option, value)),
570        SynthOption::Text(value) => Err(option_value_error(synth, option, value)),
571        SynthOption::Numbers(_) => Err(option_value_error(synth, option, "list")),
572    }
573}
574
575fn unsigned_integer_option(synth: &str, option: &str, value: &SynthOption) -> PulseResult<u64> {
576    match value {
577        SynthOption::Integer(value) if *value >= 0 => Ok(*value as u64),
578        SynthOption::Number(value)
579            if value.is_finite() && *value >= 0.0 && value.fract() == 0.0 =>
580        {
581            Ok(*value as u64)
582        }
583        SynthOption::Integer(value) => Err(option_value_error(synth, option, value)),
584        SynthOption::Number(value) => Err(option_value_error(synth, option, value)),
585        SynthOption::Text(value) => Err(option_value_error(synth, option, value)),
586        SynthOption::Numbers(_) => Err(option_value_error(synth, option, "list")),
587    }
588}
589
590fn bounded_number(
591    synth: &str,
592    option: &str,
593    value: &SynthOption,
594    min: f32,
595    max: f32,
596) -> PulseResult<f32> {
597    let value = number_option(synth, option, value)?;
598    if (min..=max).contains(&value) {
599        Ok(value)
600    } else {
601        Err(option_value_error(synth, option, value))
602    }
603}
604
605fn option_key_error(synth: &str, option: &str) -> PulseError {
606    PulseError::InvalidSynthOption {
607        synth: synth.to_string(),
608        option: option.to_string(),
609        value: "unsupported".to_string(),
610    }
611}
612
613fn option_value_error(synth: &str, option: &str, value: impl ToString) -> PulseError {
614    PulseError::InvalidSynthOption {
615        synth: synth.to_string(),
616        option: option.to_string(),
617        value: value.to_string(),
618    }
619}
620
621fn synth_option_label(value: &SynthOption) -> &'static str {
622    match value {
623        SynthOption::Number(_) => "number",
624        SynthOption::Text(_) => "text",
625        SynthOption::Integer(_) => "integer",
626        SynthOption::Numbers(_) => "list",
627    }
628}
629
630impl PulseSynth {
631    /// Returns the canonical synth name.
632    #[must_use]
633    pub fn name(&self) -> &'static str {
634        match self {
635            Self::Fm(_) => "fm",
636            Self::Additive(_) => "additive",
637            Self::Wavetable => "wavetable",
638            Self::KarplusStrong(_) => "karplus_strong",
639            Self::Granular(_) => "granular",
640        }
641    }
642
643    /// Applies note-event synth parameters to a `tunes` track builder.
644    ///
645    /// Sample-rendered synths such as Karplus-Strong and granular are inserted
646    /// elsewhere as sample events, so this method intentionally leaves the
647    /// builder unchanged for those variants.
648    #[must_use]
649    pub fn apply_to_track_builder<'a>(&self, builder: TrackBuilder<'a>) -> TrackBuilder<'a> {
650        match self {
651            Self::Fm(params) => builder.fm(*params),
652            Self::Additive(value) => builder.additive_synth(&value.harmonics),
653            Self::Wavetable => builder.wavetable(),
654            Self::KarplusStrong(_) => builder,
655            Self::Granular(_) => builder,
656        }
657    }
658}
659
660impl Default for PulseKarplusStrong {
661    fn default() -> Self {
662        Self {
663            decay: 0.996,
664            brightness: 0.5,
665            seed: None,
666        }
667    }
668}
669
670impl PulseKarplusStrong {
671    /// Renders a plucked string sample for one frequency and duration.
672    ///
673    /// # Errors
674    ///
675    /// Returns an error when frequency, duration, or sample rate is invalid.
676    pub fn to_sample(
677        &self,
678        frequency: f32,
679        duration: f32,
680        sample_rate: u32,
681    ) -> PulseResult<Sample> {
682        if !frequency.is_finite() || frequency <= 0.0 {
683            return Err(PulseError::InvalidFrequency { frequency });
684        }
685        if !duration.is_finite() || duration <= 0.0 {
686            return Err(PulseError::InvalidDuration { duration });
687        }
688        if sample_rate == 0 {
689            return Err(option_value_error(
690                "karplus_strong",
691                "sample_rate",
692                sample_rate,
693            ));
694        }
695
696        // The rendered buffer is a `Vec<f32>`, so cap the frame count at the
697        // largest allocation that could ever succeed. Computing in f64 also
698        // surfaces overflow to `inf` for enormous durations before the cast to
699        // `usize` would saturate and trigger a capacity-overflow panic.
700        let frames = f64::from(duration) * f64::from(sample_rate);
701        let max_frames = isize::MAX as usize / std::mem::size_of::<f32>();
702        if !frames.is_finite() || frames > max_frames as f64 {
703            return Err(PulseError::InvalidDuration { duration });
704        }
705        let sample_count = frames.round().max(1.0) as usize;
706        let mut synth = if let Some(seed) = self.seed {
707            KarplusStrong::with_seed(frequency, sample_rate as f32, seed)
708        } else {
709            KarplusStrong::new(frequency, sample_rate as f32)
710        }
711        .with_decay(self.decay)
712        .with_brightness(self.brightness);
713
714        Ok(Sample::from_mono(synth.generate(sample_count), sample_rate))
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    fn params(entries: &[(&str, SynthOption)]) -> SynthOptions {
723        let mut values = BTreeMap::new();
724        for (key, value) in entries {
725            values.insert((*key).to_string(), value.clone());
726        }
727        SynthOptions::Params(values)
728    }
729
730    #[test]
731    fn fm_presets_and_params_convert_to_tunes() {
732        let bell = synth_from_options("fm", SynthOptions::Preset("bell".to_string()))
733            .expect("bell should parse");
734        let PulseSynth::Fm(bell) = bell else {
735            panic!("expected fm");
736        };
737        assert_eq!(bell.mod_index, FMParams::bell().mod_index);
738
739        let custom = synth_from_options(
740            "fm",
741            params(&[
742                ("mod_ratio", SynthOption::Number(2.0)),
743                ("mod_index", SynthOption::Number(3.0)),
744            ]),
745        )
746        .expect("custom fm should parse");
747        let PulseSynth::Fm(custom) = custom else {
748            panic!("expected fm");
749        };
750        assert_eq!(custom.mod_ratio, 2.0);
751        assert_eq!(custom.mod_index, 3.0);
752    }
753
754    #[test]
755    fn additive_validates_harmonics() {
756        let additive = synth_from_options("additive", SynthOptions::Harmonics(vec![1.0, 0.5]))
757            .expect("harmonics should parse");
758
759        let PulseSynth::Additive(additive) = additive else {
760            panic!("expected additive");
761        };
762        assert_eq!(additive.harmonics, vec![1.0, 0.5]);
763
764        let invalid = synth_from_options("additive", SynthOptions::Harmonics(Vec::new()))
765            .expect_err("empty harmonics should fail");
766        assert_eq!(
767            invalid.to_string(),
768            "invalid synth option additive.harmonics: empty"
769        );
770    }
771
772    #[test]
773    fn invalid_synth_errors_are_stable() {
774        let invalid = synth_from_options("subtractive", SynthOptions::Default)
775            .expect_err("unknown synth should fail");
776        assert_eq!(invalid.to_string(), "invalid synth: subtractive");
777
778        let invalid_preset = synth_from_options("fm", SynthOptions::Preset("glass".to_string()))
779            .expect_err("unknown fm preset should fail");
780        assert_eq!(invalid_preset.to_string(), "invalid synth preset fm: glass");
781    }
782
783    #[test]
784    fn synth_catalog_describes_supported_algorithms() {
785        let names = synth_names();
786        assert_eq!(
787            names,
788            vec!["fm", "additive", "wavetable", "karplus_strong", "granular"]
789        );
790
791        let granular = synth_info("grain").expect("granular alias should resolve");
792        assert_eq!(granular.name, "granular");
793        assert_eq!(granular.category, "sample_granular");
794        assert_eq!(granular.parameters[0], "source");
795        assert_eq!(granular.parameters[1], "duration");
796
797        let invalid = synth_info("subtractive").expect_err("unknown synth should fail");
798        assert_eq!(invalid.to_string(), "invalid synth: subtractive");
799    }
800
801    #[test]
802    fn granular_params_override_preset_defaults() {
803        let granular = synth_from_options(
804            "granular",
805            params(&[
806                ("source", SynthOption::Text("source.wav".to_string())),
807                ("duration", SynthOption::Number(0.75)),
808                ("preset", SynthOption::Text("texture".to_string())),
809                ("grain_size_ms", SynthOption::Number(25.0)),
810            ]),
811        )
812        .expect("granular should parse");
813
814        let PulseSynth::Granular(granular) = granular else {
815            panic!("expected granular");
816        };
817
818        assert_eq!(granular.params.grain_size_ms, 25.0);
819        assert_eq!(granular.duration, 0.75);
820    }
821
822    #[test]
823    fn karplus_strong_rejects_invalid_render_durations() {
824        let synth = PulseKarplusStrong::default();
825
826        for duration in [0.0, -0.25, f32::NAN] {
827            let error = synth
828                .to_sample(440.0, duration, 44_100)
829                .expect_err("invalid duration should not render a sample");
830
831            assert_eq!(error.to_string(), format!("invalid duration: {duration}"));
832        }
833    }
834
835    #[test]
836    fn karplus_strong_rejects_unrenderable_render_durations() {
837        let synth = PulseKarplusStrong::default();
838
839        // A finite duration whose frame count exceeds the maximum renderable
840        // buffer must be rejected before allocation.
841        for duration in [1.0e14_f32, f32::MAX] {
842            let error = synth
843                .to_sample(440.0, duration, 44_100)
844                .expect_err("oversized duration should not allocate a sample buffer");
845
846            assert_eq!(error.to_string(), format!("invalid duration: {duration}"));
847        }
848    }
849}