Sync docs to Wiki / wiki (push) Has been cancelled
Signed-off-by: Stan Grams <sjg@haxx.space>
307 lines
9.3 KiB
Rust
307 lines
9.3 KiB
Rust
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
//! Frequency-domain downsampling via IFFT.
|
|
//!
|
|
//! Given the full-rate raw audio, this module computes a single forward FFT of
|
|
//! the entire buffer, then for each candidate frequency extracts a narrow band
|
|
//! around that frequency, applies a spectral window, and inverse-FFTs to produce
|
|
//! a complex baseband signal at a reduced sample rate (12000/NDOWN = 1333.3 Hz).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use num_complex::Complex32;
|
|
use rustfft::FftPlanner;
|
|
|
|
use super::{FT2_NDOWN, FT2_SYMBOL_PERIOD_F};
|
|
|
|
/// Reusable scratch buffers for frequency-domain downsampling.
|
|
pub struct DownsampleWorkspace {
|
|
band: Vec<Complex32>,
|
|
ifft_scratch: Vec<Complex32>,
|
|
}
|
|
|
|
impl DownsampleWorkspace {
|
|
fn new(nfft2: usize, ifft_scratch_len: usize) -> Self {
|
|
Self {
|
|
band: vec![Complex32::new(0.0, 0.0); nfft2],
|
|
ifft_scratch: vec![Complex32::new(0.0, 0.0); ifft_scratch_len],
|
|
}
|
|
}
|
|
|
|
fn prepare(&mut self, nfft2: usize, ifft_scratch_len: usize) {
|
|
if self.band.len() != nfft2 {
|
|
self.band.resize(nfft2, Complex32::new(0.0, 0.0));
|
|
} else {
|
|
self.band.fill(Complex32::new(0.0, 0.0));
|
|
}
|
|
|
|
if self.ifft_scratch.len() != ifft_scratch_len {
|
|
self.ifft_scratch
|
|
.resize(ifft_scratch_len, Complex32::new(0.0, 0.0));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Downsample context holding precomputed FFT data and spectral window.
|
|
pub struct DownsampleContext {
|
|
/// Number of raw samples.
|
|
nraw: usize,
|
|
/// Length of the downsampled FFT (nraw / NDOWN).
|
|
nfft2: usize,
|
|
/// Frequency resolution of the raw FFT (Hz per bin).
|
|
df: f32,
|
|
/// Spectral extraction window (length nfft2).
|
|
window: Vec<f32>,
|
|
/// Full spectrum of the raw audio (nraw/2 + 1 complex bins).
|
|
spectrum: Vec<Complex32>,
|
|
/// IFFT plan for the downsampled length.
|
|
ifft: std::sync::Arc<dyn rustfft::Fft<f32>>,
|
|
/// Scratch length required by the IFFT plan.
|
|
ifft_scratch_len: usize,
|
|
}
|
|
|
|
impl DownsampleContext {
|
|
/// Initialize the downsample context by computing the forward FFT of
|
|
/// the raw audio and preparing the spectral window.
|
|
///
|
|
/// If `real_fft` and `ifft` are provided, they are reused instead of
|
|
/// creating fresh planners. The real FFT must be a forward plan of length
|
|
/// `nraw` and the IFFT must be an inverse plan of length `nraw / NDOWN`.
|
|
///
|
|
/// Returns `None` if the raw audio is too short or allocation fails.
|
|
pub fn new(raw_audio: &[f32], sample_rate: f32) -> Option<Self> {
|
|
Self::new_with_plans(raw_audio, sample_rate, None, None)
|
|
}
|
|
|
|
/// Initialize with optional pre-built FFT plans for reuse across decode cycles.
|
|
pub fn new_with_plans(
|
|
raw_audio: &[f32],
|
|
sample_rate: f32,
|
|
real_fft: Option<Arc<dyn realfft::RealToComplex<f32>>>,
|
|
ifft: Option<Arc<dyn rustfft::Fft<f32>>>,
|
|
) -> Option<Self> {
|
|
let nraw = raw_audio.len();
|
|
if nraw == 0 {
|
|
return None;
|
|
}
|
|
let nfft2 = nraw / FT2_NDOWN;
|
|
if nfft2 == 0 {
|
|
return None;
|
|
}
|
|
|
|
let df = sample_rate / nraw as f32;
|
|
|
|
// Build spectral extraction window
|
|
let mut window = build_spectral_window(nfft2, df);
|
|
let inv_nfft2 = 1.0 / nfft2 as f32;
|
|
for coeff in &mut window {
|
|
*coeff *= inv_nfft2;
|
|
}
|
|
|
|
// Forward real FFT of raw audio
|
|
let fft = match real_fft {
|
|
Some(f) => f,
|
|
None => {
|
|
let mut real_planner = realfft::RealFftPlanner::<f32>::new();
|
|
real_planner.plan_fft_forward(nraw)
|
|
}
|
|
};
|
|
let mut input = fft.make_input_vec();
|
|
let mut output = fft.make_output_vec();
|
|
let mut scratch = fft.make_scratch_vec();
|
|
|
|
input.copy_from_slice(raw_audio);
|
|
fft.process_with_scratch(&mut input, &mut output, &mut scratch)
|
|
.ok()?;
|
|
|
|
let spectrum = output;
|
|
|
|
// IFFT plan for downsampled length
|
|
let ifft = match ifft {
|
|
Some(f) => f,
|
|
None => {
|
|
let mut planner = FftPlanner::<f32>::new();
|
|
planner.plan_fft_inverse(nfft2)
|
|
}
|
|
};
|
|
let ifft_scratch_len = ifft.get_inplace_scratch_len();
|
|
|
|
Some(Self {
|
|
nraw,
|
|
nfft2,
|
|
df,
|
|
window,
|
|
spectrum,
|
|
ifft,
|
|
ifft_scratch_len,
|
|
})
|
|
}
|
|
|
|
/// Number of downsampled output samples.
|
|
pub fn nfft2(&self) -> usize {
|
|
self.nfft2
|
|
}
|
|
|
|
/// Create reusable buffers for repeated downsampling with this context.
|
|
pub fn workspace(&self) -> DownsampleWorkspace {
|
|
DownsampleWorkspace::new(self.nfft2, self.ifft_scratch_len)
|
|
}
|
|
|
|
/// Downsample the raw audio around `freq_hz`, writing complex baseband
|
|
/// samples into `out`. Returns the number of samples produced.
|
|
pub fn downsample(&self, freq_hz: f32, out: &mut [Complex32]) -> usize {
|
|
let mut workspace = self.workspace();
|
|
self.downsample_with_workspace(freq_hz, out, &mut workspace)
|
|
}
|
|
|
|
/// Downsample the raw audio using reusable scratch buffers.
|
|
pub fn downsample_with_workspace(
|
|
&self,
|
|
freq_hz: f32,
|
|
out: &mut [Complex32],
|
|
workspace: &mut DownsampleWorkspace,
|
|
) -> usize {
|
|
if out.len() < self.nfft2 {
|
|
return 0;
|
|
}
|
|
|
|
workspace.prepare(self.nfft2, self.ifft_scratch_len);
|
|
let band = &mut workspace.band;
|
|
let i0 = (freq_hz / self.df).round() as i32;
|
|
let half_nraw = (self.nraw / 2) as i32;
|
|
|
|
// DC bin
|
|
if i0 >= 0 && i0 <= half_nraw && (i0 as usize) < self.spectrum.len() {
|
|
band[0] = self.spectrum[i0 as usize];
|
|
}
|
|
|
|
// Positive and negative frequency bins
|
|
for i in 1..=(self.nfft2 as i32 / 2) {
|
|
let pos = i0 + i;
|
|
if pos >= 0 && pos <= half_nraw && (pos as usize) < self.spectrum.len() {
|
|
band[i as usize] = self.spectrum[pos as usize];
|
|
}
|
|
let neg = i0 - i;
|
|
if neg >= 0 && neg <= half_nraw && (neg as usize) < self.spectrum.len() {
|
|
band[(self.nfft2 as i32 - i) as usize] = self.spectrum[neg as usize];
|
|
}
|
|
}
|
|
|
|
// Apply spectral window
|
|
for (b, &w) in band.iter_mut().zip(self.window.iter()) {
|
|
*b *= w;
|
|
}
|
|
|
|
// Inverse FFT (in-place)
|
|
self.ifft
|
|
.process_with_scratch(band, &mut workspace.ifft_scratch);
|
|
|
|
out[..self.nfft2].copy_from_slice(band);
|
|
self.nfft2
|
|
}
|
|
}
|
|
|
|
/// Build the spectral window used during band extraction.
|
|
///
|
|
/// The window has a raised-cosine transition, a flat passband covering
|
|
/// the FT2 signal bandwidth (4 * baud), and is circularly shifted by
|
|
/// one baud rate worth of bins.
|
|
fn build_spectral_window(nfft2: usize, df: f32) -> Vec<f32> {
|
|
let baud = 1.0 / FT2_SYMBOL_PERIOD_F;
|
|
let iwt = ((0.5 * baud) / df) as usize;
|
|
let iwf = ((4.0 * baud) / df) as usize;
|
|
let iws = (baud / df) as usize;
|
|
|
|
let mut window = vec![0.0f32; nfft2];
|
|
|
|
if iwt == 0 {
|
|
return window;
|
|
}
|
|
|
|
// Raised-cosine leading edge
|
|
for (i, w) in window.iter_mut().enumerate().take(iwt.min(nfft2)) {
|
|
*w = 0.5 * (1.0 + (std::f32::consts::PI * (iwt - 1 - i) as f32 / iwt as f32).cos());
|
|
}
|
|
|
|
// Flat passband
|
|
for w in window
|
|
.iter_mut()
|
|
.skip(iwt)
|
|
.take((iwt + iwf).min(nfft2) - iwt)
|
|
{
|
|
*w = 1.0;
|
|
}
|
|
|
|
// Raised-cosine trailing edge
|
|
for (i, w) in window
|
|
.iter_mut()
|
|
.enumerate()
|
|
.take((2 * iwt + iwf).min(nfft2))
|
|
.skip(iwt + iwf)
|
|
{
|
|
*w = 0.5 * (1.0 + (std::f32::consts::PI * (i - (iwt + iwf)) as f32 / iwt as f32).cos());
|
|
}
|
|
|
|
// Circular shift by iws bins
|
|
if iws > 0 && iws < nfft2 {
|
|
window.rotate_left(iws);
|
|
}
|
|
|
|
window
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn spectral_window_length() {
|
|
let w = build_spectral_window(5000, 12000.0 / 45000.0);
|
|
assert_eq!(w.len(), 5000);
|
|
}
|
|
|
|
#[test]
|
|
fn spectral_window_nonnegative() {
|
|
let w = build_spectral_window(5000, 12000.0 / 45000.0);
|
|
for &v in &w {
|
|
assert!(v >= 0.0, "Window value should be non-negative: {}", v);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn downsample_context_creation() {
|
|
let raw = vec![0.0f32; 45000];
|
|
let ctx = DownsampleContext::new(&raw, 12000.0);
|
|
assert!(ctx.is_some());
|
|
let ctx = ctx.unwrap();
|
|
assert_eq!(ctx.nfft2(), 45000 / 9);
|
|
}
|
|
|
|
#[test]
|
|
fn downsample_produces_samples() {
|
|
let raw = vec![0.0f32; 45000];
|
|
let ctx = DownsampleContext::new(&raw, 12000.0).unwrap();
|
|
let nfft2 = ctx.nfft2();
|
|
let mut out = vec![Complex32::new(0.0, 0.0); nfft2];
|
|
let n = ctx.downsample(1000.0, &mut out);
|
|
assert_eq!(n, nfft2);
|
|
}
|
|
|
|
#[test]
|
|
fn downsample_output_too_small() {
|
|
let raw = vec![0.0f32; 45000];
|
|
let ctx = DownsampleContext::new(&raw, 12000.0).unwrap();
|
|
let mut out = vec![Complex32::new(0.0, 0.0); 10];
|
|
let n = ctx.downsample(1000.0, &mut out);
|
|
assert_eq!(n, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_audio_returns_none() {
|
|
let raw: Vec<f32> = Vec::new();
|
|
assert!(DownsampleContext::new(&raw, 12000.0).is_none());
|
|
}
|
|
}
|