iris_chat_core/desktop_call/
tones.rs1use 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 #[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 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}