use crate::StdMap;
use alloc::string::String;
#[cfg(feature = "wasm")]
use alloc::string::ToString;
use alloc::vec;
use alloc::vec::Vec;
use libm::Libm;
use serde::{Deserialize, Serialize};
pub type PortId = u32;
pub type ParamId = u32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[serde(rename_all = "snake_case")]
pub enum SignalKind {
Audio,
CvBipolar,
CvUnipolar,
VoltPerOctave,
Gate,
Trigger,
Clock,
}
impl SignalKind {
pub fn voltage_range(&self) -> (f64, f64) {
match self {
SignalKind::Audio => (-5.0, 5.0),
SignalKind::CvBipolar => (-5.0, 5.0),
SignalKind::CvUnipolar => (0.0, 10.0),
SignalKind::VoltPerOctave => (-5.0, 5.0), SignalKind::Gate => (0.0, 5.0),
SignalKind::Trigger => (0.0, 5.0),
SignalKind::Clock => (0.0, 5.0),
}
}
pub fn is_summable(&self) -> bool {
matches!(
self,
SignalKind::Audio
| SignalKind::CvBipolar
| SignalKind::CvUnipolar
| SignalKind::VoltPerOctave
)
}
pub fn gate_threshold(&self) -> Option<f64> {
match self {
SignalKind::Gate | SignalKind::Trigger | SignalKind::Clock => Some(2.5),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
pub struct SignalColors {
pub audio: String,
pub cv_bipolar: String,
pub cv_unipolar: String,
pub volt_per_octave: String,
pub gate: String,
pub trigger: String,
pub clock: String,
}
impl Default for SignalColors {
fn default() -> Self {
Self {
audio: "#e94560".into(),
cv_bipolar: "#0f3460".into(),
cv_unipolar: "#00b4d8".into(),
volt_per_octave: "#90be6d".into(),
gate: "#f9c74f".into(),
trigger: "#f8961e".into(),
clock: "#9d4edd".into(),
}
}
}
impl SignalColors {
pub fn get(&self, kind: SignalKind) -> &str {
match kind {
SignalKind::Audio => &self.audio,
SignalKind::CvBipolar => &self.cv_bipolar,
SignalKind::CvUnipolar => &self.cv_unipolar,
SignalKind::VoltPerOctave => &self.volt_per_octave,
SignalKind::Gate => &self.gate,
SignalKind::Trigger => &self.trigger,
SignalKind::Clock => &self.clock,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
pub struct PortInfo {
pub id: u32,
pub name: String,
pub kind: SignalKind,
pub normalled_to: Option<String>,
pub description: Option<String>,
}
impl PortInfo {
pub fn new(id: u32, name: impl Into<String>, kind: SignalKind) -> Self {
Self {
id,
name: name.into(),
kind,
normalled_to: None,
description: None,
}
}
pub fn with_normalled_to(mut self, port_name: impl Into<String>) -> Self {
self.normalled_to = Some(port_name.into());
self
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
}
impl From<&PortDef> for PortInfo {
fn from(def: &PortDef) -> Self {
Self {
id: def.id,
name: def.name.clone(),
kind: def.kind,
normalled_to: None, description: None,
}
}
}
#[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 = "status")]
pub enum Compatibility {
Exact,
Allowed,
Warning { message: String },
}
pub fn ports_compatible(from: SignalKind, to: SignalKind) -> Compatibility {
if from == to {
return Compatibility::Exact;
}
match from.is_compatible_with(&to).warning {
None => Compatibility::Allowed,
Some(message) => Compatibility::Warning { message },
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
pub struct PortDef {
pub id: PortId,
pub name: String,
pub kind: SignalKind,
pub default: f64,
pub normalled_to: Option<PortId>,
pub has_attenuverter: bool,
}
impl PortDef {
pub fn new(id: PortId, name: impl Into<String>, kind: SignalKind) -> Self {
Self {
id,
name: name.into(),
kind,
default: 0.0,
normalled_to: None,
has_attenuverter: false,
}
}
pub fn with_default(mut self, default: f64) -> Self {
self.default = default;
self
}
pub fn with_attenuverter(mut self) -> Self {
self.has_attenuverter = true;
self
}
pub fn normalled_to(mut self, port: PortId) -> Self {
self.normalled_to = Some(port);
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
pub struct PortSpec {
pub inputs: Vec<PortDef>,
pub outputs: Vec<PortDef>,
}
impl PortSpec {
pub fn new() -> Self {
Self::default()
}
pub fn input_by_name(&self, name: &str) -> Option<&PortDef> {
self.inputs.iter().find(|p| p.name == name)
}
pub fn output_by_name(&self, name: &str) -> Option<&PortDef> {
self.outputs.iter().find(|p| p.name == name)
}
pub fn input_by_id(&self, id: PortId) -> Option<&PortDef> {
self.inputs.iter().find(|p| p.id == id)
}
pub fn output_by_id(&self, id: PortId) -> Option<&PortDef> {
self.outputs.iter().find(|p| p.id == id)
}
}
#[derive(Debug, Clone, Default)]
pub struct PortValues {
pub values: StdMap<PortId, f64>,
}
impl PortValues {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self, id: PortId) -> Option<f64> {
self.values.get(&id).copied()
}
pub fn get_or(&self, id: PortId, default: f64) -> f64 {
self.values.get(&id).copied().unwrap_or(default)
}
pub fn set(&mut self, id: PortId, value: f64) {
self.values.insert(id, value);
}
pub fn accumulate(&mut self, id: PortId, value: f64) {
*self.values.entry(id).or_insert(0.0) += value;
}
pub fn has(&self, id: PortId) -> bool {
self.values.contains_key(&id)
}
pub fn clear(&mut self) {
self.values.clear();
}
}
pub struct BlockPortValues {
buffers: StdMap<PortId, Vec<f64>>,
block_size: usize,
}
impl BlockPortValues {
pub fn new(block_size: usize) -> Self {
Self {
buffers: StdMap::new(),
block_size,
}
}
pub fn block_size(&self) -> usize {
self.block_size
}
pub fn get_buffer(&self, port: PortId) -> Option<&[f64]> {
self.buffers.get(&port).map(|v| v.as_slice())
}
pub fn get_buffer_mut(&mut self, port: PortId) -> &mut Vec<f64> {
self.buffers
.entry(port)
.or_insert_with(|| vec![0.0; self.block_size])
}
pub fn frame(&self, index: usize) -> PortValues {
let mut values = PortValues::new();
self.frame_into(index, &mut values);
values
}
pub fn frame_into(&self, index: usize, dst: &mut PortValues) {
dst.clear();
for (&port, buffer) in &self.buffers {
if index < buffer.len() {
dst.set(port, buffer[index]);
}
}
}
pub fn set_frame(&mut self, index: usize, values: PortValues) {
self.set_frame_ref(index, &values);
}
pub fn set_frame_ref(&mut self, index: usize, values: &PortValues) {
for (&port, &value) in &values.values {
let buffer = self.get_buffer_mut(port);
if index < buffer.len() {
buffer[index] = value;
}
}
}
pub fn clear(&mut self) {
for buffer in self.buffers.values_mut() {
buffer.fill(0.0);
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum ParamRange {
Linear { min: f64, max: f64 },
Exponential { min: f64, max: f64 },
VoltPerOctave { base_freq: f64 },
}
impl ParamRange {
pub fn apply(&self, normalized: f64) -> f64 {
match self {
ParamRange::Linear { min, max } => min + normalized.clamp(0.0, 1.0) * (max - min),
ParamRange::Exponential { min, max } => {
let clamped = normalized.clamp(0.0, 1.0);
if *min > 0.0 && *max > 0.0 {
min * Libm::<f64>::pow(max / min, clamped)
} else {
min + clamped * (max - min)
}
}
ParamRange::VoltPerOctave { base_freq } => {
base_freq * Libm::<f64>::pow(2.0, normalized)
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModulatedParam {
pub base: f64,
pub cv: f64,
pub attenuverter: f64,
pub range: ParamRange,
}
impl ModulatedParam {
pub const CV_FULL_SCALE_VOLTS: f64 = 5.0;
pub fn new(range: ParamRange) -> Self {
Self {
base: 0.5,
cv: 0.0,
attenuverter: 1.0,
range,
}
}
pub fn with_base(mut self, base: f64) -> Self {
self.base = base;
self
}
pub fn value(&self) -> f64 {
let modulated = self.base + (self.cv / Self::CV_FULL_SCALE_VOLTS) * self.attenuverter;
self.range.apply(modulated)
}
pub fn set_cv(&mut self, cv: f64) {
self.cv = cv;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParamDef {
pub id: ParamId,
pub name: String,
pub default: f64,
pub range: ParamRange,
}
pub trait GraphModule: Send + Sync {
fn port_spec(&self) -> &PortSpec;
fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues);
fn process_block(
&mut self,
inputs: &BlockPortValues,
outputs: &mut BlockPortValues,
frames: usize,
) {
let mut in_frame = PortValues::new();
let mut out_frame = PortValues::new();
for i in 0..frames {
inputs.frame_into(i, &mut in_frame);
out_frame.clear();
self.tick(&in_frame, &mut out_frame);
outputs.set_frame_ref(i, &out_frame);
}
}
fn reset(&mut self);
fn set_sample_rate(&mut self, sample_rate: f64);
fn breaks_feedback_cycle(&self) -> bool {
false
}
fn params(&self) -> &[ParamDef] {
&[]
}
fn get_param(&self, _id: ParamId) -> Option<f64> {
None
}
fn set_param(&mut self, _id: ParamId, _value: f64) {}
fn type_id(&self) -> &'static str {
"unknown"
}
#[cfg(feature = "alloc")]
fn serialize_state(&self) -> Option<serde_json::Value> {
None
}
#[cfg(feature = "alloc")]
fn deserialize_state(
&mut self,
_state: &serde_json::Value,
) -> Result<(), alloc::string::String> {
Ok(())
}
#[cfg(feature = "alloc")]
fn introspect(&self) -> Option<&dyn crate::introspection::ModuleIntrospection> {
None
}
#[cfg(feature = "alloc")]
fn introspect_mut(&mut self) -> Option<&mut dyn crate::introspection::ModuleIntrospection> {
None
}
}
#[macro_export]
macro_rules! impl_introspect {
() => {
#[cfg(feature = "alloc")]
fn introspect(&self) -> Option<&dyn $crate::introspection::ModuleIntrospection> {
Some(self)
}
#[cfg(feature = "alloc")]
fn introspect_mut(
&mut self,
) -> Option<&mut dyn $crate::introspection::ModuleIntrospection> {
Some(self)
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_signal_kind_ranges() {
assert_eq!(SignalKind::Audio.voltage_range(), (-5.0, 5.0));
assert_eq!(SignalKind::Gate.voltage_range(), (0.0, 5.0));
assert_eq!(SignalKind::CvUnipolar.voltage_range(), (0.0, 10.0));
}
#[test]
fn test_signal_kind_summable() {
assert!(SignalKind::Audio.is_summable());
assert!(SignalKind::CvBipolar.is_summable());
assert!(!SignalKind::Gate.is_summable());
assert!(!SignalKind::Trigger.is_summable());
}
#[test]
fn test_port_values() {
let mut pv = PortValues::new();
pv.set(0, 1.0);
pv.set(1, 2.0);
assert_eq!(pv.get(0), Some(1.0));
assert_eq!(pv.get(1), Some(2.0));
assert_eq!(pv.get(2), None);
assert_eq!(pv.get_or(2, 5.0), 5.0);
pv.accumulate(0, 0.5);
assert_eq!(pv.get(0), Some(1.5));
}
#[test]
fn test_param_range_linear() {
let range = ParamRange::Linear {
min: 0.0,
max: 100.0,
};
assert!((range.apply(0.0) - 0.0).abs() < 1e-10);
assert!((range.apply(0.5) - 50.0).abs() < 1e-10);
assert!((range.apply(1.0) - 100.0).abs() < 1e-10);
}
#[test]
fn test_param_range_exponential() {
let range = ParamRange::Exponential {
min: 20.0,
max: 20000.0,
};
assert!((range.apply(0.0) - 20.0).abs() < 1e-10);
assert!((range.apply(1.0) - 20000.0).abs() < 1e-10);
}
#[test]
fn test_param_range_voct() {
let range = ParamRange::VoltPerOctave { base_freq: 261.63 };
assert!((range.apply(0.0) - 261.63).abs() < 0.01);
assert!((range.apply(1.0) - 523.26).abs() < 0.01);
}
#[test]
fn test_modulated_param() {
let mut param = ModulatedParam::new(ParamRange::Linear {
min: 0.0,
max: 100.0,
})
.with_base(0.5);
assert!((param.value() - 50.0).abs() < 1e-10);
param.set_cv(1.0);
assert!((param.value() - 70.0).abs() < 1e-10);
param.attenuverter = -1.0;
assert!((param.value() - 30.0).abs() < 1e-10);
}
#[test]
fn test_modulated_param_full_scale_cv_is_proportional() {
let mut param = ModulatedParam::new(ParamRange::Linear {
min: 0.0,
max: 100.0,
})
.with_base(0.5);
param.set_cv(1.0);
assert!(
(param.value() - 70.0).abs() < 1e-10,
"1 V CV should be proportional, got {}",
param.value()
);
param.set_cv(5.0);
assert!((param.value() - 100.0).abs() < 1e-10);
param.set_cv(-5.0);
assert!((param.value() - 0.0).abs() < 1e-10);
}
#[test]
fn test_signal_kind_gate_threshold() {
assert!(SignalKind::Gate.gate_threshold().is_some());
assert!(SignalKind::Trigger.gate_threshold().is_some());
assert!(SignalKind::Audio.gate_threshold().is_none());
}
#[test]
fn test_port_def_with_default_and_attenuverter() {
let port = PortDef::new(0, "test", SignalKind::CvUnipolar)
.with_default(5.0)
.with_attenuverter();
assert!((port.default - 5.0).abs() < 0.001);
assert!(port.has_attenuverter);
}
#[test]
fn test_port_def_normalled_to() {
let port = PortDef::new(0, "test", SignalKind::CvUnipolar).normalled_to(1);
assert_eq!(port.normalled_to, Some(1));
}
#[test]
fn test_port_spec_lookup() {
let spec = PortSpec {
inputs: vec![
PortDef::new(0, "in1", SignalKind::Audio),
PortDef::new(1, "in2", SignalKind::CvBipolar),
],
outputs: vec![
PortDef::new(10, "out1", SignalKind::Audio),
PortDef::new(11, "out2", SignalKind::Gate),
],
};
assert!(spec.input_by_name("in1").is_some());
assert!(spec.input_by_name("nonexistent").is_none());
assert!(spec.output_by_name("out1").is_some());
assert!(spec.output_by_name("nonexistent").is_none());
assert!(spec.input_by_id(0).is_some());
assert!(spec.input_by_id(99).is_none());
assert!(spec.output_by_id(10).is_some());
assert!(spec.output_by_id(99).is_none());
}
#[test]
fn test_port_values_has() {
let mut pv = PortValues::new();
assert!(!pv.has(0));
pv.set(0, 1.0);
assert!(pv.has(0));
}
#[test]
fn test_port_values_clear() {
let mut pv = PortValues::new();
pv.set(0, 1.0);
pv.set(1, 2.0);
pv.clear();
assert!(!pv.has(0));
assert!(!pv.has(1));
}
#[test]
fn test_block_port_values() {
let mut bpv = BlockPortValues::new(64);
assert_eq!(bpv.block_size(), 64);
let buf_mut = bpv.get_buffer_mut(0);
assert_eq!(buf_mut.len(), 64);
buf_mut[0] = 1.0;
assert_eq!(bpv.get_buffer(0).unwrap()[0], 1.0);
let mut frame_vals = PortValues::new();
frame_vals.set(0, 99.0);
bpv.set_frame(1, frame_vals);
bpv.clear();
}
#[test]
fn test_signal_kind_clock() {
let range = SignalKind::Clock.voltage_range();
assert_eq!(range, (0.0, 5.0));
assert!(!SignalKind::Clock.is_summable());
}
#[test]
fn test_param_range_exponential_clamped() {
let range = ParamRange::Exponential {
min: 20.0,
max: 20000.0,
};
let below = range.apply(-0.5);
assert!((below - 20.0).abs() < 1e-10);
let above = range.apply(1.5);
assert!((above - 20000.0).abs() < 1e-10);
}
#[test]
fn test_param_range_exponential_invalid_domain_no_nan() {
let range = ParamRange::Exponential {
min: 20.0,
max: -1.0,
};
for &t in &[0.0, 0.25, 0.5, 0.75, 1.0] {
let v = range.apply(t);
assert!(v.is_finite(), "apply({}) produced non-finite {}", t, v);
}
assert!((range.apply(0.0) - 20.0).abs() < 1e-10);
assert!((range.apply(1.0) - (-1.0)).abs() < 1e-10);
let zero_max = ParamRange::Exponential {
min: 10.0,
max: 0.0,
};
assert!(zero_max.apply(0.5).is_finite());
}
#[test]
fn test_signal_colors_default() {
let colors = SignalColors::default();
assert_eq!(colors.audio, "#e94560");
assert_eq!(colors.cv_bipolar, "#0f3460");
assert_eq!(colors.cv_unipolar, "#00b4d8");
assert_eq!(colors.volt_per_octave, "#90be6d");
assert_eq!(colors.gate, "#f9c74f");
assert_eq!(colors.trigger, "#f8961e");
assert_eq!(colors.clock, "#9d4edd");
}
#[test]
fn test_signal_colors_get() {
let colors = SignalColors::default();
assert_eq!(colors.get(SignalKind::Audio), "#e94560");
assert_eq!(colors.get(SignalKind::Gate), "#f9c74f");
assert_eq!(colors.get(SignalKind::VoltPerOctave), "#90be6d");
}
#[test]
fn test_port_info_creation() {
let info = PortInfo::new(0, "test", SignalKind::Audio)
.with_description("A test port")
.with_normalled_to("other");
assert_eq!(info.id, 0);
assert_eq!(info.name, "test");
assert_eq!(info.kind, SignalKind::Audio);
assert_eq!(info.description, Some("A test port".to_string()));
assert_eq!(info.normalled_to, Some("other".to_string()));
}
#[test]
fn test_port_info_from_port_def() {
let def = PortDef::new(5, "cutoff", SignalKind::CvUnipolar);
let info = PortInfo::from(&def);
assert_eq!(info.id, 5);
assert_eq!(info.name, "cutoff");
assert_eq!(info.kind, SignalKind::CvUnipolar);
assert!(info.normalled_to.is_none());
assert!(info.description.is_none());
}
#[test]
fn test_ports_compatible_exact() {
assert_eq!(
ports_compatible(SignalKind::Audio, SignalKind::Audio),
Compatibility::Exact
);
assert_eq!(
ports_compatible(SignalKind::Gate, SignalKind::Gate),
Compatibility::Exact
);
assert_eq!(
ports_compatible(SignalKind::VoltPerOctave, SignalKind::VoltPerOctave),
Compatibility::Exact
);
}
#[test]
fn test_ports_compatible_audio_to_anything() {
assert!(matches!(
ports_compatible(SignalKind::Audio, SignalKind::CvBipolar),
Compatibility::Warning { .. }
));
assert!(matches!(
ports_compatible(SignalKind::Audio, SignalKind::Gate),
Compatibility::Warning { .. }
));
}
#[test]
fn test_ports_compatible_cv_interop() {
assert!(matches!(
ports_compatible(SignalKind::CvBipolar, SignalKind::CvUnipolar),
Compatibility::Warning { .. }
));
assert!(matches!(
ports_compatible(SignalKind::CvUnipolar, SignalKind::CvBipolar),
Compatibility::Warning { .. }
));
assert_eq!(
ports_compatible(SignalKind::VoltPerOctave, SignalKind::CvBipolar),
Compatibility::Allowed
);
}
#[test]
fn test_ports_compatible_gate_trigger_interop() {
assert!(matches!(
ports_compatible(SignalKind::Gate, SignalKind::Trigger),
Compatibility::Warning { .. }
));
assert!(matches!(
ports_compatible(SignalKind::Trigger, SignalKind::Gate),
Compatibility::Warning { .. }
));
assert_eq!(
ports_compatible(SignalKind::Clock, SignalKind::Trigger),
Compatibility::Allowed
);
assert!(matches!(
ports_compatible(SignalKind::Clock, SignalKind::Gate),
Compatibility::Warning { .. }
));
}
#[test]
fn test_ports_compatible_warnings() {
let compat = ports_compatible(SignalKind::Gate, SignalKind::Audio);
assert!(matches!(compat, Compatibility::Warning { .. }));
assert_eq!(
ports_compatible(SignalKind::CvBipolar, SignalKind::VoltPerOctave),
Compatibility::Allowed
);
}
#[test]
fn test_ports_compatible_agrees_with_is_compatible_with() {
let audio_cv = SignalKind::Audio.is_compatible_with(&SignalKind::CvBipolar);
assert!(
audio_cv.warning.is_some(),
"is_compatible_with should warn on Audio->CvBipolar"
);
assert!(
matches!(
ports_compatible(SignalKind::Audio, SignalKind::CvBipolar),
Compatibility::Warning { .. }
),
"ports_compatible should agree and warn on Audio->CvBipolar"
);
let all = [
SignalKind::Audio,
SignalKind::CvBipolar,
SignalKind::CvUnipolar,
SignalKind::VoltPerOctave,
SignalKind::Gate,
SignalKind::Trigger,
SignalKind::Clock,
];
for &a in &all {
for &b in &all {
let low = ports_compatible(a, b);
let high = a.is_compatible_with(&b);
let low_warns = matches!(low, Compatibility::Warning { .. });
assert_eq!(
low_warns,
high.warning.is_some(),
"compatibility APIs disagree for {:?} -> {:?}",
a,
b
);
}
}
}
#[test]
fn test_signal_kind_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&SignalKind::CvBipolar).unwrap(),
"\"cv_bipolar\""
);
assert_eq!(
serde_json::to_string(&SignalKind::VoltPerOctave).unwrap(),
"\"volt_per_octave\""
);
assert_eq!(
serde_json::to_string(&SignalKind::Audio).unwrap(),
"\"audio\""
);
let k: SignalKind = serde_json::from_str("\"cv_unipolar\"").unwrap();
assert_eq!(k, SignalKind::CvUnipolar);
}
#[test]
fn test_compatibility_serialization() {
let exact = Compatibility::Exact;
let json = serde_json::to_string(&exact).unwrap();
assert!(json.contains("exact"));
let warning = Compatibility::Warning {
message: "test".to_string(),
};
let json = serde_json::to_string(&warning).unwrap();
assert!(json.contains("warning"));
assert!(json.contains("test"));
}
}