#![allow(dead_code)]
use crossbeam_channel::{Receiver, Sender};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AudioFeatures {
pub rms: f32,
pub centroid: f32,
pub flux: f32,
pub bands: [f32; 8],
}
impl Default for AudioFeatures {
fn default() -> Self {
Self {
rms: 0.0,
centroid: 0.5,
flux: 0.0,
bands: [0.0; 8],
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct OdePatch {
pub sigma: Option<f64>,
pub rho: Option<f64>,
pub beta: Option<f64>,
pub trigger_system_switch: bool,
pub features: AudioFeatures,
}
impl OdePatch {
pub fn is_empty(&self) -> bool {
self.sigma.is_none()
&& self.rho.is_none()
&& self.beta.is_none()
&& !self.trigger_system_switch
}
}
#[derive(Debug, Clone)]
pub struct BridgeConfig {
pub sigma_min: f64,
pub sigma_max: f64,
pub rho_min: f64,
pub rho_max: f64,
pub beta_min: f64,
pub beta_max: f64,
pub flux_switch_threshold: f32,
pub sigma_min_delta: f64,
pub rho_min_delta: f64,
pub fft_size: usize,
pub hop_size: usize,
pub rms_ref: f32,
pub flux_ref: f32,
}
impl Default for BridgeConfig {
fn default() -> Self {
Self {
sigma_min: 5.0,
sigma_max: 30.0,
rho_min: 15.0,
rho_max: 60.0,
beta_min: 1.5,
beta_max: 4.0,
flux_switch_threshold: 0.6,
sigma_min_delta: 0.25,
rho_min_delta: 0.5,
fft_size: 1024,
hop_size: 512,
rms_ref: 0.3,
flux_ref: 50.0,
}
}
}
pub struct AudioInputAnalyzer {
config: BridgeConfig,
frame_buf: Vec<f32>,
write_pos: usize,
hop_counter: usize,
prev_magnitudes: Vec<f32>,
window: Vec<f32>,
fft: Arc<dyn rustfft::Fft<f32>>,
spectrum: Vec<rustfft::num_complex::Complex<f32>>,
tx: Sender<AudioFeatures>,
}
impl AudioInputAnalyzer {
pub fn new(config: BridgeConfig, tx: Sender<AudioFeatures>) -> Self {
let n = config.fft_size;
let window: Vec<f32> = (0..n)
.map(|i| {
0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (n - 1) as f32).cos())
})
.collect();
Self {
fft: rustfft::FftPlanner::new().plan_fft_forward(n),
spectrum: vec![rustfft::num_complex::Complex::new(0.0, 0.0); n],
frame_buf: vec![0.0; n],
write_pos: 0,
hop_counter: 0,
prev_magnitudes: vec![0.0; n / 2 + 1],
window,
config,
tx,
}
}
pub fn feed(&mut self, sample: f32) {
let n = self.config.fft_size;
self.frame_buf[self.write_pos % n] = sample;
self.write_pos += 1;
self.hop_counter += 1;
if self.hop_counter >= self.config.hop_size {
self.hop_counter = 0;
self.analyze();
}
}
fn analyze(&mut self) {
let n = self.config.fft_size;
for (i, slot) in self.spectrum.iter_mut().enumerate() {
let sample = self.frame_buf[(self.write_pos + i) % n] * self.window[i];
*slot = rustfft::num_complex::Complex::new(sample, 0.0);
}
self.fft.process(&mut self.spectrum);
let half = n / 2 + 1;
let magnitudes: Vec<f32> = self.spectrum[..half].iter().map(|c| c.norm()).collect();
let rms_raw = {
let sum_sq: f32 = self.frame_buf.iter().map(|&s| s * s).sum();
(sum_sq / n as f32).sqrt()
};
let rms = (rms_raw / self.config.rms_ref).clamp(0.0, 1.0);
let total_mag: f32 = magnitudes.iter().sum();
let centroid_raw = if total_mag > 1e-9 {
magnitudes
.iter()
.enumerate()
.map(|(k, &m)| k as f32 * m)
.sum::<f32>()
/ total_mag
} else {
half as f32 / 2.0
};
let centroid = (centroid_raw / half as f32).clamp(0.0, 1.0);
let flux_raw: f32 = magnitudes
.iter()
.zip(self.prev_magnitudes.iter())
.map(|(&a, &b)| (a - b).abs())
.sum();
let flux = (flux_raw / self.config.flux_ref).clamp(0.0, 1.0);
let band_size = half / 8;
let mut bands = [0.0f32; 8];
for (b, band) in bands.iter_mut().enumerate() {
let start = b * band_size;
let end = ((b + 1) * band_size).min(half);
let energy: f32 = magnitudes[start..end].iter().map(|&m| m * m).sum();
let peak = if end > start {
magnitudes[start..end].iter().cloned().fold(0.0f32, f32::max)
} else {
1.0
};
*band = if peak > 1e-9 {
(energy / peak / (end - start) as f32).clamp(0.0, 1.0)
} else {
0.0
};
}
self.prev_magnitudes.clone_from_slice(&magnitudes);
let _ = self.tx.send(AudioFeatures {
rms,
centroid,
flux,
bands,
});
}
}
pub struct AudioOdeBridge {
config: BridgeConfig,
feature_rx: Receiver<AudioFeatures>,
patch_tx: Sender<OdePatch>,
last_sigma: f64,
last_rho: f64,
last_beta: f64,
}
impl AudioOdeBridge {
pub fn from_channels(
config: BridgeConfig,
feature_rx: Receiver<AudioFeatures>,
patch_tx: Sender<OdePatch>,
) -> Self {
let sigma_init = (config.sigma_min + config.sigma_max) / 2.0;
let rho_init = (config.rho_min + config.rho_max) / 2.0;
let beta_init = (config.beta_min + config.beta_max) / 2.0;
Self {
config,
feature_rx,
patch_tx,
last_sigma: sigma_init,
last_rho: rho_init,
last_beta: beta_init,
}
}
pub fn process_frame(&mut self, f: AudioFeatures) {
let cfg = &self.config;
let sigma = cfg.sigma_min + (cfg.sigma_max - cfg.sigma_min) * f.rms as f64;
let rho = cfg.rho_min + (cfg.rho_max - cfg.rho_min) * f.centroid as f64;
let beta = cfg.beta_min + (cfg.beta_max - cfg.beta_min) * f.bands[3] as f64;
let sigma_changed = (sigma - self.last_sigma).abs() >= cfg.sigma_min_delta;
let rho_changed = (rho - self.last_rho).abs() >= cfg.rho_min_delta;
let beta_changed = (beta - self.last_beta).abs() >= 0.05;
let flux_triggered = f.flux >= cfg.flux_switch_threshold;
if sigma_changed || rho_changed || beta_changed || flux_triggered {
if sigma_changed {
self.last_sigma = sigma;
}
if rho_changed {
self.last_rho = rho;
}
if beta_changed {
self.last_beta = beta;
}
let patch = OdePatch {
sigma: if sigma_changed { Some(sigma) } else { None },
rho: if rho_changed { Some(rho) } else { None },
beta: if beta_changed { Some(beta) } else { None },
trigger_system_switch: flux_triggered,
features: f,
};
let _ = self.patch_tx.send(patch);
}
}
pub fn drain(&mut self) {
while let Ok(f) = self.feature_rx.try_recv() {
self.process_frame(f);
}
}
pub fn run_background(mut self, stop: Arc<AtomicBool>) {
thread::Builder::new()
.name("audio-ode-bridge".into())
.spawn(move || {
while !stop.load(Ordering::Relaxed) {
if let Ok(f) = self.feature_rx.recv_timeout(std::time::Duration::from_millis(50)) {
self.process_frame(f);
}
}
})
.expect("audio-ode-bridge thread");
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DualModeKind {
ForwardOnly,
ReverseOnly,
Both,
}
impl Default for DualModeKind {
fn default() -> Self {
Self::ForwardOnly
}
}
pub struct DualMode {
pub kind: DualModeKind,
pub bridge_config: BridgeConfig,
stop: Arc<AtomicBool>,
}
impl DualMode {
pub fn new(bridge_config: BridgeConfig) -> Self {
Self {
kind: DualModeKind::ForwardOnly,
bridge_config,
stop: Arc::new(AtomicBool::new(false)),
}
}
pub fn set_mode(&mut self, kind: DualModeKind) {
self.kind = kind;
log::info!("[audio_driven] mode changed to {:?}", kind);
}
pub fn reverse_active(&self) -> bool {
matches!(self.kind, DualModeKind::ReverseOnly | DualModeKind::Both)
}
pub fn forward_active(&self) -> bool {
matches!(self.kind, DualModeKind::ForwardOnly | DualModeKind::Both)
}
pub fn build_reverse_pipeline(
&self,
) -> (AudioInputAnalyzer, Receiver<OdePatch>, Arc<AtomicBool>) {
let (feat_tx, feat_rx) = crossbeam_channel::unbounded::<AudioFeatures>();
let (patch_tx, patch_rx) = crossbeam_channel::unbounded::<OdePatch>();
let stop = Arc::clone(&self.stop);
let analyzer = AudioInputAnalyzer::new(self.bridge_config.clone(), feat_tx);
let bridge =
AudioOdeBridge::from_channels(self.bridge_config.clone(), feat_rx, patch_tx);
bridge.run_background(Arc::clone(&stop));
(analyzer, patch_rx, stop)
}
pub fn stop(&self) {
self.stop.store(true, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_channels() -> (Sender<AudioFeatures>, Receiver<AudioFeatures>) {
crossbeam_channel::unbounded()
}
fn features_of_tone(freq: f32, sample_rate: f32) -> AudioFeatures {
let (tx, rx) = make_channels();
let mut a = AudioInputAnalyzer::new(BridgeConfig::default(), tx);
for i in 0..8192 {
a.feed(0.25 * (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin());
}
rx.try_iter().last().expect("at least one frame")
}
#[test]
fn centroid_tracks_the_pitch_of_a_pure_tone() {
for freq in [1_000.0f32, 6_000.0, 12_000.0] {
let f = features_of_tone(freq, 48_000.0);
let expected = freq / 24_000.0;
assert!(
(f.centroid - expected).abs() < 0.02,
"{freq} Hz: centroid {} expected {expected}",
f.centroid
);
}
}
#[test]
fn tone_energy_lands_in_the_right_band() {
let f = features_of_tone(10_500.0, 48_000.0);
let loudest = (0..8).max_by(|&a, &b| f.bands[a].total_cmp(&f.bands[b])).unwrap();
assert_eq!(loudest, 3, "bands {:?}", f.bands);
}
#[test]
fn audio_features_default() {
let f = AudioFeatures::default();
assert_eq!(f.rms, 0.0);
assert_eq!(f.bands, [0.0; 8]);
}
#[test]
fn analyzer_feeds_without_panic() {
let (tx, _rx) = make_channels();
let cfg = BridgeConfig {
fft_size: 16,
hop_size: 8,
..Default::default()
};
let mut analyzer = AudioInputAnalyzer::new(cfg, tx);
for i in 0..32 {
analyzer.feed((i as f32 * 0.01).sin());
}
}
#[test]
fn bridge_maps_rms_to_sigma_range() {
let (feat_tx, feat_rx) = crossbeam_channel::unbounded::<AudioFeatures>();
let (patch_tx, patch_rx) = crossbeam_channel::unbounded::<OdePatch>();
let cfg = BridgeConfig::default();
let sigma_min = cfg.sigma_min;
let sigma_max = cfg.sigma_max;
let mut bridge = AudioOdeBridge::from_channels(cfg, feat_rx, patch_tx);
let _ = feat_tx.send(AudioFeatures {
rms: 1.0,
centroid: 0.5,
flux: 0.0,
bands: [0.0; 8],
});
bridge.drain();
if let Ok(patch) = patch_rx.try_recv() {
if let Some(sigma) = patch.sigma {
assert!(
sigma >= sigma_min && sigma <= sigma_max + 1e-6,
"sigma {sigma} out of range [{sigma_min}, {sigma_max}]"
);
}
}
}
#[test]
fn bridge_triggers_switch_on_high_flux() {
let (feat_tx, feat_rx) = crossbeam_channel::unbounded::<AudioFeatures>();
let (patch_tx, patch_rx) = crossbeam_channel::unbounded::<OdePatch>();
let cfg = BridgeConfig {
flux_switch_threshold: 0.5,
..Default::default()
};
let mut bridge = AudioOdeBridge::from_channels(cfg, feat_rx, patch_tx);
let _ = feat_tx.send(AudioFeatures {
rms: 0.0,
centroid: 0.5,
flux: 0.9,
bands: [0.0; 8],
});
bridge.drain();
let patch = patch_rx.try_recv().expect("patch should be emitted");
assert!(patch.trigger_system_switch, "high flux should trigger switch");
}
#[test]
fn dual_mode_forward_active_by_default() {
let mode = DualMode::new(BridgeConfig::default());
assert!(mode.forward_active());
assert!(!mode.reverse_active());
}
#[test]
fn dual_mode_both() {
let mut mode = DualMode::new(BridgeConfig::default());
mode.set_mode(DualModeKind::Both);
assert!(mode.forward_active());
assert!(mode.reverse_active());
}
#[test]
fn ode_patch_is_empty_when_no_changes() {
let p = OdePatch {
sigma: None,
rho: None,
beta: None,
trigger_system_switch: false,
features: AudioFeatures::default(),
};
assert!(p.is_empty());
}
}