Skip to main content

clawft_plugin/voice/
echo.rs

1//! Echo cancellation for voice pipeline.
2//!
3//! Prevents the system from hearing its own TTS output during Talk Mode.
4//! Uses a reference signal (the TTS audio) to cancel echoes from the mic input.
5//!
6//! Current implementation: stub (passthrough). Real AEC will use webrtc-audio-processing
7//! or a custom delay-line canceller in a future phase.
8
9use serde::{Deserialize, Serialize};
10
11/// Configuration for echo cancellation.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct EchoCancellerConfig {
14    /// Enable echo cancellation.
15    #[serde(default = "default_true")]
16    pub enabled: bool,
17    /// Tail length in milliseconds (how far back to look for echoes).
18    /// Typical values: 50-200ms.
19    #[serde(default = "default_tail_ms")]
20    pub tail_length_ms: u32,
21    /// Suppression level (0.0 = no suppression, 1.0 = maximum suppression).
22    #[serde(default = "default_suppression")]
23    pub suppression_level: f32,
24}
25
26fn default_true() -> bool {
27    true
28}
29fn default_tail_ms() -> u32 {
30    128
31}
32fn default_suppression() -> f32 {
33    0.8
34}
35
36impl Default for EchoCancellerConfig {
37    fn default() -> Self {
38        Self {
39            enabled: true,
40            tail_length_ms: 128,
41            suppression_level: 0.8,
42        }
43    }
44}
45
46/// Echo canceller state.
47pub struct EchoCanceller {
48    config: EchoCancellerConfig,
49    /// Circular buffer for reference signal (TTS output).
50    reference_buffer: Vec<f32>,
51    /// Write position in reference buffer.
52    write_pos: usize,
53    /// Number of processed frames.
54    frames_processed: u64,
55}
56
57impl EchoCanceller {
58    /// Create a new echo canceller with the given configuration.
59    pub fn new(config: EchoCancellerConfig) -> Self {
60        // Buffer size: tail_length_ms * sample_rate / 1000
61        let buffer_size = (config.tail_length_ms as usize * 16000) / 1000;
62        Self {
63            config,
64            reference_buffer: vec![0.0; buffer_size],
65            write_pos: 0,
66            frames_processed: 0,
67        }
68    }
69
70    /// Feed reference audio (TTS output) to the canceller.
71    pub fn feed_reference(&mut self, samples: &[f32]) {
72        if !self.config.enabled {
73            return;
74        }
75        for &sample in samples {
76            self.reference_buffer[self.write_pos] = sample;
77            self.write_pos = (self.write_pos + 1) % self.reference_buffer.len();
78        }
79    }
80
81    /// Process mic input, cancelling echo from reference.
82    /// Currently a passthrough stub -- returns input unchanged.
83    pub fn process(&mut self, input: &[f32]) -> Vec<f32> {
84        self.frames_processed += 1;
85        if !self.config.enabled {
86            return input.to_vec();
87        }
88        // STUB: Real AEC would use NLMS or frequency-domain adaptive filter here.
89        // For now, just pass through.
90        input.to_vec()
91    }
92
93    /// Reset the echo canceller state.
94    pub fn reset(&mut self) {
95        self.reference_buffer.fill(0.0);
96        self.write_pos = 0;
97        self.frames_processed = 0;
98    }
99
100    /// Get number of frames processed.
101    pub fn frames_processed(&self) -> u64 {
102        self.frames_processed
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn new_creates_with_defaults() {
112        let ec = EchoCanceller::new(EchoCancellerConfig::default());
113        assert_eq!(ec.frames_processed(), 0);
114        // Buffer size = 128ms * 16000Hz / 1000 = 2048 samples
115        assert_eq!(ec.reference_buffer.len(), 2048);
116    }
117
118    #[test]
119    fn process_passthrough_preserves_input() {
120        let mut ec = EchoCanceller::new(EchoCancellerConfig::default());
121        let input = vec![0.1, 0.2, -0.3, 0.4, -0.5];
122        let output = ec.process(&input);
123        assert_eq!(input, output);
124        assert_eq!(ec.frames_processed(), 1);
125    }
126
127    #[test]
128    fn feed_reference_does_not_panic() {
129        let mut ec = EchoCanceller::new(EchoCancellerConfig::default());
130        let reference = vec![0.5; 4096]; // larger than buffer to test wrap-around
131        ec.feed_reference(&reference);
132        // Should not panic, write_pos wraps around
133        assert!(ec.write_pos < ec.reference_buffer.len());
134    }
135
136    #[test]
137    fn reset_clears_state() {
138        let mut ec = EchoCanceller::new(EchoCancellerConfig::default());
139        ec.feed_reference(&[1.0; 100]);
140        ec.process(&[0.5; 10]);
141        ec.process(&[0.5; 10]);
142        assert_eq!(ec.frames_processed(), 2);
143
144        ec.reset();
145        assert_eq!(ec.frames_processed(), 0);
146        assert_eq!(ec.write_pos, 0);
147        assert!(ec.reference_buffer.iter().all(|&s| s == 0.0));
148    }
149
150    #[test]
151    fn disabled_skips_reference_and_processing() {
152        let config = EchoCancellerConfig {
153            enabled: false,
154            ..Default::default()
155        };
156        let mut ec = EchoCanceller::new(config);
157
158        // Feed reference should be a no-op when disabled
159        ec.feed_reference(&[1.0; 100]);
160        assert!(ec.reference_buffer.iter().all(|&s| s == 0.0));
161
162        // Process still returns input (passthrough) and increments counter
163        let input = vec![0.1, 0.2, 0.3];
164        let output = ec.process(&input);
165        assert_eq!(input, output);
166        assert_eq!(ec.frames_processed(), 1);
167    }
168
169    #[test]
170    fn config_defaults_are_correct() {
171        let config = EchoCancellerConfig::default();
172        assert!(config.enabled);
173        assert_eq!(config.tail_length_ms, 128);
174        assert!((config.suppression_level - 0.8).abs() < f32::EPSILON);
175    }
176}