1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
//! # Audio Convolution and FIR Filtering
//!
//! Provides linear convolution, stateful FIR filter processing, windowed-sinc
//! filter design (low-pass, high-pass, band-pass), and a ring buffer for
//! efficient circular buffering.
//!
//! ## Example
//!
//! ```rust,ignore
//! use math_sonify::convolution::{low_pass_filter, FirFilter};
//!
//! let coeffs = low_pass_filter(1000.0, 44100.0, 63);
//! let mut filter = FirFilter::new(coeffs);
//! let output = filter.process_block(&input_signal);
//! ```
use std::collections::VecDeque;
use std::f64::consts::PI;
// ── Linear convolution ────────────────────────────────────────────────────────
/// Compute linear (full) convolution of `signal` with `kernel`.
///
/// Output length = `signal.len() + kernel.len() - 1`.
pub fn convolve(signal: &[f64], kernel: &[f64]) -> Vec<f64> {
if signal.is_empty() || kernel.is_empty() {
return Vec::new();
}
let out_len = signal.len() + kernel.len() - 1;
let mut out = vec![0.0f64; out_len];
for (n, o) in out.iter_mut().enumerate() {
let k_start = if n >= signal.len() { n - signal.len() + 1 } else { 0 };
let k_end = n.min(kernel.len() - 1);
for k in k_start..=k_end {
if n >= k && (n - k) < signal.len() {
*o += signal[n - k] * kernel[k];
}
}
}
out
}
/// Apply a FIR filter as a causal linear convolution (same as `convolve`).
///
/// The output length equals `signal.len() + coeffs.len() - 1`.
pub fn fir_filter(signal: &[f64], coeffs: &[f64]) -> Vec<f64> {
convolve(signal, coeffs)
}
// ── Stateful FIR filter ───────────────────────────────────────────────────────
/// Streaming, stateful FIR filter that maintains inter-block state.
pub struct FirFilter {
pub coeffs: Vec<f64>,
state: VecDeque<f64>,
}
impl FirFilter {
/// Create a new stateful filter with the given coefficients.
pub fn new(coeffs: Vec<f64>) -> Self {
let len = coeffs.len();
let state = VecDeque::from(vec![0.0f64; len]);
Self { coeffs, state }
}
/// Process a single sample; O(N) per call where N = number of taps.
pub fn process_sample(&mut self, sample: f64) -> f64 {
self.state.push_front(sample);
self.state.pop_back();
self.coeffs
.iter()
.zip(self.state.iter())
.map(|(c, s)| c * s)
.sum()
}
/// Process a block of samples and return the filtered block.
pub fn process_block(&mut self, samples: &[f64]) -> Vec<f64> {
samples.iter().map(|&s| self.process_sample(s)).collect()
}
}
// ── Window functions ──────────────────────────────────────────────────────────
/// Hamming window: 0.54 - 0.46·cos(2π·n/(total-1)).
pub fn hamming_window(n: usize, total: usize) -> f64 {
if total <= 1 {
return 1.0;
}
0.54 - 0.46 * (2.0 * PI * n as f64 / (total - 1) as f64).cos()
}
/// Hann window: 0.5·(1 - cos(2π·n/(total-1))).
pub fn hann_window(n: usize, total: usize) -> f64 {
if total <= 1 {
return 1.0;
}
0.5 * (1.0 - (2.0 * PI * n as f64 / (total - 1) as f64).cos())
}
/// Blackman window: 0.42 - 0.5·cos(2π·n/(total-1)) + 0.08·cos(4π·n/(total-1)).
pub fn blackman_window(n: usize, total: usize) -> f64 {
if total <= 1 {
return 1.0;
}
let t = n as f64 / (total - 1) as f64;
0.42 - 0.5 * (2.0 * PI * t).cos() + 0.08 * (4.0 * PI * t).cos()
}
// ── Windowed-sinc filter design ───────────────────────────────────────────────
/// Design a windowed-sinc low-pass FIR filter.
///
/// `cutoff_hz` — 3 dB corner frequency in Hz
/// `sample_rate` — samples per second
/// `num_taps` — filter length (should be odd for symmetric, linear-phase response)
pub fn low_pass_filter(cutoff_hz: f64, sample_rate: f64, num_taps: usize) -> Vec<f64> {
let fc = cutoff_hz / sample_rate; // normalised cutoff (0..0.5)
let m = num_taps as f64 - 1.0;
let mut coeffs: Vec<f64> = (0..num_taps)
.map(|i| {
let n = i as f64 - m / 2.0;
let sinc = if n == 0.0 { 2.0 * fc } else { (2.0 * PI * fc * n).sin() / (PI * n) };
sinc * hamming_window(i, num_taps)
})
.collect();
// Normalise so DC gain = 1.
let sum: f64 = coeffs.iter().sum();
if sum.abs() > 1e-12 {
coeffs.iter_mut().for_each(|c| *c /= sum);
}
coeffs
}
/// Design a windowed-sinc high-pass FIR filter via spectral inversion of
/// the corresponding low-pass filter.
pub fn high_pass_filter(cutoff_hz: f64, sample_rate: f64, num_taps: usize) -> Vec<f64> {
let mut lp = low_pass_filter(cutoff_hz, sample_rate, num_taps);
// Spectral inversion: negate all coefficients, then add 1 to centre tap.
lp.iter_mut().for_each(|c| *c = -*c);
let centre = num_taps / 2;
lp[centre] += 1.0;
lp
}
/// Design a band-pass FIR filter as the difference of two low-pass filters.
///
/// `low_hz` — lower cutoff, `high_hz` — upper cutoff.
pub fn band_pass_filter(
low_hz: f64,
high_hz: f64,
sample_rate: f64,
num_taps: usize,
) -> Vec<f64> {
let lp_high = low_pass_filter(high_hz, sample_rate, num_taps);
let lp_low = low_pass_filter(low_hz, sample_rate, num_taps);
lp_high.iter().zip(lp_low.iter()).map(|(h, l)| h - l).collect()
}
// ── RingBuffer ────────────────────────────────────────────────────────────────
/// A fixed-capacity ring buffer for efficient O(1) push and indexed access.
pub struct RingBuffer {
data: Vec<f64>,
head: usize,
pub size: usize,
}
impl RingBuffer {
pub fn new(size: usize) -> Self {
Self { data: vec![0.0; size], head: 0, size }
}
/// Push a new sample (overwrites the oldest).
pub fn push(&mut self, value: f64) {
self.data[self.head] = value;
self.head = (self.head + 1) % self.size;
}
/// Read sample at logical index `i` (0 = oldest, size-1 = newest).
pub fn get(&self, i: usize) -> f64 {
self.data[(self.head + i) % self.size]
}
/// Read the most recently pushed sample.
pub fn latest(&self) -> f64 {
let idx = (self.head + self.size - 1) % self.size;
self.data[idx]
}
}
// ── Unit tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn convolution_output_length_is_n_plus_m_minus_one() {
let signal = vec![1.0, 2.0, 3.0];
let kernel = vec![0.5, 0.5];
let out = convolve(&signal, &kernel);
assert_eq!(out.len(), signal.len() + kernel.len() - 1);
}
#[test]
fn convolution_identity_kernel() {
let signal = vec![1.0, 2.0, 3.0, 4.0];
let kernel = vec![1.0];
let out = convolve(&signal, &kernel);
assert_eq!(&out[..signal.len()], &signal[..]);
}
#[test]
fn hamming_window_endpoints_near_008() {
let n = 64;
// Hamming window endpoints: w(0) = w(N-1) = 0.54 - 0.46 = 0.08
let w0 = hamming_window(0, n);
let wn = hamming_window(n - 1, n);
assert!((w0 - 0.08).abs() < 1e-9, "w(0) = {w0}");
assert!((wn - 0.08).abs() < 1e-9, "w(N-1) = {wn}");
}
#[test]
fn low_pass_dc_gain_is_one() {
let coeffs = low_pass_filter(1000.0, 44100.0, 63);
let dc_gain: f64 = coeffs.iter().sum();
assert!((dc_gain - 1.0).abs() < 1e-9, "DC gain = {dc_gain}");
}
#[test]
fn fir_streaming_matches_batch() {
use std::f64::consts::PI;
let sample_rate = 8000.0;
// Simple signal: a sine wave.
let signal: Vec<f64> = (0..128)
.map(|i| (2.0 * PI * 500.0 * i as f64 / sample_rate).sin())
.collect();
let coeffs = low_pass_filter(1000.0, sample_rate, 31);
let batch = fir_filter(&signal, &coeffs);
let mut filter = FirFilter::new(coeffs);
let streaming: Vec<f64> = filter.process_block(&signal);
// The stateful filter's output should match the first `signal.len()`
// samples of the linear convolution.
for (i, (&b, &s)) in batch.iter().zip(streaming.iter()).enumerate() {
assert!(
(b - s).abs() < 1e-10,
"mismatch at sample {i}: batch={b}, streaming={s}"
);
}
}
#[test]
fn high_pass_attenuates_dc() {
let coeffs = high_pass_filter(2000.0, 44100.0, 63);
// DC gain of a high-pass filter should be ≈ 0.
let dc_gain: f64 = coeffs.iter().sum();
assert!(dc_gain.abs() < 0.01, "HP DC gain too large: {dc_gain}");
}
#[test]
fn ring_buffer_push_and_latest() {
let mut rb = RingBuffer::new(4);
rb.push(1.0);
rb.push(2.0);
rb.push(3.0);
assert!((rb.latest() - 3.0).abs() < 1e-12);
}
#[test]
fn ring_buffer_overwrites_oldest() {
let mut rb = RingBuffer::new(3);
rb.push(10.0);
rb.push(20.0);
rb.push(30.0);
rb.push(40.0); // Overwrites 10.0
// Oldest should now be 20.0.
assert!((rb.get(0) - 20.0).abs() < 1e-12, "oldest = {}", rb.get(0));
}
}