use alloc::collections::VecDeque;
use alloc::string::String;
#[cfg(feature = "wasm")]
use alloc::string::ToString;
use alloc::vec;
use alloc::vec::Vec;
use core::f64::consts::PI;
use serde::{Deserialize, Serialize};
use crate::graph::NodeId;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum ObservableValue {
Param {
node_id: String,
param_id: String,
value: f64,
},
Level {
node_id: String,
port_id: u32,
rms_db: f64,
peak_db: f64,
},
Gate {
node_id: String,
port_id: u32,
active: bool,
},
Scope {
node_id: String,
port_id: u32,
samples: Vec<f32>,
},
Spectrum {
node_id: String,
port_id: u32,
bins: Vec<f32>,
freq_range: (f32, f32),
},
}
impl ObservableValue {
pub fn key(&self) -> String {
match self {
ObservableValue::Param {
node_id, param_id, ..
} => {
alloc::format!("param:{}:{}", node_id, param_id)
}
ObservableValue::Level {
node_id, port_id, ..
} => {
alloc::format!("level:{}:{}", node_id, port_id)
}
ObservableValue::Gate {
node_id, port_id, ..
} => {
alloc::format!("gate:{}:{}", node_id, port_id)
}
ObservableValue::Scope {
node_id, port_id, ..
} => {
alloc::format!("scope:{}:{}", node_id, port_id)
}
ObservableValue::Spectrum {
node_id, port_id, ..
} => {
alloc::format!("spectrum:{}:{}", node_id, port_id)
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum SubscriptionTarget {
Param { node_id: String, param_id: String },
Level { node_id: String, port_id: u32 },
Gate { node_id: String, port_id: u32 },
Scope {
node_id: String,
port_id: u32,
buffer_size: usize,
},
Spectrum {
node_id: String,
port_id: u32,
fft_size: usize,
},
}
impl SubscriptionTarget {
pub fn id(&self) -> String {
match self {
SubscriptionTarget::Param { node_id, param_id } => {
alloc::format!("param:{}:{}", node_id, param_id)
}
SubscriptionTarget::Level { node_id, port_id } => {
alloc::format!("level:{}:{}", node_id, port_id)
}
SubscriptionTarget::Gate { node_id, port_id } => {
alloc::format!("gate:{}:{}", node_id, port_id)
}
SubscriptionTarget::Scope {
node_id, port_id, ..
} => {
alloc::format!("scope:{}:{}", node_id, port_id)
}
SubscriptionTarget::Spectrum {
node_id, port_id, ..
} => {
alloc::format!("spectrum:{}:{}", node_id, port_id)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BufferKind {
Level,
Gate,
Scope,
Spectrum,
Param,
}
#[derive(Debug, Clone)]
enum ReadyResult {
Level { rms_db: f64, peak_db: f64 },
Gate { active: bool },
ScopeFull,
SpectrumFull,
}
#[derive(Debug)]
struct PortBuffer {
samples: Vec<f32>,
target_size: usize,
gate_active: bool,
kind: BufferKind,
node_id: Option<NodeId>,
ready: Option<ReadyResult>,
}
impl PortBuffer {
fn new(kind: BufferKind, size: usize) -> Self {
Self {
samples: Vec::with_capacity(size),
target_size: size,
gate_active: false,
kind,
node_id: None,
ready: None,
}
}
#[inline]
fn push(&mut self, sample: f32) {
if self.samples.len() < self.target_size {
self.samples.push(sample);
}
}
#[inline]
fn is_full(&self) -> bool {
self.samples.len() >= self.target_size
}
#[inline]
fn clear(&mut self) {
self.samples.clear();
}
}
#[derive(Debug, Clone)]
pub struct ObserverConfig {
pub max_update_rate: u32,
pub max_pending_updates: usize,
pub default_scope_buffer_size: usize,
pub default_fft_size: usize,
pub level_buffer_size: usize,
pub sample_rate: f64,
}
impl Default for ObserverConfig {
fn default() -> Self {
Self {
max_update_rate: 60,
max_pending_updates: 1000,
default_scope_buffer_size: 512,
default_fft_size: 256,
level_buffer_size: 128,
sample_rate: 44100.0,
}
}
}
#[derive(Debug)]
pub struct StateObserver {
subscriptions: Vec<SubscriptionTarget>,
buffers: Vec<PortBuffer>,
pending_updates: VecDeque<ObservableValue>,
config: ObserverConfig,
}
impl StateObserver {
pub fn new() -> Self {
Self::with_config(ObserverConfig::default())
}
pub fn with_config(config: ObserverConfig) -> Self {
Self {
subscriptions: Vec::new(),
buffers: Vec::new(),
pending_updates: VecDeque::new(),
config,
}
}
pub fn set_sample_rate(&mut self, sample_rate: f64) {
self.config.sample_rate = sample_rate;
}
pub fn add_subscriptions(&mut self, targets: Vec<SubscriptionTarget>) {
for target in targets {
if !self.subscriptions.iter().any(|s| s.id() == target.id()) {
let buffer = Self::make_buffer(&target, &self.config);
self.subscriptions.push(target);
self.buffers.push(buffer);
}
}
}
fn make_buffer(target: &SubscriptionTarget, config: &ObserverConfig) -> PortBuffer {
match target {
SubscriptionTarget::Level { .. } => {
PortBuffer::new(BufferKind::Level, config.level_buffer_size)
}
SubscriptionTarget::Gate { .. } => PortBuffer::new(BufferKind::Gate, 1),
SubscriptionTarget::Scope { buffer_size, .. } => {
PortBuffer::new(BufferKind::Scope, *buffer_size)
}
SubscriptionTarget::Spectrum { fft_size, .. } => {
PortBuffer::new(BufferKind::Spectrum, *fft_size)
}
SubscriptionTarget::Param { .. } => PortBuffer::new(BufferKind::Param, 0),
}
}
pub fn remove_subscriptions(&mut self, ids: &[String]) {
let mut i = 0;
while i < self.subscriptions.len() {
if ids.contains(&self.subscriptions[i].id()) {
self.subscriptions.remove(i);
self.buffers.remove(i);
} else {
i += 1;
}
}
}
pub fn clear_subscriptions(&mut self) {
self.subscriptions.clear();
self.buffers.clear();
}
pub fn subscriptions(&self) -> &[SubscriptionTarget] {
&self.subscriptions
}
pub fn is_subscribed(&self, target: &SubscriptionTarget) -> bool {
self.subscriptions.iter().any(|s| s.id() == target.id())
}
pub fn push_update(&mut self, value: ObservableValue) {
if self.subscriptions.iter().any(|s| s.id() == value.key()) {
self.enqueue(value);
}
}
fn enqueue(&mut self, value: ObservableValue) {
if let Some(pos) = self
.pending_updates
.iter()
.position(|v| v.key() == value.key())
{
self.pending_updates.remove(pos);
}
self.pending_updates.push_back(value);
while self.pending_updates.len() > self.config.max_pending_updates {
self.pending_updates.pop_front();
}
}
pub fn drain_updates(&mut self) -> Vec<ObservableValue> {
self.flush_ready();
self.pending_updates.drain(..).collect()
}
pub fn pending_updates(&self) -> impl Iterator<Item = &ObservableValue> {
self.pending_updates.iter()
}
pub fn pending_count(&self) -> usize {
self.pending_updates.len()
}
pub fn config(&self) -> &ObserverConfig {
&self.config
}
pub fn collect_sample(&mut self, patch: &crate::graph::Patch) {
const THRESHOLD_ON: f32 = 2.5;
const THRESHOLD_OFF: f32 = 0.5;
for i in 0..self.subscriptions.len() {
let (node_name, port_id) = match &self.subscriptions[i] {
SubscriptionTarget::Level { node_id, port_id }
| SubscriptionTarget::Gate { node_id, port_id }
| SubscriptionTarget::Scope {
node_id, port_id, ..
}
| SubscriptionTarget::Spectrum {
node_id, port_id, ..
} => (node_id.as_str(), *port_id),
SubscriptionTarget::Param { .. } => continue,
};
let buffer = &mut self.buffers[i];
if buffer.node_id.is_none() {
buffer.node_id = patch.get_node_id_by_name(node_name);
}
let Some(nid) = buffer.node_id else { continue };
let Some(value) = patch.get_output_value(nid, port_id) else {
continue;
};
let sample = value as f32;
match buffer.kind {
BufferKind::Level => {
buffer.push(sample);
if buffer.is_full() {
let rms_db = calculate_rms_db(&buffer.samples);
let peak_db = calculate_peak_db(&buffer.samples);
buffer.clear();
buffer.ready = Some(ReadyResult::Level { rms_db, peak_db });
}
}
BufferKind::Gate => {
let was_active = buffer.gate_active;
if buffer.gate_active {
if sample < THRESHOLD_OFF {
buffer.gate_active = false;
}
} else if sample > THRESHOLD_ON {
buffer.gate_active = true;
}
if buffer.gate_active != was_active {
buffer.ready = Some(ReadyResult::Gate {
active: buffer.gate_active,
});
}
}
BufferKind::Scope => {
buffer.push(sample);
if buffer.is_full() {
buffer.ready = Some(ReadyResult::ScopeFull);
}
}
BufferKind::Spectrum => {
buffer.push(sample);
if buffer.is_full() {
buffer.ready = Some(ReadyResult::SpectrumFull);
}
}
BufferKind::Param => {}
}
}
}
fn flush_ready(&mut self) {
for i in 0..self.buffers.len() {
let Some(ready) = self.buffers[i].ready.take() else {
continue;
};
let (node_id, port_id) = match &self.subscriptions[i] {
SubscriptionTarget::Level { node_id, port_id }
| SubscriptionTarget::Gate { node_id, port_id }
| SubscriptionTarget::Scope {
node_id, port_id, ..
}
| SubscriptionTarget::Spectrum {
node_id, port_id, ..
} => (node_id.clone(), *port_id),
SubscriptionTarget::Param { .. } => continue,
};
let value = match ready {
ReadyResult::Level { rms_db, peak_db } => ObservableValue::Level {
node_id,
port_id,
rms_db,
peak_db,
},
ReadyResult::Gate { active } => ObservableValue::Gate {
node_id,
port_id,
active,
},
ReadyResult::ScopeFull => {
let samples = self.buffers[i].samples.clone();
self.buffers[i].clear();
ObservableValue::Scope {
node_id,
port_id,
samples,
}
}
ReadyResult::SpectrumFull => {
let bins = compute_magnitude_spectrum(&self.buffers[i].samples);
let freq_range = (0.0, self.config.sample_rate as f32 / 2.0);
self.buffers[i].clear();
ObservableValue::Spectrum {
node_id,
port_id,
bins,
freq_range,
}
}
};
self.enqueue(value);
}
}
pub fn collect_from_patch(&mut self, patch: &crate::graph::Patch) {
self.collect_params(patch);
self.collect_sample(patch);
self.flush_ready();
}
fn collect_params(&mut self, patch: &crate::graph::Patch) {
let mut updates: Vec<ObservableValue> = Vec::new();
for sub in &self.subscriptions {
if let SubscriptionTarget::Param { node_id, param_id } = sub {
if let Some(nid) = patch.get_node_id_by_name(node_id) {
if let Ok(idx) = param_id.parse::<u32>() {
if let Some(value) = patch.get_param(nid, idx) {
updates.push(ObservableValue::Param {
node_id: node_id.clone(),
param_id: param_id.clone(),
value,
});
}
}
}
}
}
for update in updates {
self.enqueue(update);
}
}
}
impl Default for StateObserver {
fn default() -> Self {
Self::new()
}
}
pub fn calculate_rms_db(samples: &[f32]) -> f64 {
if samples.is_empty() {
return -f64::INFINITY;
}
let sum_sq: f64 = samples.iter().map(|&s| (s as f64) * (s as f64)).sum();
let rms = libm::Libm::<f64>::sqrt(sum_sq / samples.len() as f64);
if rms > 0.0 {
20.0 * libm::log10(rms)
} else {
-f64::INFINITY
}
}
pub fn calculate_peak_db(samples: &[f32]) -> f64 {
let peak = samples
.iter()
.map(|&s| s.abs())
.fold(0.0_f32, |a, b| a.max(b)) as f64;
if peak > 0.0 {
20.0 * libm::log10(peak)
} else {
-f64::INFINITY
}
}
#[derive(Debug, Clone)]
pub struct LevelMeterState {
pub rms_db: f64,
pub peak_db: f64,
pub peak_hold_db: f64,
samples_since_peak: usize,
}
impl Default for LevelMeterState {
fn default() -> Self {
Self {
rms_db: -f64::INFINITY,
peak_db: -f64::INFINITY,
peak_hold_db: -f64::INFINITY,
samples_since_peak: 0,
}
}
}
const PEAK_HOLD_DECAY_DB_PER_SAMPLE: f64 = 20.0 / 44_100.0;
impl LevelMeterState {
pub fn update(&mut self, samples: &[f32], peak_hold_samples: usize) {
self.rms_db = calculate_rms_db(samples);
self.peak_db = calculate_peak_db(samples);
if self.peak_db >= self.peak_hold_db {
self.peak_hold_db = self.peak_db;
self.samples_since_peak = 0;
} else {
self.samples_since_peak = self.samples_since_peak.saturating_add(samples.len());
if self.samples_since_peak > peak_hold_samples {
let decay = PEAK_HOLD_DECAY_DB_PER_SAMPLE * samples.len() as f64;
self.peak_hold_db = (self.peak_hold_db - decay).max(self.peak_db);
self.samples_since_peak = peak_hold_samples + 1;
}
}
}
pub fn reset(&mut self) {
self.rms_db = -f64::INFINITY;
self.peak_db = -f64::INFINITY;
self.peak_hold_db = -f64::INFINITY;
self.samples_since_peak = 0;
}
}
#[derive(Debug, Clone)]
pub struct GateDetector {
pub threshold_on: f32,
pub threshold_off: f32,
pub active: bool,
}
impl GateDetector {
pub fn new() -> Self {
Self {
threshold_on: 2.5, threshold_off: 0.5, active: false,
}
}
pub fn with_thresholds(threshold_on: f32, threshold_off: f32) -> Self {
Self {
threshold_on,
threshold_off,
active: false,
}
}
pub fn process(&mut self, sample: f32) -> bool {
if self.active {
if sample < self.threshold_off {
self.active = false;
}
} else if sample > self.threshold_on {
self.active = true;
}
self.active
}
pub fn reset(&mut self) {
self.active = false;
}
}
impl Default for GateDetector {
fn default() -> Self {
Self::new()
}
}
pub(crate) fn fft_radix2(re: &mut [f64], im: &mut [f64]) {
let n = re.len();
debug_assert_eq!(n, im.len());
debug_assert!(n == 0 || n.is_power_of_two());
if n <= 1 {
return;
}
let mut j = 0usize;
for i in 1..n {
let mut bit = n >> 1;
while j & bit != 0 {
j ^= bit;
bit >>= 1;
}
j |= bit;
if i < j {
re.swap(i, j);
im.swap(i, j);
}
}
let mut len = 2;
while len <= n {
let ang = -2.0 * PI / len as f64; let (wlen_re, wlen_im) = (libm::cos(ang), libm::sin(ang));
let half = len / 2;
let mut base = 0;
while base < n {
let (mut w_re, mut w_im) = (1.0_f64, 0.0_f64);
for k in 0..half {
let a = base + k;
let b = base + k + half;
let t_re = re[b] * w_re - im[b] * w_im;
let t_im = re[b] * w_im + im[b] * w_re;
re[b] = re[a] - t_re;
im[b] = im[a] - t_im;
re[a] += t_re;
im[a] += t_im;
let nw_re = w_re * wlen_re - w_im * wlen_im;
let nw_im = w_re * wlen_im + w_im * wlen_re;
w_re = nw_re;
w_im = nw_im;
}
base += len;
}
len <<= 1;
}
}
fn compute_magnitude_spectrum(samples: &[f32]) -> Vec<f32> {
let n = samples.len();
if n < 2 {
return vec![];
}
if !n.is_power_of_two() {
return compute_magnitude_spectrum_dft(samples);
}
let mut re: Vec<f64> = Vec::with_capacity(n);
let mut im: Vec<f64> = vec![0.0; n];
for (i, &s) in samples.iter().enumerate() {
let window = 0.5 * (1.0 - libm::cos(2.0 * PI * i as f64 / (n - 1) as f64));
re.push(s as f64 * window);
}
fft_radix2(&mut re, &mut im);
let num_bins = n / 2;
let mut magnitudes = Vec::with_capacity(num_bins);
for k in 0..num_bins {
let magnitude = libm::sqrt(re[k] * re[k] + im[k] * im[k]) / n as f64;
let magnitude_db = if magnitude > 1e-10 {
20.0 * libm::log10(magnitude)
} else {
-100.0
};
magnitudes.push(magnitude_db.clamp(-100.0, 0.0) as f32);
}
magnitudes
}
fn compute_magnitude_spectrum_dft(samples: &[f32]) -> Vec<f32> {
let n = samples.len();
if n < 2 {
return vec![];
}
let windowed: Vec<f64> = samples
.iter()
.enumerate()
.map(|(i, &s)| {
let window = 0.5 * (1.0 - libm::cos(2.0 * PI * i as f64 / (n - 1) as f64));
s as f64 * window
})
.collect();
let num_bins = n / 2;
let mut magnitudes = Vec::with_capacity(num_bins);
for k in 0..num_bins {
let mut real = 0.0;
let mut imag = 0.0;
for (i, &sample) in windowed.iter().enumerate() {
let angle = -2.0 * PI * k as f64 * i as f64 / n as f64;
real += sample * libm::cos(angle);
imag += sample * libm::sin(angle);
}
let magnitude = libm::sqrt(real * real + imag * imag) / n as f64;
let magnitude_db = if magnitude > 1e-10 {
20.0 * libm::log10(magnitude)
} else {
-100.0
};
magnitudes.push(magnitude_db.clamp(-100.0, 0.0) as f32);
}
magnitudes
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_observable_value_key() {
let param = ObservableValue::Param {
node_id: "vco1".into(),
param_id: "frequency".into(),
value: 440.0,
};
assert_eq!(param.key(), "param:vco1:frequency");
let level = ObservableValue::Level {
node_id: "output".into(),
port_id: 0,
rms_db: -12.0,
peak_db: -6.0,
};
assert_eq!(level.key(), "level:output:0");
}
#[test]
fn test_subscription_target_id() {
let param = SubscriptionTarget::Param {
node_id: "vco1".into(),
param_id: "frequency".into(),
};
assert_eq!(param.id(), "param:vco1:frequency");
let scope = SubscriptionTarget::Scope {
node_id: "vco1".into(),
port_id: 0,
buffer_size: 512,
};
assert_eq!(scope.id(), "scope:vco1:0");
}
#[test]
fn test_state_observer_subscriptions() {
let mut observer = StateObserver::new();
let target = SubscriptionTarget::Param {
node_id: "vco1".into(),
param_id: "frequency".into(),
};
observer.add_subscriptions(vec![target.clone()]);
assert!(observer.is_subscribed(&target));
assert_eq!(observer.subscriptions().len(), 1);
observer.add_subscriptions(vec![target.clone()]);
assert_eq!(observer.subscriptions().len(), 1);
observer.remove_subscriptions(&[target.id()]);
assert!(!observer.is_subscribed(&target));
assert_eq!(observer.subscriptions().len(), 0);
}
#[test]
fn test_state_observer_push_update() {
let mut observer = StateObserver::new();
observer.add_subscriptions(vec![SubscriptionTarget::Param {
node_id: "vco1".into(),
param_id: "frequency".into(),
}]);
observer.push_update(ObservableValue::Param {
node_id: "vco1".into(),
param_id: "frequency".into(),
value: 440.0,
});
assert_eq!(observer.pending_count(), 1);
observer.push_update(ObservableValue::Param {
node_id: "vco1".into(),
param_id: "frequency".into(),
value: 880.0,
});
assert_eq!(observer.pending_count(), 1);
let updates = observer.drain_updates();
assert_eq!(updates.len(), 1);
if let ObservableValue::Param { value, .. } = &updates[0] {
assert_eq!(*value, 880.0);
} else {
panic!("Expected Param update");
}
assert_eq!(observer.pending_count(), 0);
}
#[test]
fn test_state_observer_ignores_unsubscribed() {
let mut observer = StateObserver::new();
observer.push_update(ObservableValue::Param {
node_id: "vco1".into(),
param_id: "frequency".into(),
value: 440.0,
});
assert_eq!(observer.pending_count(), 0);
}
#[test]
fn test_state_observer_creates_buffers() {
let mut observer = StateObserver::new();
observer.add_subscriptions(vec![SubscriptionTarget::Level {
node_id: "vco1".into(),
port_id: 0,
}]);
assert_eq!(observer.buffers.len(), 1);
assert_eq!(observer.buffers[0].kind, BufferKind::Level);
observer.add_subscriptions(vec![SubscriptionTarget::Param {
node_id: "vco1".into(),
param_id: "freq".into(),
}]);
assert_eq!(observer.buffers.len(), 2);
assert_eq!(observer.buffers[1].kind, BufferKind::Param);
}
#[test]
fn test_state_observer_cleans_up_buffers() {
let mut observer = StateObserver::new();
observer.add_subscriptions(vec![SubscriptionTarget::Level {
node_id: "vco1".into(),
port_id: 0,
}]);
assert_eq!(observer.buffers.len(), 1);
observer.remove_subscriptions(&["level:vco1:0".into()]);
assert_eq!(observer.buffers.len(), 0);
assert_eq!(observer.subscriptions().len(), 0);
}
#[test]
fn test_calculate_rms_db() {
assert!(calculate_rms_db(&[]).is_infinite());
assert!(calculate_rms_db(&[0.0, 0.0, 0.0]).is_infinite());
let rms_unity = calculate_rms_db(&[1.0, -1.0]);
assert!((rms_unity - 0.0).abs() < 0.1);
let rms_half = calculate_rms_db(&[0.5, -0.5]);
assert!((rms_half - (-6.0)).abs() < 0.1); }
#[test]
fn test_calculate_peak_db() {
assert!(calculate_peak_db(&[]).is_infinite());
assert!(calculate_peak_db(&[0.0, 0.0]).is_infinite());
let peak_unity = calculate_peak_db(&[1.0, -0.5]);
assert!((peak_unity - 0.0).abs() < 0.01);
let peak_half = calculate_peak_db(&[0.5, -0.25]);
assert!((peak_half - (-6.02)).abs() < 0.1); }
#[test]
fn test_level_meter_state() {
let mut meter = LevelMeterState::default();
meter.update(&[0.5, -0.5, 0.5, -0.5], 44100);
assert!(!meter.rms_db.is_infinite());
assert!(!meter.peak_db.is_infinite());
assert_eq!(meter.peak_hold_db, meter.peak_db);
let prev_peak_hold = meter.peak_hold_db;
meter.update(&[0.1, -0.1], 44100);
assert_eq!(meter.peak_hold_db, prev_peak_hold);
}
#[test]
fn test_gate_detector() {
let mut gate = GateDetector::new();
assert!(!gate.active);
assert!(!gate.process(1.0));
assert!(gate.process(3.0));
assert!(gate.active);
assert!(gate.process(1.0));
assert!(!gate.process(0.1));
assert!(!gate.active);
}
#[test]
fn test_compute_magnitude_spectrum() {
assert!(compute_magnitude_spectrum(&[]).is_empty());
let dc_signal: Vec<f32> = vec![1.0; 64];
let spectrum = compute_magnitude_spectrum(&dc_signal);
assert_eq!(spectrum.len(), 32);
assert!(spectrum[0] > spectrum[1]);
}
#[test]
fn test_observable_value_serialization() {
let value = ObservableValue::Param {
node_id: "vco1".into(),
param_id: "freq".into(),
value: 440.0,
};
let json = serde_json::to_string(&value).unwrap();
assert!(json.contains("\"type\":\"param\""));
assert!(json.contains("\"node_id\":\"vco1\""));
let deserialized: ObservableValue = serde_json::from_str(&json).unwrap();
assert_eq!(value.key(), deserialized.key());
}
#[test]
fn test_subscription_target_serialization() {
let target = SubscriptionTarget::Scope {
node_id: "vco1".into(),
port_id: 0,
buffer_size: 512,
};
let json = serde_json::to_string(&target).unwrap();
assert!(json.contains("\"type\":\"scope\""));
assert!(json.contains("\"buffer_size\":512"));
let deserialized: SubscriptionTarget = serde_json::from_str(&json).unwrap();
assert_eq!(target.id(), deserialized.id());
}
#[test]
fn test_level_observable() {
let level = ObservableValue::Level {
node_id: "output".into(),
port_id: 0,
rms_db: -12.5,
peak_db: -3.2,
};
let json = serde_json::to_string(&level).unwrap();
assert!(json.contains("\"type\":\"level\""));
assert!(json.contains("\"rms_db\":-12.5"));
}
#[test]
fn test_gate_observable() {
let gate = ObservableValue::Gate {
node_id: "lfo".into(),
port_id: 1,
active: true,
};
let json = serde_json::to_string(&gate).unwrap();
assert!(json.contains("\"type\":\"gate\""));
assert!(json.contains("\"active\":true"));
}
#[test]
fn test_scope_observable() {
let scope = ObservableValue::Scope {
node_id: "osc".into(),
port_id: 0,
samples: vec![0.0, 0.5, 1.0, 0.5, 0.0, -0.5, -1.0, -0.5],
};
let json = serde_json::to_string(&scope).unwrap();
assert!(json.contains("\"type\":\"scope\""));
assert!(json.contains("\"samples\""));
}
#[test]
fn test_spectrum_observable() {
let spectrum = ObservableValue::Spectrum {
node_id: "analyzer".into(),
port_id: 0,
bins: vec![-20.0, -30.0, -40.0, -50.0],
freq_range: (0.0, 22050.0),
};
let json = serde_json::to_string(&spectrum).unwrap();
assert!(json.contains("\"type\":\"spectrum\""));
assert!(json.contains("\"freq_range\""));
}
#[test]
fn test_level_meter_peak_hold_and_decay() {
let mut meter = LevelMeterState::default();
let hold = 100usize;
meter.update(&[1.0, -1.0], hold);
let held = meter.peak_hold_db;
assert!((held - 0.0).abs() < 0.1);
meter.update(&[0.1; 50], hold); assert_eq!(
meter.peak_hold_db, held,
"peak hold must hold within the window"
);
meter.update(&[0.1; 60], hold); assert!(
meter.peak_hold_db < held,
"peak hold must decay after the window"
);
let current_peak = meter.peak_db;
assert!(
meter.peak_hold_db > current_peak + 1.0,
"decay must be gradual, not an instant snap"
);
let after_first = meter.peak_hold_db;
meter.update(&[0.1; 60], hold);
assert!(
meter.peak_hold_db < after_first,
"decay must continue every update after the hold window"
);
assert!(
meter.peak_hold_db >= current_peak,
"decay never overshoots the current peak"
);
}
#[test]
fn test_fft_matches_dft_on_sine() {
let n = 64usize;
let bin = 5usize;
let input: Vec<f64> = (0..n)
.map(|i| libm::cos(2.0 * PI * bin as f64 * i as f64 / n as f64))
.collect();
let mut re = input.clone();
let mut im = vec![0.0f64; n];
fft_radix2(&mut re, &mut im);
let dft_mag = |k: usize| -> f64 {
let mut r = 0.0;
let mut i = 0.0;
for (t, &x) in input.iter().enumerate() {
let ang = -2.0 * PI * k as f64 * t as f64 / n as f64;
r += x * libm::cos(ang);
i += x * libm::sin(ang);
}
libm::sqrt(r * r + i * i)
};
let mut peak_bin = 0usize;
let mut peak_mag = -1.0;
for k in 0..n / 2 {
let fft_mag = libm::sqrt(re[k] * re[k] + im[k] * im[k]);
assert!(
(fft_mag - dft_mag(k)).abs() < 1e-9,
"bin {k}: fft {fft_mag} vs dft {}",
dft_mag(k)
);
if fft_mag > peak_mag {
peak_mag = fft_mag;
peak_bin = k;
}
}
assert_eq!(peak_bin, bin, "FFT peak bin must match the input frequency");
}
fn build_constant_patch(value: f64) -> crate::graph::Patch {
let mut patch = crate::graph::Patch::new(44_100.0);
let level = alloc::sync::Arc::new(crate::io::AtomicF64::new(value));
let src = patch.add("src", crate::io::ExternalInput::audio(level));
let out = patch.add("out", crate::modules::StereoOutput::new());
patch.connect(src.out("out"), out.in_("left")).unwrap();
patch.set_output(out.id());
patch.compile().unwrap();
patch
}
#[test]
fn test_collect_sample_per_sample_capture() {
let mut patch = build_constant_patch(0.5);
let mut obs = StateObserver::new();
obs.add_subscriptions(vec![SubscriptionTarget::Scope {
node_id: "src".into(),
port_id: 0,
buffer_size: 8,
}]);
for _ in 0..8 {
patch.tick();
obs.collect_sample(&patch);
}
let updates = obs.drain_updates();
let samples = updates
.iter()
.find_map(|u| match u {
ObservableValue::Scope { samples, .. } => Some(samples.clone()),
_ => None,
})
.expect("expected a scope update after 8 per-sample captures");
assert_eq!(samples.len(), 8);
for s in &samples {
assert!((s - 0.5).abs() < 1e-6);
}
}
#[test]
fn test_spectrum_freq_range_uses_true_capture_rate() {
let mut patch = build_constant_patch(0.25);
let mut obs = StateObserver::new();
let fft_size = 16usize;
obs.add_subscriptions(vec![SubscriptionTarget::Spectrum {
node_id: "src".into(),
port_id: 0,
fft_size,
}]);
for _ in 0..fft_size {
patch.tick();
obs.collect_sample(&patch);
}
let updates = obs.drain_updates();
let (bins, freq_range) = updates
.iter()
.find_map(|u| match u {
ObservableValue::Spectrum {
bins, freq_range, ..
} => Some((bins.clone(), *freq_range)),
_ => None,
})
.expect("expected a spectrum update");
assert_eq!(freq_range, (0.0, 22_050.0));
assert_eq!(bins.len(), fft_size / 2);
}
#[test]
fn test_collect_sample_is_allocation_free() {
let mut patch = build_constant_patch(0.5);
let mut obs = StateObserver::new();
obs.add_subscriptions(vec![
SubscriptionTarget::Level {
node_id: "src".into(),
port_id: 0,
},
SubscriptionTarget::Scope {
node_id: "src".into(),
port_id: 0,
buffer_size: 64,
},
SubscriptionTarget::Spectrum {
node_id: "src".into(),
port_id: 0,
fft_size: 64,
},
SubscriptionTarget::Gate {
node_id: "src".into(),
port_id: 0,
},
]);
patch.tick();
obs.collect_sample(&patch);
let _ = alloc_guard::count_allocations(|| {});
let allocs = alloc_guard::count_allocations(|| {
for _ in 0..2048 {
obs.collect_sample(&patch);
}
});
assert_eq!(
allocs, 0,
"collect_sample must be allocation-free on the audio path"
);
}
}
#[cfg(all(test, feature = "std"))]
mod alloc_guard {
use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;
thread_local! {
static COUNTING: Cell<bool> = const { Cell::new(false) };
static LOCAL_COUNT: Cell<usize> = const { Cell::new(0) };
}
struct CountingAllocator;
#[inline]
fn note_alloc() {
let _ = COUNTING.try_with(|c| {
if c.get() {
let _ = LOCAL_COUNT.try_with(|n| n.set(n.get() + 1));
}
});
}
unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
note_alloc();
System.alloc(layout)
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
System.dealloc(ptr, layout);
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
note_alloc();
System.realloc(ptr, layout, new_size)
}
}
#[global_allocator]
static GLOBAL: CountingAllocator = CountingAllocator;
pub(super) fn count_allocations<F: FnOnce()>(f: F) -> usize {
LOCAL_COUNT.with(|n| n.set(0));
COUNTING.with(|c| c.set(true));
f();
COUNTING.with(|c| c.set(false));
LOCAL_COUNT.with(|n| n.get())
}
}