Skip to main content

active_call/media/
ambiance.rs

1use super::processor::Processor;
2use crate::media::{AudioFrame, INTERNAL_SAMPLERATE, Samples};
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5use std::sync::{Arc, Mutex};
6use tracing::info;
7
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9#[serde(rename_all = "camelCase")]
10pub struct AmbianceOption {
11    pub path: Option<String>,
12    pub duck_level: Option<f32>,
13    pub normal_level: Option<f32>,
14    pub transition_speed: Option<f32>,
15    pub enabled: Option<bool>,
16}
17
18impl AmbianceOption {
19    pub fn merge(&mut self, other: &AmbianceOption) {
20        if self.path.is_none() {
21            self.path = other.path.clone();
22        }
23        if self.duck_level.is_none() {
24            self.duck_level = other.duck_level;
25        }
26        if self.normal_level.is_none() {
27            self.normal_level = other.normal_level;
28        }
29        if self.transition_speed.is_none() {
30            self.transition_speed = other.transition_speed;
31        }
32        if self.enabled.is_none() {
33            self.enabled = other.enabled;
34        }
35    }
36}
37
38pub struct AmbianceProcessor {
39    samples: Vec<i16>,
40    cursor: usize,
41    duck_level: f32,
42    normal_level: f32,
43    enabled: bool,
44    current_level: f32,
45    transition_speed: f32,
46    resample_phase: u32,
47    resample_step: u32,
48}
49
50impl AmbianceProcessor {
51    pub async fn new(option: AmbianceOption) -> Result<Self> {
52        let path = option
53            .path
54            .ok_or_else(|| anyhow::anyhow!("Ambiance path required"))?;
55
56        let samples =
57            crate::media::loader::load_audio_as_pcm(&path, INTERNAL_SAMPLERATE, true).await?;
58
59        info!("Loading ambiance {}: samples={}", path, samples.len());
60
61        let normal_level = option.normal_level.unwrap_or(0.3);
62        Ok(Self {
63            samples,
64            cursor: 0,
65            duck_level: option.duck_level.unwrap_or(0.1),
66            normal_level,
67            enabled: option.enabled.unwrap_or(true),
68            current_level: normal_level,
69            transition_speed: option.transition_speed.unwrap_or(0.01),
70            resample_phase: 0,
71            resample_step: 1 << 16,
72        })
73    }
74
75    pub fn set_enabled(&mut self, enabled: bool) {
76        self.enabled = enabled;
77    }
78
79    pub fn set_levels(&mut self, normal: f32, duck: f32) {
80        self.normal_level = normal;
81        self.duck_level = duck;
82    }
83
84    #[inline]
85    fn get_ambient_sample_with_rate(&mut self, target_sample_rate: u32) -> i16 {
86        if self.samples.is_empty() {
87            return 0;
88        }
89
90        self.resample_step =
91            (((INTERNAL_SAMPLERATE as u64) << 16) / target_sample_rate as u64) as u32;
92        let sample = self.samples[self.cursor];
93
94        self.resample_phase += self.resample_step;
95        while self.resample_phase >= (1 << 16) {
96            self.resample_phase -= 1 << 16;
97            self.cursor = (self.cursor + 1) % self.samples.len();
98        }
99
100        sample
101    }
102
103    #[inline]
104    fn soft_mix(signal: i16, ambient: i16, level: f32) -> i16 {
105        let ambient_scaled = (ambient as i32 * (level * 256.0) as i32) >> 8;
106        let signal_i32 = signal as i32;
107        let mixed = signal_i32 + ambient_scaled;
108
109        if mixed > 32767 {
110            let over = mixed - 32767;
111            (32767 - (over >> 2)) as i16
112        } else if mixed < -32768 {
113            let under = -32768 - mixed;
114            (-32768 + (under >> 2)) as i16
115        } else {
116            mixed as i16
117        }
118    }
119}
120
121impl Processor for AmbianceProcessor {
122    fn process_frame(&mut self, frame: &mut AudioFrame) -> Result<()> {
123        if !self.enabled || self.samples.is_empty() {
124            return Ok(());
125        }
126
127        let is_server_side_speaking = match &frame.samples {
128            Samples::PCM { samples } => !samples.is_empty(),
129            Samples::RTP { .. } => true,
130            Samples::Empty => false,
131        };
132
133        let target_level = if is_server_side_speaking {
134            self.duck_level
135        } else {
136            self.normal_level
137        };
138
139        if (self.current_level - target_level).abs() > 0.001 {
140            if self.current_level < target_level {
141                self.current_level = (self.current_level + self.transition_speed).min(target_level);
142            } else {
143                self.current_level = (self.current_level - self.transition_speed).max(target_level);
144            }
145        }
146
147        let sample_rate = if frame.sample_rate > 0 {
148            frame.sample_rate
149        } else {
150            INTERNAL_SAMPLERATE
151        };
152        let channels = frame.channels.max(1) as usize;
153
154        match &mut frame.samples {
155            Samples::PCM { samples } => {
156                let frame_sample_count = samples.len() / channels;
157                for i in 0..frame_sample_count {
158                    let ambient = self.get_ambient_sample_with_rate(sample_rate);
159                    for c in 0..channels {
160                        let idx = i * channels + c;
161                        if idx < samples.len() {
162                            samples[idx] =
163                                Self::soft_mix(samples[idx], ambient, self.current_level);
164                        }
165                    }
166                }
167            }
168            Samples::Empty => {
169                let frame_size = (sample_rate as usize * 20) / 1000;
170                let mut ambient_samples = Vec::with_capacity(frame_size * channels);
171                for _ in 0..frame_size {
172                    let ambient = self.get_ambient_sample_with_rate(sample_rate);
173                    let ambient_scaled =
174                        ((ambient as i32 * (self.current_level * 256.0) as i32) >> 8) as i16;
175                    for _ in 0..channels {
176                        ambient_samples.push(ambient_scaled);
177                    }
178                }
179                frame.samples = Samples::PCM {
180                    samples: ambient_samples,
181                };
182                frame.sample_rate = sample_rate;
183                frame.channels = channels as u16;
184            }
185            _ => {}
186        }
187
188        Ok(())
189    }
190}
191
192/// Share one loaded wav / playhead across the TTS mixer and the idle filler.
193#[derive(Clone)]
194pub struct SharedAmbianceProcessor {
195    inner: Arc<Mutex<AmbianceProcessor>>,
196}
197
198impl SharedAmbianceProcessor {
199    pub fn new(inner: Arc<Mutex<AmbianceProcessor>>) -> Self {
200        Self { inner }
201    }
202
203    pub fn inner(&self) -> Arc<Mutex<AmbianceProcessor>> {
204        self.inner.clone()
205    }
206}
207
208impl Processor for SharedAmbianceProcessor {
209    fn process_frame(&mut self, frame: &mut AudioFrame) -> Result<()> {
210        self.inner.lock().unwrap().process_frame(frame)
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::media::AudioFrame;
218
219    fn loud_processor() -> AmbianceProcessor {
220        AmbianceProcessor {
221            samples: vec![8000i16; INTERNAL_SAMPLERATE as usize],
222            cursor: 0,
223            duck_level: 0.5,
224            normal_level: 1.0,
225            enabled: true,
226            current_level: 1.0,
227            transition_speed: 1.0,
228            resample_phase: 0,
229            resample_step: 1 << 16,
230        }
231    }
232
233    #[test]
234    fn empty_frame_becomes_ambiance_pcm() {
235        let mut processor = loud_processor();
236        let mut frame = AudioFrame {
237            track_id: "server-side-track".to_string(),
238            samples: Samples::Empty,
239            timestamp: 0,
240            sample_rate: INTERNAL_SAMPLERATE,
241            channels: 1,
242            ..Default::default()
243        };
244        processor.process_frame(&mut frame).unwrap();
245        match frame.samples {
246            Samples::PCM { samples } => {
247                assert_eq!(samples.len(), 320);
248                assert!(
249                    samples.iter().any(|s| *s != 0),
250                    "idle frame should carry ambiance"
251                );
252            }
253            other => panic!("expected PCM, got {:?}", other),
254        }
255    }
256
257    #[test]
258    fn pcm_frame_is_mixed() {
259        let mut processor = loud_processor();
260        let mut frame = AudioFrame {
261            track_id: "server-side-track".to_string(),
262            samples: Samples::PCM {
263                samples: vec![1000; 320],
264            },
265            timestamp: 0,
266            sample_rate: INTERNAL_SAMPLERATE,
267            channels: 1,
268            ..Default::default()
269        };
270        processor.process_frame(&mut frame).unwrap();
271        match frame.samples {
272            Samples::PCM { samples } => {
273                assert!(
274                    samples.iter().any(|s| *s != 1000),
275                    "tts frame should mix ambiance"
276                );
277            }
278            other => panic!("expected PCM, got {:?}", other),
279        }
280    }
281
282    /// Cost of one idle tick (20ms frame mix) — the idle loop runs this 50x/s
283    /// per call. Skipped in debug builds (repo convention, see perf_analysis.rs).
284    #[test]
285    fn perf_idle_mix_cost() {
286        if cfg!(debug_assertions) {
287            println!("Skipping ambiance idle mix perf test in debug mode.");
288            return;
289        }
290        let mut processor = loud_processor();
291        let budget_us = 100.0;
292        let iterations = 10_000u32;
293
294        let start = std::time::Instant::now();
295        for i in 0..iterations {
296            let mut frame = AudioFrame {
297                track_id: "server-side-track".to_string(),
298                samples: Samples::Empty,
299                timestamp: i as u64,
300                sample_rate: INTERNAL_SAMPLERATE,
301                channels: 1,
302                ..Default::default()
303            };
304            processor.process_frame(&mut frame).unwrap();
305        }
306        let per_call_us = start.elapsed().as_micros() as f64 / iterations as f64;
307        println!("ambiance idle mix: {:.2} µs per 20ms frame", per_call_us);
308        assert!(
309            per_call_us < budget_us,
310            "idle mix {:.2} µs/frame exceeds {:.0} µs budget",
311            per_call_us,
312            budget_us
313        );
314    }
315}