use crate::error::{PulseError, PulseResult};
use std::collections::BTreeMap;
use tunes::composition::TrackBuilder;
use tunes::synthesis::fm_synthesis::FMParams;
use tunes::synthesis::granular::GranularParams;
use tunes::synthesis::karplus_strong::KarplusStrong;
use tunes::synthesis::sample::Sample;
#[derive(Debug, Clone, PartialEq)]
pub enum SynthOption {
Number(f32),
Text(String),
Integer(i64),
Numbers(Vec<f32>),
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum SynthOptions {
#[default]
Default,
Preset(String),
Params(BTreeMap<String, SynthOption>),
Harmonics(Vec<f32>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SynthInfo {
pub name: &'static str,
pub category: &'static str,
pub parameters: &'static [&'static str],
pub presets: &'static [&'static str],
pub aliases: &'static [&'static str],
}
#[derive(Debug, Clone, PartialEq)]
pub enum PulseSynth {
Fm(FMParams),
Additive(PulseAdditiveSynth),
Wavetable,
KarplusStrong(PulseKarplusStrong),
Granular(PulseGranularSynth),
}
#[derive(Debug, Clone, PartialEq)]
pub struct PulseAdditiveSynth {
pub harmonics: Vec<f32>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PulseKarplusStrong {
pub decay: f32,
pub brightness: f32,
pub seed: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct PulseGranularSynth {
pub source: String,
pub params: GranularParams,
pub duration: f32,
}
impl PartialEq for PulseGranularSynth {
fn eq(&self, other: &Self) -> bool {
self.source == other.source
&& self.duration == other.duration
&& self.params.grain_size_ms == other.params.grain_size_ms
&& self.params.density == other.params.density
&& self.params.position == other.params.position
&& self.params.position_spread == other.params.position_spread
&& self.params.pitch_variation == other.params.pitch_variation
}
}
pub fn synth_from_options(name: &str, options: SynthOptions) -> PulseResult<PulseSynth> {
match normalize_name(name).as_str() {
"fm" => build_fm(options).map(PulseSynth::Fm),
"additive" => build_additive(options).map(PulseSynth::Additive),
"wavetable" => build_wavetable(options),
"karplusstrong" | "karplus" | "pluck" => {
build_karplus_strong(options).map(PulseSynth::KarplusStrong)
}
"granular" | "grain" | "grains" => build_granular(options).map(PulseSynth::Granular),
_ => Err(PulseError::InvalidSynth {
name: name.to_string(),
}),
}
}
#[must_use]
pub fn synth_infos() -> &'static [SynthInfo] {
static INFOS: &[SynthInfo] = &[
SynthInfo {
name: "fm",
category: "frequency_modulation",
parameters: &[
"mod_ratio",
"mod_index",
"index_envelope_attack",
"index_envelope_decay",
"index_envelope_sustain",
"index_envelope_release",
"index_env_amount",
],
presets: &[
"electric_piano",
"bell",
"brass",
"bass",
"metallic_pad",
"growl",
"none",
],
aliases: &["fm"],
},
SynthInfo {
name: "additive",
category: "additive",
parameters: &["harmonics"],
presets: &["saw", "sawtooth", "organ", "bell", "pad"],
aliases: &["additive"],
},
SynthInfo {
name: "wavetable",
category: "wavetable",
parameters: &[],
presets: &["rich"],
aliases: &["wavetable"],
},
SynthInfo {
name: "karplus_strong",
category: "physical_modeling",
parameters: &["decay", "brightness", "seed"],
presets: &["guitar", "harp", "bass", "muted"],
aliases: &["karplus_strong", "karplus", "pluck"],
},
SynthInfo {
name: "granular",
category: "sample_granular",
parameters: &[
"source",
"duration",
"preset",
"grain_size_ms",
"density",
"position",
"position_spread",
"pitch_variation",
],
presets: &[
"default",
"balanced",
"texture",
"time_stretch",
"stretch",
"freeze",
"frozen",
"glitch",
"stutter",
"cloud",
"swarm",
],
aliases: &["granular", "grain", "grains"],
},
];
INFOS
}
#[must_use]
pub fn synth_names() -> Vec<&'static str> {
synth_infos().iter().map(|info| info.name).collect()
}
pub fn synth_info(name: &str) -> PulseResult<&'static SynthInfo> {
let normalized = normalize_name(name);
synth_infos()
.iter()
.find(|info| {
normalize_name(info.name) == normalized
|| info
.aliases
.iter()
.any(|alias| normalize_name(alias) == normalized)
})
.ok_or_else(|| PulseError::InvalidSynth {
name: name.to_string(),
})
}
fn build_fm(options: SynthOptions) -> PulseResult<FMParams> {
match options {
SynthOptions::Default => Ok(FMParams::electric_piano()),
SynthOptions::Preset(preset) => fm_preset(&preset),
SynthOptions::Params(params) => {
let mut mod_ratio = 1.0;
let mut mod_index = 2.5;
let mut attack = 0.01;
let mut decay = 0.1;
let mut sustain = 0.7;
let mut release = 0.2;
let mut amount = 0.0;
let mut use_envelope = false;
for (key, value) in params {
match key.as_str() {
"mod_ratio" => {
mod_ratio = bounded_number("fm", "mod_ratio", &value, 0.01, f32::MAX)?;
}
"mod_index" => {
mod_index = bounded_number("fm", "mod_index", &value, 0.0, f32::MAX)?;
}
"index_envelope_attack" => {
attack =
bounded_number("fm", "index_envelope_attack", &value, 0.001, 10.0)?;
use_envelope = true;
}
"index_envelope_decay" => {
decay = bounded_number("fm", "index_envelope_decay", &value, 0.001, 10.0)?;
use_envelope = true;
}
"index_envelope_sustain" => {
sustain = bounded_number("fm", "index_envelope_sustain", &value, 0.0, 1.0)?;
use_envelope = true;
}
"index_envelope_release" => {
release =
bounded_number("fm", "index_envelope_release", &value, 0.001, 10.0)?;
use_envelope = true;
}
"index_env_amount" => {
amount = bounded_number("fm", "index_env_amount", &value, 0.0, 1.0)?;
use_envelope = true;
}
_ => return Err(option_key_error("fm", &key)),
}
}
if use_envelope {
Ok(FMParams::with_index_envelope(
mod_ratio, mod_index, attack, decay, sustain, release, amount,
))
} else {
Ok(FMParams::new(mod_ratio, mod_index))
}
}
SynthOptions::Harmonics(_) => Err(option_value_error("fm", "options", "harmonics")),
}
}
fn fm_preset(preset: &str) -> PulseResult<FMParams> {
match normalize_name(preset).as_str() {
"electricpiano" => Ok(FMParams::electric_piano()),
"bell" => Ok(FMParams::bell()),
"brass" => Ok(FMParams::brass()),
"bass" => Ok(FMParams::bass()),
"metallicpad" => Ok(FMParams::metallic_pad()),
"growl" => Ok(FMParams::growl()),
"none" => Ok(FMParams::none()),
_ => Err(PulseError::InvalidSynthPreset {
synth: "fm".to_string(),
preset: preset.to_string(),
}),
}
}
fn build_additive(options: SynthOptions) -> PulseResult<PulseAdditiveSynth> {
let harmonics = match options {
SynthOptions::Default => vec![1.0, 0.5, 0.33, 0.25, 0.2],
SynthOptions::Preset(preset) => additive_preset(&preset)?,
SynthOptions::Harmonics(harmonics) => harmonics,
SynthOptions::Params(params) => additive_harmonics_from_params(params)?,
};
validate_harmonics(&harmonics)?;
Ok(PulseAdditiveSynth { harmonics })
}
fn additive_preset(preset: &str) -> PulseResult<Vec<f32>> {
match normalize_name(preset).as_str() {
"saw" | "sawtooth" => Ok(vec![1.0, 0.5, 0.33, 0.25, 0.2, 0.166]),
"organ" => Ok(vec![1.0, 0.0, 0.5, 0.0, 0.3, 0.0, 0.2]),
"bell" => Ok(vec![1.0, 0.65, 0.0, 0.35, 0.0, 0.2]),
"pad" => Ok(vec![1.0, 0.35, 0.25, 0.18, 0.12]),
_ => Err(PulseError::InvalidSynthPreset {
synth: "additive".to_string(),
preset: preset.to_string(),
}),
}
}
fn additive_harmonics_from_params(params: BTreeMap<String, SynthOption>) -> PulseResult<Vec<f32>> {
if params.is_empty() {
return Err(option_value_error("additive", "harmonics", "empty"));
}
let mut harmonics = None;
for (key, value) in params {
match key.as_str() {
"harmonics" => {
let SynthOption::Numbers(values) = value else {
return Err(option_value_error(
"additive",
"harmonics",
synth_option_label(&value),
));
};
harmonics = Some(values);
}
_ => return Err(option_key_error("additive", &key)),
}
}
Ok(harmonics.unwrap_or_default())
}
fn build_wavetable(options: SynthOptions) -> PulseResult<PulseSynth> {
match options {
SynthOptions::Default => Ok(PulseSynth::Wavetable),
SynthOptions::Preset(preset) if normalize_name(&preset) == "rich" => {
Ok(PulseSynth::Wavetable)
}
SynthOptions::Preset(preset) => Err(PulseError::InvalidSynthPreset {
synth: "wavetable".to_string(),
preset,
}),
SynthOptions::Params(params) if params.is_empty() => Ok(PulseSynth::Wavetable),
SynthOptions::Params(_) | SynthOptions::Harmonics(_) => {
Err(option_value_error("wavetable", "options", "unsupported"))
}
}
}
fn build_karplus_strong(options: SynthOptions) -> PulseResult<PulseKarplusStrong> {
let mut synth = PulseKarplusStrong::default();
match options {
SynthOptions::Default => {}
SynthOptions::Preset(preset) => synth = karplus_preset(&preset)?,
SynthOptions::Params(params) => {
for (key, value) in params {
match key.as_str() {
"decay" => {
synth.decay = bounded_number("karplus_strong", "decay", &value, 0.0, 1.0)?;
}
"brightness" => {
synth.brightness =
bounded_number("karplus_strong", "brightness", &value, 0.0, 1.0)?;
}
"seed" => {
synth.seed =
Some(unsigned_integer_option("karplus_strong", "seed", &value)?);
}
_ => return Err(option_key_error("karplus_strong", &key)),
}
}
}
SynthOptions::Harmonics(_) => {
return Err(option_value_error("karplus_strong", "options", "harmonics"));
}
}
Ok(synth)
}
fn karplus_preset(preset: &str) -> PulseResult<PulseKarplusStrong> {
match normalize_name(preset).as_str() {
"guitar" => Ok(PulseKarplusStrong {
decay: 0.996,
brightness: 0.7,
seed: None,
}),
"harp" => Ok(PulseKarplusStrong {
decay: 0.998,
brightness: 0.85,
seed: None,
}),
"bass" => Ok(PulseKarplusStrong {
decay: 0.998,
brightness: 0.35,
seed: None,
}),
"muted" => Ok(PulseKarplusStrong {
decay: 0.99,
brightness: 0.45,
seed: None,
}),
_ => Err(PulseError::InvalidSynthPreset {
synth: "karplus_strong".to_string(),
preset: preset.to_string(),
}),
}
}
fn build_granular(options: SynthOptions) -> PulseResult<PulseGranularSynth> {
let SynthOptions::Params(params) = options else {
return Err(option_value_error("granular", "options", "params"));
};
let mut source = None;
let mut duration = None;
let mut granular = GranularParams::default();
for (key, value) in ¶ms {
match key.as_str() {
"source" | "path" | "sample" => {
let SynthOption::Text(value) = value else {
return Err(option_value_error(
"granular",
"source",
synth_option_label(value),
));
};
source = Some(value.clone());
}
"duration" => {
duration = Some(bounded_number(
"granular",
"duration",
value,
0.001,
f32::MAX,
)?);
}
"preset" => {
let SynthOption::Text(preset) = value else {
return Err(option_value_error(
"granular",
"preset",
synth_option_label(value),
));
};
granular = granular_preset(preset)?;
}
"grain_size_ms" | "density" | "position" | "position_spread" | "pitch_variation" => {}
_ => return Err(option_key_error("granular", key)),
}
}
for (key, value) in params {
match key.as_str() {
"source" | "path" | "sample" | "duration" | "preset" => {}
"grain_size_ms" => {
granular.grain_size_ms =
bounded_number("granular", "grain_size_ms", &value, 5.0, 500.0)?;
}
"density" => {
granular.density = bounded_number("granular", "density", &value, 0.0, 1.0)?;
}
"position" => {
granular.position = bounded_number("granular", "position", &value, 0.0, 1.0)?;
}
"position_spread" => {
granular.position_spread =
bounded_number("granular", "position_spread", &value, 0.0, 1.0)?;
}
"pitch_variation" => {
granular.pitch_variation =
bounded_number("granular", "pitch_variation", &value, 0.0, 1.0)?;
}
_ => unreachable!("granular option keys were validated before applying values"),
}
}
let source = source.ok_or_else(|| option_value_error("granular", "source", "missing"))?;
if source.trim().is_empty() {
return Err(option_value_error("granular", "source", "empty"));
}
let duration = duration.ok_or_else(|| option_value_error("granular", "duration", "missing"))?;
Ok(PulseGranularSynth {
source,
params: granular,
duration,
})
}
fn granular_preset(preset: &str) -> PulseResult<GranularParams> {
match normalize_name(preset).as_str() {
"default" | "balanced" => Ok(GranularParams::default()),
"texture" => Ok(GranularParams::texture()),
"timestretch" | "stretch" => Ok(GranularParams::time_stretch()),
"freeze" | "frozen" => Ok(GranularParams::freeze()),
"glitch" | "stutter" => Ok(GranularParams::glitch()),
"cloud" | "swarm" => Ok(GranularParams::cloud()),
_ => Err(PulseError::InvalidSynthPreset {
synth: "granular".to_string(),
preset: preset.to_string(),
}),
}
}
fn validate_harmonics(harmonics: &[f32]) -> PulseResult<()> {
if harmonics.is_empty() {
return Err(option_value_error("additive", "harmonics", "empty"));
}
let mut has_audible_harmonic = false;
for &harmonic in harmonics {
if !harmonic.is_finite() || harmonic < 0.0 {
return Err(option_value_error("additive", "harmonics", harmonic));
}
has_audible_harmonic |= harmonic > 0.0;
}
if !has_audible_harmonic {
return Err(option_value_error("additive", "harmonics", "silent"));
}
Ok(())
}
fn normalize_name(value: &str) -> String {
value
.trim()
.to_ascii_lowercase()
.replace(['_', '-', ' '], "")
}
fn number_option(synth: &str, option: &str, value: &SynthOption) -> PulseResult<f32> {
match value {
SynthOption::Number(value) if value.is_finite() => Ok(*value),
SynthOption::Integer(value) => Ok(*value as f32),
SynthOption::Number(value) => Err(option_value_error(synth, option, value)),
SynthOption::Text(value) => Err(option_value_error(synth, option, value)),
SynthOption::Numbers(_) => Err(option_value_error(synth, option, "list")),
}
}
fn unsigned_integer_option(synth: &str, option: &str, value: &SynthOption) -> PulseResult<u64> {
match value {
SynthOption::Integer(value) if *value >= 0 => Ok(*value as u64),
SynthOption::Number(value)
if value.is_finite() && *value >= 0.0 && value.fract() == 0.0 =>
{
Ok(*value as u64)
}
SynthOption::Integer(value) => Err(option_value_error(synth, option, value)),
SynthOption::Number(value) => Err(option_value_error(synth, option, value)),
SynthOption::Text(value) => Err(option_value_error(synth, option, value)),
SynthOption::Numbers(_) => Err(option_value_error(synth, option, "list")),
}
}
fn bounded_number(
synth: &str,
option: &str,
value: &SynthOption,
min: f32,
max: f32,
) -> PulseResult<f32> {
let value = number_option(synth, option, value)?;
if (min..=max).contains(&value) {
Ok(value)
} else {
Err(option_value_error(synth, option, value))
}
}
fn option_key_error(synth: &str, option: &str) -> PulseError {
PulseError::InvalidSynthOption {
synth: synth.to_string(),
option: option.to_string(),
value: "unsupported".to_string(),
}
}
fn option_value_error(synth: &str, option: &str, value: impl ToString) -> PulseError {
PulseError::InvalidSynthOption {
synth: synth.to_string(),
option: option.to_string(),
value: value.to_string(),
}
}
fn synth_option_label(value: &SynthOption) -> &'static str {
match value {
SynthOption::Number(_) => "number",
SynthOption::Text(_) => "text",
SynthOption::Integer(_) => "integer",
SynthOption::Numbers(_) => "list",
}
}
impl PulseSynth {
#[must_use]
pub fn name(&self) -> &'static str {
match self {
Self::Fm(_) => "fm",
Self::Additive(_) => "additive",
Self::Wavetable => "wavetable",
Self::KarplusStrong(_) => "karplus_strong",
Self::Granular(_) => "granular",
}
}
#[must_use]
pub fn apply_to_track_builder<'a>(&self, builder: TrackBuilder<'a>) -> TrackBuilder<'a> {
match self {
Self::Fm(params) => builder.fm(*params),
Self::Additive(value) => builder.additive_synth(&value.harmonics),
Self::Wavetable => builder.wavetable(),
Self::KarplusStrong(_) => builder,
Self::Granular(_) => builder,
}
}
}
impl Default for PulseKarplusStrong {
fn default() -> Self {
Self {
decay: 0.996,
brightness: 0.5,
seed: None,
}
}
}
impl PulseKarplusStrong {
pub fn to_sample(
&self,
frequency: f32,
duration: f32,
sample_rate: u32,
) -> PulseResult<Sample> {
if !frequency.is_finite() || frequency <= 0.0 {
return Err(PulseError::InvalidFrequency { frequency });
}
if !duration.is_finite() || duration <= 0.0 {
return Err(PulseError::InvalidDuration { duration });
}
if sample_rate == 0 {
return Err(option_value_error(
"karplus_strong",
"sample_rate",
sample_rate,
));
}
let frames = f64::from(duration) * f64::from(sample_rate);
let max_frames = isize::MAX as usize / std::mem::size_of::<f32>();
if !frames.is_finite() || frames > max_frames as f64 {
return Err(PulseError::InvalidDuration { duration });
}
let sample_count = frames.round().max(1.0) as usize;
let mut synth = if let Some(seed) = self.seed {
KarplusStrong::with_seed(frequency, sample_rate as f32, seed)
} else {
KarplusStrong::new(frequency, sample_rate as f32)
}
.with_decay(self.decay)
.with_brightness(self.brightness);
Ok(Sample::from_mono(synth.generate(sample_count), sample_rate))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn params(entries: &[(&str, SynthOption)]) -> SynthOptions {
let mut values = BTreeMap::new();
for (key, value) in entries {
values.insert((*key).to_string(), value.clone());
}
SynthOptions::Params(values)
}
#[test]
fn fm_presets_and_params_convert_to_tunes() {
let bell = synth_from_options("fm", SynthOptions::Preset("bell".to_string()))
.expect("bell should parse");
let PulseSynth::Fm(bell) = bell else {
panic!("expected fm");
};
assert_eq!(bell.mod_index, FMParams::bell().mod_index);
let custom = synth_from_options(
"fm",
params(&[
("mod_ratio", SynthOption::Number(2.0)),
("mod_index", SynthOption::Number(3.0)),
]),
)
.expect("custom fm should parse");
let PulseSynth::Fm(custom) = custom else {
panic!("expected fm");
};
assert_eq!(custom.mod_ratio, 2.0);
assert_eq!(custom.mod_index, 3.0);
}
#[test]
fn additive_validates_harmonics() {
let additive = synth_from_options("additive", SynthOptions::Harmonics(vec![1.0, 0.5]))
.expect("harmonics should parse");
let PulseSynth::Additive(additive) = additive else {
panic!("expected additive");
};
assert_eq!(additive.harmonics, vec![1.0, 0.5]);
let invalid = synth_from_options("additive", SynthOptions::Harmonics(Vec::new()))
.expect_err("empty harmonics should fail");
assert_eq!(
invalid.to_string(),
"invalid synth option additive.harmonics: empty"
);
}
#[test]
fn invalid_synth_errors_are_stable() {
let invalid = synth_from_options("subtractive", SynthOptions::Default)
.expect_err("unknown synth should fail");
assert_eq!(invalid.to_string(), "invalid synth: subtractive");
let invalid_preset = synth_from_options("fm", SynthOptions::Preset("glass".to_string()))
.expect_err("unknown fm preset should fail");
assert_eq!(invalid_preset.to_string(), "invalid synth preset fm: glass");
}
#[test]
fn synth_catalog_describes_supported_algorithms() {
let names = synth_names();
assert_eq!(
names,
vec!["fm", "additive", "wavetable", "karplus_strong", "granular"]
);
let granular = synth_info("grain").expect("granular alias should resolve");
assert_eq!(granular.name, "granular");
assert_eq!(granular.category, "sample_granular");
assert_eq!(granular.parameters[0], "source");
assert_eq!(granular.parameters[1], "duration");
let invalid = synth_info("subtractive").expect_err("unknown synth should fail");
assert_eq!(invalid.to_string(), "invalid synth: subtractive");
}
#[test]
fn granular_params_override_preset_defaults() {
let granular = synth_from_options(
"granular",
params(&[
("source", SynthOption::Text("source.wav".to_string())),
("duration", SynthOption::Number(0.75)),
("preset", SynthOption::Text("texture".to_string())),
("grain_size_ms", SynthOption::Number(25.0)),
]),
)
.expect("granular should parse");
let PulseSynth::Granular(granular) = granular else {
panic!("expected granular");
};
assert_eq!(granular.params.grain_size_ms, 25.0);
assert_eq!(granular.duration, 0.75);
}
#[test]
fn karplus_strong_rejects_invalid_render_durations() {
let synth = PulseKarplusStrong::default();
for duration in [0.0, -0.25, f32::NAN] {
let error = synth
.to_sample(440.0, duration, 44_100)
.expect_err("invalid duration should not render a sample");
assert_eq!(error.to_string(), format!("invalid duration: {duration}"));
}
}
#[test]
fn karplus_strong_rejects_unrenderable_render_durations() {
let synth = PulseKarplusStrong::default();
for duration in [1.0e14_f32, f32::MAX] {
let error = synth
.to_sample(440.0, duration, 44_100)
.expect_err("oversized duration should not allocate a sample buffer");
assert_eq!(error.to_string(), format!("invalid duration: {duration}"));
}
}
}