Skip to content
File

Blob: firmware/crates/radio-core/src/spectrum.rs

rust157 lines
1//! Streaming windowing and spectrum mapping. The caller supplies FFT storage
2//! and runs the transform; this module contains no native code or allocation.
3use crate::{music::BANDS, playback::Due};
4use std::fmt;
5 
6pub const FFT_SIZE: usize = 2048;
7pub const SAMPLE_RATE: u32 = 48_000;
8pub const STEREO_SAMPLES: usize = 960 * 2;
9pub const HISTORY_FLOATS: usize = FFT_SIZE * 2;
10/// Maximum analysis age: 120 ms, or three nominal 25 Hz spectrum intervals.
11pub const MAX_AGE_US: u64 = 120_000;
12 
13#[derive(Debug, Clone, Copy)]
14pub struct Tag {
15 pub due: Due,
16 pub queued_us: u64,
17}
18 
19impl Tag {
20 pub fn is_fresh(self, epoch: u32, now_us: u64) -> bool {
21 self.due.epoch == epoch && now_us >= self.queued_us && now_us - self.queued_us <= MAX_AGE_US
22 }
23}
24 
25/// Decoder/window continuity follows the transport timestamp and playback epoch.
26#[derive(Debug, Default)]
27pub struct Continuity {
28 previous: Option<Due>,
29}
30impl Continuity {
31 pub fn begin(&mut self, due: Due) -> bool {
32 let reset = self.previous.is_none_or(|last| {
33 last.epoch != due.epoch || last.pts_ms.wrapping_add(20) != due.pts_ms
34 });
35 self.previous = Some(due);
36 reset
37 }
38 pub fn clear(&mut self) {
39 self.previous = None;
40 }
41}
42 
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct InvalidBuffer;
45impl fmt::Display for InvalidBuffer {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 f.write_str("invalid analysis buffer size")
48 }
49}
50impl std::error::Error for InvalidBuffer {}
51 
52/// Borrows caller-owned storage: one mono PCM ring and one periodic Hann window.
53/// Debug output omits audio samples.
54pub struct Window<'a> {
55 storage: &'a mut [f32],
56 position: usize,
57 filled: usize,
58 normalization: f32,
59 ranges: [(usize, usize); BANDS],
60}
61 
62impl fmt::Debug for Window<'_> {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 f.debug_struct("Window")
65 .field("filled", &self.filled)
66 .field("fft_size", &FFT_SIZE)
67 .finish_non_exhaustive()
68 }
69}
70 
71impl<'a> Window<'a> {
72 pub fn new(storage: &'a mut [f32]) -> Result<Self, InvalidBuffer> {
73 if storage.len() != HISTORY_FLOATS {
74 return Err(InvalidBuffer);
75 }
76 storage.fill(0.0);
77 let mut sum = 0.0;
78 for (i, weight) in storage[FFT_SIZE..].iter_mut().enumerate() {
79 *weight = 0.5 - 0.5 * (std::f32::consts::TAU * i as f32 / FFT_SIZE as f32).cos();
80 sum += *weight;
81 }
82 let ranges = std::array::from_fn(|band| {
83 let low = 30.0 * (20_000.0_f32 / 30.0).powf(band as f32 / BANDS as f32);
84 let high = 30.0 * (20_000.0_f32 / 30.0).powf((band + 1) as f32 / BANDS as f32);
85 let scale = FFT_SIZE as f32 / SAMPLE_RATE as f32;
86 let first = (low * scale).ceil() as usize;
87 let end = (high * scale).ceil() as usize;
88 if first == end {
89 let nearest = (((low + high) * 0.5 * scale).round() as usize).max(1);
90 (nearest, nearest + 1)
91 } else {
92 (first.max(1), end.min(FFT_SIZE / 2 + 1))
93 }
94 });
95 Ok(Self {
96 storage,
97 position: 0,
98 filled: 0,
99 normalization: 4.0 / (sum * sum),
100 ranges,
101 })
102 }
103 
104 pub fn clear(&mut self) {
105 self.storage[..FFT_SIZE].fill(0.0);
106 self.position = 0;
107 self.filled = 0;
108 }
109 
110 pub fn push_stereo(&mut self, pcm: &[i16]) -> Result<(), InvalidBuffer> {
111 if pcm.len() % 2 != 0 {
112 return Err(InvalidBuffer);
113 }
114 for sample in pcm.chunks_exact(2) {
115 self.storage[self.position] =
116 (i32::from(sample[0]) + i32::from(sample[1])) as f32 / 65536.0;
117 self.position = (self.position + 1) % FFT_SIZE;
118 self.filled = (self.filled + 1).min(FFT_SIZE);
119 }
120 Ok(())
121 }
122 
123 /// Writes interleaved real/imaginary input in chronological order.
124 pub fn write_complex(&self, out: &mut [f32]) -> Result<bool, InvalidBuffer> {
125 if out.len() != FFT_SIZE * 2 {
126 return Err(InvalidBuffer);
127 }
128 if self.filled < FFT_SIZE {
129 return Ok(false);
130 }
131 for (i, pair) in out.chunks_exact_mut(2).enumerate() {
132 pair[0] = self.storage[(self.position + i) % FFT_SIZE] * self.storage[FFT_SIZE + i];
133 pair[1] = 0.0;
134 }
135 Ok(true)
136 }
137 
138 /// Maps complex FFT bins in frequency order to 32 logarithmic bands.
139 /// Encodes -72..-6 dBFS as 0..255, clamping values outside that range.
140 pub fn bands(&self, transformed: &[f32]) -> Result<[u8; BANDS], InvalidBuffer> {
141 if transformed.len() != FFT_SIZE * 2 {
142 return Err(InvalidBuffer);
143 }
144 Ok(std::array::from_fn(|band| {
145 let (first, end) = self.ranges[band];
146 let power = (first..end)
147 .map(|bin| {
148 let real = transformed[bin * 2];
149 let imag = transformed[bin * 2 + 1];
150 (real * real + imag * imag) * self.normalization
151 })
152 .fold(1e-12_f32, f32::max);
153 ((10.0 * power.log10() + 72.0) * (255.0 / 66.0)).clamp(0.0, 255.0) as u8
154 }))
155 }
156}