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