Skip to main content

mlua_pulse/
effects.rs

1//! Track and master effects exposed to Lua and converted into `tunes` effects.
2
3use crate::error::{PulseError, PulseResult};
4use std::collections::BTreeMap;
5use tunes::composition::TrackBuilder;
6use tunes::synthesis::effects::{Chorus, Compressor, Delay, Limiter, Phaser, Reverb, EQ};
7use tunes::synthesis::filter::{Filter, FilterSlope, FilterType};
8use tunes::track::{Mixer, Track};
9
10/// Metadata describing one Lua-visible effect.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct EffectInfo {
13    /// Canonical effect name.
14    pub name: &'static str,
15    /// Broad effect family.
16    pub category: &'static str,
17    /// Allowed placement scopes, such as `track` or `master`.
18    pub scopes: &'static [&'static str],
19    /// Supported parameter names.
20    pub parameters: &'static [&'static str],
21    /// Supported preset names.
22    pub presets: &'static [&'static str],
23}
24
25/// One parsed effect option value.
26#[derive(Debug, Clone, PartialEq)]
27pub enum EffectOption {
28    /// Floating point option value.
29    Number(f32),
30    /// Text option value, commonly used for filter kinds.
31    Text(String),
32    /// Integer option value, commonly used for filter slopes and phaser stages.
33    Integer(i64),
34}
35
36/// Parsed options for building an effect.
37#[derive(Debug, Clone, Default, PartialEq)]
38pub enum EffectOptions {
39    /// Use the effect's default settings.
40    #[default]
41    Default,
42    /// Use a named preset.
43    Preset(String),
44    /// Use named parameter values.
45    Params(BTreeMap<String, EffectOption>),
46}
47
48const TRACK_AND_MASTER: &[&str] = &["track", "master"];
49const TRACK_ONLY: &[&str] = &["track"];
50
51const EFFECT_INFOS: &[EffectInfo] = &[
52    EffectInfo {
53        name: "delay",
54        category: "time",
55        scopes: TRACK_AND_MASTER,
56        parameters: &["time", "feedback", "mix"],
57        presets: &[
58            "eighth_note",
59            "quarter_note",
60            "dotted_eighth",
61            "half_note",
62            "slapback",
63            "ping_pong",
64            "doubling",
65            "ambient",
66        ],
67    },
68    EffectInfo {
69        name: "reverb",
70        category: "space",
71        scopes: TRACK_AND_MASTER,
72        parameters: &["room_size", "damping", "mix"],
73        presets: &[
74            "room",
75            "hall",
76            "plate",
77            "chamber",
78            "cathedral",
79            "ambient",
80            "subtle",
81            "spring",
82        ],
83    },
84    EffectInfo {
85        name: "filter",
86        category: "filter",
87        scopes: TRACK_ONLY,
88        parameters: &["type", "cutoff", "resonance", "slope"],
89        presets: &[],
90    },
91    EffectInfo {
92        name: "compressor",
93        category: "dynamics",
94        scopes: TRACK_AND_MASTER,
95        parameters: &["threshold", "ratio", "attack", "release", "makeup_gain"],
96        presets: &["gentle", "drum_bus", "bass", "master", "aggressive"],
97    },
98    EffectInfo {
99        name: "limiter",
100        category: "dynamics",
101        scopes: TRACK_AND_MASTER,
102        parameters: &["threshold", "release"],
103        presets: &[
104            "transparent",
105            "standard",
106            "brick_wall",
107            "mastering",
108            "safety",
109        ],
110    },
111    EffectInfo {
112        name: "chorus",
113        category: "modulation",
114        scopes: TRACK_AND_MASTER,
115        parameters: &["rate", "depth", "mix"],
116        presets: &["subtle", "classic", "wide", "vibrato", "thick"],
117    },
118    EffectInfo {
119        name: "phaser",
120        category: "modulation",
121        scopes: TRACK_AND_MASTER,
122        parameters: &["rate", "depth", "feedback", "stages", "mix"],
123        presets: &["slow", "classic", "fast", "subtle", "deep"],
124    },
125    EffectInfo {
126        name: "eq",
127        category: "filter",
128        scopes: TRACK_AND_MASTER,
129        parameters: &["low_gain", "mid_gain", "high_gain", "low_freq", "high_freq"],
130        presets: &["flat", "bass_boost", "bright", "warm", "phone"],
131    },
132];
133
134/// Delay effect parameters.
135#[derive(Debug, Clone, PartialEq)]
136pub struct PulseDelay {
137    /// Delay time in seconds.
138    pub time: f32,
139    /// Feedback amount in `0..=0.99`.
140    pub feedback: f32,
141    /// Wet/dry mix in `0..=1`.
142    pub mix: f32,
143}
144
145/// Reverb effect parameters.
146#[derive(Debug, Clone, PartialEq)]
147pub struct PulseReverb {
148    /// Room size in `0..=1`.
149    pub room_size: f32,
150    /// High-frequency damping in `0..=1`.
151    pub damping: f32,
152    /// Wet/dry mix in `0..=1`.
153    pub mix: f32,
154}
155
156/// Filter effect parameters.
157#[derive(Debug, Clone, PartialEq)]
158pub struct PulseFilter {
159    /// Filter algorithm.
160    pub kind: PulseFilterKind,
161    /// Cutoff frequency in Hertz.
162    pub cutoff: f32,
163    /// Resonance amount in `0..=0.99`.
164    pub resonance: f32,
165    /// Filter slope.
166    pub slope: PulseFilterSlope,
167}
168
169/// Compressor effect parameters.
170#[derive(Debug, Clone, PartialEq)]
171pub struct PulseCompressor {
172    /// Compression threshold in the normalized `tunes` amplitude domain.
173    pub threshold: f32,
174    /// Compression ratio.
175    pub ratio: f32,
176    /// Attack time in seconds.
177    pub attack: f32,
178    /// Release time in seconds.
179    pub release: f32,
180    /// Makeup gain multiplier.
181    pub makeup_gain: f32,
182}
183
184/// Limiter effect parameters.
185#[derive(Debug, Clone, PartialEq)]
186pub struct PulseLimiter {
187    /// Limiter threshold in dB-style `tunes` units.
188    pub threshold: f32,
189    /// Release time in seconds.
190    pub release: f32,
191}
192
193/// Chorus effect parameters.
194#[derive(Debug, Clone, PartialEq)]
195pub struct PulseChorus {
196    /// Modulation rate in Hertz.
197    pub rate: f32,
198    /// Modulation depth.
199    pub depth: f32,
200    /// Wet/dry mix in `0..=1`.
201    pub mix: f32,
202}
203
204/// Phaser effect parameters.
205#[derive(Debug, Clone, PartialEq)]
206pub struct PulsePhaser {
207    /// Modulation rate in Hertz.
208    pub rate: f32,
209    /// Modulation depth in `0..=1`.
210    pub depth: f32,
211    /// Feedback amount in `0..=0.95`.
212    pub feedback: f32,
213    /// Number of phaser stages.
214    pub stages: usize,
215    /// Wet/dry mix in `0..=1`.
216    pub mix: f32,
217}
218
219/// Three-band EQ parameters.
220#[derive(Debug, Clone, PartialEq)]
221pub struct PulseEq {
222    /// Low band gain multiplier.
223    pub low_gain: f32,
224    /// Mid band gain multiplier.
225    pub mid_gain: f32,
226    /// High band gain multiplier.
227    pub high_gain: f32,
228    /// Low/mid crossover frequency in Hertz.
229    pub low_freq: f32,
230    /// Mid/high crossover frequency in Hertz.
231    pub high_freq: f32,
232}
233
234/// Filter algorithm exposed by `PulseFilter`.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum PulseFilterKind {
237    /// Low-pass filter.
238    LowPass,
239    /// High-pass filter.
240    HighPass,
241    /// Band-pass filter.
242    BandPass,
243    /// Notch filter.
244    Notch,
245    /// All-pass filter.
246    AllPass,
247    /// Moog-style ladder filter.
248    Moog,
249}
250
251/// Filter slope exposed by `PulseFilter`.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub enum PulseFilterSlope {
254    /// 12 dB per octave slope.
255    Pole12,
256    /// 24 dB per octave slope.
257    Pole24,
258}
259
260/// One effect instance ready to apply to a track or master bus.
261#[derive(Debug, Clone, PartialEq)]
262pub enum PulseEffect {
263    /// Delay.
264    Delay(PulseDelay),
265    /// Reverb.
266    Reverb(PulseReverb),
267    /// Filter. This is track-only.
268    Filter(PulseFilter),
269    /// Compressor.
270    Compressor(PulseCompressor),
271    /// Limiter.
272    Limiter(PulseLimiter),
273    /// Chorus.
274    Chorus(PulseChorus),
275    /// Phaser.
276    Phaser(PulsePhaser),
277    /// Three-band EQ.
278    Eq(PulseEq),
279}
280
281impl Default for PulseDelay {
282    fn default() -> Self {
283        Self {
284            time: 0.25,
285            feedback: 0.4,
286            mix: 0.35,
287        }
288    }
289}
290
291impl Default for PulseReverb {
292    fn default() -> Self {
293        Self {
294            room_size: 0.5,
295            damping: 0.5,
296            mix: 0.25,
297        }
298    }
299}
300
301impl Default for PulseFilter {
302    fn default() -> Self {
303        Self {
304            kind: PulseFilterKind::LowPass,
305            cutoff: 1200.0,
306            resonance: 0.3,
307            slope: PulseFilterSlope::Pole12,
308        }
309    }
310}
311
312impl Default for PulseCompressor {
313    fn default() -> Self {
314        Self {
315            threshold: 0.5,
316            ratio: 2.0,
317            attack: 0.01,
318            release: 0.1,
319            makeup_gain: 1.0,
320        }
321    }
322}
323
324impl Default for PulseLimiter {
325    fn default() -> Self {
326        Self {
327            threshold: -0.3,
328            release: 0.05,
329        }
330    }
331}
332
333impl Default for PulseChorus {
334    fn default() -> Self {
335        Self {
336            rate: 1.5,
337            depth: 5.0,
338            mix: 0.5,
339        }
340    }
341}
342
343impl Default for PulsePhaser {
344    fn default() -> Self {
345        Self {
346            rate: 0.5,
347            depth: 0.8,
348            feedback: 0.6,
349            stages: 4,
350            mix: 0.6,
351        }
352    }
353}
354
355impl Default for PulseEq {
356    fn default() -> Self {
357        Self {
358            low_gain: 1.0,
359            mid_gain: 1.0,
360            high_gain: 1.0,
361            low_freq: 300.0,
362            high_freq: 3000.0,
363        }
364    }
365}
366
367/// Returns metadata for all Lua-visible effects.
368#[must_use]
369pub fn effect_infos() -> &'static [EffectInfo] {
370    EFFECT_INFOS
371}
372
373/// Returns effect names in stable API order.
374#[must_use]
375pub fn effect_names() -> Vec<&'static str> {
376    EFFECT_INFOS.iter().map(|info| info.name).collect()
377}
378
379/// Looks up one effect's metadata by name.
380///
381/// # Errors
382///
383/// Returns `PulseError::InvalidEffect` when `name` is unknown.
384pub fn effect_info(name: &str) -> PulseResult<&'static EffectInfo> {
385    let normalized = normalize_name(name);
386    EFFECT_INFOS
387        .iter()
388        .find(|info| normalize_name(info.name) == normalized)
389        .ok_or_else(|| PulseError::InvalidEffect {
390            name: name.to_string(),
391        })
392}
393
394/// Builds an effect from a name and parsed options.
395///
396/// # Errors
397///
398/// Returns an error when the name, preset, option name, or option value is invalid.
399pub fn effect_from_options(name: &str, options: EffectOptions) -> PulseResult<PulseEffect> {
400    match normalize_name(name).as_str() {
401        "delay" => build_delay(options).map(PulseEffect::Delay),
402        "reverb" => build_reverb(options).map(PulseEffect::Reverb),
403        "filter" => build_filter(options).map(PulseEffect::Filter),
404        "compressor" => build_compressor(options).map(PulseEffect::Compressor),
405        "limiter" => build_limiter(options).map(PulseEffect::Limiter),
406        "chorus" => build_chorus(options).map(PulseEffect::Chorus),
407        "phaser" => build_phaser(options).map(PulseEffect::Phaser),
408        "eq" => build_eq(options).map(PulseEffect::Eq),
409        _ => Err(PulseError::InvalidEffect {
410            name: name.to_string(),
411        }),
412    }
413}
414
415fn normalize_name(value: &str) -> String {
416    value
417        .trim()
418        .to_ascii_lowercase()
419        .replace(['_', '-', ' '], "")
420}
421
422fn option_key_error(effect: &str, option: &str) -> PulseError {
423    PulseError::InvalidEffectOption {
424        effect: effect.to_string(),
425        option: option.to_string(),
426        value: "unsupported".to_string(),
427    }
428}
429
430fn option_value_error(effect: &str, option: &str, value: impl ToString) -> PulseError {
431    PulseError::InvalidEffectOption {
432        effect: effect.to_string(),
433        option: option.to_string(),
434        value: value.to_string(),
435    }
436}
437
438fn invalid_preset(effect: &str, preset: &str) -> PulseError {
439    PulseError::InvalidEffectPreset {
440        effect: effect.to_string(),
441        preset: preset.to_string(),
442    }
443}
444
445fn number_option(effect: &str, option: &str, value: &EffectOption) -> PulseResult<f32> {
446    match value {
447        EffectOption::Number(value) if value.is_finite() => Ok(*value),
448        EffectOption::Integer(value) => Ok(*value as f32),
449        EffectOption::Number(value) => Err(option_value_error(effect, option, value)),
450        EffectOption::Text(value) => Err(option_value_error(effect, option, value)),
451    }
452}
453
454fn integer_option(effect: &str, option: &str, value: &EffectOption) -> PulseResult<i64> {
455    match value {
456        EffectOption::Integer(value) => Ok(*value),
457        EffectOption::Number(value) if value.is_finite() && value.fract() == 0.0 => {
458            Ok(*value as i64)
459        }
460        EffectOption::Number(value) => Err(option_value_error(effect, option, value)),
461        EffectOption::Text(value) => Err(option_value_error(effect, option, value)),
462    }
463}
464
465fn text_option(effect: &str, option: &str, value: &EffectOption) -> PulseResult<String> {
466    match value {
467        EffectOption::Text(value) => Ok(value.clone()),
468        EffectOption::Number(value) => Err(option_value_error(effect, option, value)),
469        EffectOption::Integer(value) => Err(option_value_error(effect, option, value)),
470    }
471}
472
473fn bounded_number(
474    effect: &str,
475    option: &str,
476    value: &EffectOption,
477    min: f32,
478    max: f32,
479) -> PulseResult<f32> {
480    let value = number_option(effect, option, value)?;
481    if (min..=max).contains(&value) {
482        Ok(value)
483    } else {
484        Err(option_value_error(effect, option, value))
485    }
486}
487
488fn unsupported_option(effect: &str, key: &str) -> PulseResult<()> {
489    Err(option_key_error(effect, key))
490}
491
492fn build_delay(options: EffectOptions) -> PulseResult<PulseDelay> {
493    match options {
494        EffectOptions::Default => Ok(PulseDelay::default()),
495        EffectOptions::Preset(preset) => match normalize_name(&preset).as_str() {
496            "eighthnote" => Ok(PulseDelay {
497                time: 0.125,
498                feedback: 0.35,
499                mix: 0.3,
500            }),
501            "quarternote" => Ok(PulseDelay {
502                time: 0.25,
503                feedback: 0.4,
504                mix: 0.35,
505            }),
506            "dottedeighth" => Ok(PulseDelay {
507                time: 0.1875,
508                feedback: 0.35,
509                mix: 0.3,
510            }),
511            "halfnote" => Ok(PulseDelay {
512                time: 0.5,
513                feedback: 0.45,
514                mix: 0.4,
515            }),
516            "slapback" => Ok(PulseDelay {
517                time: 0.08,
518                feedback: 0.0,
519                mix: 0.3,
520            }),
521            "pingpong" => Ok(PulseDelay {
522                time: 0.375,
523                feedback: 0.5,
524                mix: 0.4,
525            }),
526            "doubling" => Ok(PulseDelay {
527                time: 0.03,
528                feedback: 0.0,
529                mix: 0.2,
530            }),
531            "ambient" => Ok(PulseDelay {
532                time: 1.0,
533                feedback: 0.6,
534                mix: 0.5,
535            }),
536            _ => Err(invalid_preset("delay", &preset)),
537        },
538        EffectOptions::Params(params) => {
539            let mut effect = PulseDelay::default();
540            for (key, value) in params {
541                match key.as_str() {
542                    "time" => effect.time = bounded_number("delay", "time", &value, 0.001, 10.0)?,
543                    "feedback" => {
544                        effect.feedback = bounded_number("delay", "feedback", &value, 0.0, 0.99)?;
545                    }
546                    "mix" => effect.mix = bounded_number("delay", "mix", &value, 0.0, 1.0)?,
547                    _ => unsupported_option("delay", &key)?,
548                }
549            }
550            Ok(effect)
551        }
552    }
553}
554
555fn build_reverb(options: EffectOptions) -> PulseResult<PulseReverb> {
556    match options {
557        EffectOptions::Default => Ok(PulseReverb::default()),
558        EffectOptions::Preset(preset) => match normalize_name(&preset).as_str() {
559            "room" => Ok(PulseReverb {
560                room_size: 0.3,
561                damping: 0.7,
562                mix: 0.2,
563            }),
564            "hall" => Ok(PulseReverb {
565                room_size: 0.8,
566                damping: 0.5,
567                mix: 0.3,
568            }),
569            "plate" => Ok(PulseReverb {
570                room_size: 0.5,
571                damping: 0.3,
572                mix: 0.25,
573            }),
574            "chamber" => Ok(PulseReverb {
575                room_size: 0.6,
576                damping: 0.6,
577                mix: 0.25,
578            }),
579            "cathedral" => Ok(PulseReverb {
580                room_size: 0.95,
581                damping: 0.4,
582                mix: 0.4,
583            }),
584            "ambient" => Ok(PulseReverb {
585                room_size: 0.9,
586                damping: 0.4,
587                mix: 0.5,
588            }),
589            "subtle" => Ok(PulseReverb {
590                room_size: 0.4,
591                damping: 0.6,
592                mix: 0.15,
593            }),
594            "spring" => Ok(PulseReverb {
595                room_size: 0.4,
596                damping: 0.8,
597                mix: 0.3,
598            }),
599            _ => Err(invalid_preset("reverb", &preset)),
600        },
601        EffectOptions::Params(params) => {
602            let mut effect = PulseReverb::default();
603            for (key, value) in params {
604                match key.as_str() {
605                    "room_size" => {
606                        effect.room_size = bounded_number("reverb", "room_size", &value, 0.0, 1.0)?;
607                    }
608                    "damping" => {
609                        effect.damping = bounded_number("reverb", "damping", &value, 0.0, 1.0)?;
610                    }
611                    "mix" => effect.mix = bounded_number("reverb", "mix", &value, 0.0, 1.0)?,
612                    _ => unsupported_option("reverb", &key)?,
613                }
614            }
615            Ok(effect)
616        }
617    }
618}
619
620fn build_filter(options: EffectOptions) -> PulseResult<PulseFilter> {
621    match options {
622        EffectOptions::Default => Ok(PulseFilter::default()),
623        EffectOptions::Preset(preset) => Err(invalid_preset("filter", &preset)),
624        EffectOptions::Params(params) => {
625            let mut effect = PulseFilter::default();
626            for (key, value) in params {
627                match key.as_str() {
628                    "type" => {
629                        let kind = text_option("filter", "type", &value)?;
630                        effect.kind = match normalize_name(&kind).as_str() {
631                            "lowpass" => PulseFilterKind::LowPass,
632                            "highpass" => PulseFilterKind::HighPass,
633                            "bandpass" => PulseFilterKind::BandPass,
634                            "notch" => PulseFilterKind::Notch,
635                            "allpass" => PulseFilterKind::AllPass,
636                            "moog" => PulseFilterKind::Moog,
637                            _ => return Err(option_value_error("filter", "type", kind)),
638                        };
639                    }
640                    "cutoff" => {
641                        effect.cutoff = bounded_number("filter", "cutoff", &value, 20.0, 20000.0)?;
642                    }
643                    "resonance" => {
644                        effect.resonance =
645                            bounded_number("filter", "resonance", &value, 0.0, 0.99)?;
646                    }
647                    "slope" => {
648                        let slope = integer_option("filter", "slope", &value)?;
649                        effect.slope = match slope {
650                            12 => PulseFilterSlope::Pole12,
651                            24 => PulseFilterSlope::Pole24,
652                            _ => return Err(option_value_error("filter", "slope", slope)),
653                        };
654                    }
655                    _ => unsupported_option("filter", &key)?,
656                }
657            }
658            Ok(effect)
659        }
660    }
661}
662
663fn build_compressor(options: EffectOptions) -> PulseResult<PulseCompressor> {
664    match options {
665        EffectOptions::Default => Ok(PulseCompressor::default()),
666        EffectOptions::Preset(preset) => match normalize_name(&preset).as_str() {
667            "gentle" => Ok(PulseCompressor {
668                threshold: 0.5,
669                ratio: 2.0,
670                attack: 0.01,
671                release: 0.1,
672                makeup_gain: 1.0,
673            }),
674            "drumbus" => Ok(PulseCompressor {
675                threshold: 0.6,
676                ratio: 4.0,
677                attack: 0.01,
678                release: 0.15,
679                makeup_gain: 1.0,
680            }),
681            "bass" => Ok(PulseCompressor {
682                threshold: 0.5,
683                ratio: 6.0,
684                attack: 0.02,
685                release: 0.2,
686                makeup_gain: 1.0,
687            }),
688            "master" => Ok(PulseCompressor {
689                threshold: 0.6,
690                ratio: 2.5,
691                attack: 0.01,
692                release: 0.1,
693                makeup_gain: 1.0,
694            }),
695            "aggressive" => Ok(PulseCompressor {
696                threshold: 0.3,
697                ratio: 8.0,
698                attack: 0.005,
699                release: 0.08,
700                makeup_gain: 1.0,
701            }),
702            _ => Err(invalid_preset("compressor", &preset)),
703        },
704        EffectOptions::Params(params) => {
705            let mut effect = PulseCompressor::default();
706            for (key, value) in params {
707                match key.as_str() {
708                    "threshold" => {
709                        effect.threshold =
710                            bounded_number("compressor", "threshold", &value, 0.0, 1.0)?;
711                    }
712                    "ratio" => {
713                        effect.ratio = bounded_number("compressor", "ratio", &value, 1.0, 20.0)?;
714                    }
715                    "attack" => {
716                        effect.attack = bounded_number("compressor", "attack", &value, 0.001, 1.0)?;
717                    }
718                    "release" => {
719                        effect.release =
720                            bounded_number("compressor", "release", &value, 0.001, 5.0)?;
721                    }
722                    "makeup_gain" => {
723                        effect.makeup_gain =
724                            bounded_number("compressor", "makeup_gain", &value, 0.1, 4.0)?;
725                    }
726                    _ => unsupported_option("compressor", &key)?,
727                }
728            }
729            Ok(effect)
730        }
731    }
732}
733
734fn build_limiter(options: EffectOptions) -> PulseResult<PulseLimiter> {
735    match options {
736        EffectOptions::Default => Ok(PulseLimiter::default()),
737        EffectOptions::Preset(preset) => match normalize_name(&preset).as_str() {
738            "transparent" => Ok(PulseLimiter {
739                threshold: -0.5,
740                release: 0.1,
741            }),
742            "standard" => Ok(PulseLimiter {
743                threshold: -0.3,
744                release: 0.05,
745            }),
746            "brickwall" => Ok(PulseLimiter {
747                threshold: -0.1,
748                release: 0.005,
749            }),
750            "mastering" => Ok(PulseLimiter {
751                threshold: -0.2,
752                release: 0.08,
753            }),
754            "safety" => Ok(PulseLimiter {
755                threshold: 0.0,
756                release: 0.01,
757            }),
758            _ => Err(invalid_preset("limiter", &preset)),
759        },
760        EffectOptions::Params(params) => {
761            let mut effect = PulseLimiter::default();
762            for (key, value) in params {
763                match key.as_str() {
764                    "threshold" => {
765                        effect.threshold =
766                            bounded_number("limiter", "threshold", &value, -60.0, 0.0)?;
767                    }
768                    "release" => {
769                        effect.release = bounded_number("limiter", "release", &value, 0.001, 5.0)?;
770                    }
771                    _ => unsupported_option("limiter", &key)?,
772                }
773            }
774            Ok(effect)
775        }
776    }
777}
778
779fn build_chorus(options: EffectOptions) -> PulseResult<PulseChorus> {
780    match options {
781        EffectOptions::Default => Ok(PulseChorus::default()),
782        EffectOptions::Preset(preset) => match normalize_name(&preset).as_str() {
783            "subtle" => Ok(PulseChorus {
784                rate: 0.5,
785                depth: 3.0,
786                mix: 0.3,
787            }),
788            "classic" => Ok(PulseChorus {
789                rate: 1.5,
790                depth: 5.0,
791                mix: 0.5,
792            }),
793            "wide" => Ok(PulseChorus {
794                rate: 0.8,
795                depth: 8.0,
796                mix: 0.6,
797            }),
798            "vibrato" => Ok(PulseChorus {
799                rate: 5.0,
800                depth: 3.0,
801                mix: 1.0,
802            }),
803            "thick" => Ok(PulseChorus {
804                rate: 2.0,
805                depth: 7.0,
806                mix: 0.7,
807            }),
808            _ => Err(invalid_preset("chorus", &preset)),
809        },
810        EffectOptions::Params(params) => {
811            let mut effect = PulseChorus::default();
812            for (key, value) in params {
813                match key.as_str() {
814                    "rate" => effect.rate = bounded_number("chorus", "rate", &value, 0.1, 10.0)?,
815                    "depth" => {
816                        effect.depth = bounded_number("chorus", "depth", &value, 0.5, 50.0)?;
817                    }
818                    "mix" => effect.mix = bounded_number("chorus", "mix", &value, 0.0, 1.0)?,
819                    _ => unsupported_option("chorus", &key)?,
820                }
821            }
822            Ok(effect)
823        }
824    }
825}
826
827fn build_phaser(options: EffectOptions) -> PulseResult<PulsePhaser> {
828    match options {
829        EffectOptions::Default => Ok(PulsePhaser::default()),
830        EffectOptions::Preset(preset) => match normalize_name(&preset).as_str() {
831            "slow" => Ok(PulsePhaser {
832                rate: 0.3,
833                depth: 0.7,
834                feedback: 0.5,
835                stages: 4,
836                mix: 0.5,
837            }),
838            "classic" => Ok(PulsePhaser {
839                rate: 0.5,
840                depth: 0.8,
841                feedback: 0.6,
842                stages: 4,
843                mix: 0.6,
844            }),
845            "fast" => Ok(PulsePhaser {
846                rate: 2.0,
847                depth: 0.9,
848                feedback: 0.7,
849                stages: 6,
850                mix: 0.7,
851            }),
852            "subtle" => Ok(PulsePhaser {
853                rate: 0.4,
854                depth: 0.5,
855                feedback: 0.3,
856                stages: 4,
857                mix: 0.4,
858            }),
859            "deep" => Ok(PulsePhaser {
860                rate: 0.6,
861                depth: 1.0,
862                feedback: 0.8,
863                stages: 8,
864                mix: 0.8,
865            }),
866            _ => Err(invalid_preset("phaser", &preset)),
867        },
868        EffectOptions::Params(params) => {
869            let mut effect = PulsePhaser::default();
870            for (key, value) in params {
871                match key.as_str() {
872                    "rate" => effect.rate = bounded_number("phaser", "rate", &value, 0.1, 10.0)?,
873                    "depth" => {
874                        effect.depth = bounded_number("phaser", "depth", &value, 0.0, 1.0)?;
875                    }
876                    "feedback" => {
877                        effect.feedback = bounded_number("phaser", "feedback", &value, 0.0, 0.95)?;
878                    }
879                    "mix" => effect.mix = bounded_number("phaser", "mix", &value, 0.0, 1.0)?,
880                    "stages" => {
881                        let stages = integer_option("phaser", "stages", &value)?;
882                        effect.stages = match stages {
883                            2 | 4 | 6 | 8 => stages as usize,
884                            _ => return Err(option_value_error("phaser", "stages", stages)),
885                        };
886                    }
887                    _ => unsupported_option("phaser", &key)?,
888                }
889            }
890            Ok(effect)
891        }
892    }
893}
894
895fn build_eq(options: EffectOptions) -> PulseResult<PulseEq> {
896    match options {
897        EffectOptions::Default => Ok(PulseEq::default()),
898        EffectOptions::Preset(preset) => match normalize_name(&preset).as_str() {
899            "flat" => Ok(PulseEq::default()),
900            "bassboost" => Ok(PulseEq {
901                low_gain: 1.5,
902                mid_gain: 1.0,
903                high_gain: 1.0,
904                low_freq: 100.0,
905                high_freq: 3000.0,
906            }),
907            "bright" => Ok(PulseEq {
908                low_gain: 0.8,
909                mid_gain: 1.0,
910                high_gain: 1.4,
911                low_freq: 300.0,
912                high_freq: 5000.0,
913            }),
914            "warm" => Ok(PulseEq {
915                low_gain: 1.3,
916                mid_gain: 1.0,
917                high_gain: 0.9,
918                low_freq: 150.0,
919                high_freq: 3000.0,
920            }),
921            "phone" => Ok(PulseEq {
922                low_gain: 0.2,
923                mid_gain: 1.4,
924                high_gain: 0.2,
925                low_freq: 600.0,
926                high_freq: 3000.0,
927            }),
928            _ => Err(invalid_preset("eq", &preset)),
929        },
930        EffectOptions::Params(params) => {
931            let mut effect = PulseEq::default();
932            for (key, value) in params {
933                match key.as_str() {
934                    "low_gain" => {
935                        effect.low_gain = bounded_number("eq", "low_gain", &value, 0.0, 4.0)?;
936                    }
937                    "mid_gain" => {
938                        effect.mid_gain = bounded_number("eq", "mid_gain", &value, 0.0, 4.0)?;
939                    }
940                    "high_gain" => {
941                        effect.high_gain = bounded_number("eq", "high_gain", &value, 0.0, 4.0)?;
942                    }
943                    "low_freq" => {
944                        effect.low_freq = bounded_number("eq", "low_freq", &value, 20.0, 20000.0)?;
945                    }
946                    "high_freq" => {
947                        effect.high_freq =
948                            bounded_number("eq", "high_freq", &value, 20.0, 20000.0)?;
949                    }
950                    _ => unsupported_option("eq", &key)?,
951                }
952            }
953            if effect.high_freq <= effect.low_freq {
954                return Err(option_value_error("eq", "high_freq", effect.high_freq));
955            }
956            Ok(effect)
957        }
958    }
959}
960
961impl PulseDelay {
962    /// Converts this wrapper to the corresponding `tunes` delay.
963    #[must_use]
964    pub fn to_tunes(&self) -> Delay {
965        Delay::new(self.time, self.feedback, self.mix)
966    }
967}
968
969impl PulseReverb {
970    /// Converts this wrapper to the corresponding `tunes` reverb.
971    #[must_use]
972    pub fn to_tunes(&self) -> Reverb {
973        Reverb::new(self.room_size, self.damping, self.mix)
974    }
975}
976
977impl PulseFilter {
978    /// Converts this wrapper to the corresponding `tunes` filter.
979    #[must_use]
980    pub fn to_tunes(&self) -> Filter {
981        let filter_type = match self.kind {
982            PulseFilterKind::LowPass => FilterType::LowPass,
983            PulseFilterKind::HighPass => FilterType::HighPass,
984            PulseFilterKind::BandPass => FilterType::BandPass,
985            PulseFilterKind::Notch => FilterType::Notch,
986            PulseFilterKind::AllPass => FilterType::AllPass,
987            PulseFilterKind::Moog => FilterType::Moog,
988        };
989        let slope = match self.slope {
990            PulseFilterSlope::Pole12 => FilterSlope::Pole12dB,
991            PulseFilterSlope::Pole24 => FilterSlope::Pole24dB,
992        };
993        Filter::with_slope(filter_type, self.cutoff, self.resonance, slope)
994    }
995}
996
997impl PulseCompressor {
998    /// Converts this wrapper to the corresponding `tunes` compressor.
999    #[must_use]
1000    pub fn to_tunes(&self) -> Compressor {
1001        Compressor::new(
1002            self.threshold,
1003            self.ratio,
1004            self.attack,
1005            self.release,
1006            self.makeup_gain,
1007        )
1008    }
1009}
1010
1011impl PulseLimiter {
1012    /// Converts this wrapper to the corresponding `tunes` limiter.
1013    #[must_use]
1014    pub fn to_tunes(&self) -> Limiter {
1015        Limiter::new(self.threshold, self.release)
1016    }
1017}
1018
1019impl PulseChorus {
1020    /// Converts this wrapper to the corresponding `tunes` chorus.
1021    #[must_use]
1022    pub fn to_tunes(&self) -> Chorus {
1023        Chorus::new(self.rate, self.depth, self.mix)
1024    }
1025}
1026
1027impl PulsePhaser {
1028    /// Converts this wrapper to the corresponding `tunes` phaser.
1029    #[must_use]
1030    pub fn to_tunes(&self) -> Phaser {
1031        Phaser::new(self.rate, self.depth, self.feedback, self.stages, self.mix)
1032    }
1033}
1034
1035impl PulseEq {
1036    /// Converts this wrapper to the corresponding `tunes` EQ.
1037    #[must_use]
1038    pub fn to_tunes(&self) -> EQ {
1039        EQ::new(
1040            self.low_gain,
1041            self.mid_gain,
1042            self.high_gain,
1043            self.low_freq,
1044            self.high_freq,
1045        )
1046    }
1047}
1048
1049impl PulseEffect {
1050    /// Returns the canonical effect name.
1051    #[must_use]
1052    pub fn name(&self) -> &'static str {
1053        match self {
1054            Self::Delay(_) => "delay",
1055            Self::Reverb(_) => "reverb",
1056            Self::Filter(_) => "filter",
1057            Self::Compressor(_) => "compressor",
1058            Self::Limiter(_) => "limiter",
1059            Self::Chorus(_) => "chorus",
1060            Self::Phaser(_) => "phaser",
1061            Self::Eq(_) => "eq",
1062        }
1063    }
1064
1065    /// Returns whether this effect can be applied to the master bus.
1066    #[must_use]
1067    pub fn allowed_on_master(&self) -> bool {
1068        !matches!(self, Self::Filter(_))
1069    }
1070
1071    /// Applies this effect to a `tunes` track builder.
1072    #[must_use]
1073    pub fn apply_to_track_builder<'a>(&self, builder: TrackBuilder<'a>) -> TrackBuilder<'a> {
1074        match self {
1075            Self::Delay(value) => builder.delay(value.to_tunes()),
1076            Self::Reverb(value) => builder.reverb(value.to_tunes()),
1077            Self::Filter(value) => builder.filter(value.to_tunes()),
1078            Self::Compressor(value) => builder.compressor(value.to_tunes()),
1079            Self::Limiter(value) => builder.limiter(value.to_tunes()),
1080            Self::Chorus(value) => builder.chorus(value.to_tunes()),
1081            Self::Phaser(value) => builder.phaser(value.to_tunes()),
1082            Self::Eq(value) => builder.eq(value.to_tunes()),
1083        }
1084    }
1085
1086    /// Applies this effect directly to an existing `tunes` track.
1087    pub(crate) fn apply_to_track(&self, track: &mut Track) {
1088        match self {
1089            Self::Delay(value) => {
1090                track.effects = track.effects.clone().with_delay(value.to_tunes())
1091            }
1092            Self::Reverb(value) => {
1093                track.effects = track.effects.clone().with_reverb(value.to_tunes());
1094            }
1095            Self::Filter(value) => {
1096                track.filter = value.to_tunes();
1097            }
1098            Self::Compressor(value) => {
1099                track.effects = track.effects.clone().with_compressor(value.to_tunes());
1100            }
1101            Self::Limiter(value) => {
1102                track.effects = track.effects.clone().with_limiter(value.to_tunes());
1103            }
1104            Self::Chorus(value) => {
1105                track.effects = track.effects.clone().with_chorus(value.to_tunes());
1106            }
1107            Self::Phaser(value) => {
1108                track.effects = track.effects.clone().with_phaser(value.to_tunes());
1109            }
1110            Self::Eq(value) => {
1111                track.effects = track.effects.clone().with_eq(value.to_tunes());
1112            }
1113        }
1114    }
1115
1116    /// Applies this effect to a `tunes` mixer master bus.
1117    ///
1118    /// # Errors
1119    ///
1120    /// Returns an invalid-scope error when called for a track-only effect.
1121    pub fn apply_to_master(&self, mixer: &mut Mixer) -> PulseResult<()> {
1122        match self {
1123            Self::Delay(value) => mixer.master_delay(value.to_tunes()),
1124            Self::Reverb(value) => mixer.master_reverb(value.to_tunes()),
1125            Self::Filter(_) => {
1126                return Err(PulseError::InvalidEffectScope {
1127                    effect: "filter".to_string(),
1128                    scope: "master".to_string(),
1129                });
1130            }
1131            Self::Compressor(value) => mixer.master_compressor(value.to_tunes()),
1132            Self::Limiter(value) => mixer.master_limiter(value.to_tunes()),
1133            Self::Chorus(value) => mixer.master_chorus(value.to_tunes()),
1134            Self::Phaser(value) => mixer.master_phaser(value.to_tunes()),
1135            Self::Eq(value) => mixer.master_eq(value.to_tunes()),
1136        }
1137        Ok(())
1138    }
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143    use super::*;
1144    use std::collections::BTreeMap;
1145
1146    fn params(entries: &[(&str, EffectOption)]) -> EffectOptions {
1147        let mut values = BTreeMap::new();
1148        for (key, value) in entries {
1149            values.insert((*key).to_string(), value.clone());
1150        }
1151        EffectOptions::Params(values)
1152    }
1153
1154    #[test]
1155    fn effect_catalog_exposes_v1_effects() {
1156        let names = effect_names();
1157
1158        assert_eq!(names.len(), 8);
1159        assert!(names.contains(&"delay"));
1160        assert!(names.contains(&"reverb"));
1161        assert!(names.contains(&"filter"));
1162        assert!(names.contains(&"compressor"));
1163        assert!(names.contains(&"limiter"));
1164        assert!(names.contains(&"chorus"));
1165        assert!(names.contains(&"phaser"));
1166        assert!(names.contains(&"eq"));
1167
1168        let delay = effect_info("delay").expect("delay info should exist");
1169        assert_eq!(delay.category, "time");
1170        assert!(delay.scopes.contains(&"track"));
1171        assert!(delay.scopes.contains(&"master"));
1172        assert!(delay.parameters.contains(&"time"));
1173        assert!(delay.presets.contains(&"quarter_note"));
1174
1175        let filter = effect_info("filter").expect("filter info should exist");
1176        assert_eq!(filter.scopes, &["track"]);
1177    }
1178
1179    #[test]
1180    fn invalid_effect_name_reports_stable_error() {
1181        let error = effect_from_options("shimmer", EffectOptions::Default)
1182            .expect_err("unknown effect should fail");
1183
1184        assert_eq!(error.to_string(), "invalid effect: shimmer");
1185    }
1186
1187    #[test]
1188    fn delay_validates_params_and_presets() {
1189        let effect = effect_from_options(
1190            "delay",
1191            params(&[
1192                ("time", EffectOption::Number(0.25)),
1193                ("feedback", EffectOption::Number(0.35)),
1194                ("mix", EffectOption::Number(0.3)),
1195            ]),
1196        )
1197        .expect("valid delay should parse");
1198
1199        let PulseEffect::Delay(delay) = effect else {
1200            panic!("expected delay");
1201        };
1202        assert_eq!(delay.time, 0.25);
1203        assert_eq!(delay.feedback, 0.35);
1204        assert_eq!(delay.mix, 0.3);
1205
1206        let preset = effect_from_options("delay", EffectOptions::Preset("slapback".to_string()))
1207            .expect("delay preset should parse");
1208        assert!(matches!(preset, PulseEffect::Delay(_)));
1209
1210        let invalid =
1211            effect_from_options("delay", params(&[("feedback", EffectOption::Number(1.2))]))
1212                .expect_err("feedback outside range should fail");
1213        assert_eq!(
1214            invalid.to_string(),
1215            "invalid effect option delay.feedback: 1.2"
1216        );
1217    }
1218
1219    #[test]
1220    fn filter_validates_type_cutoff_resonance_and_scope() {
1221        let effect = effect_from_options(
1222            "filter",
1223            params(&[
1224                ("type", EffectOption::Text("low_pass".to_string())),
1225                ("cutoff", EffectOption::Number(1800.0)),
1226                ("resonance", EffectOption::Number(0.4)),
1227                ("slope", EffectOption::Integer(24)),
1228            ]),
1229        )
1230        .expect("valid filter should parse");
1231
1232        let PulseEffect::Filter(filter) = effect else {
1233            panic!("expected filter");
1234        };
1235        assert_eq!(filter.kind, PulseFilterKind::LowPass);
1236        assert_eq!(filter.cutoff, 1800.0);
1237        assert_eq!(filter.resonance, 0.4);
1238        assert_eq!(filter.slope, PulseFilterSlope::Pole24);
1239        assert!(!PulseEffect::Filter(filter).allowed_on_master());
1240
1241        let invalid = effect_from_options(
1242            "filter",
1243            params(&[("type", EffectOption::Text("comb".to_string()))]),
1244        )
1245        .expect_err("unknown filter type should fail");
1246        assert_eq!(
1247            invalid.to_string(),
1248            "invalid effect option filter.type: comb"
1249        );
1250    }
1251
1252    #[test]
1253    fn eq_rejects_invalid_frequency_order() {
1254        let invalid = effect_from_options(
1255            "eq",
1256            params(&[
1257                ("low_freq", EffectOption::Number(5000.0)),
1258                ("high_freq", EffectOption::Number(200.0)),
1259            ]),
1260        )
1261        .expect_err("high frequency must be above low frequency");
1262
1263        assert_eq!(
1264            invalid.to_string(),
1265            "invalid effect option eq.high_freq: 200"
1266        );
1267    }
1268
1269    #[test]
1270    fn all_default_effects_convert_to_tunes_types() {
1271        let effects = [
1272            effect_from_options("delay", EffectOptions::Default).unwrap(),
1273            effect_from_options("reverb", EffectOptions::Default).unwrap(),
1274            effect_from_options("filter", EffectOptions::Default).unwrap(),
1275            effect_from_options("compressor", EffectOptions::Default).unwrap(),
1276            effect_from_options("limiter", EffectOptions::Default).unwrap(),
1277            effect_from_options("chorus", EffectOptions::Default).unwrap(),
1278            effect_from_options("phaser", EffectOptions::Default).unwrap(),
1279            effect_from_options("eq", EffectOptions::Default).unwrap(),
1280        ];
1281
1282        for effect in effects {
1283            match effect {
1284                PulseEffect::Delay(value) => {
1285                    let _ = value.to_tunes();
1286                }
1287                PulseEffect::Reverb(value) => {
1288                    let _ = value.to_tunes();
1289                }
1290                PulseEffect::Filter(value) => {
1291                    let _ = value.to_tunes();
1292                }
1293                PulseEffect::Compressor(value) => {
1294                    let _ = value.to_tunes();
1295                }
1296                PulseEffect::Limiter(value) => {
1297                    let _ = value.to_tunes();
1298                }
1299                PulseEffect::Chorus(value) => {
1300                    let _ = value.to_tunes();
1301                }
1302                PulseEffect::Phaser(value) => {
1303                    let _ = value.to_tunes();
1304                }
1305                PulseEffect::Eq(value) => {
1306                    let _ = value.to_tunes();
1307                }
1308            }
1309        }
1310    }
1311}