1use std::fmt;
22
23#[derive(Debug, Clone, PartialEq)]
26pub struct Segment {
27 pub freqs: Vec<f64>,
28 pub ms: u32,
29}
30
31#[derive(Debug, Clone, PartialEq)]
33pub struct Tone {
34 pub segments: Vec<Segment>,
35}
36
37pub const DE_RINGBACK: &str = "425/1000,0/4000";
46
47pub const DE_BUSY: &str = "425/480,0/480";
50
51pub const DE_CONGESTION: &str = "425/240,0/240";
56
57pub struct Partial {
68 pub ratio: f64,
69 pub amp: f64,
70}
71
72const fn partial(ratio: f64, amp: f64) -> Partial {
73 Partial { ratio, amp }
74}
75
76pub struct Timbre {
78 pub name: &'static str,
79 pub partials: &'static [Partial],
80 pub decay_ms: u32,
82}
83
84pub const SOFT: Timbre = Timbre {
90 name: "soft",
91 partials: &[partial(1.0, 1.0), partial(2.0, 0.14)],
92 decay_ms: 500,
93};
94
95pub struct Chime {
98 pub freqs: &'static [f64],
100 pub spacing_ms: u32,
104 pub timbre: &'static Timbre,
105 pub period_ms: u32,
107}
108
109pub const RING: Chime = Chime {
112 freqs: &[783.99, 1046.50, 1318.51],
113 spacing_ms: 170,
114 timbre: &SOFT,
115 period_ms: 2500,
116};
117
118pub const MESSAGE: Chime = Chime {
121 freqs: &[1046.50, 783.99],
122 spacing_ms: 200,
123 timbre: &SOFT,
124 period_ms: 1200,
125};
126
127pub fn render_chime(chime: &Chime, srate: u32) -> Vec<i16> {
129 let n = (srate as u64 * chime.period_ms as u64 / 1000) as usize;
130 let mut buf = vec![0.0f64; n];
131 let tau = chime.timbre.decay_ms as f64 / 1000.0 / 6.9;
133 let attack = (srate as f64 * 0.003) as usize; for (i, &freq) in chime.freqs.iter().enumerate() {
135 let onset = (srate as u64 * (i as u64 * chime.spacing_ms as u64) / 1000) as usize;
136 if onset >= n {
140 break;
141 }
142 for (age, slot) in buf[onset..].iter_mut().enumerate() {
143 let t = age as f64 / srate as f64;
144 let decay = (-t / tau).exp();
145 if decay < 0.0005 {
146 break;
147 }
148 let rise = if age < attack {
149 age as f64 / attack as f64
150 } else {
151 1.0
152 };
153 let mut v = 0.0;
154 for p in chime.timbre.partials {
155 v += p.amp * (std::f64::consts::TAU * freq * p.ratio * t).sin();
156 }
157 *slot += v * decay * rise;
158 }
159 }
160 let peak = buf.iter().fold(0.0f64, |m, v| m.max(v.abs()));
163 let gain = if peak > 0.0 { LEVEL / peak } else { 0.0 };
164 buf.iter()
165 .map(|v| {
166 (v * gain * i16::MAX as f64)
167 .round()
168 .clamp(-32768.0, 32767.0) as i16
169 })
170 .collect()
171}
172
173const MAX_SEGMENTS: usize = 32;
178const MAX_FREQS: usize = 4;
179const MAX_TOTAL_MS: u32 = 60_000;
180const FREQ_RANGE: std::ops::RangeInclusive<f64> = 20.0..=20_000.0;
181
182impl std::str::FromStr for Tone {
183 type Err = ParseError;
184
185 fn from_str(spec: &str) -> Result<Self, ParseError> {
186 let spec = spec.trim();
187 if spec.is_empty() {
188 return Err(ParseError("is empty".into()));
189 }
190 if spec.contains('*') || spec.contains('!') {
191 return Err(ParseError(
192 "uses '*' or '!', which ringo does not support — write the \
193 cadence out with '+' and commas instead"
194 .into(),
195 ));
196 }
197 let mut segments = Vec::new();
198 let mut total_ms = 0u32;
199 for (i, element) in spec.split(',').enumerate() {
200 let nth = i + 1;
201 let element = element.trim();
202 let Some((freqs, ms)) = element.split_once('/') else {
203 return Err(ParseError(format!(
204 "element {nth} ('{element}') has no duration — write it as freq/milliseconds"
205 )));
206 };
207 let ms: u32 = ms.trim().parse().map_err(|_| {
208 ParseError(format!(
209 "element {nth}: '{}' is not a duration in milliseconds",
210 ms.trim()
211 ))
212 })?;
213 if ms == 0 {
214 return Err(ParseError(format!("element {nth} lasts no time at all")));
215 }
216 total_ms = total_ms.saturating_add(ms);
217 let freqs = parse_freqs(freqs.trim(), nth)?;
218 segments.push(Segment { freqs, ms });
219 if segments.len() > MAX_SEGMENTS {
220 return Err(ParseError(format!("has more than {MAX_SEGMENTS} elements")));
221 }
222 }
223 if total_ms > MAX_TOTAL_MS {
224 return Err(ParseError(format!(
225 "lasts {total_ms} ms, more than the {MAX_TOTAL_MS} ms a cadence may take"
226 )));
227 }
228 Ok(Tone { segments })
229 }
230}
231
232fn parse_freqs(spec: &str, nth: usize) -> Result<Vec<f64>, ParseError> {
233 if spec == "0" {
235 return Ok(Vec::new());
236 }
237 let mut freqs = Vec::new();
238 for part in spec.split('+') {
239 let part = part.trim();
240 let f: f64 = part
241 .parse()
242 .map_err(|_| ParseError(format!("element {nth}: '{part}' is not a frequency in Hz")))?;
243 if !FREQ_RANGE.contains(&f) {
244 return Err(ParseError(format!(
245 "element {nth}: {f} Hz is outside {}–{} Hz",
246 FREQ_RANGE.start(),
247 FREQ_RANGE.end()
248 )));
249 }
250 freqs.push(f);
251 if freqs.len() > MAX_FREQS {
252 return Err(ParseError(format!(
253 "element {nth} mixes more than {MAX_FREQS} frequencies"
254 )));
255 }
256 }
257 Ok(freqs)
258}
259
260#[derive(Debug, Clone, PartialEq)]
263pub struct ParseError(pub String);
264
265impl fmt::Display for ParseError {
266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267 f.write_str(&self.0)
268 }
269}
270
271impl std::error::Error for ParseError {}
272
273const LEVEL: f64 = 0.5;
279
280const FADE_MS: f64 = 5.0;
284
285pub fn render(tone: &Tone, srate: u32, periods: u32) -> Vec<i16> {
287 let mut out = Vec::new();
288 for _ in 0..periods {
289 for seg in &tone.segments {
290 let n = (srate as u64 * seg.ms as u64 / 1000) as usize;
291 if seg.freqs.is_empty() {
292 out.extend(std::iter::repeat_n(0i16, n));
293 continue;
294 }
295 let amp = LEVEL / seg.freqs.len() as f64;
298 let fade = ((srate as f64 * FADE_MS / 1000.0) as usize).min(n / 2);
299 for i in 0..n {
300 let t = i as f64 / srate as f64;
301 let mut v = 0.0;
302 for &f in &seg.freqs {
303 v += amp * (std::f64::consts::TAU * f * t).sin();
304 }
305 v *= envelope(i, n, fade);
306 out.push((v * i16::MAX as f64).round().clamp(-32768.0, 32767.0) as i16);
307 }
308 }
309 }
310 out
311}
312
313fn envelope(i: usize, n: usize, fade: usize) -> f64 {
316 if fade == 0 {
317 return 1.0;
318 }
319 let rising = if i < fade {
320 i as f64 / fade as f64
321 } else {
322 1.0
323 };
324 let falling = if i + fade >= n {
325 (n - i) as f64 / fade as f64
326 } else {
327 1.0
328 };
329 let g = rising.min(falling).clamp(0.0, 1.0);
330 0.5 - 0.5 * (std::f64::consts::PI * g).cos()
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 const SRATE: u32 = 48000;
338
339 fn parse(spec: &str) -> Tone {
340 spec.parse()
341 .unwrap_or_else(|e| panic!("'{spec}' should parse: {e}"))
342 }
343
344 #[test]
345 fn a_chime_longer_than_its_period_is_truncated_not_fatal() {
346 let overlong = Chime {
349 freqs: &[440.0, 550.0, 660.0, 880.0],
350 spacing_ms: 500,
351 timbre: &SOFT,
352 period_ms: 300,
353 };
354 let s = render_chime(&overlong, SRATE);
355 assert_eq!(s.len(), SRATE as usize * 300 / 1000);
356 }
357
358 #[test]
359 fn a_chime_fits_inside_its_period() {
360 for c in [&RING, &MESSAGE] {
361 let s = render_chime(c, SRATE);
362 assert_eq!(s.len(), SRATE as usize * c.period_ms as usize / 1000);
363 assert!(s.iter().any(|&v| v != 0), "the chime must make a sound");
364 }
365 }
366
367 #[test]
368 fn every_built_in_spec_parses() {
369 for spec in [DE_RINGBACK, DE_BUSY, DE_CONGESTION] {
370 let t = parse(spec);
371 assert_eq!(t.segments.len(), 2, "'{spec}' should be tone + gap");
372 assert!(t.segments[1].freqs.is_empty(), "'{spec}' needs a gap");
373 }
374 }
375
376 #[test]
377 fn parses_a_single_frequency_cadence() {
378 let t = parse("425/1000,0/4000");
379 assert_eq!(t.segments[0].freqs, vec![425.0]);
380 assert_eq!(t.segments[0].ms, 1000);
381 assert_eq!(t.segments[1].freqs, Vec::<f64>::new());
382 assert_eq!(t.segments[1].ms, 4000);
383 }
384
385 #[test]
386 fn parses_a_mixed_frequency_cadence() {
387 let t = parse("440+480/2000,0/4000");
389 assert_eq!(t.segments[0].freqs, vec![440.0, 480.0]);
390 }
391
392 #[test]
393 fn parses_the_british_double_ring() {
394 let t = parse("400+450/400,0/200,400+450/400,0/2000");
396 assert_eq!(t.segments.len(), 4);
397 assert_eq!(t.segments[2].freqs, vec![400.0, 450.0]);
398 }
399
400 #[test]
401 fn tolerates_whitespace() {
402 assert_eq!(parse(" 425/480 , 0/480 "), parse("425/480,0/480"));
403 }
404
405 #[test]
406 fn rejects_a_missing_duration() {
407 let e = "425".parse::<Tone>().unwrap_err().to_string();
408 assert!(e.contains("no duration"), "unhelpful: {e}");
409 }
410
411 #[test]
412 fn rejects_nonsense_numbers() {
413 assert!("abc/500".parse::<Tone>().is_err());
414 assert!("425/abc".parse::<Tone>().is_err());
415 assert!("425/0".parse::<Tone>().is_err());
416 assert!("".parse::<Tone>().is_err());
417 }
418
419 #[test]
420 fn rejects_inaudible_frequencies() {
421 assert!("2/500".parse::<Tone>().is_err());
422 assert!("48000/500".parse::<Tone>().is_err());
423 }
424
425 #[test]
426 fn names_the_asterisk_syntax_it_cannot_read() {
427 let e = "!425/240,!0/240".parse::<Tone>().unwrap_err().to_string();
430 assert!(e.contains('!'), "unhelpful: {e}");
431 let e = "425*25/240".parse::<Tone>().unwrap_err().to_string();
432 assert!(e.contains('*'), "unhelpful: {e}");
433 }
434
435 #[test]
436 fn refuses_a_cadence_that_never_ends() {
437 assert!("425/59000,0/59000".parse::<Tone>().is_err());
438 }
439
440 #[test]
441 fn a_period_is_as_long_as_its_cadence() {
442 let t = parse(DE_BUSY);
443 let want = SRATE as usize * 960 / 1000;
444 assert_eq!(render(&t, SRATE, 1).len(), want);
445 }
446
447 #[test]
448 fn periods_repeat_exactly() {
449 let t = parse(DE_BUSY);
450 let one = render(&t, SRATE, 1);
451 let three = render(&t, SRATE, 3);
452 assert_eq!(three.len(), one.len() * 3);
453 assert_eq!(&three[..one.len()], &one[..], "each period is identical");
454 }
455
456 #[test]
457 fn the_gap_is_actually_silent() {
458 let s = render(&parse(DE_BUSY), SRATE, 1);
459 let gap = &s[SRATE as usize * 480 / 1000..];
460 assert!(
461 gap.iter().all(|&v| v == 0),
462 "the cadence gap must be silent"
463 );
464 }
465
466 #[test]
467 fn segments_start_and_end_near_zero() {
468 for spec in [DE_RINGBACK, DE_BUSY, DE_CONGESTION, "440+480/2000,0/4000"] {
471 let s = render(&parse(spec), SRATE, 1);
472 assert!(s[0].abs() < 100, "'{spec}' clicks on entry");
473 assert!(s.last().unwrap().abs() < 100, "'{spec}' clicks on exit");
474 }
475 }
476
477 #[test]
478 fn stays_below_full_scale() {
479 for spec in [DE_RINGBACK, "440+480/2000,0/4000"] {
480 let peak = render(&parse(spec), SRATE, 1)
481 .iter()
482 .map(|v| v.abs())
483 .max()
484 .unwrap();
485 assert!(peak > 1000, "'{spec}' is inaudibly quiet");
486 assert!(
487 (peak as f64) < LEVEL * 1.05 * i16::MAX as f64,
488 "'{spec}' exceeds its level budget (peak {peak})"
489 );
490 }
491 }
492
493 #[test]
494 fn a_single_tone_lands_on_its_frequency() {
495 let s = render(&parse(DE_BUSY), SRATE, 1);
498 let burst = &s[..SRATE as usize * 480 / 1000];
499 let crossings = burst
500 .windows(2)
501 .filter(|w| (w[0] < 0) != (w[1] < 0))
502 .count();
503 let expect = (2.0 * 425.0 * 0.48) as usize;
504 assert!(
505 crossings.abs_diff(expect) <= 2,
506 "expected ~{expect} zero crossings, got {crossings}"
507 );
508 }
509}