1use 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#[derive(Debug, Clone, PartialEq)]
13pub enum SynthOption {
14 Number(f32),
16 Text(String),
18 Integer(i64),
20 Numbers(Vec<f32>),
22}
23
24#[derive(Debug, Clone, Default, PartialEq)]
26pub enum SynthOptions {
27 #[default]
29 Default,
30 Preset(String),
32 Params(BTreeMap<String, SynthOption>),
34 Harmonics(Vec<f32>),
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct SynthInfo {
41 pub name: &'static str,
43 pub category: &'static str,
45 pub parameters: &'static [&'static str],
47 pub presets: &'static [&'static str],
49 pub aliases: &'static [&'static str],
51}
52
53#[derive(Debug, Clone, PartialEq)]
55pub enum PulseSynth {
56 Fm(FMParams),
58 Additive(PulseAdditiveSynth),
60 Wavetable,
62 KarplusStrong(PulseKarplusStrong),
64 Granular(PulseGranularSynth),
66}
67
68#[derive(Debug, Clone, PartialEq)]
70pub struct PulseAdditiveSynth {
71 pub harmonics: Vec<f32>,
73}
74
75#[derive(Debug, Clone, PartialEq)]
77pub struct PulseKarplusStrong {
78 pub decay: f32,
80 pub brightness: f32,
82 pub seed: Option<u64>,
84}
85
86#[derive(Debug, Clone)]
88pub struct PulseGranularSynth {
89 pub source: String,
91 pub params: GranularParams,
93 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
109pub 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#[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#[must_use]
212pub fn synth_names() -> Vec<&'static str> {
213 synth_infos().iter().map(|info| info.name).collect()
214}
215
216pub 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 ¶ms {
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 #[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 #[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 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 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 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}