Skip to main content

rill_patchbay/sensor/
hearing.rs

1//! # Hearing — signal analysis for acoustic sensors
2//!
3//! Algorithms that analyse signal buffers and produce scalar features
4//! (pitch, envelope, zero-crossing rate). Used by [`AcousticSensor`]
5//! to turn signal data into control parameters.
6//!
7//! Future: wire these into graph telemetry so `AcousticSensor` receives
8//! `Telemetry::SignalData` from a specific graph node.
9
10use std::collections::VecDeque;
11
12/// Trait for signal analysis algorithms.
13pub trait Hearing: Send + 'static {
14    /// Process a block of signal data and return a scalar value.
15    fn process(&mut self, audio: &[f32]) -> f32;
16
17    /// Name of the algorithm.
18    fn name(&self) -> &str;
19}
20
21/// Pitch detector using autocorrelation.
22pub struct PitchDetector {
23    sample_rate: f32,
24    min_freq: f32,
25    max_freq: f32,
26    last_pitch: f32,
27    buffer: VecDeque<f32>,
28}
29
30impl PitchDetector {
31    /// Create a new pitch detector.
32    pub fn new(sample_rate: f32) -> Self {
33        Self {
34            sample_rate,
35            min_freq: 20.0,
36            max_freq: 2000.0,
37            last_pitch: 0.0,
38            buffer: VecDeque::with_capacity(2048),
39        }
40    }
41
42    fn autocorrelate(&self, signal: &[f32]) -> Option<f32> {
43        if signal.len() < 100 {
44            return None;
45        }
46        let min_period = (self.sample_rate / self.max_freq) as usize;
47        let max_period = (self.sample_rate / self.min_freq) as usize;
48        let mut best_corr = 0.0;
49        let mut best_period = min_period;
50
51        for period in min_period..max_period.min(signal.len() / 2) {
52            let mut corr = 0.0;
53            let mut energy = 0.0;
54            for i in 0..period {
55                if i + period < signal.len() {
56                    corr += signal[i] * signal[i + period];
57                    energy += signal[i] * signal[i] + signal[i + period] * signal[i + period];
58                }
59            }
60            if energy > 0.0 {
61                let norm_corr = corr / (energy.sqrt() + 1e-6);
62                if norm_corr > best_corr {
63                    best_corr = norm_corr;
64                    best_period = period;
65                }
66            }
67        }
68
69        if best_corr > 0.1 {
70            Some(self.sample_rate / best_period as f32)
71        } else {
72            None
73        }
74    }
75}
76
77impl Hearing for PitchDetector {
78    fn process(&mut self, audio: &[f32]) -> f32 {
79        for &sample in audio {
80            self.buffer.push_back(sample);
81        }
82        while self.buffer.len() > 2048 {
83            self.buffer.pop_front();
84        }
85        let signal: Vec<f32> = self.buffer.iter().copied().collect();
86        if let Some(pitch) = self.autocorrelate(&signal) {
87            self.last_pitch = pitch;
88        }
89        (self.last_pitch - self.min_freq) / (self.max_freq - self.min_freq)
90    }
91
92    fn name(&self) -> &str {
93        "pitch"
94    }
95}
96
97/// Envelope follower (tracks amplitude).
98pub struct EnvelopeFollower {
99    attack: f32,
100    release: f32,
101    envelope: f32,
102    sample_rate: f32,
103}
104
105impl EnvelopeFollower {
106    /// Create a new envelope follower.
107    pub fn new(sample_rate: f32) -> Self {
108        Self {
109            attack: 0.01,
110            release: 0.1,
111            envelope: 0.0,
112            sample_rate,
113        }
114    }
115
116    /// Set attack time in seconds.
117    pub fn with_attack(mut self, attack_sec: f32) -> Self {
118        self.attack = attack_sec;
119        self
120    }
121
122    /// Set release time in seconds.
123    pub fn with_release(mut self, release_sec: f32) -> Self {
124        self.release = release_sec;
125        self
126    }
127}
128
129impl Hearing for EnvelopeFollower {
130    fn process(&mut self, audio: &[f32]) -> f32 {
131        let attack_coef = (-1.0 / (self.attack * self.sample_rate)).exp();
132        let release_coef = (-1.0 / (self.release * self.sample_rate)).exp();
133        for &sample in audio {
134            let input = sample.abs();
135            if input > self.envelope {
136                self.envelope = attack_coef * self.envelope + (1.0 - attack_coef) * input;
137            } else {
138                self.envelope = release_coef * self.envelope + (1.0 - release_coef) * input;
139            }
140        }
141        self.envelope
142    }
143
144    fn name(&self) -> &str {
145        "envelope"
146    }
147}
148
149/// Zero-crossing frequency detector.
150pub struct ZeroCrossing {
151    last_sample: f32,
152    crossings: u32,
153    samples: u32,
154    sample_rate: f32,
155    frequency: f32,
156}
157
158impl ZeroCrossing {
159    /// Create a new zero-crossing detector.
160    pub fn new(sample_rate: f32) -> Self {
161        Self {
162            last_sample: 0.0,
163            crossings: 0,
164            samples: 0,
165            sample_rate,
166            frequency: 0.0,
167        }
168    }
169}
170
171impl Hearing for ZeroCrossing {
172    fn process(&mut self, audio: &[f32]) -> f32 {
173        for &sample in audio {
174            if self.last_sample <= 0.0 && sample > 0.0 {
175                self.crossings += 1;
176            }
177            self.last_sample = sample;
178            self.samples += 1;
179        }
180        if self.samples > self.sample_rate as u32 / 10 {
181            self.frequency = self.crossings as f32 / (self.samples as f32 / self.sample_rate);
182            self.crossings = 0;
183            self.samples = 0;
184        }
185        self.frequency / 1000.0
186    }
187
188    fn name(&self) -> &str {
189        "zero_crossing"
190    }
191}