Add bit crusher, constant, math, envelope follower, phaser, ring modulator, sample and hold, and vocoder nodes

This commit is contained in:
Skyler Lehmkuhl 2025-10-29 03:14:01 -04:00
parent dc32fc4200
commit 0ae168cbca
14 changed files with 1743 additions and 42 deletions

View File

@ -710,11 +710,18 @@ impl Engine {
"Reverb" => Box::new(ReverbNode::new("Reverb".to_string())),
"Chorus" => Box::new(ChorusNode::new("Chorus".to_string())),
"Compressor" => Box::new(CompressorNode::new("Compressor".to_string())),
"Constant" => Box::new(ConstantNode::new("Constant".to_string())),
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())),
"Limiter" => Box::new(LimiterNode::new("Limiter".to_string())),
"Math" => Box::new(MathNode::new("Math".to_string())),
"EQ" => Box::new(EQNode::new("EQ".to_string())),
"Flanger" => Box::new(FlangerNode::new("Flanger".to_string())),
"FMSynth" => Box::new(FMSynthNode::new("FM Synth".to_string())),
"Phaser" => Box::new(PhaserNode::new("Phaser".to_string())),
"BitCrusher" => Box::new(BitCrusherNode::new("Bit Crusher".to_string())),
"Vocoder" => Box::new(VocoderNode::new("Vocoder".to_string())),
"RingModulator" => Box::new(RingModulatorNode::new("Ring Modulator".to_string())),
"SampleHold" => Box::new(SampleHoldNode::new("Sample & Hold".to_string())),
"WavetableOscillator" => Box::new(WavetableOscillatorNode::new("Wavetable".to_string())),
"SimpleSampler" => Box::new(SimpleSamplerNode::new("Sampler".to_string())),
"SlewLimiter" => Box::new(SlewLimiterNode::new("Slew Limiter".to_string())),
@ -777,11 +784,18 @@ impl Engine {
"Reverb" => Box::new(ReverbNode::new("Reverb".to_string())),
"Chorus" => Box::new(ChorusNode::new("Chorus".to_string())),
"Compressor" => Box::new(CompressorNode::new("Compressor".to_string())),
"Constant" => Box::new(ConstantNode::new("Constant".to_string())),
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())),
"Limiter" => Box::new(LimiterNode::new("Limiter".to_string())),
"Math" => Box::new(MathNode::new("Math".to_string())),
"EQ" => Box::new(EQNode::new("EQ".to_string())),
"Flanger" => Box::new(FlangerNode::new("Flanger".to_string())),
"FMSynth" => Box::new(FMSynthNode::new("FM Synth".to_string())),
"Phaser" => Box::new(PhaserNode::new("Phaser".to_string())),
"BitCrusher" => Box::new(BitCrusherNode::new("Bit Crusher".to_string())),
"Vocoder" => Box::new(VocoderNode::new("Vocoder".to_string())),
"RingModulator" => Box::new(RingModulatorNode::new("Ring Modulator".to_string())),
"SampleHold" => Box::new(SampleHoldNode::new("Sample & Hold".to_string())),
"WavetableOscillator" => Box::new(WavetableOscillatorNode::new("Wavetable".to_string())),
"SimpleSampler" => Box::new(SimpleSamplerNode::new("Sampler".to_string())),
"SlewLimiter" => Box::new(SlewLimiterNode::new("Slew Limiter".to_string())),

View File

@ -797,11 +797,18 @@ impl InstrumentGraph {
"Reverb" => Box::new(ReverbNode::new("Reverb")),
"Chorus" => Box::new(ChorusNode::new("Chorus")),
"Compressor" => Box::new(CompressorNode::new("Compressor")),
"Constant" => Box::new(ConstantNode::new("Constant")),
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower")),
"Limiter" => Box::new(LimiterNode::new("Limiter")),
"Math" => Box::new(MathNode::new("Math")),
"EQ" => Box::new(EQNode::new("EQ")),
"Flanger" => Box::new(FlangerNode::new("Flanger")),
"FMSynth" => Box::new(FMSynthNode::new("FM Synth")),
"Phaser" => Box::new(PhaserNode::new("Phaser")),
"BitCrusher" => Box::new(BitCrusherNode::new("Bit Crusher")),
"Vocoder" => Box::new(VocoderNode::new("Vocoder")),
"RingModulator" => Box::new(RingModulatorNode::new("Ring Modulator")),
"SampleHold" => Box::new(SampleHoldNode::new("Sample & Hold")),
"WavetableOscillator" => Box::new(WavetableOscillatorNode::new("Wavetable")),
"SimpleSampler" => Box::new(SimpleSamplerNode::new("Sampler")),
"SlewLimiter" => Box::new(SlewLimiterNode::new("Slew Limiter")),

View File

@ -0,0 +1,187 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_BIT_DEPTH: u32 = 0;
const PARAM_SAMPLE_RATE_REDUCTION: u32 = 1;
const PARAM_MIX: u32 = 2;
/// Bit Crusher effect - reduces bit depth and sample rate for lo-fi sound
pub struct BitCrusherNode {
name: String,
bit_depth: f32, // 1 to 16 bits
sample_rate_reduction: f32, // 1 to 48000 Hz (crushed sample rate)
mix: f32, // 0.0 to 1.0 (dry/wet)
// State for sample rate reduction
hold_left: f32,
hold_right: f32,
hold_counter: f32,
sample_rate: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl BitCrusherNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_BIT_DEPTH, "Bit Depth", 1.0, 16.0, 8.0, ParameterUnit::Generic),
Parameter::new(PARAM_SAMPLE_RATE_REDUCTION, "Sample Rate", 100.0, 48000.0, 8000.0, ParameterUnit::Frequency),
Parameter::new(PARAM_MIX, "Mix", 0.0, 1.0, 1.0, ParameterUnit::Generic),
];
Self {
name,
bit_depth: 8.0,
sample_rate_reduction: 8000.0,
mix: 1.0,
hold_left: 0.0,
hold_right: 0.0,
hold_counter: 0.0,
sample_rate: 48000,
inputs,
outputs,
parameters,
}
}
/// Quantize sample to specified bit depth
fn quantize(&self, sample: f32) -> f32 {
// Calculate number of quantization levels
let levels = 2.0_f32.powf(self.bit_depth);
// Quantize: scale up, round, scale back down
let scaled = sample * levels / 2.0;
let quantized = scaled.round();
quantized * 2.0 / levels
}
}
impl AudioNode for BitCrusherNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_BIT_DEPTH => self.bit_depth = value.clamp(1.0, 16.0),
PARAM_SAMPLE_RATE_REDUCTION => self.sample_rate_reduction = value.clamp(100.0, 48000.0),
PARAM_MIX => self.mix = value.clamp(0.0, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_BIT_DEPTH => self.bit_depth,
PARAM_SAMPLE_RATE_REDUCTION => self.sample_rate_reduction,
PARAM_MIX => self.mix,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
}
let input = inputs[0];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
let frames = input.len() / 2;
let output_frames = output.len() / 2;
let frames_to_process = frames.min(output_frames);
// Calculate sample hold period
let hold_period = self.sample_rate as f32 / self.sample_rate_reduction;
for frame in 0..frames_to_process {
let left_in = input[frame * 2];
let right_in = input[frame * 2 + 1];
// Sample rate reduction: hold samples
if self.hold_counter <= 0.0 {
// Time to sample a new value
self.hold_left = self.quantize(left_in);
self.hold_right = self.quantize(right_in);
self.hold_counter = hold_period;
}
self.hold_counter -= 1.0;
// Mix dry and wet
let wet_left = self.hold_left;
let wet_right = self.hold_right;
output[frame * 2] = left_in * (1.0 - self.mix) + wet_left * self.mix;
output[frame * 2 + 1] = right_in * (1.0 - self.mix) + wet_right * self.mix;
}
}
fn reset(&mut self) {
self.hold_left = 0.0;
self.hold_right = 0.0;
self.hold_counter = 0.0;
}
fn node_type(&self) -> &str {
"BitCrusher"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
bit_depth: self.bit_depth,
sample_rate_reduction: self.sample_rate_reduction,
mix: self.mix,
hold_left: 0.0,
hold_right: 0.0,
hold_counter: 0.0,
sample_rate: self.sample_rate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
}

