1use std::ops::Add;
2use std::time::Duration;
3
4use sim_lib_pitch_core::Pitch;
5use thiserror::Error;
6
7#[derive(Debug, Error, Clone, PartialEq)]
9pub enum SoundCoreError {
10 #[error("frequency must be positive")]
12 InvalidFrequency,
13 #[error("amplitude must be non-negative")]
15 InvalidAmplitude,
16 #[error("envelope sustain must be between 0.0 and 1.0")]
18 InvalidSustain,
19 #[error("tone duration must be positive")]
21 InvalidDuration,
22 #[error("tone must contain at least one partial")]
24 EmptyPartials,
25 #[error("time-stretch factor must be positive")]
27 InvalidStretch,
28}
29
30#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
43pub struct Frequency(pub f64);
44
45impl Frequency {
46 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 pub fn ratio(self, other: Frequency) -> f64 {
58 self.0 / other.0
59 }
60
61 pub fn cents_above(self, other: Frequency) -> f64 {
63 1200.0 * self.ratio(other).log2()
64 }
65
66 pub fn shift_cents(self, cents: f64) -> Frequency {
69 Frequency(self.0 * 2.0_f64.powf(cents / 1200.0))
70 }
71}
72
73#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
85pub struct Amplitude(pub f64);
86
87impl Amplitude {
88 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 pub fn from_db(db: f64) -> Self {
100 Self(10f64.powf(db / 20.0))
101 }
102
103 pub fn to_db(self) -> f64 {
105 20.0 * self.0.log10()
106 }
107}
108
109#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
111pub struct Phase(pub f64);
112
113impl Phase {
114 pub fn normalized(self) -> Self {
116 let tau = std::f64::consts::TAU;
117 Self(self.0.rem_euclid(tau))
118 }
119}
120
121#[derive(Copy, Clone, Debug, PartialEq)]
123pub struct Partial {
124 pub frequency: Frequency,
126 pub amplitude: Amplitude,
128 pub phase: Phase,
130}
131
132impl Partial {
133 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#[derive(Clone, Debug, PartialEq)]
152pub enum EnvelopeShape {
153 Linear,
155 Exponential(f64),
157 Custom(String),
159}
160
161#[derive(Clone, Debug, PartialEq)]
163pub struct Envelope {
164 pub attack: Duration,
166 pub decay: Duration,
168 pub sustain: f64,
170 pub release: Duration,
172 pub shape: EnvelopeShape,
174}
175
176impl Envelope {
177 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 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#[derive(Clone, Debug, PartialEq)]
239pub struct Tone {
240 pub partials: Vec<Partial>,
242 pub envelope: Envelope,
244 pub duration: Duration,
246}
247
248impl Tone {
249 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 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 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 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 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 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 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 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
376pub 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
389pub 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}