Add beat node

This commit is contained in:
Skyler Lehmkuhl 2026-02-19 01:19:40 -05:00
parent 21a49235fc
commit 89bbd3614f
5 changed files with 263 additions and 4 deletions

View File

@ -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())),

View File

@ -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::<AutomationInputNode>() {
auto_node.set_playback_time(playback_time);
} else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::<BeatNode>() {
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")),

View File

@ -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<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl BeatNode {
pub fn new(name: impl Into<String>) -> 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<MidiEvent>],
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<dyn AudioNode> {
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
}
}

View File

@ -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;

View File

@ -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,