Skip to main content

rlx_ir/ops/
spectral.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Spectral front-ends for EEG models — windowed spectrogram, per-band power,
17//! and per-band differential entropy — composed on the existing `rfft` helper.
18//!
19//! These are the tokenizer/feature stages that BrainBERT, BIOT,
20//! SleepTransformer, CBraMod, Uni-NTFM, FoME and MAET currently compute
21//! host-side in plain Rust. Expressed as graph subtrees they run on-device and
22//! are differentiable.
23//!
24//! Because `rfft` zero-pads the frame to the next power of two, the frequency
25//! axis has `next_pow2(frame_len)/2 + 1` bins.
26
27use crate::infer::GraphExt as _;
28use crate::op::Activation;
29use crate::{DType, Graph, NodeId, fft::FftNorm};
30
31/// Analysis window applied to each frame before the FFT.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum WindowKind {
34    /// No window (boxcar).
35    Rectangular,
36    /// Hann (a.k.a. Hanning) — the usual EEG default.
37    Hann,
38    /// Hamming.
39    Hamming,
40    /// Blackman.
41    Blackman,
42}
43
44impl WindowKind {
45    fn coeffs(self, n: usize) -> Vec<f32> {
46        use std::f32::consts::PI;
47        if n == 1 {
48            return vec![1.0];
49        }
50        let d = (n - 1) as f32;
51        (0..n)
52            .map(|i| {
53                let t = i as f32 / d;
54                match self {
55                    WindowKind::Rectangular => 1.0,
56                    WindowKind::Hann => 0.5 - 0.5 * (2.0 * PI * t).cos(),
57                    WindowKind::Hamming => 0.54 - 0.46 * (2.0 * PI * t).cos(),
58                    WindowKind::Blackman => {
59                        0.42 - 0.5 * (2.0 * PI * t).cos() + 0.08 * (4.0 * PI * t).cos()
60                    }
61                }
62            })
63            .collect()
64    }
65}
66
67impl Graph {
68    /// Windowed short-time spectrogram of the last axis.
69    ///
70    /// `[..., T]` → `[n_frames, ..., n_bins]` where
71    /// `n_frames = 1 + (T − frame_len)/hop` and `n_bins = next_pow2(frame_len)/2 + 1`.
72    ///
73    /// * `power` — return `|X|²` (true) or magnitude `|X|` (false).
74    /// * `log`   — apply `log(· + 1e-8)` to the result (log-power / log-magnitude).
75    pub fn spectrogram(
76        &mut self,
77        x: NodeId,
78        frame_len: usize,
79        hop: usize,
80        window: WindowKind,
81        power: bool,
82        log: bool,
83    ) -> NodeId {
84        assert!(frame_len > 0 && hop > 0, "spectrogram: frame_len/hop > 0");
85        let shape = self.shape(x).clone();
86        let last = shape.rank() - 1;
87        let t = shape.dim(last).unwrap_static();
88        assert!(t >= frame_len, "spectrogram: T {t} < frame_len {frame_len}");
89        let n_frames = 1 + (t - frame_len) / hop;
90
91        // Stack time-domain frames into [n_frames, ..., frame_len].
92        let mut rows = Vec::with_capacity(n_frames);
93        for f in 0..n_frames {
94            let start = f * hop;
95            let frame = self.narrow_(x, last, start, frame_len);
96            let mut dims: Vec<i64> = self
97                .shape(frame)
98                .dims()
99                .iter()
100                .map(|d| d.unwrap_static() as i64)
101                .collect();
102            dims.insert(0, 1);
103            rows.push(self.reshape_(frame, dims));
104        }
105        let framed = if rows.len() == 1 {
106            rows.pop().unwrap()
107        } else {
108            self.concat_(rows, 0)
109        };
110
111        // Apply the analysis window (broadcast over the frame axis).
112        let framed = if window == WindowKind::Rectangular {
113            framed
114        } else {
115            let w = self.const_f32_tensor(window.coeffs(frame_len), &[frame_len]);
116            self.mul(framed, w)
117        };
118
119        let (re, im) = self.rfft(framed, FftNorm::Backward);
120        let re2 = self.mul(re, re);
121        let im2 = self.mul(im, im);
122        let mag2 = self.add(re2, im2);
123        let out = if power { mag2 } else { self.sqrt(mag2) };
124        if log { self.log_eps(out, 1e-8) } else { out }
125    }
126
127    /// Per-band power of the last axis via a single `rfft`.
128    ///
129    /// `[..., T]` → `[..., n_bands]`. Each band `(lo, hi)` in Hz sums the
130    /// (unnormalized) power `|X|²` over the rFFT bins whose center frequency
131    /// falls in `[lo, hi]`, using `sample_rate` to map Hz → bin index.
132    pub fn band_power(&mut self, x: NodeId, sample_rate: f32, bands: &[(f32, f32)]) -> NodeId {
133        assert!(!bands.is_empty(), "band_power: need ≥1 band");
134        let shape = self.shape(x).clone();
135        let last = shape.rank() - 1;
136        let t = shape.dim(last).unwrap_static();
137        let n_pad = crate::fft::next_pow2(t);
138        let n_bins = n_pad / 2 + 1;
139
140        let (re, im) = self.rfft(x, FftNorm::Backward);
141        let re2 = self.mul(re, re);
142        let im2 = self.mul(im, im);
143        let power = self.add(re2, im2); // [.., n_bins]
144        let plast = self.shape(power).rank() - 1;
145
146        let hz_per_bin = sample_rate / n_pad as f32;
147        let mut cols = Vec::with_capacity(bands.len());
148        for &(lo, hi) in bands {
149            let mut b0 = (lo / hz_per_bin).ceil() as isize;
150            let mut b1 = (hi / hz_per_bin).floor() as isize;
151            b0 = b0.clamp(0, n_bins as isize - 1);
152            b1 = b1.clamp(0, n_bins as isize - 1);
153            let (b0, b1) = if b1 < b0 { (b0, b0) } else { (b0, b1) };
154            let seg = self.narrow_(power, plast, b0 as usize, (b1 - b0 + 1) as usize);
155            cols.push(self.sum(seg, vec![plast], true)); // [.., 1]
156        }
157        if cols.len() == 1 {
158            cols.pop().unwrap()
159        } else {
160            self.concat_(cols, plast)
161        }
162    }
163
164    /// Per-band differential entropy: `0.5·log(2πe · band_power + 1e-8)`.
165    ///
166    /// For a band-limited Gaussian signal this equals its differential entropy
167    /// up to the constant — the classic EEG "DE" feature (SEED, MAET, FoME).
168    /// Returns `[..., n_bands]`.
169    pub fn differential_entropy(
170        &mut self,
171        x: NodeId,
172        sample_rate: f32,
173        bands: &[(f32, f32)],
174    ) -> NodeId {
175        let bp = self.band_power(x, sample_rate, bands);
176        // 2πe
177        let c = self.constant(2.0 * std::f64::consts::PI * std::f64::consts::E, DType::F32);
178        let scaled = self.mul(bp, c);
179        let logv = self.log_eps(scaled, 1e-8);
180        let half = self.constant(0.5, DType::F32);
181        self.mul(logv, half)
182    }
183
184    fn log_eps(&mut self, x: NodeId, eps: f32) -> NodeId {
185        let e = self.constant(eps as f64, DType::F32);
186        let shifted = self.add(x, e);
187        let s = crate::shape::unary_shape(self.shape(shifted));
188        self.activation(Activation::Log, shifted, s)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::Shape;
196
197    fn dims(g: &Graph, id: NodeId) -> Vec<usize> {
198        g.shape(id)
199            .dims()
200            .iter()
201            .map(|d| d.unwrap_static())
202            .collect()
203    }
204    fn input(g: &mut Graph, shape: &[usize]) -> NodeId {
205        g.input("x", Shape::new(shape, DType::F32))
206    }
207
208    #[test]
209    fn spectrogram_shape() {
210        let mut g = Graph::new("spec");
211        let x = input(&mut g, &[2, 256]); // [C, T]
212        // frame_len=64 (pow2) → n_bins = 64/2+1 = 33; n_frames = 1+(256-64)/32 = 7
213        let y = g.spectrogram(x, 64, 32, WindowKind::Hann, true, true);
214        assert_eq!(dims(&g, y), vec![7, 2, 33]);
215    }
216
217    #[test]
218    fn band_power_shape() {
219        let mut g = Graph::new("bp");
220        let x = input(&mut g, &[4, 512]); // [C, T]
221        let bands = [
222            (0.5, 4.0),
223            (4.0, 8.0),
224            (8.0, 13.0),
225            (13.0, 30.0),
226            (30.0, 45.0),
227        ];
228        let y = g.band_power(x, 128.0, &bands);
229        assert_eq!(dims(&g, y), vec![4, 5]);
230    }
231
232    #[test]
233    fn differential_entropy_shape() {
234        let mut g = Graph::new("de");
235        let x = input(&mut g, &[4, 512]);
236        let bands = [(1.0, 4.0), (4.0, 8.0), (8.0, 14.0)];
237        let y = g.differential_entropy(x, 200.0, &bands);
238        assert_eq!(dims(&g, y), vec![4, 3]);
239    }
240}