Skip to main content

parakeet_rs/
audio.rs

1use crate::config::PreprocessorConfig;
2use crate::error::{Error, Result};
3use hound::{WavReader, WavSpec};
4use ndarray::Array2;
5use realfft::RealToComplex;
6use std::f32::consts::PI;
7use std::path::Path;
8use std::sync::Arc;
9
10/// Cached, reusable mel filterbank + FFT plan keyed to a preprocessor
11/// cfgs.
12pub struct FeatureCache {
13    pub mel_basis: Array2<f32>,
14    pub fft_plan: Arc<dyn RealToComplex<f32>>,
15}
16
17impl FeatureCache {
18    pub fn from_config(config: &PreprocessorConfig) -> Self {
19        let mel_basis =
20            create_mel_filterbank(config.n_fft, config.feature_size, config.sampling_rate);
21        let mut planner = realfft::RealFftPlanner::<f32>::new();
22        let fft_plan = planner.plan_fft_forward(config.n_fft);
23        Self {
24            mel_basis,
25            fft_plan,
26        }
27    }
28}
29
30pub fn load_audio<P: AsRef<Path>>(path: P) -> Result<(Vec<f32>, WavSpec)> {
31    let mut reader = WavReader::open(path)?;
32    let spec = reader.spec();
33
34    let samples: Vec<f32> = match spec.sample_format {
35        hound::SampleFormat::Float => reader
36            .samples::<f32>()
37            .collect::<std::result::Result<Vec<_>, _>>()
38            .map_err(|e| Error::Audio(format!("Failed to read float samples: {e}")))?,
39        hound::SampleFormat::Int => reader
40            .samples::<i16>()
41            .map(|s| s.map(|s| s as f32 / 32768.0))
42            .collect::<std::result::Result<Vec<_>, _>>()
43            .map_err(|e| Error::Audio(format!("Failed to read int samples: {e}")))?,
44    };
45
46    Ok((samples, spec))
47}
48
49pub fn apply_preemphasis(audio: &[f32], coef: f32) -> Vec<f32> {
50    if audio.is_empty() {
51        return Vec::new();
52    }
53
54    let mut result = Vec::with_capacity(audio.len());
55    result.push(audio[0]);
56
57    for i in 1..audio.len() {
58        result.push(audio[i] - coef * audio[i - 1]);
59    }
60
61    result
62}
63
64fn hann_window(window_length: usize) -> Vec<f32> {
65    (0..window_length)
66        .map(|i| 0.5 - 0.5 * ((2.0 * PI * i as f32) / (window_length as f32 - 1.0)).cos())
67        .collect()
68}
69
70// We use proper FFT here instead of naive DFT because the model was trained
71// on correctly computed spectrograms. Naive DFT produces wrong frequency bins
72// and the model outputs all blank tokens. realfft (real-valued FFT wrapper around
73// RustFFT) gives us O(n log n) performance and numerically correct results.
74pub fn stft(
75    audio: &[f32],
76    n_fft: usize,
77    hop_length: usize,
78    win_length: usize,
79) -> Result<Array2<f32>> {
80    let mut planner = realfft::RealFftPlanner::<f32>::new();
81    let plan = planner.plan_fft_forward(n_fft);
82    stft_with_plan(audio, &plan, n_fft, hop_length, win_length)
83}
84
85/// Same as [`stft`] but takes a pre built FFT plan to avoid rebuilding it
86/// per call. The plan is deterministic from `n_fft`.
87pub fn stft_with_plan(
88    audio: &[f32],
89    plan: &Arc<dyn RealToComplex<f32>>,
90    n_fft: usize,
91    hop_length: usize,
92    win_length: usize,
93) -> Result<Array2<f32>> {
94    let pad_amount = n_fft / 2;
95    let mut padded = vec![0.0f32; pad_amount];
96    padded.extend_from_slice(audio);
97    padded.resize(padded.len() + pad_amount, 0.0);
98
99    let window = hann_window(win_length);
100    // NeMo reports floor(audio_len / hop_length) valid frames. torch.stft
101    // produces one additional trailing frame, which NeMo masks out before
102    // normalization and encoding.
103    let num_frames = audio.len() / hop_length;
104    let freq_bins = n_fft / 2 + 1;
105    let mut spectrogram = Array2::<f32>::zeros((freq_bins, num_frames));
106    // torch.stft centers a window shorter than n_fft in the FFT buffer.
107    let window_offset = (n_fft - win_length) / 2;
108
109    let mut input = vec![0.0f32; n_fft];
110    let mut output = plan.make_output_vec();
111    let mut scratch = plan.make_scratch_vec();
112
113    for frame_idx in 0..num_frames {
114        let start = frame_idx * hop_length;
115
116        input.fill(0.0);
117        for i in 0..win_length {
118            input[window_offset + i] = padded[start + window_offset + i] * window[i];
119        }
120
121        plan.process_with_scratch(&mut input, &mut output, &mut scratch)
122            .map_err(|e| Error::Audio(format!("FFT failed: {e}")))?;
123
124        for k in 0..freq_bins {
125            spectrogram[[k, frame_idx]] = output[k].norm_sqr();
126        }
127    }
128
129    Ok(spectrogram)
130}
131
132// Slaney mel scale (again librosa)
133const F_SP: f64 = 200.0 / 3.0;
134const MIN_LOG_HZ: f64 = 1000.0;
135const MIN_LOG_MEL: f64 = MIN_LOG_HZ / F_SP;
136const LOG_STEP: f64 = 0.06875177742094912;
137
138fn hz_to_mel_slaney(hz: f64) -> f64 {
139    if hz < MIN_LOG_HZ {
140        hz / F_SP
141    } else {
142        MIN_LOG_MEL + (hz / MIN_LOG_HZ).ln() / LOG_STEP
143    }
144}
145
146fn mel_to_hz_slaney(mel: f64) -> f64 {
147    if mel < MIN_LOG_MEL {
148        mel * F_SP
149    } else {
150        MIN_LOG_HZ * ((mel - MIN_LOG_MEL) * LOG_STEP).exp()
151    }
152}
153
154pub fn create_mel_filterbank(n_fft: usize, n_mels: usize, sample_rate: usize) -> Array2<f32> {
155    let freq_bins = n_fft / 2 + 1;
156    let mut filterbank = Array2::<f32>::zeros((n_mels, freq_bins));
157
158    let fmax = sample_rate as f64 / 2.0;
159    let mel_min = hz_to_mel_slaney(0.0);
160    let mel_max = hz_to_mel_slaney(fmax);
161
162    // Mel cent freq
163    let mel_points: Vec<f64> = (0..=n_mels + 1)
164        .map(|i| mel_to_hz_slaney(mel_min + (mel_max - mel_min) * i as f64 / (n_mels + 1) as f64))
165        .collect();
166
167    // FFT bin freq
168    let fft_freqs: Vec<f64> = (0..freq_bins)
169        .map(|i| i as f64 * sample_rate as f64 / n_fft as f64)
170        .collect();
171
172    // librosa's ramp
173    let fdiff: Vec<f64> = mel_points.windows(2).map(|w| w[1] - w[0]).collect();
174
175    for i in 0..n_mels {
176        for (k, &freq) in fft_freqs.iter().enumerate() {
177            let lower = (freq - mel_points[i]) / fdiff[i];
178            let upper = (mel_points[i + 2] - freq) / fdiff[i + 1];
179            filterbank[[i, k]] = 0.0f64.max(lower.min(upper)) as f32;
180        }
181    }
182
183    // Slaney norm
184    for i in 0..n_mels {
185        let enorm = 2.0 / (mel_points[i + 2] - mel_points[i]);
186        for k in 0..freq_bins {
187            filterbank[[i, k]] *= enorm as f32;
188        }
189    }
190
191    filterbank
192}
193
194/// Extract mel spectrogram features from raw audio samples.
195///
196/// The `cache` holds the mel filterbank and FFT plan built once at model
197/// load — these are deterministic from `config` and identical across calls,
198/// so reusing them avoids rebuilding ~15-20 µs of arithmetic per request.
199///
200/// # Arguments
201///
202/// * `audio` - Audio samples as f32 values
203/// * `sample_rate` - Sample rate in Hz
204/// * `channels` - Number of audio channels
205/// * `config` - Preprocessor configuration
206/// * `cache` - Pre-built mel filterbank + FFT plan (see [`FeatureCache::from_config`])
207///
208/// # Returns
209///
210/// 2D array of mel spectrogram features (time_steps x feature_size)
211pub fn extract_features_with_cache(
212    mut audio: Vec<f32>,
213    sample_rate: u32,
214    channels: u16,
215    config: &PreprocessorConfig,
216    cache: &FeatureCache,
217) -> Result<Array2<f32>> {
218    if sample_rate != config.sampling_rate as u32 {
219        return Err(Error::Audio(format!(
220            "Audio sample rate {} doesn't match expected {}. Please resample your audio first.",
221            sample_rate, config.sampling_rate
222        )));
223    }
224
225    if channels > 1 {
226        let mono: Vec<f32> = audio
227            .chunks(channels as usize)
228            .map(|chunk| chunk.iter().sum::<f32>() / channels as f32)
229            .collect();
230        audio = mono;
231    }
232
233    audio = apply_preemphasis(&audio, config.preemphasis);
234
235    let spectrogram = stft_with_plan(
236        &audio,
237        &cache.fft_plan,
238        config.n_fft,
239        config.hop_length,
240        config.win_length,
241    )?;
242
243    let mel_spectrogram = cache.mel_basis.dot(&spectrogram);
244    // Log with additive guard (NeMo: log_zero_guard_type="add", value=2^-24)
245    let log_zero_guard: f32 = 2.0f32.powi(-24);
246    let mel_spectrogram = mel_spectrogram.mapv(|x| (x + log_zero_guard).ln());
247
248    let mut mel_spectrogram = mel_spectrogram.t().to_owned();
249
250    // Normalize per_feature: mean=0, std=1 with Bessel's correction (N-1)
251    let num_frames = mel_spectrogram.shape()[0];
252    let num_features = mel_spectrogram.shape()[1];
253
254    if num_frames <= 1 {
255        return Ok(mel_spectrogram);
256    }
257
258    for feat_idx in 0..num_features {
259        let mut column = mel_spectrogram.column_mut(feat_idx);
260        let mean: f32 = column.iter().sum::<f32>() / num_frames as f32;
261        let variance: f32 =
262            column.iter().map(|&x| (x - mean).powi(2)).sum::<f32>() / (num_frames as f32 - 1.0);
263        let std = variance.sqrt() + 1e-5;
264
265        for val in column.iter_mut() {
266            *val = (*val - mean) / std;
267        }
268    }
269
270    Ok(mel_spectrogram)
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    /// Generate a pure sine wave at the given frequency and sample rate.
278    fn sine_wave(freq_hz: f32, sample_rate: usize, num_samples: usize) -> Vec<f32> {
279        (0..num_samples)
280            .map(|i| (2.0 * PI * freq_hz * i as f32 / sample_rate as f32).sin())
281            .collect()
282    }
283
284    #[test]
285    fn stft_concentrates_power_at_expected_bin() {
286        // 1kHz sine at 16kHz sample rate, 1 second
287        let n_fft = 512;
288        let hop_length = 160;
289        let win_length = 400;
290        let sample_rate = 16000;
291        let audio = sine_wave(1000.0, sample_rate, sample_rate);
292
293        let spec = stft(&audio, n_fft, hop_length, win_length).unwrap();
294
295        // Expected bin for 1kHz: freq_hz * n_fft / sample_rate = 1000 * 512 / 16000 = 32
296        let expected_bin = 32;
297        let freq_bins = n_fft / 2 + 1;
298        let num_frames = spec.shape()[1];
299
300        // Check that bin 32 has the highest power in most frames (skip edge frames)
301        let mut correct_frames = 0;
302        for frame in 2..num_frames.saturating_sub(2) {
303            let mut max_bin = 0;
304            let mut max_power = 0.0f32;
305            for bin in 0..freq_bins {
306                if spec[[bin, frame]] > max_power {
307                    max_power = spec[[bin, frame]];
308                    max_bin = bin;
309                }
310            }
311            if max_bin == expected_bin {
312                correct_frames += 1;
313            }
314        }
315
316        let interior_frames = num_frames.saturating_sub(4);
317        assert!(
318            correct_frames > interior_frames / 2,
319            "Expected bin {expected_bin} to dominate in most frames, but only {correct_frames}/{interior_frames}"
320        );
321    }
322
323    #[test]
324    fn stft_output_shape_is_correct() {
325        let n_fft = 512;
326        let hop_length = 160;
327        let win_length = 400;
328        let audio = vec![0.0f32; 16000]; // 1 second of silence
329
330        let spec = stft(&audio, n_fft, hop_length, win_length).unwrap();
331
332        let freq_bins = n_fft / 2 + 1;
333        assert_eq!(spec.shape()[0], freq_bins);
334        assert_eq!(spec.shape()[1], audio.len() / hop_length);
335    }
336}