Skip to main content

ringo_core/
tones.rs

1//! Telephony tone synthesis.
2//!
3//! Call-progress tones are not recordings — they are specifications (ITU-T
4//! E.180 and the national supplements): a few sine frequencies in a fixed
5//! on/off cadence. Generating them beats shipping WAV files on every count:
6//! nothing to license, any sample rate we like instead of 8 kHz mu-law, an
7//! exact cadence, and a seamless loop.
8//!
9//! A tone is written the way Asterisk's `indications.conf` writes it, so any of
10//! its 50 country zones can be pasted in verbatim:
11//!
12//! ```text
13//! 425/1000,0/4000        Germany, ringback: 425 Hz for 1 s, then 4 s of silence
14//! 440+480/2000,0/4000    North America, ringback: two mixed frequencies
15//! ```
16//!
17//! Elements are separated by commas; each is `freq[+freq…]/milliseconds`, and a
18//! frequency of `0` is silence. Asterisk's `*` (modulation) and `!` (play once)
19//! are not supported — say so in the error rather than mis-playing them.
20
21use std::fmt;
22
23/// One stretch of a tone: which frequencies sound, and for how long.
24/// An empty `freqs` is silence — that is how a cadence's gap is written.
25#[derive(Debug, Clone, PartialEq)]
26pub struct Segment {
27    pub freqs: Vec<f64>,
28    pub ms: u32,
29}
30
31/// A complete tone: the segments of exactly one cadence period.
32#[derive(Debug, Clone, PartialEq)]
33pub struct Tone {
34    pub segments: Vec<Segment>,
35}
36
37// ─── The built-in tones ──────────────────────────────────────────────────────
38//
39// Written as specs rather than built as structs, so the defaults go through the
40// very same parser a user's config does — one code path, exercised on every
41// start.
42
43/// Germany, ringback (Freiton). Shared with most of Europe and, per ITU-T,
44/// much of the world. Asterisk zone `[de]`, `ring`.
45pub const DE_RINGBACK: &str = "425/1000,0/4000";
46
47/// Germany, busy (Besetztton) — the called party is on the phone.
48/// Asterisk zone `[de]`, `busy`.
49pub const DE_BUSY: &str = "425/480,0/480";
50
51/// Germany, congestion (Gassenbesetztton) — the network could not put the call
52/// through. Same frequency as busy at twice the rate, which is exactly the
53/// distinction between a SIP 486 and any other failure.
54/// Asterisk zone `[de]`, `congestion`.
55pub const DE_CONGESTION: &str = "425/240,0/240";
56
57// ─── Chimes ──────────────────────────────────────────────────────────────────
58//
59// Ring and message are not signals, they are sounds — no specification says
60// what an incoming call should sound like. But "a short melodic motif of struck
61// notes" is a description, not a recording: a fundamental with its partials,
62// decaying the way something physically hit decays. That is a few lines of
63// arithmetic, and it leaves ringo with no third-party audio at all.
64
65/// A partial of a struck note: its frequency as a multiple of the fundamental,
66/// and its amplitude relative to it.
67pub 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
76/// How a struck note is coloured and how long it rings.
77pub struct Timbre {
78    pub name: &'static str,
79    pub partials: &'static [Partial],
80    /// Time to fade to inaudibility. Short reads as wooden, long as metallic.
81    pub decay_ms: u32,
82}
83
84/// Nearly a pure sine, with a touch of the octave — the quietest of the
85/// timbres we tried, and the one that stays bearable on the tenth repetition.
86/// Adding another is six lines: partials as multiples of the fundamental, plus
87/// a decay. A marimba is 1/4/10 and 600 ms, struck glass 1/2/2.4/3/4.5 and
88/// 1100 ms.
89pub 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
95/// A short motif of struck notes, plus the silence that follows it — so a
96/// looping alert carries its own cadence and needs no gap bolted on.
97pub struct Chime {
98    /// Note frequencies in Hz, played in order.
99    pub freqs: &'static [f64],
100    /// Time between note onsets. Shorter than the decay, so notes ring into
101    /// one another instead of being cut off — that overlap is most of what
102    /// makes this sound designed rather than beeped.
103    pub spacing_ms: u32,
104    pub timbre: &'static Timbre,
105    /// One full period, motif plus trailing silence.
106    pub period_ms: u32,
107}
108
109/// Incoming call: an ascending G–C–E triad, repeating every 2.5 s. Modern
110/// ringtones are a short motif in a cadence, not a continuous tone.
111pub 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
118/// New voicemail: two descending notes, played once. Deliberately smaller than
119/// the ring — it reports something, it does not ask for you.
120pub const MESSAGE: Chime = Chime {
121    freqs: &[1046.50, 783.99],
122    spacing_ms: 200,
123    timbre: &SOFT,
124    period_ms: 1200,
125};
126
127/// Render one period of `chime` as mono S16 samples at `srate`.
128pub 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    // Amplitude falls to -60 dB after decay_ms.
132    let tau = chime.timbre.decay_ms as f64 / 1000.0 / 6.9;
133    let attack = (srate as f64 * 0.003) as usize; // 3 ms, enough to kill the click
134    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        // A motif longer than its period would index past the buffer. The
137        // built-ins never do, but Chime is public — a caller composing one must
138        // get a truncated chime, not a panicked process.
139        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    // Notes overlap, so the peak is whatever it turns out to be — normalize
161    // rather than trying to budget amplitudes per note.
162    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
173// ─── Parsing ─────────────────────────────────────────────────────────────────
174
175/// Guard rails. Generous enough for any real cadence, tight enough that a
176/// mistyped config cannot allocate its way through memory.
177const 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    // A lone 0 is how a cadence writes its gap.
234    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/// Why a tone spec could not be read. The message completes the sentence
261/// "the tone …", so it reads as one line in a log.
262#[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
273// ─── Synthesis ───────────────────────────────────────────────────────────────
274
275/// Peak amplitude of a segment, as a fraction of full scale. Tones carry far
276/// more energy than speech at the same peak, so this sits well below 1.0 — an
277/// alert should be audible, not startling.
278const LEVEL: f64 = 0.5;
279
280/// Fade in and out of every tone segment. A sine cut mid-cycle is a step, and a
281/// step is a click; a few milliseconds of raised cosine removes it without
282/// audibly softening the cadence.
283const FADE_MS: f64 = 5.0;
284
285/// Render `periods` repetitions of `tone` as mono S16 samples at `srate`.
286pub 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            // Split the level across the components so a dual tone peaks at the
296            // same place a single one does.
297            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
313/// Raised-cosine gain for sample `i` of `n`, ramping over `fade` samples at
314/// each end.
315fn 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        // Chime is public, so a caller can compose one whose motif outlasts the
347        // period. That must clip, not abort the process.
348        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        // The North American ringback, pasted from Asterisk's [us] zone.
388        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        // Four elements — the reason a tone is a segment list and not a triple.
395        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        // `!` and `*` appear in real indications.conf zones, so someone will
428        // paste one. Saying which part is unsupported beats "invalid".
429        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        // The whole point of the envelope: no step at a segment boundary, so no
469        // click when the cadence repeats.
470        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        // Count zero crossings over the tone segment: a 425 Hz sine crosses zero
496        // twice per cycle, so ~2 * 425 * 0.48 s over the 480 ms burst.
497        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}