diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 205950e..e0061e1 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1166,6 +1166,7 @@ impl Engine { "Compressor" => Box::new(CompressorNode::new("Compressor".to_string())), "Constant" => Box::new(ConstantNode::new("Constant".to_string())), "BpmDetector" => Box::new(BpmDetectorNode::new("BPM Detector".to_string())), + "Beat" => Box::new(BeatNode::new("Beat".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())), @@ -1253,6 +1254,7 @@ impl Engine { "Compressor" => Box::new(CompressorNode::new("Compressor".to_string())), "Constant" => Box::new(ConstantNode::new("Constant".to_string())), "BpmDetector" => Box::new(BpmDetectorNode::new("BPM Detector".to_string())), + "Beat" => Box::new(BeatNode::new("Beat".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())), diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs index ea031e7..3e179fc 100644 --- a/daw-backend/src/audio/node_graph/graph.rs +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -445,12 +445,13 @@ impl AudioGraph { // Update playback time self.playback_time = playback_time; - // Update playback time for all automation nodes before processing - use super::nodes::AutomationInputNode; + // Update playback time for all time-dependent nodes before processing + use super::nodes::{AutomationInputNode, BeatNode}; for node in self.graph.node_weights_mut() { - // Try to downcast to AutomationInputNode and update its playback time if let Some(auto_node) = node.node.as_any_mut().downcast_mut::() { auto_node.set_playback_time(playback_time); + } else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::() { + beat_node.set_playback_time(playback_time); } } @@ -967,6 +968,7 @@ impl AudioGraph { "Chorus" => Box::new(ChorusNode::new("Chorus")), "Compressor" => Box::new(CompressorNode::new("Compressor")), "Constant" => Box::new(ConstantNode::new("Constant")), + "Beat" => Box::new(BeatNode::new("Beat")), "EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower")), "Limiter" => Box::new(LimiterNode::new("Limiter")), "Math" => Box::new(MathNode::new("Math")), diff --git a/daw-backend/src/audio/node_graph/nodes/beat.rs b/daw-backend/src/audio/node_graph/nodes/beat.rs new file mode 100644 index 0000000..ce51bbb --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/beat.rs @@ -0,0 +1,239 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +const PARAM_RESOLUTION: u32 = 0; + +/// Hardcoded BPM until project tempo is implemented +const DEFAULT_BPM: f32 = 120.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BeatResolution { + Whole = 0, // 1/1 + Half = 1, // 1/2 + Quarter = 2, // 1/4 + Eighth = 3, // 1/8 + Sixteenth = 4, // 1/16 + QuarterT = 5, // 1/4 triplet + EighthT = 6, // 1/8 triplet +} + +impl BeatResolution { + fn from_f32(value: f32) -> Self { + match value.round() as i32 { + 0 => BeatResolution::Whole, + 1 => BeatResolution::Half, + 2 => BeatResolution::Quarter, + 3 => BeatResolution::Eighth, + 4 => BeatResolution::Sixteenth, + 5 => BeatResolution::QuarterT, + 6 => BeatResolution::EighthT, + _ => BeatResolution::Quarter, + } + } + + /// How many subdivisions per quarter note beat + fn subdivisions_per_beat(&self) -> f64 { + match self { + BeatResolution::Whole => 0.25, // 1 per 4 beats + BeatResolution::Half => 0.5, // 1 per 2 beats + BeatResolution::Quarter => 1.0, // 1 per beat + BeatResolution::Eighth => 2.0, // 2 per beat + BeatResolution::Sixteenth => 4.0, // 4 per beat + BeatResolution::QuarterT => 1.5, // 3 per 2 beats (triplet) + BeatResolution::EighthT => 3.0, // 3 per beat (triplet) + } + } +} + +/// Beat clock node — generates tempo-synced CV signals. +/// +/// Outputs: +/// - BPM: constant CV proportional to tempo (bpm / 240) +/// - Beat Phase: sawtooth 0→1 per beat subdivision +/// - Bar Phase: sawtooth 0→1 per bar (4 beats) +/// - Gate: 1.0 for first half of each subdivision, 0.0 otherwise +pub struct BeatNode { + name: String, + bpm: f32, + resolution: BeatResolution, + /// Playback time in seconds, set by the graph before process() + playback_time: f64, + /// Previous playback_time to detect paused state + prev_playback_time: f64, + /// Cached output values held when paused + held_beat_phase: f32, + held_bar_phase: f32, + held_gate: f32, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl BeatNode { + pub fn new(name: impl Into) -> Self { + let inputs = vec![]; + + let outputs = vec![ + NodePort::new("BPM", SignalType::CV, 0), + NodePort::new("Beat Phase", SignalType::CV, 1), + NodePort::new("Bar Phase", SignalType::CV, 2), + NodePort::new("Gate", SignalType::CV, 3), + ]; + + let parameters = vec![ + Parameter::new(PARAM_RESOLUTION, "Resolution", 0.0, 6.0, 2.0, ParameterUnit::Generic), + ]; + + Self { + name: name.into(), + bpm: DEFAULT_BPM, + resolution: BeatResolution::Quarter, + playback_time: 0.0, + prev_playback_time: -1.0, + held_beat_phase: 0.0, + held_bar_phase: 0.0, + held_gate: 0.0, + inputs, + outputs, + parameters, + } + } + + pub fn set_playback_time(&mut self, time: f64) { + self.playback_time = time; + } +} + +impl AudioNode for BeatNode { + 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_RESOLUTION => self.resolution = BeatResolution::from_f32(value), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_RESOLUTION => self.resolution as i32 as f32, + _ => 0.0, + } + } + + fn process( + &mut self, + _inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if outputs.len() < 4 { + return; + } + + let bpm_cv = (self.bpm / 240.0).clamp(0.0, 1.0); + let len = outputs[0].len(); + + // Detect paused: playback_time hasn't changed since last process() + let paused = self.playback_time == self.prev_playback_time; + self.prev_playback_time = self.playback_time; + + if paused { + // Hold last values + for i in 0..len { + outputs[0][i] = bpm_cv; + outputs[1][i] = self.held_beat_phase; + outputs[2][i] = self.held_bar_phase; + outputs[3][i] = self.held_gate; + } + return; + } + + let beats_per_second = self.bpm as f64 / 60.0; + let sample_period = 1.0 / sample_rate as f64; + let subs_per_beat = self.resolution.subdivisions_per_beat(); + + for i in 0..len { + // Derive beat position from timeline playback time + let time = self.playback_time + i as f64 * sample_period; + let beat_pos = time * beats_per_second; + + // Beat subdivision phase: 0→1 sawtooth + let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32; + + // Bar phase: 0→1 over 4 quarter-note beats + let bar_phase = ((beat_pos / 4.0) % 1.0) as f32; + + // Gate: high for first half of each subdivision + let gate = if sub_phase < 0.5 { 1.0f32 } else { 0.0 }; + + outputs[0][i] = bpm_cv; + outputs[1][i] = sub_phase; + outputs[2][i] = bar_phase; + outputs[3][i] = gate; + } + + // Cache last sample's values for hold when paused + if len > 0 { + self.held_beat_phase = outputs[1][len - 1]; + self.held_bar_phase = outputs[2][len - 1]; + self.held_gate = outputs[3][len - 1]; + } + } + + fn reset(&mut self) { + self.playback_time = 0.0; + self.prev_playback_time = -1.0; + self.held_beat_phase = 0.0; + self.held_bar_phase = 0.0; + self.held_gate = 0.0; + } + + fn node_type(&self) -> &str { + "Beat" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + bpm: self.bpm, + resolution: self.resolution, + playback_time: 0.0, + prev_playback_time: -1.0, + held_beat_phase: 0.0, + held_bar_phase: 0.0, + held_gate: 0.0, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/mod.rs b/daw-backend/src/audio/node_graph/nodes/mod.rs index 60abb2b..acafda1 100644 --- a/daw-backend/src/audio/node_graph/nodes/mod.rs +++ b/daw-backend/src/audio/node_graph/nodes/mod.rs @@ -2,6 +2,7 @@ mod adsr; mod audio_input; mod audio_to_cv; mod automation_input; +mod beat; mod bit_crusher; mod bpm_detector; mod chorus; @@ -44,6 +45,7 @@ pub use adsr::ADSRNode; pub use audio_input::AudioInputNode; pub use audio_to_cv::AudioToCVNode; pub use automation_input::{AutomationInputNode, AutomationKeyframe, InterpolationType}; +pub use beat::BeatNode; pub use bit_crusher::BitCrusherNode; pub use bpm_detector::BpmDetectorNode; pub use chorus::ChorusNode; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs index 612086c..2e86822 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs @@ -23,6 +23,7 @@ pub enum NodeTemplate { MidiInput, AudioInput, AutomationInput, + Beat, // Generators Oscillator, @@ -121,6 +122,7 @@ impl NodeTemplate { NodeTemplate::Quantizer => "Quantizer", NodeTemplate::EnvelopeFollower => "EnvelopeFollower", NodeTemplate::BpmDetector => "BpmDetector", + NodeTemplate::Beat => "Beat", NodeTemplate::Mod => "Mod", NodeTemplate::Oscilloscope => "Oscilloscope", NodeTemplate::VoiceAllocator => "VoiceAllocator", @@ -360,6 +362,7 @@ impl NodeTemplateTrait for NodeTemplate { NodeTemplate::Quantizer => "Quantizer".into(), NodeTemplate::EnvelopeFollower => "Envelope Follower".into(), NodeTemplate::BpmDetector => "BPM Detector".into(), + NodeTemplate::Beat => "Beat".into(), NodeTemplate::Mod => "Modulator".into(), // Analysis NodeTemplate::Oscilloscope => "Oscilloscope".into(), @@ -376,7 +379,7 @@ impl NodeTemplateTrait for NodeTemplate { fn node_finder_categories(&self, _user_state: &mut Self::UserState) -> Vec<&'static str> { match self { - NodeTemplate::MidiInput | NodeTemplate::AudioInput | NodeTemplate::AutomationInput => vec!["Inputs"], + NodeTemplate::MidiInput | NodeTemplate::AudioInput | NodeTemplate::AutomationInput | NodeTemplate::Beat => vec!["Inputs"], NodeTemplate::Oscillator | NodeTemplate::WavetableOscillator | NodeTemplate::FmSynth | NodeTemplate::Noise | NodeTemplate::SimpleSampler | NodeTemplate::MultiSampler => vec!["Generators"], NodeTemplate::Filter | NodeTemplate::Gain | NodeTemplate::Echo | NodeTemplate::Reverb @@ -745,6 +748,16 @@ impl NodeTemplateTrait for NodeTemplate { graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true); graph.add_output_param(node_id, "BPM".into(), DataType::CV); } + NodeTemplate::Beat => { + graph.add_input_param(node_id, "Resolution".into(), DataType::CV, + ValueType::float_param(2.0, 0.0, 6.0, "", 0, + Some(&["1/1", "1/2", "1/4", "1/8", "1/16", "1/4T", "1/8T"])), + InputParamKind::ConstantOnly, true); + graph.add_output_param(node_id, "BPM".into(), DataType::CV); + graph.add_output_param(node_id, "Beat Phase".into(), DataType::CV); + graph.add_output_param(node_id, "Bar Phase".into(), DataType::CV); + graph.add_output_param(node_id, "Gate".into(), DataType::CV); + } NodeTemplate::Mod => { graph.add_input_param(node_id, "Carrier".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true); graph.add_input_param(node_id, "Modulator".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true); @@ -1118,6 +1131,7 @@ impl NodeTemplateIter for AllNodeTemplates { NodeTemplate::Quantizer, NodeTemplate::EnvelopeFollower, NodeTemplate::BpmDetector, + NodeTemplate::Beat, NodeTemplate::Mod, // Analysis NodeTemplate::Oscilloscope,