Skip to main content

sim_lib_sound_core/
model.rs

1use std::ops::Add;
2use std::time::Duration;
3
4use sim_lib_pitch_core::Pitch;
5use thiserror::Error;
6
7/// Error raised when sound primitives are constructed with invalid values.
8#[derive(Debug, Error, Clone, PartialEq)]
9pub enum SoundCoreError {
10    /// A frequency was zero, negative, or non-finite.
11    #[error("frequency must be positive")]
12    InvalidFrequency,
13    /// An amplitude was negative or non-finite.
14    #[error("amplitude must be non-negative")]
15    InvalidAmplitude,
16    /// An envelope sustain level fell outside the `0.0..=1.0` range.
17    #[error("envelope sustain must be between 0.0 and 1.0")]
18    InvalidSustain,
19    /// A tone duration was zero.
20    #[error("tone duration must be positive")]
21    InvalidDuration,
22    /// A tone was built without any partials.
23    #[error("tone must contain at least one partial")]
24    EmptyPartials,
25    /// A time-stretch factor was zero, negative, or non-finite.
26    #[error("time-stretch factor must be positive")]
27    InvalidStretch,
28}
29
30/// A positive frequency in hertz.
31///
32/// # Examples
33///
34/// ```
35/// use sim_lib_sound_core::Frequency;
36///
37/// let a4 = Frequency::new(440.0).unwrap();
38/// let a5 = Frequency::new(880.0).unwrap();
39/// assert!((a5.cents_above(a4) - 1200.0).abs() < 1e-9);
40/// assert!(Frequency::new(0.0).is_err());
41/// ```
42#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
43pub struct Frequency(pub f64);
44
45impl Frequency {
46    /// Builds a frequency, returning [`SoundCoreError::InvalidFrequency`] when
47    /// `hz` is not a positive, finite value.
48    pub fn new(hz: f64) -> Result<Self, SoundCoreError> {
49        if hz.is_finite() && hz > 0.0 {
50            Ok(Self(hz))
51        } else {
52            Err(SoundCoreError::InvalidFrequency)
53        }
54    }
55
56    /// Returns the linear ratio of this frequency to `other`.
57    pub fn ratio(self, other: Frequency) -> f64 {
58        self.0 / other.0
59    }
60
61    /// Returns the interval from `other` to this frequency, measured in cents.
62    pub fn cents_above(self, other: Frequency) -> f64 {
63        1200.0 * self.ratio(other).log2()
64    }
65
66    /// Returns this frequency shifted by `cents` (positive raises, negative
67    /// lowers).
68    pub fn shift_cents(self, cents: f64) -> Frequency {
69        Frequency(self.0 * 2.0_f64.powf(cents / 1200.0))
70    }
71}
72
73/// A non-negative linear amplitude.
74///
75/// # Examples
76///
77/// ```
78/// use sim_lib_sound_core::Amplitude;
79///
80/// let unity = Amplitude::from_db(0.0);
81/// assert!((unity.0 - 1.0).abs() < 1e-9);
82/// assert!(Amplitude::new(-1.0).is_err());
83/// ```
84#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
85pub struct Amplitude(pub f64);
86
87impl Amplitude {
88    /// Builds an amplitude, returning [`SoundCoreError::InvalidAmplitude`] when
89    /// `linear` is negative or non-finite.
90    pub fn new(linear: f64) -> Result<Self, SoundCoreError> {
91        if linear.is_finite() && linear >= 0.0 {
92            Ok(Self(linear))
93        } else {
94            Err(SoundCoreError::InvalidAmplitude)
95        }
96    }
97
98    /// Builds an amplitude from a decibel value relative to unity gain.
99    pub fn from_db(db: f64) -> Self {
100        Self(10f64.powf(db / 20.0))
101    }
102
103    /// Returns this amplitude expressed in decibels relative to unity gain.
104    pub fn to_db(self) -> f64 {
105        20.0 * self.0.log10()
106    }
107}
108
109/// A phase angle in radians.
110#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
111pub struct Phase(pub f64);
112
113impl Phase {
114    /// Returns this phase wrapped into the `0.0..TAU` range.
115    pub fn normalized(self) -> Self {
116        let tau = std::f64::consts::TAU;
117        Self(self.0.rem_euclid(tau))
118    }
119}
120
121/// A single sinusoidal component of a tone.
122#[derive(Copy, Clone, Debug, PartialEq)]
123pub struct Partial {
124    /// Frequency of the component.
125    pub frequency: Frequency,
126    /// Linear amplitude of the component.
127    pub amplitude: Amplitude,
128    /// Starting phase of the component.
129    pub phase: Phase,
130}
131
132impl Partial {
133    /// Builds a validated partial, normalizing the phase and rejecting invalid
134    /// frequency or amplitude values.
135    pub fn new(
136        frequency: Frequency,
137        amplitude: Amplitude,
138        phase: Phase,
139    ) -> Result<Self, SoundCoreError> {
140        let _ = Frequency::new(frequency.0)?;
141        let _ = Amplitude::new(amplitude.0)?;
142        Ok(Self {
143            frequency,
144            amplitude,
145            phase: phase.normalized(),
146        })
147    }
148}
149
150/// The interpolation curve applied across an [`Envelope`].
151#[derive(Clone, Debug, PartialEq)]
152pub enum EnvelopeShape {
153    /// Straight-line segments between envelope stages.
154    Linear,
155    /// Linear segments raised to the given exponent for a curved response.
156    Exponential(f64),
157    /// A named custom shape, treated as linear by the built-in sampler.
158    Custom(String),
159}
160
161/// An attack/decay/sustain/release amplitude envelope.
162#[derive(Clone, Debug, PartialEq)]
163pub struct Envelope {
164    /// Time taken to rise from silence to full level.
165    pub attack: Duration,
166    /// Time taken to fall from full level to the sustain level.
167    pub decay: Duration,
168    /// Held level during the sustain phase, in `0.0..=1.0`.
169    pub sustain: f64,
170    /// Time taken to fall from the sustain level back to silence.
171    pub release: Duration,
172    /// Interpolation curve applied across the stages.
173    pub shape: EnvelopeShape,
174}
175
176impl Envelope {
177    /// Builds an envelope, returning [`SoundCoreError::InvalidSustain`] when
178    /// `sustain` falls outside `0.0..=1.0`.
179    pub fn new(
180        attack: Duration,
181        decay: Duration,
182        sustain: f64,
183        release: Duration,
184        shape: EnvelopeShape,
185    ) -> Result<Self, SoundCoreError> {
186        if !sustain.is_finite() || !(0.0..=1.0).contains(&sustain) {
187            return Err(SoundCoreError::InvalidSustain);
188        }
189        Ok(Self {
190            attack,
191            decay,
192            sustain,
193            release,
194            shape,
195        })
196    }
197
198    /// Returns the envelope level in `0.0..=1.0` at elapsed time `t` for a tone
199    /// of length `total`.
200    pub fn sample_level(&self, t: Duration, total: Duration) -> f64 {
201        let elapsed = t.as_secs_f64();
202        let attack = self.attack.as_secs_f64();
203        let decay = self.decay.as_secs_f64();
204        let release = self.release.as_secs_f64();
205        let total_secs = total.as_secs_f64();
206        let release_start = (total_secs - release).max(0.0);
207        match &self.shape {
208            EnvelopeShape::Linear | EnvelopeShape::Custom(_) => {
209                if attack > 0.0 && elapsed < attack {
210                    elapsed / attack
211                } else if decay > 0.0 && elapsed < attack + decay {
212                    let progress = (elapsed - attack) / decay;
213                    1.0 + (self.sustain - 1.0) * progress
214                } else if elapsed < release_start {
215                    self.sustain
216                } else if release > 0.0 && elapsed <= total_secs {
217                    let progress = ((elapsed - release_start) / release).clamp(0.0, 1.0);
218                    self.sustain * (1.0 - progress)
219                } else {
220                    0.0
221                }
222            }
223            EnvelopeShape::Exponential(curve) => {
224                let base = self.clone().with_shape(EnvelopeShape::Linear);
225                base.sample_level(t, total).powf((*curve).max(0.01))
226            }
227        }
228    }
229
230    fn with_shape(mut self, shape: EnvelopeShape) -> Self {
231        self.shape = shape;
232        self
233    }
234}
235
236/// A complete tone: a set of [`Partial`]s shaped by an [`Envelope`] over a
237/// fixed duration.
238#[derive(Clone, Debug, PartialEq)]
239pub struct Tone {
240    /// Sinusoidal components that sum to form the tone.
241    pub partials: Vec<Partial>,
242    /// Amplitude envelope applied across the tone.
243    pub envelope: Envelope,
244    /// Total sounding length of the tone.
245    pub duration: Duration,
246}
247
248impl Tone {
249    /// Builds a pure sine tone at `frequency` with the default envelope.
250    pub fn sine(frequency: Frequency, duration: Duration) -> Self {
251        Self::from_partials(
252            vec![Partial {
253                frequency,
254                amplitude: Amplitude(1.0),
255                phase: Phase(0.0),
256            }],
257            default_envelope(),
258            duration,
259        )
260        .expect("sine tone is valid")
261    }
262
263    /// Builds a sawtooth tone from `partials` harmonics with `1/n` amplitudes.
264    pub fn sawtooth(frequency: Frequency, duration: Duration, partials: usize) -> Self {
265        Self::harmonic_series(frequency, duration, partials, |n| 1.0 / n as f64, |_| true)
266    }
267
268    /// Builds a square tone from the odd harmonics within `partials`, with
269    /// `1/n` amplitudes.
270    pub fn square(frequency: Frequency, duration: Duration, partials: usize) -> Self {
271        Self::harmonic_series(
272            frequency,
273            duration,
274            partials,
275            |n| 1.0 / n as f64,
276            |n| n % 2 == 1,
277        )
278    }
279
280    /// Builds a triangle tone from the odd harmonics within `partials`, with
281    /// `1/n^2` amplitudes.
282    pub fn triangle(frequency: Frequency, duration: Duration, partials: usize) -> Self {
283        Self::harmonic_series(
284            frequency,
285            duration,
286            partials,
287            |n| 1.0 / ((n * n) as f64),
288            |n| n % 2 == 1,
289        )
290    }
291
292    /// Builds a tone from explicit partials, rejecting empty partial lists,
293    /// zero durations, and invalid component values.
294    pub fn from_partials(
295        partials: Vec<Partial>,
296        envelope: Envelope,
297        duration: Duration,
298    ) -> Result<Self, SoundCoreError> {
299        if duration.is_zero() {
300            return Err(SoundCoreError::InvalidDuration);
301        }
302        if partials.is_empty() {
303            return Err(SoundCoreError::EmptyPartials);
304        }
305        for partial in &partials {
306            let _ = Frequency::new(partial.frequency.0)?;
307            let _ = Amplitude::new(partial.amplitude.0)?;
308        }
309        Ok(Self {
310            partials,
311            envelope,
312            duration,
313        })
314    }
315
316    /// Returns the tone with every partial shifted by `cents`.
317    pub fn transpose_cents(mut self, cents: f64) -> Self {
318        for partial in &mut self.partials {
319            partial.frequency = partial.frequency.shift_cents(cents);
320        }
321        self
322    }
323
324    /// Returns the tone with every partial amplitude scaled by `gain`.
325    pub fn amplify(mut self, gain: f64) -> Self {
326        for partial in &mut self.partials {
327            partial.amplitude = Amplitude(partial.amplitude.0 * gain);
328        }
329        self
330    }
331
332    /// Returns the tone with its duration and envelope stages scaled by
333    /// `factor`, rejecting non-positive factors.
334    pub fn time_stretch(mut self, factor: f64) -> Result<Self, SoundCoreError> {
335        if !factor.is_finite() || factor <= 0.0 {
336            return Err(SoundCoreError::InvalidStretch);
337        }
338        self.duration = Duration::from_secs_f64(self.duration.as_secs_f64() * factor);
339        self.envelope.attack = Duration::from_secs_f64(self.envelope.attack.as_secs_f64() * factor);
340        self.envelope.decay = Duration::from_secs_f64(self.envelope.decay.as_secs_f64() * factor);
341        self.envelope.release =
342            Duration::from_secs_f64(self.envelope.release.as_secs_f64() * factor);
343        Ok(self)
344    }
345
346    fn harmonic_series(
347        frequency: Frequency,
348        duration: Duration,
349        partial_count: usize,
350        amp: impl Fn(usize) -> f64,
351        include: impl Fn(usize) -> bool,
352    ) -> Self {
353        let partials = (1..=partial_count.max(1))
354            .filter(|n| include(*n))
355            .map(|n| Partial {
356                frequency: Frequency(frequency.0 * n as f64),
357                amplitude: Amplitude(amp(n)),
358                phase: Phase(0.0),
359            })
360            .collect();
361        Self::from_partials(partials, default_envelope(), duration)
362            .expect("harmonic-series tone is valid")
363    }
364}
365
366impl Add for Tone {
367    type Output = Self;
368
369    fn add(mut self, other: Self) -> Self::Output {
370        self.partials.extend(other.partials);
371        self.duration = self.duration.max(other.duration);
372        self
373    }
374}
375
376/// Returns a general-purpose default envelope (short attack and decay, high
377/// sustain, moderate release, linear shape).
378pub fn default_envelope() -> Envelope {
379    Envelope::new(
380        Duration::from_millis(10),
381        Duration::from_millis(50),
382        0.8,
383        Duration::from_millis(100),
384        EnvelopeShape::Linear,
385    )
386    .expect("default envelope is valid")
387}
388
389/// Returns the 12-tone equal-temperament frequency of `pitch`, with A4 (MIDI
390/// 69) anchored at 440 Hz.
391pub fn equal_temperament_frequency(pitch: Pitch) -> Frequency {
392    let semitones = pitch.semitone() - 69;
393    Frequency(440.0 * 2.0_f64.powf(semitones as f64 / 12.0))
394}