View File

@ -0,0 +1,113 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_VALUE: u32 = 0;
/// Constant CV source - outputs a constant voltage
/// Useful for providing fixed values to CV inputs, offsets, etc.
pub struct ConstantNode {
name: String,
value: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl ConstantNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![];
let outputs = vec![
NodePort::new("CV Out", SignalType::CV, 0),
];
let parameters = vec![
Parameter::new(PARAM_VALUE, "Value", -10.0, 10.0, 0.0, ParameterUnit::Generic),
];
Self {
name,
value: 0.0,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for ConstantNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_VALUE => self.value = value.clamp(-10.0, 10.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_VALUE => self.value,
_ => 0.0,
}
}
fn process(
&mut self,
_inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let length = output.len();
// Fill output with constant value
for i in 0..length {
output[i] = self.value;
}
}
fn reset(&mut self) {
// No state to reset
}
fn node_type(&self) -> &str {
"Constant"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
value: self.value,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
}

View File

@ -0,0 +1,158 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_ATTACK: u32 = 0;
const PARAM_RELEASE: u32 = 1;
/// Envelope Follower - extracts amplitude envelope from audio signal
/// Outputs a CV signal that follows the loudness of the input
pub struct EnvelopeFollowerNode {
name: String,
attack_time: f32, // seconds
release_time: f32, // seconds
envelope: f32, // current envelope level
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl EnvelopeFollowerNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("CV Out", SignalType::CV, 0),
];
let parameters = vec![
Parameter::new(PARAM_ATTACK, "Attack", 0.001, 1.0, 0.01, ParameterUnit::Time),
Parameter::new(PARAM_RELEASE, "Release", 0.001, 1.0, 0.1, ParameterUnit::Time),
];
Self {
name,
attack_time: 0.01,
release_time: 0.1,
envelope: 0.0,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for EnvelopeFollowerNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_ATTACK => self.attack_time = value.clamp(0.001, 1.0),
PARAM_RELEASE => self.release_time = value.clamp(0.001, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_ATTACK => self.attack_time,
PARAM_RELEASE => self.release_time,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let length = output.len();
let input = if !inputs.is_empty() && !inputs[0].is_empty() {
inputs[0]
} else {
&[]
};
// Calculate filter coefficients
// One-pole filter: y[n] = y[n-1] + coefficient * (x[n] - y[n-1])
let sample_duration = 1.0 / sample_rate as f32;
// Time constant τ = time to reach ~63% of target
// Coefficient = 1 - e^(-1/(τ * sample_rate))
// Simplified approximation: coefficient ≈ sample_duration / time_constant
let attack_coeff = (sample_duration / self.attack_time).min(1.0);
let release_coeff = (sample_duration / self.release_time).min(1.0);
// Process each sample
for i in 0..length {
// Get absolute value of input (rectify)
let input_level = if i < input.len() {
input[i].abs()
} else {
0.0
};
// Apply attack or release
let coeff = if input_level > self.envelope {
attack_coeff // Rising - use attack time
} else {
release_coeff // Falling - use release time
};
// One-pole filter
self.envelope += (input_level - self.envelope) * coeff;
output[i] = self.envelope;
}
}
fn reset(&mut self) {
self.envelope = 0.0;
}
fn node_type(&self) -> &str {
"EnvelopeFollower"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
attack_time: self.attack_time,
release_time: self.release_time,
envelope: self.envelope,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
}

View File

@ -2,7 +2,6 @@ use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, Par
use crate::audio::midi::MidiEvent;
const PARAM_OPERATION: u32 = 0;
const PARAM_OPERAND: u32 = 1;
/// Mathematical and logical operations on CV signals
/// Operations:
@ -14,7 +13,6 @@ const PARAM_OPERAND: u32 = 1;
pub struct MathNode {
name: String,
operation: u32,
operand: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
@ -35,13 +33,11 @@ impl MathNode {
let parameters = vec![
Parameter::new(PARAM_OPERATION, "Operation", 0.0, 13.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_OPERAND, "Operand", -10.0, 10.0, 1.0, ParameterUnit::Generic),
];
Self {
name,
operation: 0,
operand: 1.0,
inputs,
outputs,
parameters,
@ -98,7 +94,6 @@ impl AudioNode for MathNode {
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_OPERATION => self.operation = (value as u32).clamp(0, 13),
PARAM_OPERAND => self.operand = value.clamp(-10.0, 10.0),
_ => {}
}
}
@ -106,7 +101,6 @@ impl AudioNode for MathNode {
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_OPERATION => self.operation as f32,
PARAM_OPERAND => self.operand,
_ => 0.0,
}
}
@ -126,27 +120,20 @@ impl AudioNode for MathNode {
let output = &mut outputs[0];
let length = output.len();
// Get input A (or use 0.0)
let input_a = if !inputs.is_empty() && !inputs[0].is_empty() {
inputs[0]
} else {
&[]
};
// Get input B (or use operand parameter)
let input_b = if inputs.len() > 1 && !inputs[1].is_empty() {
inputs[1]
} else {
&[]
};
// Process each sample
for i in 0..length {
let a = if i < input_a.len() { input_a[i] } else { 0.0 };
let b = if i < input_b.len() {
input_b[i]
// Get input A (or 0.0 if not connected)
let a = if !inputs.is_empty() && i < inputs[0].len() {
inputs[0][i]
} else {
self.operand
0.0
};
// Get input B (or 0.0 if not connected)
let b = if inputs.len() > 1 && i < inputs[1].len() {
inputs[1][i]
} else {
0.0
};
output[i] = self.apply_operation(a, b);
@ -169,7 +156,6 @@ impl AudioNode for MathNode {
Box::new(Self {
name: self.name.clone(),
operation: self.operation,
operand: self.operand,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),

View File

@ -1,9 +1,12 @@
mod adsr;
mod audio_to_cv;
mod bit_crusher;
mod chorus;
mod compressor;
mod constant;
mod delay;
mod distortion;
mod envelope_follower;
mod eq;
mod filter;
mod flanger;
@ -21,21 +24,28 @@ mod oscillator;
mod oscilloscope;
mod output;
mod pan;
mod phaser;
mod quantizer;
mod reverb;
mod ring_modulator;
mod sample_hold;
mod simple_sampler;
mod slew_limiter;
mod splitter;
mod template_io;
mod vocoder;
mod voice_allocator;
mod wavetable_oscillator;
pub use adsr::ADSRNode;
pub use audio_to_cv::AudioToCVNode;
pub use bit_crusher::BitCrusherNode;
pub use chorus::ChorusNode;
pub use compressor::CompressorNode;
pub use constant::ConstantNode;
pub use delay::DelayNode;
pub use distortion::DistortionNode;
pub use envelope_follower::EnvelopeFollowerNode;
pub use eq::EQNode;
pub use filter::FilterNode;
pub use flanger::FlangerNode;
@ -53,11 +63,15 @@ pub use oscillator::OscillatorNode;
pub use oscilloscope::OscilloscopeNode;
pub use output::AudioOutputNode;
pub use pan::PanNode;
pub use phaser::PhaserNode;
pub use quantizer::QuantizerNode;
pub use reverb::ReverbNode;
pub use ring_modulator::RingModulatorNode;
pub use sample_hold::SampleHoldNode;
pub use simple_sampler::SimpleSamplerNode;
pub use slew_limiter::SlewLimiterNode;
pub use splitter::SplitterNode;
pub use template_io::{TemplateInputNode, TemplateOutputNode};
pub use vocoder::VocoderNode;
pub use voice_allocator::VoiceAllocatorNode;
pub use wavetable_oscillator::WavetableOscillatorNode;

View File

@ -0,0 +1,289 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use std::f32::consts::PI;
const PARAM_RATE: u32 = 0;
const PARAM_DEPTH: u32 = 1;
const PARAM_STAGES: u32 = 2;
const PARAM_FEEDBACK: u32 = 3;
const PARAM_WET_DRY: u32 = 4;
const MAX_STAGES: usize = 8;
/// First-order all-pass filter for phaser
struct AllPassFilter {
a1: f32,
zm1_left: f32,
zm1_right: f32,
}
impl AllPassFilter {
fn new() -> Self {
Self {
a1: 0.0,
zm1_left: 0.0,
zm1_right: 0.0,
}
}
fn set_coefficient(&mut self, frequency: f32, sample_rate: f32) {
// First-order all-pass coefficient
// a1 = (tan(π*f/fs) - 1) / (tan(π*f/fs) + 1)
let tan_val = ((PI * frequency) / sample_rate).tan();
self.a1 = (tan_val - 1.0) / (tan_val + 1.0);
}
fn process(&mut self, input: f32, is_left: bool) -> f32 {
let zm1 = if is_left {
&mut self.zm1_left
} else {
&mut self.zm1_right
};
// All-pass filter: y[n] = a1*x[n] + x[n-1] - a1*y[n-1]
let output = self.a1 * input + *zm1;
*zm1 = input - self.a1 * output;
output
}
fn reset(&mut self) {
self.zm1_left = 0.0;
self.zm1_right = 0.0;
}
}
/// Phaser effect using cascaded all-pass filters
pub struct PhaserNode {
name: String,
rate: f32, // LFO rate in Hz (0.1 to 10 Hz)
depth: f32, // Modulation depth 0.0 to 1.0
stages: usize, // Number of all-pass stages (2, 4, 6, or 8)
feedback: f32, // Feedback amount -0.95 to 0.95
wet_dry: f32, // 0.0 = dry only, 1.0 = wet only
// All-pass filters
filters: Vec<AllPassFilter>,
// Feedback buffers
feedback_left: f32,
feedback_right: f32,
// LFO state
lfo_phase: f32,
sample_rate: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl PhaserNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_RATE, "Rate", 0.1, 10.0, 0.5, ParameterUnit::Frequency),
Parameter::new(PARAM_DEPTH, "Depth", 0.0, 1.0, 0.7, ParameterUnit::Generic),
Parameter::new(PARAM_STAGES, "Stages", 2.0, 8.0, 6.0, ParameterUnit::Generic),
Parameter::new(PARAM_FEEDBACK, "Feedback", -0.95, 0.95, 0.5, ParameterUnit::Generic),
Parameter::new(PARAM_WET_DRY, "Wet/Dry", 0.0, 1.0, 0.5, ParameterUnit::Generic),
];
let mut filters = Vec::with_capacity(MAX_STAGES);
for _ in 0..MAX_STAGES {
filters.push(AllPassFilter::new());
}
Self {
name,
rate: 0.5,
depth: 0.7,
stages: 6,
feedback: 0.5,
wet_dry: 0.5,
filters,
feedback_left: 0.0,
feedback_right: 0.0,
lfo_phase: 0.0,
sample_rate: 48000,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for PhaserNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_RATE => {
self.rate = value.clamp(0.1, 10.0);
}
PARAM_DEPTH => {
self.depth = value.clamp(0.0, 1.0);
}
PARAM_STAGES => {
// Round to even numbers: 2, 4, 6, 8
let stages = (value.round() as usize).clamp(2, 8);
self.stages = if stages % 2 == 0 { stages } else { stages + 1 };
}
PARAM_FEEDBACK => {
self.feedback = value.clamp(-0.95, 0.95);
}
PARAM_WET_DRY => {
self.wet_dry = value.clamp(0.0, 1.0);
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_RATE => self.rate,
PARAM_DEPTH => self.depth,
PARAM_STAGES => self.stages as f32,
PARAM_FEEDBACK => self.feedback,
PARAM_WET_DRY => self.wet_dry,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
}
let input = inputs[0];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
let frames = input.len() / 2;
let output_frames = output.len() / 2;
let frames_to_process = frames.min(output_frames);
let dry_gain = 1.0 - self.wet_dry;
let wet_gain = self.wet_dry;
// Frequency range for all-pass filters (200 Hz to 2000 Hz)
let min_freq = 200.0;
let max_freq = 2000.0;
for frame in 0..frames_to_process {
let left_in = input[frame * 2];
let right_in = input[frame * 2 + 1];
// Generate LFO value (sine wave, 0 to 1)
let lfo_value = (self.lfo_phase * 2.0 * PI).sin() * 0.5 + 0.5;
// Calculate modulated frequency
let frequency = min_freq + (max_freq - min_freq) * lfo_value * self.depth;
// Update all filter coefficients
for filter in self.filters.iter_mut().take(self.stages) {
filter.set_coefficient(frequency, self.sample_rate as f32);
}
// Add feedback
let mut left_sig = left_in + self.feedback_left * self.feedback;
let mut right_sig = right_in + self.feedback_right * self.feedback;
// Process through all-pass filter chain
for i in 0..self.stages {
left_sig = self.filters[i].process(left_sig, true);
right_sig = self.filters[i].process(right_sig, false);
}
// Store feedback
self.feedback_left = left_sig;
self.feedback_right = right_sig;
// Mix dry and wet signals
output[frame * 2] = left_in * dry_gain + left_sig * wet_gain;
output[frame * 2 + 1] = right_in * dry_gain + right_sig * wet_gain;
// Advance LFO phase
self.lfo_phase += self.rate / self.sample_rate as f32;
if self.lfo_phase >= 1.0 {
self.lfo_phase -= 1.0;
}
}
}
fn reset(&mut self) {
for filter in &mut self.filters {
filter.reset();
}
self.feedback_left = 0.0;
self.feedback_right = 0.0;
self.lfo_phase = 0.0;
}
fn node_type(&self) -> &str {
"Phaser"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
let mut filters = Vec::with_capacity(MAX_STAGES);
for _ in 0..MAX_STAGES {
filters.push(AllPassFilter::new());
}
Box::new(Self {
name: self.name.clone(),
rate: self.rate,
depth: self.depth,
stages: self.stages,
feedback: self.feedback,
wet_dry: self.wet_dry,
filters,
feedback_left: 0.0,
feedback_right: 0.0,
lfo_phase: 0.0,
sample_rate: self.sample_rate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
}

View File

@ -83,28 +83,32 @@ impl QuantizerNode {
// Clamp to reasonable range
let input_midi_note = input_midi_note.clamp(0.0, 127.0);
// Get scale intervals
// Get scale intervals (relative to root)
let intervals = self.get_scale_intervals();
// Find which octave we're in (relative to C)
let octave = (input_midi_note / 12.0).floor() as i32;
let note_in_octave = (input_midi_note % 12.0) as u32;
let note_in_octave = input_midi_note % 12.0;
// Find the nearest note in the scale
// Adjust note relative to root (e.g., if root is D (2), then C becomes 10, D becomes 0)
let note_relative_to_root = (note_in_octave - self.root_note as f32 + 12.0) % 12.0;
// Find the nearest note in the scale (scale intervals are relative to root)
let mut closest_interval = intervals[0];
let mut min_distance = (note_in_octave as i32 - closest_interval as i32).abs();
let mut min_distance = (note_relative_to_root - closest_interval as f32).abs();
for &interval in &intervals {
let distance = (note_in_octave as i32 - interval as i32).abs();
let distance = (note_relative_to_root - interval as f32).abs();
if distance < min_distance {
min_distance = distance;
closest_interval = interval;
}
}
// Calculate final MIDI note (adjusted for root note)
// Start from the octave * 12, add root note, add scale interval
let quantized_midi_note = (octave * 12) as f32 + self.root_note as f32 + closest_interval as f32;
// Calculate final MIDI note
// The scale interval is relative to root, so add root back to get absolute note
let quantized_note_in_octave = (self.root_note + closest_interval) % 12;
let quantized_midi_note = (octave * 12) as f32 + quantized_note_in_octave as f32;
// Clamp result
let quantized_midi_note = quantized_midi_note.clamp(0.0, 127.0);

View File

@ -0,0 +1,137 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_MIX: u32 = 0;
/// Ring Modulator - multiplies two signals together
/// Creates metallic, inharmonic timbres by multiplying carrier and modulator
pub struct RingModulatorNode {
name: String,
mix: f32, // 0.0 = dry (carrier only), 1.0 = fully modulated
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl RingModulatorNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Carrier", SignalType::Audio, 0),
NodePort::new("Modulator", SignalType::Audio, 1),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_MIX, "Mix", 0.0, 1.0, 1.0, ParameterUnit::Generic),
];
Self {
name,
mix: 1.0,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for RingModulatorNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_MIX => self.mix = value.clamp(0.0, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_MIX => self.mix,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let length = output.len();
// Get carrier input
let carrier = if !inputs.is_empty() && !inputs[0].is_empty() {
inputs[0]
} else {
&[]
};
// Get modulator input
let modulator = if inputs.len() > 1 && !inputs[1].is_empty() {
inputs[1]
} else {
&[]
};
// Process each sample
for i in 0..length {
let carrier_sample = if i < carrier.len() { carrier[i] } else { 0.0 };
let modulator_sample = if i < modulator.len() { modulator[i] } else { 0.0 };
// Ring modulation: multiply the two signals
let modulated = carrier_sample * modulator_sample;
// Mix between dry (carrier) and wet (modulated)
output[i] = carrier_sample * (1.0 - self.mix) + modulated * self.mix;
}
}
fn reset(&mut self) {
// No state to reset
}
fn node_type(&self) -> &str {
"RingModulator"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
mix: self.mix,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
}

View File

@ -0,0 +1,137 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType};
use crate::audio::midi::MidiEvent;
/// Sample & Hold - samples input CV when triggered by a gate signal
/// Classic modular synth utility for creating stepped sequences
pub struct SampleHoldNode {
name: String,
held_value: f32,
last_gate: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl SampleHoldNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("CV In", SignalType::CV, 0),
NodePort::new("Gate In", SignalType::CV, 1),
];
let outputs = vec![
NodePort::new("CV Out", SignalType::CV, 0),
];
let parameters = vec![];
Self {
name,
held_value: 0.0,
last_gate: 0.0,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for SampleHoldNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, _id: u32, _value: f32) {
// No parameters
}
fn get_parameter(&self, _id: u32) -> f32 {
0.0
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let length = output.len();
// Get CV input
let cv_input = if !inputs.is_empty() && !inputs[0].is_empty() {
inputs[0]
} else {
&[]
};
// Get Gate input
let gate_input = if inputs.len() > 1 && !inputs[1].is_empty() {
inputs[1]
} else {
&[]
};
// Process each sample
for i in 0..length {
let cv = if i < cv_input.len() { cv_input[i] } else { 0.0 };
let gate = if i < gate_input.len() { gate_input[i] } else { 0.0 };
// Detect rising edge (trigger)
let gate_active = gate > 0.5;
let last_gate_active = self.last_gate > 0.5;
if gate_active && !last_gate_active {
// Rising edge detected - sample the input
self.held_value = cv;
}
self.last_gate = gate;
output[i] = self.held_value;
}
}
fn reset(&mut self) {
self.held_value = 0.0;
self.last_gate = 0.0;
}
fn node_type(&self) -> &str {
"SampleHold"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
held_value: self.held_value,
last_gate: self.last_gate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
}

View File

@ -0,0 +1,362 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use std::f32::consts::PI;
const PARAM_BANDS: u32 = 0;
const PARAM_ATTACK: u32 = 1;
const PARAM_RELEASE: u32 = 2;
const PARAM_MIX: u32 = 3;
const MAX_BANDS: usize = 32;
/// Simple bandpass filter using biquad
struct BandpassFilter {
// Biquad coefficients
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
// State variables (separate for modulator and carrier, L/R channels)
mod_z1_left: f32,
mod_z2_left: f32,
mod_z1_right: f32,
mod_z2_right: f32,
car_z1_left: f32,
car_z2_left: f32,
car_z1_right: f32,
car_z2_right: f32,
}
impl BandpassFilter {
fn new() -> Self {
Self {
b0: 0.0,
b1: 0.0,
b2: 0.0,
a1: 0.0,
a2: 0.0,
mod_z1_left: 0.0,
mod_z2_left: 0.0,
mod_z1_right: 0.0,
mod_z2_right: 0.0,
car_z1_left: 0.0,
car_z2_left: 0.0,
car_z1_right: 0.0,
car_z2_right: 0.0,
}
}
fn set_bandpass(&mut self, frequency: f32, q: f32, sample_rate: f32) {
let omega = 2.0 * PI * frequency / sample_rate;
let sin_omega = omega.sin();
let cos_omega = omega.cos();
let alpha = sin_omega / (2.0 * q);
let a0 = 1.0 + alpha;
self.b0 = alpha / a0;
self.b1 = 0.0;
self.b2 = -alpha / a0;
self.a1 = -2.0 * cos_omega / a0;
self.a2 = (1.0 - alpha) / a0;
}
fn process_modulator(&mut self, input: f32, is_left: bool) -> f32 {
let (z1, z2) = if is_left {
(&mut self.mod_z1_left, &mut self.mod_z2_left)
} else {
(&mut self.mod_z1_right, &mut self.mod_z2_right)
};
let output = self.b0 * input + self.b1 * *z1 + self.b2 * *z2 - self.a1 * *z1 - self.a2 * *z2;
*z2 = *z1;
*z1 = output;
output
}
fn process_carrier(&mut self, input: f32, is_left: bool) -> f32 {
let (z1, z2) = if is_left {
(&mut self.car_z1_left, &mut self.car_z2_left)
} else {
(&mut self.car_z1_right, &mut self.car_z2_right)
};
let output = self.b0 * input + self.b1 * *z1 + self.b2 * *z2 - self.a1 * *z1 - self.a2 * *z2;
*z2 = *z1;
*z1 = output;
output
}
fn reset(&mut self) {
self.mod_z1_left = 0.0;
self.mod_z2_left = 0.0;
self.mod_z1_right = 0.0;
self.mod_z2_right = 0.0;
self.car_z1_left = 0.0;
self.car_z2_left = 0.0;
self.car_z1_right = 0.0;
self.car_z2_right = 0.0;
}
}
/// Vocoder band with filter and envelope follower
struct VocoderBand {
filter: BandpassFilter,
envelope_left: f32,
envelope_right: f32,
}
impl VocoderBand {
fn new() -> Self {
Self {
filter: BandpassFilter::new(),
envelope_left: 0.0,
envelope_right: 0.0,
}
}
fn reset(&mut self) {
self.filter.reset();
self.envelope_left = 0.0;
self.envelope_right = 0.0;
}
}
/// Vocoder effect - imposes spectral envelope of modulator onto carrier
pub struct VocoderNode {
name: String,
num_bands: usize, // 8 to 32 bands
attack_time: f32, // 0.001 to 0.1 seconds
release_time: f32, // 0.001 to 1.0 seconds
mix: f32, // 0.0 to 1.0
bands: Vec<VocoderBand>,
sample_rate: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl VocoderNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Modulator", SignalType::Audio, 0),
NodePort::new("Carrier", SignalType::Audio, 1),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_BANDS, "Bands", 8.0, 32.0, 16.0, ParameterUnit::Generic),
Parameter::new(PARAM_ATTACK, "Attack", 0.001, 0.1, 0.01, ParameterUnit::Time),
Parameter::new(PARAM_RELEASE, "Release", 0.001, 1.0, 0.05, ParameterUnit::Time),
Parameter::new(PARAM_MIX, "Mix", 0.0, 1.0, 1.0, ParameterUnit::Generic),
];
let mut bands = Vec::with_capacity(MAX_BANDS);
for _ in 0..MAX_BANDS {
bands.push(VocoderBand::new());
}
Self {
name,
num_bands: 16,
attack_time: 0.01,
release_time: 0.05,
mix: 1.0,
bands,
sample_rate: 48000,
inputs,
outputs,
parameters,
}
}
fn setup_bands(&mut self) {
// Distribute bands logarithmically from 200 Hz to 5000 Hz
let min_freq: f32 = 200.0;
let max_freq: f32 = 5000.0;
let q: f32 = 4.0; // Fairly narrow bands
for i in 0..self.num_bands {
let t = i as f32 / (self.num_bands - 1) as f32;
let freq = min_freq * (max_freq / min_freq).powf(t);
self.bands[i].filter.set_bandpass(freq, q, self.sample_rate as f32);
}
}
}
impl AudioNode for VocoderNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_BANDS => {
let bands = (value.round() as usize).clamp(8, 32);
if bands != self.num_bands {
self.num_bands = bands;
self.setup_bands();
}
}
PARAM_ATTACK => self.attack_time = value.clamp(0.001, 0.1),
PARAM_RELEASE => self.release_time = value.clamp(0.001, 1.0),
PARAM_MIX => self.mix = value.clamp(0.0, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_BANDS => self.num_bands as f32,
PARAM_ATTACK => self.attack_time,
PARAM_RELEASE => self.release_time,
PARAM_MIX => self.mix,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.len() < 2 || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
self.setup_bands();
}
let modulator = inputs[0];
let carrier = inputs[1];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
let mod_frames = modulator.len() / 2;
let car_frames = carrier.len() / 2;
let out_frames = output.len() / 2;
let frames_to_process = mod_frames.min(car_frames).min(out_frames);
// Calculate envelope follower coefficients
let sample_duration = 1.0 / self.sample_rate as f32;
let attack_coeff = (sample_duration / self.attack_time).min(1.0);
let release_coeff = (sample_duration / self.release_time).min(1.0);
for frame in 0..frames_to_process {
let mod_left = modulator[frame * 2];
let mod_right = modulator[frame * 2 + 1];
let car_left = carrier[frame * 2];
let car_right = carrier[frame * 2 + 1];
let mut out_left = 0.0;
let mut out_right = 0.0;
// Process each band
for i in 0..self.num_bands {
let band = &mut self.bands[i];
// Filter modulator and carrier through bandpass
let mod_band_left = band.filter.process_modulator(mod_left, true);
let mod_band_right = band.filter.process_modulator(mod_right, false);
let car_band_left = band.filter.process_carrier(car_left, true);
let car_band_right = band.filter.process_carrier(car_right, false);
// Extract envelope from modulator band (rectify + smooth)
let mod_level_left = mod_band_left.abs();
let mod_level_right = mod_band_right.abs();
// Envelope follower
let coeff_left = if mod_level_left > band.envelope_left {
attack_coeff
} else {
release_coeff
};
let coeff_right = if mod_level_right > band.envelope_right {
attack_coeff
} else {
release_coeff
};
band.envelope_left += (mod_level_left - band.envelope_left) * coeff_left;
band.envelope_right += (mod_level_right - band.envelope_right) * coeff_right;
// Apply envelope to carrier band
out_left += car_band_left * band.envelope_left;
out_right += car_band_right * band.envelope_right;
}
// Normalize output (roughly compensate for band summing)
let norm_factor = 1.0 / (self.num_bands as f32).sqrt();
out_left *= norm_factor;
out_right *= norm_factor;
// Mix with carrier (dry signal)
output[frame * 2] = car_left * (1.0 - self.mix) + out_left * self.mix;
output[frame * 2 + 1] = car_right * (1.0 - self.mix) + out_right * self.mix;
}
}
fn reset(&mut self) {
for band in &mut self.bands {
band.reset();
}
}
fn node_type(&self) -> &str {
"Vocoder"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
let mut bands = Vec::with_capacity(MAX_BANDS);
for _ in 0..MAX_BANDS {
bands.push(VocoderBand::new());
}
let mut node = Self {
name: self.name.clone(),
num_bands: self.num_bands,
attack_time: self.attack_time,
release_time: self.release_time,
mix: self.mix,
bands,
sample_rate: self.sample_rate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
};
node.setup_bands();
Box::new(node)
}
}

View File

@ -7467,6 +7467,87 @@ function nodeEditor() {
});
});
// Handle number inputs
const numberInputs = nodeElement.querySelectorAll('input[type="number"][data-param]');
numberInputs.forEach(numberInput => {
// Track parameter change action for undo/redo
let paramAction = null;
// Prevent node dragging when interacting with number input
numberInput.addEventListener("mousedown", (e) => {
e.stopPropagation();
// Initialize undo action
const paramId = parseInt(e.target.getAttribute("data-param"));
const currentValue = parseFloat(e.target.value);
const nodeData = editor.getNodeFromId(nodeId);
if (nodeData && nodeData.data.backendId !== null) {
const currentTrackId = getCurrentMidiTrack();
if (currentTrackId !== null) {
paramAction = actions.graphSetParameter.initialize(
currentTrackId,
nodeData.data.backendId,
paramId,
nodeId,
currentValue
);
}
}
});
numberInput.addEventListener("pointerdown", (e) => {
e.stopPropagation();
});
numberInput.addEventListener("input", (e) => {
const paramId = parseInt(e.target.getAttribute("data-param"));
let value = parseFloat(e.target.value);
// Validate and clamp to min/max
const min = parseFloat(e.target.min);
const max = parseFloat(e.target.max);
if (!isNaN(value)) {
value = Math.max(min, Math.min(max, value));
} else {
value = 0;
}
console.log(`[setupNodeParameters] Number input - nodeId: ${nodeId}, paramId: ${paramId}, value: ${value}`);
// Update corresponding slider
const slider = nodeElement.querySelector(`input[type="range"][data-param="${paramId}"]`);
if (slider) {
slider.value = value;
}
// Send to backend
const nodeData = editor.getNodeFromId(nodeId);
if (nodeData && nodeData.data.backendId !== null) {
const currentTrackId = getCurrentMidiTrack();
if (currentTrackId !== null) {
invoke("graph_set_parameter", {
trackId: currentTrackId,
nodeId: nodeData.data.backendId,
paramId: paramId,
value: value
}).catch(err => {
console.error("Failed to set parameter:", err);
});
}
}
});
numberInput.addEventListener("change", (e) => {
const value = parseFloat(e.target.value);
// Finalize undo action
if (paramAction) {
actions.graphSetParameter.finalize(paramAction, value);
paramAction = null;
}
});
});
// Handle Load Sample button for SimpleSampler
const loadSampleBtn = nodeElement.querySelector(".load-sample-btn");
if (loadSampleBtn) {

View File

@ -1015,6 +1015,29 @@ export const nodeTypes = {
`
},
Constant: {
name: 'Constant',
category: NodeCategory.UTILITY,
description: 'Constant CV source - outputs a fixed voltage',
inputs: [],
outputs: [
{ name: 'CV Out', type: SignalType.CV, index: 0 }
],
parameters: [
{ id: 0, name: 'value', label: 'Value', min: -10, max: 10, default: 0, unit: '' }
],
getHTML: (nodeId) => `
<div class="node-content">
<div class="node-title">Constant</div>
<div class="node-param">
<label>Value:</label>
<input type="number" class="node-number-input" data-node="${nodeId}" data-param="0" min="-10" max="10" value="0" step="0.01" style="width: 60px; padding: 2px;">
<input type="range" data-node="${nodeId}" data-param="0" min="-10" max="10" value="0" step="0.01">
</div>
</div>
`
},
Math: {
name: 'Math',
category: NodeCategory.UTILITY,
@ -1027,14 +1050,13 @@ export const nodeTypes = {
{ name: 'CV Out', type: SignalType.CV, index: 0 }
],
parameters: [
{ id: 0, name: 'operation', label: 'Operation', min: 0, max: 13, default: 0, unit: '' },
{ id: 1, name: 'operand', label: 'Operand', min: -10, max: 10, default: 1, unit: '' }
{ id: 0, name: 'operation', label: 'Operation', min: 0, max: 13, default: 0, unit: '' }
],
getHTML: (nodeId) => `
<div class="node-content">
<div class="node-title">Math</div>
<div class="node-param">
<label>Op: <span id="mathop-${nodeId}">Add</span></label>
<label>Op: <span id="operation-${nodeId}">Add</span></label>
<select class="node-select" data-node="${nodeId}" data-param="0" style="width: 100%; padding: 2px;">
<option value="0">Add</option>
<option value="1">Subtract</option>
@ -1052,10 +1074,6 @@ export const nodeTypes = {
<option value="13">Equal</option>
</select>
</div>
<div class="node-param">
<label>B: <span id="mathoperand-${nodeId}">1.0</span></label>
<input type="range" data-node="${nodeId}" data-param="1" min="-10" max="10" value="1" step="0.1">
</div>
</div>
`
},
@ -1079,7 +1097,7 @@ export const nodeTypes = {
<div class="node-content">
<div class="node-title">Quantizer</div>
<div class="node-param">
<label>Scale: <span id="quantscale-${nodeId}">Chromatic</span></label>
<label>Scale: <span id="scale-${nodeId}">Chromatic</span></label>
<select class="node-select" data-node="${nodeId}" data-param="0" style="width: 100%; padding: 2px;">
<option value="0">Chromatic</option>
<option value="1">Major</option>
@ -1095,7 +1113,7 @@ export const nodeTypes = {
</select>
</div>
<div class="node-param">
<label>Root: <span id="quantroot-${nodeId}">C</span></label>
<label>Root: <span id="root-${nodeId}">C</span></label>
<select class="node-select" data-node="${nodeId}" data-param="1" style="width: 100%; padding: 2px;">
<option value="0">C</option>
<option value="1">C#</option>
@ -1199,6 +1217,200 @@ export const nodeTypes = {
</div>
</div>
`
},
SampleHold: {
name: 'Sample & Hold',
category: NodeCategory.UTILITY,
description: 'Samples CV input when gate signal goes high',
inputs: [
{ name: 'CV In', type: SignalType.CV, index: 0 },
{ name: 'Gate In', type: SignalType.CV, index: 1 }
],
outputs: [
{ name: 'CV Out', type: SignalType.CV, index: 0 }
],
parameters: [],
getHTML: (nodeId) => `
<div class="node-content">
<div class="node-title">Sample & Hold</div>
<div style="padding: 8px; font-size: 11px; color: #888;">
Samples CV input<br>on gate rising edge
</div>
</div>
`
},
EnvelopeFollower: {
name: 'Envelope Follower',
category: NodeCategory.UTILITY,
description: 'Extracts amplitude envelope from audio signal',
inputs: [
{ name: 'Audio In', type: SignalType.AUDIO, index: 0 }
],
outputs: [
{ name: 'CV Out', type: SignalType.CV, index: 0 }
],
parameters: [
{ id: 0, name: 'attack', label: 'Attack', min: 0.001, max: 1.0, default: 0.01, unit: 's' },
{ id: 1, name: 'release', label: 'Release', min: 0.001, max: 1.0, default: 0.1, unit: 's' }
],
getHTML: (nodeId) => `
<div class="node-content">
<div class="node-title">Envelope Follower</div>
<div class="node-param">
<label>Attack: <span id="attack-${nodeId}">0.01</span> s</label>
<input type="range" data-node="${nodeId}" data-param="0" min="0.001" max="1.0" value="0.01" step="0.001">
</div>
<div class="node-param">
<label>Release: <span id="release-${nodeId}">0.1</span> s</label>
<input type="range" data-node="${nodeId}" data-param="1" min="0.001" max="1.0" value="0.1" step="0.001">
</div>
</div>
`
},
RingModulator: {
name: 'Ring Modulator',
category: NodeCategory.EFFECT,
description: 'Multiplies carrier and modulator for metallic timbres',
inputs: [
{ name: 'Carrier', type: SignalType.AUDIO, index: 0 },
{ name: 'Modulator', type: SignalType.AUDIO, index: 1 }
],
outputs: [
{ name: 'Audio Out', type: SignalType.AUDIO, index: 0 }
],
parameters: [
{ id: 0, name: 'mix', label: 'Mix', min: 0.0, max: 1.0, default: 1.0, unit: '' }
],
getHTML: (nodeId) => `
<div class="node-content">
<div class="node-title">Ring Modulator</div>
<div class="node-param">
<label>Mix: <span id="mix-${nodeId}">1.00</span></label>
<input type="range" data-node="${nodeId}" data-param="0" min="0.0" max="1.0" value="1.0" step="0.01">
</div>
</div>
`
},
Phaser: {
name: 'Phaser',
category: NodeCategory.EFFECT,
description: 'Sweeping all-pass filters for phase shifting effect',
inputs: [
{ name: 'Audio In', type: SignalType.AUDIO, index: 0 }
],
outputs: [
{ name: 'Audio Out', type: SignalType.AUDIO, index: 0 }
],
parameters: [
{ id: 0, name: 'rate', label: 'Rate', min: 0.1, max: 10.0, default: 0.5, unit: 'Hz' },
{ id: 1, name: 'depth', label: 'Depth', min: 0.0, max: 1.0, default: 0.7, unit: '' },
{ id: 2, name: 'stages', label: 'Stages', min: 2, max: 8, default: 6, unit: '' },
{ id: 3, name: 'feedback', label: 'Feedback', min: -0.95, max: 0.95, default: 0.5, unit: '' },
{ id: 4, name: 'wetdry', label: 'Wet/Dry', min: 0.0, max: 1.0, default: 0.5, unit: '' }
],
getHTML: (nodeId) => `
<div class="node-content">
<div class="node-title">Phaser</div>
<div class="node-param">
<label>Rate: <span id="rate-${nodeId}">0.5</span> Hz</label>
<input type="range" data-node="${nodeId}" data-param="0" min="0.1" max="10.0" value="0.5" step="0.1">
</div>
<div class="node-param">
<label>Depth: <span id="depth-${nodeId}">0.7</span></label>
<input type="range" data-node="${nodeId}" data-param="1" min="0.0" max="1.0" value="0.7" step="0.01">
</div>
<div class="node-param">
<label>Stages: <span id="stages-${nodeId}">6</span></label>
<input type="range" data-node="${nodeId}" data-param="2" min="2" max="8" value="6" step="2">
</div>
<div class="node-param">
<label>Feedback: <span id="feedback-${nodeId}">0.5</span></label>
<input type="range" data-node="${nodeId}" data-param="3" min="-0.95" max="0.95" value="0.5" step="0.01">
</div>
<div class="node-param">
<label>Wet/Dry: <span id="wetdry-${nodeId}">0.5</span></label>
<input type="range" data-node="${nodeId}" data-param="4" min="0.0" max="1.0" value="0.5" step="0.01">
</div>
</div>
`
},
BitCrusher: {
name: 'Bit Crusher',
category: NodeCategory.EFFECT,
description: 'Lo-fi effect via bit depth and sample rate reduction',
inputs: [
{ name: 'Audio In', type: SignalType.AUDIO, index: 0 }
],
outputs: [
{ name: 'Audio Out', type: SignalType.AUDIO, index: 0 }
],
parameters: [
{ id: 0, name: 'bitdepth', label: 'Bit Depth', min: 1, max: 16, default: 8, unit: 'bits' },
{ id: 1, name: 'samplerate', label: 'Sample Rate', min: 100, max: 48000, default: 8000, unit: 'Hz' },
{ id: 2, name: 'mix', label: 'Mix', min: 0.0, max: 1.0, default: 1.0, unit: '' }
],
getHTML: (nodeId) => `
<div class="node-content">
<div class="node-title">Bit Crusher</div>
<div class="node-param">
<label>Bit Depth: <span id="bitdepth-${nodeId}">8</span> bits</label>
<input type="range" data-node="${nodeId}" data-param="0" min="1" max="16" value="8" step="1">
</div>
<div class="node-param">
<label>Sample Rate: <span id="samplerate-${nodeId}">8000</span> Hz</label>
<input type="range" data-node="${nodeId}" data-param="1" min="100" max="48000" value="8000" step="100">
</div>
<div class="node-param">
<label>Mix: <span id="mix-${nodeId}">1.0</span></label>
<input type="range" data-node="${nodeId}" data-param="2" min="0.0" max="1.0" value="1.0" step="0.01">
</div>
</div>
`
},
Vocoder: {
name: 'Vocoder',
category: NodeCategory.EFFECT,
description: 'Multi-band vocoder - modulator controls carrier spectrum',
inputs: [
{ name: 'Modulator', type: SignalType.AUDIO, index: 0 },
{ name: 'Carrier', type: SignalType.AUDIO, index: 1 }
],
outputs: [
{ name: 'Audio Out', type: SignalType.AUDIO, index: 0 }
],
parameters: [
{ id: 0, name: 'bands', label: 'Bands', min: 8, max: 32, default: 16, unit: '' },
{ id: 1, name: 'attack', label: 'Attack', min: 0.001, max: 0.1, default: 0.01, unit: 's' },
{ id: 2, name: 'release', label: 'Release', min: 0.001, max: 1.0, default: 0.05, unit: 's' },
{ id: 3, name: 'mix', label: 'Mix', min: 0.0, max: 1.0, default: 1.0, unit: '' }
],
getHTML: (nodeId) => `
<div class="node-content">
<div class="node-title">Vocoder</div>
<div class="node-param">
<label>Bands: <span id="bands-${nodeId}">16</span></label>
<input type="range" data-node="${nodeId}" data-param="0" min="8" max="32" value="16" step="1">
</div>
<div class="node-param">
<label>Attack: <span id="attack-${nodeId}">0.01</span> s</label>
<input type="range" data-node="${nodeId}" data-param="1" min="0.001" max="0.1" value="0.01" step="0.001">
</div>
<div class="node-param">
<label>Release: <span id="release-${nodeId}">0.05</span> s</label>
<input type="range" data-node="${nodeId}" data-param="2" min="0.001" max="1.0" value="0.05" step="0.001">
</div>
<div class="node-param">
<label>Mix: <span id="mix-${nodeId}">1.0</span></label>
<input type="range" data-node="${nodeId}" data-param="3" min="0.0" max="1.0" value="1.0" step="0.01">
</div>
</div>
`
}
};