Skip to main content

iris_chat_core/desktop_call/
tones.rs

1//! Outgoing progress sounds. This output-only worker never opens capture devices
2//! or blocks the UI while the operating system opens an audio stream.
3use std::sync::{
4    atomic::{AtomicU8, Ordering},
5    Arc,
6};
7
8#[derive(uniffi::Object)]
9pub struct DesktopCallTone {
10    state: Arc<AtomicU8>,
11}
12#[uniffi::export]
13impl DesktopCallTone {
14    #[uniffi::constructor]
15    pub fn new(ringing: bool) -> Arc<Self> {
16        let state = Arc::new(AtomicU8::new(if ringing { 2 } else { 1 }));
17        #[cfg(feature = "desktop-media")]
18        {
19            let state = state.clone();
20            std::thread::spawn(move || play(state));
21        }
22        Arc::new(Self { state })
23    }
24    pub fn set_ringing(&self, ringing: bool) {
25        // Stop is terminal: a stale UI snapshot cannot revive a retired worker.
26        #[allow(
27            deprecated,
28            reason = "The Linux release toolchain uses Rust 1.93, before Atomic::try_update stabilized."
29        )]
30        let _ = self
31            .state
32            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |old| {
33                (old != 0).then_some(if ringing { 2 } else { 1 })
34            });
35    }
36    pub fn stop(&self) {
37        self.state.store(0, Ordering::Release);
38    }
39}
40impl Drop for DesktopCallTone {
41    fn drop(&mut self) {
42        self.stop();
43    }
44}
45
46#[cfg(feature = "desktop-media")]
47fn play(state: Arc<AtomicU8>) {
48    use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
49    let Some(device) = cpal::default_host().default_output_device() else {
50        return;
51    };
52    let Ok(config) = device.default_output_config() else {
53        return;
54    };
55    if state.load(Ordering::Acquire) == 0 {
56        return;
57    }
58    let stream = match config.sample_format() {
59        cpal::SampleFormat::F32 => stream::<f32>(&device, &config.into(), state.clone()),
60        cpal::SampleFormat::I16 => stream::<i16>(&device, &config.into(), state.clone()),
61        cpal::SampleFormat::U16 => stream::<u16>(&device, &config.into(), state.clone()),
62        _ => return,
63    };
64    let Ok(stream) = stream else { return };
65    if stream.play().is_err() {
66        return;
67    }
68    while state.load(Ordering::Acquire) != 0 {
69        std::thread::sleep(std::time::Duration::from_millis(20));
70    }
71}
72
73#[cfg(feature = "desktop-media")]
74fn stream<T>(
75    device: &cpal::Device,
76    config: &cpal::StreamConfig,
77    state: Arc<AtomicU8>,
78) -> Result<cpal::Stream, cpal::BuildStreamError>
79where
80    T: cpal::SizedSample + cpal::FromSample<f32>,
81{
82    use cpal::traits::DeviceTrait;
83    let rate = config.sample_rate.0 as f64;
84    let channels = config.channels as usize;
85    let mut position = 0.0;
86    let mut last = 0;
87    let failed = state.clone();
88    device.build_output_stream(
89        config,
90        move |output: &mut [T], _| {
91            let tone = state.load(Ordering::Acquire);
92            if tone != last {
93                position = 0.0;
94                last = tone;
95            }
96            for frame in output.chunks_mut(channels) {
97                let value = sample(tone, position as usize);
98                position += 16_000.0 / rate;
99                frame.fill(T::from_sample(value));
100            }
101        },
102        move |_| {
103            failed.store(0, Ordering::Release);
104        },
105        None,
106    )
107}
108
109#[cfg(any(feature = "desktop-media", test))]
110fn sample(tone: u8, position: usize) -> f32 {
111    let wav: &[u8] = match tone {
112        1 => include_bytes!("../../assets/call-audio/call-connecting.wav"),
113        2 => include_bytes!("../../assets/call-audio/call-ringing.wav"),
114        _ => return 0.0,
115    };
116    // Canonical mono 16-bit PCM files from scripts/generate-call-tones.py.
117    let index = 44 + (position % ((wav.len() - 44) / 2)) * 2;
118    let Some(&[low, high]) = wav.get(index..index + 2) else {
119        return 0.0;
120    };
121    f32::from(i16::from_le_bytes([low, high])) / 32768.0
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    #[test]
128    fn outgoing_tones_have_distinct_cadence_and_stop_is_terminal() {
129        assert!((0..1_920).any(|i| sample(1, i).abs() > 0.1));
130        assert!((8_000..32_000).all(|i| sample(1, i) == 0.0));
131        assert!((16_000..32_000).any(|i| sample(2, i).abs() > 0.1));
132        assert!((32_000..96_000).all(|i| sample(2, i) == 0.0));
133        assert_eq!(sample(2, 123), sample(2, 96_123));
134        let tone = DesktopCallTone {
135            state: Arc::new(AtomicU8::new(1)),
136        };
137        tone.set_ringing(true);
138        assert_eq!(tone.state.load(Ordering::Acquire), 2);
139        tone.stop();
140        tone.set_ringing(false);
141        assert_eq!(tone.state.load(Ordering::Acquire), 0);
142        assert_eq!(sample(0, 123), 0.0);
143    }
144}