Add script node
This commit is contained in:
parent
ac575482f3
commit
c66487b25e
|
|
@ -36,6 +36,9 @@ dasp_rms = "0.11"
|
||||||
petgraph = "0.6"
|
petgraph = "0.6"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
# BeamDSP scripting engine
|
||||||
|
beamdsp = { path = "../lightningbeam-ui/beamdsp" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|
|
||||||
|
|
@ -1169,6 +1169,7 @@ impl Engine {
|
||||||
"Beat" => Box::new(BeatNode::new("Beat".to_string())),
|
"Beat" => Box::new(BeatNode::new("Beat".to_string())),
|
||||||
"Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".to_string())),
|
"Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".to_string())),
|
||||||
"Sequencer" => Box::new(SequencerNode::new("Sequencer".to_string())),
|
"Sequencer" => Box::new(SequencerNode::new("Sequencer".to_string())),
|
||||||
|
"Script" => Box::new(ScriptNode::new("Script".to_string())),
|
||||||
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())),
|
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())),
|
||||||
"Limiter" => Box::new(LimiterNode::new("Limiter".to_string())),
|
"Limiter" => Box::new(LimiterNode::new("Limiter".to_string())),
|
||||||
"Math" => Box::new(MathNode::new("Math".to_string())),
|
"Math" => Box::new(MathNode::new("Math".to_string())),
|
||||||
|
|
@ -1259,6 +1260,7 @@ impl Engine {
|
||||||
"Beat" => Box::new(BeatNode::new("Beat".to_string())),
|
"Beat" => Box::new(BeatNode::new("Beat".to_string())),
|
||||||
"Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".to_string())),
|
"Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".to_string())),
|
||||||
"Sequencer" => Box::new(SequencerNode::new("Sequencer".to_string())),
|
"Sequencer" => Box::new(SequencerNode::new("Sequencer".to_string())),
|
||||||
|
"Script" => Box::new(ScriptNode::new("Script".to_string())),
|
||||||
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())),
|
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())),
|
||||||
"Limiter" => Box::new(LimiterNode::new("Limiter".to_string())),
|
"Limiter" => Box::new(LimiterNode::new("Limiter".to_string())),
|
||||||
"Math" => Box::new(MathNode::new("Math".to_string())),
|
"Math" => Box::new(MathNode::new("Math".to_string())),
|
||||||
|
|
@ -1657,6 +1659,58 @@ impl Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Command::GraphSetScript(track_id, node_id, source) => {
|
||||||
|
use crate::audio::node_graph::nodes::ScriptNode;
|
||||||
|
|
||||||
|
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||||
|
let graph = &mut track.instrument_graph;
|
||||||
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
|
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||||
|
if let Some(script_node) = graph_node.node.as_any_mut().downcast_mut::<ScriptNode>() {
|
||||||
|
match script_node.set_script(&source) {
|
||||||
|
Ok(ui_decl) => {
|
||||||
|
// Send compile success event back to frontend
|
||||||
|
let _ = self.event_tx.push(AudioEvent::ScriptCompiled {
|
||||||
|
track_id,
|
||||||
|
node_id,
|
||||||
|
success: true,
|
||||||
|
error: None,
|
||||||
|
ui_declaration: Some(ui_decl),
|
||||||
|
source: source.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = self.event_tx.push(AudioEvent::ScriptCompiled {
|
||||||
|
track_id,
|
||||||
|
node_id,
|
||||||
|
success: false,
|
||||||
|
error: Some(e),
|
||||||
|
ui_declaration: None,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::GraphSetScriptSample(track_id, node_id, slot_index, data, sample_rate, name) => {
|
||||||
|
use crate::audio::node_graph::nodes::ScriptNode;
|
||||||
|
|
||||||
|
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||||
|
let graph = &mut track.instrument_graph;
|
||||||
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
|
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||||
|
if let Some(script_node) = graph_node.node.as_any_mut().downcast_mut::<ScriptNode>() {
|
||||||
|
script_node.set_sample(slot_index, data, sample_rate, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Command::SamplerLoadSample(track_id, node_id, file_path) => {
|
Command::SamplerLoadSample(track_id, node_id, file_path) => {
|
||||||
use crate::audio::node_graph::nodes::SimpleSamplerNode;
|
use crate::audio::node_graph::nodes::SimpleSamplerNode;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -906,6 +906,17 @@ impl AudioGraph {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Script nodes, serialize the source code
|
||||||
|
if node.node_type() == "Script" {
|
||||||
|
use crate::audio::node_graph::nodes::ScriptNode;
|
||||||
|
if let Some(script_node) = node.as_any().downcast_ref::<ScriptNode>() {
|
||||||
|
let source = script_node.source_code();
|
||||||
|
if !source.is_empty() {
|
||||||
|
serialized.script_source = Some(source.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save position if available
|
// Save position if available
|
||||||
if let Some(pos) = self.get_node_position(node_idx) {
|
if let Some(pos) = self.get_node_position(node_idx) {
|
||||||
serialized.set_position(pos.0, pos.1);
|
serialized.set_position(pos.0, pos.1);
|
||||||
|
|
@ -992,6 +1003,7 @@ impl AudioGraph {
|
||||||
"Beat" => Box::new(BeatNode::new("Beat")),
|
"Beat" => Box::new(BeatNode::new("Beat")),
|
||||||
"Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator")),
|
"Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator")),
|
||||||
"Sequencer" => Box::new(SequencerNode::new("Sequencer")),
|
"Sequencer" => Box::new(SequencerNode::new("Sequencer")),
|
||||||
|
"Script" => Box::new(ScriptNode::new("Script")),
|
||||||
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower")),
|
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower")),
|
||||||
"Limiter" => Box::new(LimiterNode::new("Limiter")),
|
"Limiter" => Box::new(LimiterNode::new("Limiter")),
|
||||||
"Math" => Box::new(MathNode::new("Math")),
|
"Math" => Box::new(MathNode::new("Math")),
|
||||||
|
|
@ -1034,7 +1046,22 @@ impl AudioGraph {
|
||||||
let node_idx = graph.add_node(node);
|
let node_idx = graph.add_node(node);
|
||||||
index_map.insert(serialized_node.id, node_idx);
|
index_map.insert(serialized_node.id, node_idx);
|
||||||
|
|
||||||
// Set parameters
|
// Restore script source for Script nodes (must come before parameter setting
|
||||||
|
// since set_script rebuilds parameters)
|
||||||
|
if let Some(ref source) = serialized_node.script_source {
|
||||||
|
if serialized_node.node_type == "Script" {
|
||||||
|
use crate::audio::node_graph::nodes::ScriptNode;
|
||||||
|
if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) {
|
||||||
|
if let Some(script_node) = graph_node.node.as_any_mut().downcast_mut::<ScriptNode>() {
|
||||||
|
if let Err(e) = script_node.set_script(source) {
|
||||||
|
eprintln!("Warning: failed to compile script for node {}: {}", serialized_node.id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set parameters (after script compilation so param slots exist)
|
||||||
for (¶m_id, &value) in &serialized_node.parameters {
|
for (¶m_id, &value) in &serialized_node.parameters {
|
||||||
if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) {
|
if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) {
|
||||||
graph_node.node.set_parameter(param_id, value);
|
graph_node.node.set_parameter(param_id, value);
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ mod quantizer;
|
||||||
mod reverb;
|
mod reverb;
|
||||||
mod ring_modulator;
|
mod ring_modulator;
|
||||||
mod sample_hold;
|
mod sample_hold;
|
||||||
|
mod script_node;
|
||||||
mod sequencer;
|
mod sequencer;
|
||||||
mod simple_sampler;
|
mod simple_sampler;
|
||||||
mod slew_limiter;
|
mod slew_limiter;
|
||||||
|
|
@ -79,6 +80,7 @@ pub use quantizer::QuantizerNode;
|
||||||
pub use reverb::ReverbNode;
|
pub use reverb::ReverbNode;
|
||||||
pub use ring_modulator::RingModulatorNode;
|
pub use ring_modulator::RingModulatorNode;
|
||||||
pub use sample_hold::SampleHoldNode;
|
pub use sample_hold::SampleHoldNode;
|
||||||
|
pub use script_node::ScriptNode;
|
||||||
pub use sequencer::SequencerNode;
|
pub use sequencer::SequencerNode;
|
||||||
pub use simple_sampler::SimpleSamplerNode;
|
pub use simple_sampler::SimpleSamplerNode;
|
||||||
pub use slew_limiter::SlewLimiterNode;
|
pub use slew_limiter::SlewLimiterNode;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||||
|
use crate::audio::midi::MidiEvent;
|
||||||
|
use beamdsp::{ScriptVM, SampleSlot};
|
||||||
|
|
||||||
|
/// A user-scriptable audio node powered by the BeamDSP VM
|
||||||
|
pub struct ScriptNode {
|
||||||
|
name: String,
|
||||||
|
script_name: String,
|
||||||
|
inputs: Vec<NodePort>,
|
||||||
|
outputs: Vec<NodePort>,
|
||||||
|
parameters: Vec<Parameter>,
|
||||||
|
category: NodeCategory,
|
||||||
|
vm: ScriptVM,
|
||||||
|
source_code: String,
|
||||||
|
ui_declaration: beamdsp::UiDeclaration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptNode {
|
||||||
|
/// Create a default empty Script node (compiles a passthrough on first script set)
|
||||||
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
|
let name = name.into();
|
||||||
|
// Default: single audio in, single audio out, no params
|
||||||
|
let inputs = vec![
|
||||||
|
NodePort::new("Audio In", SignalType::Audio, 0),
|
||||||
|
];
|
||||||
|
let outputs = vec![
|
||||||
|
NodePort::new("Audio Out", SignalType::Audio, 0),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create a minimal VM that just halts (no-op)
|
||||||
|
let vm = ScriptVM::new(
|
||||||
|
vec![255], // Halt
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
0,
|
||||||
|
&[],
|
||||||
|
0,
|
||||||
|
&[],
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
script_name: "Script".into(),
|
||||||
|
inputs,
|
||||||
|
outputs,
|
||||||
|
parameters: Vec::new(),
|
||||||
|
category: NodeCategory::Effect,
|
||||||
|
vm,
|
||||||
|
source_code: String::new(),
|
||||||
|
ui_declaration: beamdsp::UiDeclaration { elements: Vec::new() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile and load a new script, replacing the current one.
|
||||||
|
/// Returns Ok(ui_declaration) on success, or Err(error_message) on failure.
|
||||||
|
pub fn set_script(&mut self, source: &str) -> Result<beamdsp::UiDeclaration, String> {
|
||||||
|
let compiled = beamdsp::compile(source).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Update ports
|
||||||
|
self.inputs = compiled.input_ports.iter().enumerate().map(|(i, p)| {
|
||||||
|
let sig = match p.signal {
|
||||||
|
beamdsp::ast::SignalKind::Audio => SignalType::Audio,
|
||||||
|
beamdsp::ast::SignalKind::Cv => SignalType::CV,
|
||||||
|
beamdsp::ast::SignalKind::Midi => SignalType::Midi,
|
||||||
|
};
|
||||||
|
NodePort::new(&p.name, sig, i)
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
self.outputs = compiled.output_ports.iter().enumerate().map(|(i, p)| {
|
||||||
|
let sig = match p.signal {
|
||||||
|
beamdsp::ast::SignalKind::Audio => SignalType::Audio,
|
||||||
|
beamdsp::ast::SignalKind::Cv => SignalType::CV,
|
||||||
|
beamdsp::ast::SignalKind::Midi => SignalType::Midi,
|
||||||
|
};
|
||||||
|
NodePort::new(&p.name, sig, i)
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// Update parameters
|
||||||
|
self.parameters = compiled.parameters.iter().enumerate().map(|(i, p)| {
|
||||||
|
let unit = if p.unit == "dB" {
|
||||||
|
ParameterUnit::Decibels
|
||||||
|
} else if p.unit == "Hz" {
|
||||||
|
ParameterUnit::Frequency
|
||||||
|
} else if p.unit == "s" {
|
||||||
|
ParameterUnit::Time
|
||||||
|
} else if p.unit == "%" {
|
||||||
|
ParameterUnit::Percent
|
||||||
|
} else {
|
||||||
|
ParameterUnit::Generic
|
||||||
|
};
|
||||||
|
Parameter::new(i as u32, &p.name, p.min, p.max, p.default, unit)
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// Update category
|
||||||
|
self.category = match compiled.category {
|
||||||
|
beamdsp::ast::CategoryKind::Generator => NodeCategory::Generator,
|
||||||
|
beamdsp::ast::CategoryKind::Effect => NodeCategory::Effect,
|
||||||
|
beamdsp::ast::CategoryKind::Utility => NodeCategory::Utility,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.script_name = compiled.name.clone();
|
||||||
|
self.vm = compiled.vm;
|
||||||
|
self.source_code = compiled.source;
|
||||||
|
self.ui_declaration = compiled.ui_declaration.clone();
|
||||||
|
|
||||||
|
Ok(compiled.ui_declaration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set audio data for a sample slot
|
||||||
|
pub fn set_sample(&mut self, slot_index: usize, data: Vec<f32>, sample_rate: u32, name: String) {
|
||||||
|
if slot_index < self.vm.sample_slots.len() {
|
||||||
|
let frame_count = data.len() / 2;
|
||||||
|
self.vm.sample_slots[slot_index] = SampleSlot {
|
||||||
|
data,
|
||||||
|
frame_count,
|
||||||
|
sample_rate,
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn source_code(&self) -> &str {
|
||||||
|
&self.source_code
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ui_declaration(&self) -> &beamdsp::UiDeclaration {
|
||||||
|
&self.ui_declaration
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sample_slot_names(&self) -> Vec<String> {
|
||||||
|
self.vm.sample_slots.iter().map(|s| s.name.clone()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioNode for ScriptNode {
|
||||||
|
fn category(&self) -> NodeCategory {
|
||||||
|
self.category
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
let idx = id as usize;
|
||||||
|
if idx < self.vm.params.len() {
|
||||||
|
self.vm.params[idx] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_parameter(&self, id: u32) -> f32 {
|
||||||
|
let idx = id as usize;
|
||||||
|
if idx < self.vm.params.len() {
|
||||||
|
self.vm.params[idx]
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine buffer size from output buffer length
|
||||||
|
let buffer_size = outputs[0].len();
|
||||||
|
|
||||||
|
// Execute VM — on error, zero all outputs (fail silent on audio thread)
|
||||||
|
if let Err(_) = self.vm.execute(inputs, outputs, sample_rate, buffer_size) {
|
||||||
|
for out in outputs.iter_mut() {
|
||||||
|
out.fill(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.vm.reset_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_type(&self) -> &str {
|
||||||
|
"Script"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_node(&self) -> Box<dyn AudioNode> {
|
||||||
|
let mut cloned = Self {
|
||||||
|
name: self.name.clone(),
|
||||||
|
script_name: self.script_name.clone(),
|
||||||
|
inputs: self.inputs.clone(),
|
||||||
|
outputs: self.outputs.clone(),
|
||||||
|
parameters: self.parameters.clone(),
|
||||||
|
category: self.category,
|
||||||
|
vm: self.vm.clone(),
|
||||||
|
source_code: self.source_code.clone(),
|
||||||
|
ui_declaration: self.ui_declaration.clone(),
|
||||||
|
};
|
||||||
|
cloned.vm.reset_state();
|
||||||
|
Box::new(cloned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any(&self) -> &dyn std::any::Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -123,6 +123,10 @@ pub struct SerializedNode {
|
||||||
/// For sampler nodes: loaded sample data
|
/// For sampler nodes: loaded sample data
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub sample_data: Option<SampleData>,
|
pub sample_data: Option<SampleData>,
|
||||||
|
|
||||||
|
/// For Script nodes: BeamDSP source code
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub script_source: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialized group definition (frontend-only visual grouping, stored opaquely by backend)
|
/// Serialized group definition (frontend-only visual grouping, stored opaquely by backend)
|
||||||
|
|
@ -217,6 +221,7 @@ impl SerializedNode {
|
||||||
position: (0.0, 0.0),
|
position: (0.0, 0.0),
|
||||||
template_graph: None,
|
template_graph: None,
|
||||||
sample_data: None,
|
sample_data: None,
|
||||||
|
script_source: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,11 @@ pub enum Command {
|
||||||
/// Save a VoiceAllocator's template graph as a preset (track_id, voice_allocator_id, preset_path, preset_name)
|
/// Save a VoiceAllocator's template graph as a preset (track_id, voice_allocator_id, preset_path, preset_name)
|
||||||
GraphSaveTemplatePreset(TrackId, u32, String, String),
|
GraphSaveTemplatePreset(TrackId, u32, String, String),
|
||||||
|
|
||||||
|
/// Compile and set a BeamDSP script on a Script node (track_id, node_id, source_code)
|
||||||
|
GraphSetScript(TrackId, u32, String),
|
||||||
|
/// Load audio sample data into a Script node's sample slot (track_id, node_id, slot_index, audio_data, sample_rate, name)
|
||||||
|
GraphSetScriptSample(TrackId, u32, usize, Vec<f32>, u32, String),
|
||||||
|
|
||||||
/// Load a sample into a SimpleSampler node (track_id, node_id, file_path)
|
/// Load a sample into a SimpleSampler node (track_id, node_id, file_path)
|
||||||
SamplerLoadSample(TrackId, u32, String),
|
SamplerLoadSample(TrackId, u32, String),
|
||||||
/// Load a sample from the audio pool into a SimpleSampler node (track_id, node_id, pool_index)
|
/// Load a sample from the audio pool into a SimpleSampler node (track_id, node_id, pool_index)
|
||||||
|
|
@ -266,6 +271,16 @@ pub enum AudioEvent {
|
||||||
GraphPresetLoaded(TrackId),
|
GraphPresetLoaded(TrackId),
|
||||||
/// Preset has been saved to file (track_id, preset_path)
|
/// Preset has been saved to file (track_id, preset_path)
|
||||||
GraphPresetSaved(TrackId, String),
|
GraphPresetSaved(TrackId, String),
|
||||||
|
/// Script compilation result (track_id, node_id, success, error, ui_declaration, source)
|
||||||
|
ScriptCompiled {
|
||||||
|
track_id: TrackId,
|
||||||
|
node_id: u32,
|
||||||
|
success: bool,
|
||||||
|
error: Option<String>,
|
||||||
|
ui_declaration: Option<beamdsp::UiDeclaration>,
|
||||||
|
source: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Export progress (frames_rendered, total_frames)
|
/// Export progress (frames_rendered, total_frames)
|
||||||
ExportProgress {
|
ExportProgress {
|
||||||
frames_rendered: usize,
|
frames_rendered: usize,
|
||||||
|
|
|
||||||
|
|
@ -678,6 +678,13 @@ version = "1.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beamdsp"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bincode"
|
name = "bincode"
|
||||||
version = "1.3.3"
|
version = "1.3.3"
|
||||||
|
|
@ -1655,6 +1662,7 @@ name = "daw-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"beamdsp",
|
||||||
"cpal",
|
"cpal",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dasp_envelope",
|
"dasp_envelope",
|
||||||
|
|
@ -3438,6 +3446,7 @@ dependencies = [
|
||||||
name = "lightningbeam-editor"
|
name = "lightningbeam-editor"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"beamdsp",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"clap",
|
"clap",
|
||||||
"cpal",
|
"cpal",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"lightningbeam-editor",
|
"lightningbeam-editor",
|
||||||
"lightningbeam-core",
|
"lightningbeam-core",
|
||||||
|
"beamdsp",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|
@ -49,6 +50,9 @@ notify-rust = "4.11"
|
||||||
[profile.dev.package.daw-backend]
|
[profile.dev.package.daw-backend]
|
||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|
||||||
|
[profile.dev.package.beamdsp]
|
||||||
|
opt-level = 2
|
||||||
|
|
||||||
# Also optimize symphonia (audio decoder) and cpal (audio I/O) — these
|
# Also optimize symphonia (audio decoder) and cpal (audio I/O) — these
|
||||||
# run in the audio callback path and are heavily numeric.
|
# run in the audio callback path and are heavily numeric.
|
||||||
[profile.dev.package.symphonia]
|
[profile.dev.package.symphonia]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,613 @@
|
||||||
|
# BeamDSP Language Reference
|
||||||
|
|
||||||
|
BeamDSP is a domain-specific language for writing audio processing scripts in Lightningbeam. Scripts are compiled to bytecode and run on the real-time audio thread with guaranteed bounded execution time and constant memory usage.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```
|
||||||
|
name "Simple Gain"
|
||||||
|
category effect
|
||||||
|
|
||||||
|
inputs {
|
||||||
|
audio_in: audio
|
||||||
|
}
|
||||||
|
|
||||||
|
outputs {
|
||||||
|
audio_out: audio
|
||||||
|
}
|
||||||
|
|
||||||
|
params {
|
||||||
|
gain: 1.0 [0.0, 2.0] ""
|
||||||
|
}
|
||||||
|
|
||||||
|
process {
|
||||||
|
for i in 0..buffer_size {
|
||||||
|
audio_out[i * 2] = audio_in[i * 2] * gain;
|
||||||
|
audio_out[i * 2 + 1] = audio_in[i * 2 + 1] * gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Save this as a `.bdsp` file or create it directly in the Script Editor pane.
|
||||||
|
|
||||||
|
## Script Structure
|
||||||
|
|
||||||
|
A BeamDSP script is composed of **header blocks** followed by a **process block**. All blocks are optional except `name`, `category`, and `process`.
|
||||||
|
|
||||||
|
```
|
||||||
|
name "Display Name"
|
||||||
|
category effect|generator|utility
|
||||||
|
|
||||||
|
inputs { ... }
|
||||||
|
outputs { ... }
|
||||||
|
params { ... }
|
||||||
|
state { ... }
|
||||||
|
ui { ... }
|
||||||
|
process { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### name
|
||||||
|
|
||||||
|
```
|
||||||
|
name "My Effect"
|
||||||
|
```
|
||||||
|
|
||||||
|
Sets the display name shown in the node graph.
|
||||||
|
|
||||||
|
### category
|
||||||
|
|
||||||
|
```
|
||||||
|
category effect
|
||||||
|
```
|
||||||
|
|
||||||
|
One of:
|
||||||
|
- **`effect`** — Processes audio (has inputs and outputs)
|
||||||
|
- **`generator`** — Produces audio or CV (outputs only, no audio inputs)
|
||||||
|
- **`utility`** — Signal routing, mixing, or other utility functions
|
||||||
|
|
||||||
|
### inputs
|
||||||
|
|
||||||
|
Declares input ports. Each input has a name and signal type.
|
||||||
|
|
||||||
|
```
|
||||||
|
inputs {
|
||||||
|
audio_in: audio
|
||||||
|
mod_signal: cv
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Signal types:
|
||||||
|
- **`audio`** — Stereo interleaved audio (2 samples per frame: left, right)
|
||||||
|
- **`cv`** — Mono control voltage (1 sample per frame, NaN when unconnected)
|
||||||
|
|
||||||
|
### outputs
|
||||||
|
|
||||||
|
Declares output ports. Same syntax as inputs.
|
||||||
|
|
||||||
|
```
|
||||||
|
outputs {
|
||||||
|
audio_out: audio
|
||||||
|
env_out: cv
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### params
|
||||||
|
|
||||||
|
Declares user-adjustable parameters. Each parameter has a default value, range, and unit string.
|
||||||
|
|
||||||
|
```
|
||||||
|
params {
|
||||||
|
frequency: 440.0 [20.0, 20000.0] "Hz"
|
||||||
|
gain: 1.0 [0.0, 2.0] ""
|
||||||
|
mix: 0.5 [0.0, 1.0] ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Format: `name: default [min, max] "unit"`
|
||||||
|
|
||||||
|
Parameters appear as sliders in the node's UI. They are read-only inside the `process` block.
|
||||||
|
|
||||||
|
### state
|
||||||
|
|
||||||
|
Declares persistent variables that survive across process calls. State is zero-initialized and can be reset.
|
||||||
|
|
||||||
|
```
|
||||||
|
state {
|
||||||
|
phase: f32
|
||||||
|
counter: int
|
||||||
|
active: bool
|
||||||
|
buffer: [44100]f32
|
||||||
|
indices: [16]int
|
||||||
|
clip: sample
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Types:
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `f32` | 32-bit float |
|
||||||
|
| `int` | 32-bit signed integer |
|
||||||
|
| `bool` | Boolean |
|
||||||
|
| `[N]f32` | Fixed-size float array (N is a constant) |
|
||||||
|
| `[N]int` | Fixed-size integer array (N is a constant) |
|
||||||
|
| `sample` | Loadable audio sample (stereo interleaved, read-only in process) |
|
||||||
|
|
||||||
|
State arrays are allocated once at compile time and never resized. The `sample` type holds audio data loaded through the node's UI.
|
||||||
|
|
||||||
|
### ui
|
||||||
|
|
||||||
|
Declares the layout of controls rendered below the node in the graph editor. If omitted, a default UI is generated with sliders for all parameters and pickers for all samples.
|
||||||
|
|
||||||
|
```
|
||||||
|
ui {
|
||||||
|
sample clip
|
||||||
|
param frequency
|
||||||
|
param gain
|
||||||
|
group "Mix" {
|
||||||
|
param mix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Elements:
|
||||||
|
| Element | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `param name` | Slider for the named parameter |
|
||||||
|
| `sample name` | Audio clip picker for the named sample state variable |
|
||||||
|
| `group "label" { ... }` | Collapsible section containing child elements |
|
||||||
|
|
||||||
|
### process
|
||||||
|
|
||||||
|
The process block runs once per audio callback, processing all frames in the current buffer.
|
||||||
|
|
||||||
|
```
|
||||||
|
process {
|
||||||
|
for i in 0..buffer_size {
|
||||||
|
audio_out[i * 2] = audio_in[i * 2];
|
||||||
|
audio_out[i * 2 + 1] = audio_in[i * 2 + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
BeamDSP has three scalar types:
|
||||||
|
|
||||||
|
| Type | Description | Literal examples |
|
||||||
|
|------|-------------|-----------------|
|
||||||
|
| `f32` | 32-bit float | `1.0`, `0.5`, `3.14` |
|
||||||
|
| `int` | 32-bit signed integer | `0`, `42`, `256` |
|
||||||
|
| `bool` | Boolean | `true`, `false` |
|
||||||
|
|
||||||
|
Type conversions use cast syntax:
|
||||||
|
- `int(expr)` — Convert float to integer (truncates toward zero)
|
||||||
|
- `float(expr)` — Convert integer to float
|
||||||
|
|
||||||
|
Arithmetic between `int` and `f32` promotes the result to `f32`.
|
||||||
|
|
||||||
|
## Variables
|
||||||
|
|
||||||
|
### Local variables
|
||||||
|
|
||||||
|
```
|
||||||
|
let x = 1.0;
|
||||||
|
let mut counter = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `let` to declare a local variable. Add `mut` to allow reassignment. Local variables exist only within the current block scope.
|
||||||
|
|
||||||
|
### Built-in variables
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `sample_rate` | `int` | Audio sample rate in Hz (e.g., 44100) |
|
||||||
|
| `buffer_size` | `int` | Number of frames in the current buffer |
|
||||||
|
|
||||||
|
### Inputs and outputs
|
||||||
|
|
||||||
|
Input and output ports are accessed as arrays:
|
||||||
|
|
||||||
|
```
|
||||||
|
// Audio is stereo interleaved: [L0, R0, L1, R1, ...]
|
||||||
|
let left = audio_in[i * 2];
|
||||||
|
let right = audio_in[i * 2 + 1];
|
||||||
|
audio_out[i * 2] = left;
|
||||||
|
audio_out[i * 2 + 1] = right;
|
||||||
|
|
||||||
|
// CV is mono: one sample per frame
|
||||||
|
let mod_value = mod_in[i];
|
||||||
|
cv_out[i] = mod_value;
|
||||||
|
```
|
||||||
|
|
||||||
|
Input arrays are read-only. Output arrays are write-only.
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
Parameters are available as read-only `f32` variables:
|
||||||
|
|
||||||
|
```
|
||||||
|
audio_out[i * 2] = audio_in[i * 2] * gain;
|
||||||
|
```
|
||||||
|
|
||||||
|
### State variables
|
||||||
|
|
||||||
|
State scalars and arrays are mutable and persist across calls:
|
||||||
|
|
||||||
|
```
|
||||||
|
state {
|
||||||
|
phase: f32
|
||||||
|
buffer: [1024]f32
|
||||||
|
}
|
||||||
|
|
||||||
|
process {
|
||||||
|
phase = phase + 0.01;
|
||||||
|
buffer[0] = phase;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Control Flow
|
||||||
|
|
||||||
|
### if / else
|
||||||
|
|
||||||
|
```
|
||||||
|
if phase >= 1.0 {
|
||||||
|
phase = phase - 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if value > threshold {
|
||||||
|
audio_out[i * 2] = 1.0;
|
||||||
|
} else {
|
||||||
|
audio_out[i * 2] = 0.0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### for loops
|
||||||
|
|
||||||
|
For loops iterate from 0 to an upper bound (exclusive). The loop variable is an immutable `int`.
|
||||||
|
|
||||||
|
```
|
||||||
|
for i in 0..buffer_size {
|
||||||
|
// i goes from 0 to buffer_size - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for j in 0..len(buffer) {
|
||||||
|
buffer[j] = 0.0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The upper bound must be an integer expression. Typically `buffer_size`, `len(array)`, or a constant.
|
||||||
|
|
||||||
|
There are no `while` loops, no recursion, and no user-defined functions. This is by design — it guarantees bounded execution time on the audio thread.
|
||||||
|
|
||||||
|
## Operators
|
||||||
|
|
||||||
|
### Arithmetic
|
||||||
|
| Operator | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `+` | Addition |
|
||||||
|
| `-` | Subtraction (binary) or negation (unary) |
|
||||||
|
| `*` | Multiplication |
|
||||||
|
| `/` | Division |
|
||||||
|
| `%` | Modulo |
|
||||||
|
|
||||||
|
### Comparison
|
||||||
|
| Operator | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `==` | Equal |
|
||||||
|
| `!=` | Not equal |
|
||||||
|
| `<` | Less than |
|
||||||
|
| `>` | Greater than |
|
||||||
|
| `<=` | Less than or equal |
|
||||||
|
| `>=` | Greater than or equal |
|
||||||
|
|
||||||
|
### Logical
|
||||||
|
| Operator | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `&&` | Logical AND |
|
||||||
|
| `\|\|` | Logical OR |
|
||||||
|
| `!` | Logical NOT (unary) |
|
||||||
|
|
||||||
|
## Built-in Functions
|
||||||
|
|
||||||
|
### Trigonometric
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `sin(x)` | Sine |
|
||||||
|
| `cos(x)` | Cosine |
|
||||||
|
| `tan(x)` | Tangent |
|
||||||
|
| `asin(x)` | Arc sine |
|
||||||
|
| `acos(x)` | Arc cosine |
|
||||||
|
| `atan(x)` | Arc tangent |
|
||||||
|
| `atan2(y, x)` | Two-argument arc tangent |
|
||||||
|
|
||||||
|
### Exponential
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `exp(x)` | e^x |
|
||||||
|
| `log(x)` | Natural logarithm |
|
||||||
|
| `log2(x)` | Base-2 logarithm |
|
||||||
|
| `pow(x, y)` | x raised to power y |
|
||||||
|
| `sqrt(x)` | Square root |
|
||||||
|
|
||||||
|
### Rounding
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `floor(x)` | Round toward negative infinity |
|
||||||
|
| `ceil(x)` | Round toward positive infinity |
|
||||||
|
| `round(x)` | Round to nearest integer |
|
||||||
|
| `trunc(x)` | Round toward zero |
|
||||||
|
| `fract(x)` | Fractional part (x - floor(x)) |
|
||||||
|
|
||||||
|
### Clamping and interpolation
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `abs(x)` | Absolute value |
|
||||||
|
| `sign(x)` | Sign (-1.0, 0.0, or 1.0) |
|
||||||
|
| `min(x, y)` | Minimum of two values |
|
||||||
|
| `max(x, y)` | Maximum of two values |
|
||||||
|
| `clamp(x, lo, hi)` | Clamp x to [lo, hi] |
|
||||||
|
| `mix(a, b, t)` | Linear interpolation: a*(1-t) + b*t |
|
||||||
|
| `smoothstep(edge0, edge1, x)` | Hermite interpolation between 0 and 1 |
|
||||||
|
|
||||||
|
### Array
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `len(array)` | Length of a state array (returns `int`) |
|
||||||
|
|
||||||
|
### CV
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `cv_or(value, default)` | Returns `default` if `value` is NaN (unconnected CV), otherwise returns `value` |
|
||||||
|
|
||||||
|
### Sample
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `sample_len(s)` | Number of frames in sample (0 if unloaded, returns `int`) |
|
||||||
|
| `sample_read(s, index)` | Read sample data at index (0.0 if out of bounds, returns `f32`) |
|
||||||
|
| `sample_rate_of(s)` | Original sample rate of the loaded audio (returns `int`) |
|
||||||
|
|
||||||
|
Sample data is stereo interleaved, so frame N has left at index `N*2` and right at `N*2+1`.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
```
|
||||||
|
// This is a line comment
|
||||||
|
let x = 1.0; // Inline comment
|
||||||
|
```
|
||||||
|
|
||||||
|
Line comments start with `//` and extend to the end of the line.
|
||||||
|
|
||||||
|
## Semicolons
|
||||||
|
|
||||||
|
Semicolons are **optional** statement terminators. You can use them or omit them.
|
||||||
|
|
||||||
|
```
|
||||||
|
let x = 1.0; // with semicolons
|
||||||
|
let y = 2.0
|
||||||
|
|
||||||
|
audio_out[0] = x + y
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Stereo Delay
|
||||||
|
|
||||||
|
```
|
||||||
|
name "Stereo Delay"
|
||||||
|
category effect
|
||||||
|
|
||||||
|
inputs {
|
||||||
|
audio_in: audio
|
||||||
|
}
|
||||||
|
|
||||||
|
outputs {
|
||||||
|
audio_out: audio
|
||||||
|
}
|
||||||
|
|
||||||
|
params {
|
||||||
|
delay_time: 0.5 [0.01, 2.0] "s"
|
||||||
|
feedback: 0.3 [0.0, 0.95] ""
|
||||||
|
mix: 0.5 [0.0, 1.0] ""
|
||||||
|
}
|
||||||
|
|
||||||
|
state {
|
||||||
|
buffer: [88200]f32
|
||||||
|
write_pos: int
|
||||||
|
}
|
||||||
|
|
||||||
|
ui {
|
||||||
|
param delay_time
|
||||||
|
param feedback
|
||||||
|
param mix
|
||||||
|
}
|
||||||
|
|
||||||
|
process {
|
||||||
|
let delay_samples = int(delay_time * float(sample_rate)) * 2;
|
||||||
|
for i in 0..buffer_size {
|
||||||
|
let l = audio_in[i * 2];
|
||||||
|
let r = audio_in[i * 2 + 1];
|
||||||
|
let read_pos = (write_pos - delay_samples + len(buffer)) % len(buffer);
|
||||||
|
let dl = buffer[read_pos];
|
||||||
|
let dr = buffer[read_pos + 1];
|
||||||
|
buffer[write_pos] = l + dl * feedback;
|
||||||
|
buffer[write_pos + 1] = r + dr * feedback;
|
||||||
|
write_pos = (write_pos + 2) % len(buffer);
|
||||||
|
audio_out[i * 2] = l * (1.0 - mix) + dl * mix;
|
||||||
|
audio_out[i * 2 + 1] = r * (1.0 - mix) + dr * mix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sine Oscillator
|
||||||
|
|
||||||
|
```
|
||||||
|
name "Sine Oscillator"
|
||||||
|
category generator
|
||||||
|
|
||||||
|
outputs {
|
||||||
|
audio_out: audio
|
||||||
|
}
|
||||||
|
|
||||||
|
params {
|
||||||
|
frequency: 440.0 [20.0, 20000.0] "Hz"
|
||||||
|
amplitude: 0.5 [0.0, 1.0] ""
|
||||||
|
}
|
||||||
|
|
||||||
|
state {
|
||||||
|
phase: f32
|
||||||
|
}
|
||||||
|
|
||||||
|
ui {
|
||||||
|
param frequency
|
||||||
|
param amplitude
|
||||||
|
}
|
||||||
|
|
||||||
|
process {
|
||||||
|
let inc = frequency / float(sample_rate);
|
||||||
|
for i in 0..buffer_size {
|
||||||
|
let sample = sin(phase * 6.2831853) * amplitude;
|
||||||
|
audio_out[i * 2] = sample;
|
||||||
|
audio_out[i * 2 + 1] = sample;
|
||||||
|
phase = phase + inc;
|
||||||
|
if phase >= 1.0 {
|
||||||
|
phase = phase - 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sample Player
|
||||||
|
|
||||||
|
```
|
||||||
|
name "One-Shot Player"
|
||||||
|
category generator
|
||||||
|
|
||||||
|
outputs {
|
||||||
|
audio_out: audio
|
||||||
|
}
|
||||||
|
|
||||||
|
params {
|
||||||
|
speed: 1.0 [0.1, 4.0] ""
|
||||||
|
}
|
||||||
|
|
||||||
|
state {
|
||||||
|
clip: sample
|
||||||
|
phase: f32
|
||||||
|
}
|
||||||
|
|
||||||
|
ui {
|
||||||
|
sample clip
|
||||||
|
param speed
|
||||||
|
}
|
||||||
|
|
||||||
|
process {
|
||||||
|
let frames = sample_len(clip);
|
||||||
|
for i in 0..buffer_size {
|
||||||
|
let idx = int(phase) * 2;
|
||||||
|
audio_out[i * 2] = sample_read(clip, idx);
|
||||||
|
audio_out[i * 2 + 1] = sample_read(clip, idx + 1);
|
||||||
|
phase = phase + speed;
|
||||||
|
if phase >= float(frames) {
|
||||||
|
phase = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CV-Controlled Filter (Tone Control)
|
||||||
|
|
||||||
|
```
|
||||||
|
name "Tone Control"
|
||||||
|
category effect
|
||||||
|
|
||||||
|
inputs {
|
||||||
|
audio_in: audio
|
||||||
|
cutoff_cv: cv
|
||||||
|
}
|
||||||
|
|
||||||
|
outputs {
|
||||||
|
audio_out: audio
|
||||||
|
}
|
||||||
|
|
||||||
|
params {
|
||||||
|
cutoff: 1000.0 [20.0, 20000.0] "Hz"
|
||||||
|
resonance: 0.5 [0.0, 1.0] ""
|
||||||
|
}
|
||||||
|
|
||||||
|
state {
|
||||||
|
lp_l: f32
|
||||||
|
lp_r: f32
|
||||||
|
}
|
||||||
|
|
||||||
|
ui {
|
||||||
|
param cutoff
|
||||||
|
param resonance
|
||||||
|
}
|
||||||
|
|
||||||
|
process {
|
||||||
|
for i in 0..buffer_size {
|
||||||
|
let cv_mod = cv_or(cutoff_cv[i], 0.0);
|
||||||
|
let freq = clamp(cutoff + cv_mod * 5000.0, 20.0, 20000.0);
|
||||||
|
let rc = 1.0 / (6.2831853 * freq);
|
||||||
|
let dt = 1.0 / float(sample_rate);
|
||||||
|
let alpha = dt / (rc + dt);
|
||||||
|
|
||||||
|
let l = audio_in[i * 2];
|
||||||
|
let r = audio_in[i * 2 + 1];
|
||||||
|
|
||||||
|
lp_l = lp_l + alpha * (l - lp_l);
|
||||||
|
lp_r = lp_r + alpha * (r - lp_r);
|
||||||
|
|
||||||
|
audio_out[i * 2] = lp_l;
|
||||||
|
audio_out[i * 2 + 1] = lp_r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### LFO
|
||||||
|
|
||||||
|
```
|
||||||
|
name "LFO"
|
||||||
|
category generator
|
||||||
|
|
||||||
|
outputs {
|
||||||
|
cv_out: cv
|
||||||
|
}
|
||||||
|
|
||||||
|
params {
|
||||||
|
rate: 1.0 [0.01, 20.0] "Hz"
|
||||||
|
depth: 1.0 [0.0, 1.0] ""
|
||||||
|
}
|
||||||
|
|
||||||
|
state {
|
||||||
|
phase: f32
|
||||||
|
}
|
||||||
|
|
||||||
|
ui {
|
||||||
|
param rate
|
||||||
|
param depth
|
||||||
|
}
|
||||||
|
|
||||||
|
process {
|
||||||
|
let inc = rate / float(sample_rate);
|
||||||
|
for i in 0..buffer_size {
|
||||||
|
cv_out[i] = sin(phase * 6.2831853) * depth;
|
||||||
|
phase = phase + inc;
|
||||||
|
if phase >= 1.0 {
|
||||||
|
phase = phase - 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safety Model
|
||||||
|
|
||||||
|
BeamDSP scripts run on the real-time audio thread. The language enforces safety through compile-time restrictions:
|
||||||
|
|
||||||
|
- **Bounded time**: Only `for i in 0..N` loops with statically bounded N. No `while` loops, no recursion, no user-defined functions. An instruction counter limit (~10 million) acts as a safety net.
|
||||||
|
- **Constant memory**: All state arrays have compile-time sizes. The VM uses a fixed-size stack (256 slots) and fixed locals (64 slots). No heap allocation occurs during processing.
|
||||||
|
- **Fail-silent**: If the VM encounters a runtime error (stack overflow, instruction limit exceeded), all outputs are zeroed for that buffer. Audio does not glitch — it simply goes silent.
|
||||||
|
|
||||||
|
## File Format
|
||||||
|
|
||||||
|
BeamDSP scripts use the `.bdsp` file extension. Files are plain UTF-8 text. You can export and import `.bdsp` files through the Script Editor pane or the node graph's script picker dropdown.
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
[package]
|
||||||
|
name = "beamdsp"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
use crate::token::Span;
|
||||||
|
use crate::ui_decl::UiElement;
|
||||||
|
|
||||||
|
/// Top-level script AST
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Script {
|
||||||
|
pub name: String,
|
||||||
|
pub category: CategoryKind,
|
||||||
|
pub inputs: Vec<PortDecl>,
|
||||||
|
pub outputs: Vec<PortDecl>,
|
||||||
|
pub params: Vec<ParamDecl>,
|
||||||
|
pub state: Vec<StateDecl>,
|
||||||
|
pub ui: Option<Vec<UiElement>>,
|
||||||
|
pub process: Block,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CategoryKind {
|
||||||
|
Generator,
|
||||||
|
Effect,
|
||||||
|
Utility,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SignalKind {
|
||||||
|
Audio,
|
||||||
|
Cv,
|
||||||
|
Midi,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PortDecl {
|
||||||
|
pub name: String,
|
||||||
|
pub signal: SignalKind,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ParamDecl {
|
||||||
|
pub name: String,
|
||||||
|
pub default: f32,
|
||||||
|
pub min: f32,
|
||||||
|
pub max: f32,
|
||||||
|
pub unit: String,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StateDecl {
|
||||||
|
pub name: String,
|
||||||
|
pub ty: StateType,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum StateType {
|
||||||
|
F32,
|
||||||
|
Int,
|
||||||
|
Bool,
|
||||||
|
ArrayF32(usize),
|
||||||
|
ArrayInt(usize),
|
||||||
|
Sample,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Block = Vec<Stmt>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Stmt {
|
||||||
|
Let {
|
||||||
|
name: String,
|
||||||
|
mutable: bool,
|
||||||
|
init: Expr,
|
||||||
|
span: Span,
|
||||||
|
},
|
||||||
|
Assign {
|
||||||
|
target: LValue,
|
||||||
|
value: Expr,
|
||||||
|
span: Span,
|
||||||
|
},
|
||||||
|
If {
|
||||||
|
cond: Expr,
|
||||||
|
then_block: Block,
|
||||||
|
else_block: Option<Block>,
|
||||||
|
span: Span,
|
||||||
|
},
|
||||||
|
For {
|
||||||
|
var: String,
|
||||||
|
end: Expr,
|
||||||
|
body: Block,
|
||||||
|
span: Span,
|
||||||
|
},
|
||||||
|
ExprStmt(Expr),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum LValue {
|
||||||
|
Ident(String, Span),
|
||||||
|
Index(String, Box<Expr>, Span),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Expr {
|
||||||
|
FloatLit(f32, Span),
|
||||||
|
IntLit(i32, Span),
|
||||||
|
BoolLit(bool, Span),
|
||||||
|
Ident(String, Span),
|
||||||
|
BinOp(Box<Expr>, BinOp, Box<Expr>, Span),
|
||||||
|
UnaryOp(UnaryOp, Box<Expr>, Span),
|
||||||
|
Call(String, Vec<Expr>, Span),
|
||||||
|
Index(Box<Expr>, Box<Expr>, Span),
|
||||||
|
Cast(CastKind, Box<Expr>, Span),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Expr {
|
||||||
|
pub fn span(&self) -> Span {
|
||||||
|
match self {
|
||||||
|
Expr::FloatLit(_, s) => *s,
|
||||||
|
Expr::IntLit(_, s) => *s,
|
||||||
|
Expr::BoolLit(_, s) => *s,
|
||||||
|
Expr::Ident(_, s) => *s,
|
||||||
|
Expr::BinOp(_, _, _, s) => *s,
|
||||||
|
Expr::UnaryOp(_, _, s) => *s,
|
||||||
|
Expr::Call(_, _, s) => *s,
|
||||||
|
Expr::Index(_, _, s) => *s,
|
||||||
|
Expr::Cast(_, _, s) => *s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BinOp {
|
||||||
|
Add,
|
||||||
|
Sub,
|
||||||
|
Mul,
|
||||||
|
Div,
|
||||||
|
Mod,
|
||||||
|
Eq,
|
||||||
|
Ne,
|
||||||
|
Lt,
|
||||||
|
Gt,
|
||||||
|
Le,
|
||||||
|
Ge,
|
||||||
|
And,
|
||||||
|
Or,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum UnaryOp {
|
||||||
|
Neg,
|
||||||
|
Not,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CastKind {
|
||||||
|
ToInt,
|
||||||
|
ToFloat,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,991 @@
|
||||||
|
use crate::ast::*;
|
||||||
|
use crate::error::CompileError;
|
||||||
|
use crate::opcodes::OpCode;
|
||||||
|
use crate::token::Span;
|
||||||
|
use crate::ui_decl::{UiDeclaration, UiElement};
|
||||||
|
use crate::vm::ScriptVM;
|
||||||
|
|
||||||
|
/// Type tracked during codegen to select typed opcodes
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum VType {
|
||||||
|
F32,
|
||||||
|
Int,
|
||||||
|
Bool,
|
||||||
|
ArrayF32,
|
||||||
|
ArrayInt,
|
||||||
|
Sample,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a named variable lives in the VM
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum VarLoc {
|
||||||
|
Local(u16, VType),
|
||||||
|
Param(u16),
|
||||||
|
StateScalar(u16, VType),
|
||||||
|
InputBuffer(u8),
|
||||||
|
OutputBuffer(u8),
|
||||||
|
StateArray(u16, VType), // VType is the element type
|
||||||
|
SampleSlot(u8),
|
||||||
|
BuiltinSampleRate,
|
||||||
|
BuiltinBufferSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Compiler {
|
||||||
|
code: Vec<u8>,
|
||||||
|
constants_f32: Vec<f32>,
|
||||||
|
constants_i32: Vec<i32>,
|
||||||
|
vars: Vec<(String, VarLoc)>,
|
||||||
|
next_local: u16,
|
||||||
|
scope_stack: Vec<u16>, // local count at scope entry
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Compiler {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
code: Vec::new(),
|
||||||
|
constants_f32: Vec::new(),
|
||||||
|
constants_i32: Vec::new(),
|
||||||
|
vars: Vec::new(),
|
||||||
|
next_local: 0,
|
||||||
|
scope_stack: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(&mut self, op: OpCode) {
|
||||||
|
self.code.push(op as u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_u8(&mut self, v: u8) {
|
||||||
|
self.code.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_u16(&mut self, v: u16) {
|
||||||
|
self.code.extend_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_u32(&mut self, v: u32) {
|
||||||
|
self.code.extend_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns index into constants_f32
|
||||||
|
fn add_const_f32(&mut self, v: f32) -> u16 {
|
||||||
|
// Reuse existing constant if possible
|
||||||
|
for (i, &c) in self.constants_f32.iter().enumerate() {
|
||||||
|
if c.to_bits() == v.to_bits() {
|
||||||
|
return i as u16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let idx = self.constants_f32.len() as u16;
|
||||||
|
self.constants_f32.push(v);
|
||||||
|
idx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns index into constants_i32
|
||||||
|
fn add_const_i32(&mut self, v: i32) -> u16 {
|
||||||
|
for (i, &c) in self.constants_i32.iter().enumerate() {
|
||||||
|
if c == v {
|
||||||
|
return i as u16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let idx = self.constants_i32.len() as u16;
|
||||||
|
self.constants_i32.push(v);
|
||||||
|
idx
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_scope(&mut self) {
|
||||||
|
self.scope_stack.push(self.next_local);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pop_scope(&mut self) {
|
||||||
|
let prev = self.scope_stack.pop().unwrap();
|
||||||
|
// Remove variables defined in this scope
|
||||||
|
self.vars.retain(|(_, loc)| {
|
||||||
|
if let VarLoc::Local(idx, _) = loc {
|
||||||
|
*idx < prev
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.next_local = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alloc_local(&mut self, name: String, ty: VType) -> u16 {
|
||||||
|
let idx = self.next_local;
|
||||||
|
self.next_local += 1;
|
||||||
|
self.vars.push((name, VarLoc::Local(idx, ty)));
|
||||||
|
idx
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup(&self, name: &str) -> Option<VarLoc> {
|
||||||
|
self.vars.iter().rev().find(|(n, _)| n == name).map(|(_, l)| *l)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a placeholder u32 and return the offset where it was written
|
||||||
|
fn emit_jump_placeholder(&mut self, op: OpCode) -> usize {
|
||||||
|
self.emit(op);
|
||||||
|
let pos = self.code.len();
|
||||||
|
self.emit_u32(0);
|
||||||
|
pos
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patch a previously emitted u32 placeholder
|
||||||
|
fn patch_jump(&mut self, placeholder_pos: usize) {
|
||||||
|
let target = self.code.len() as u32;
|
||||||
|
let bytes = target.to_le_bytes();
|
||||||
|
self.code[placeholder_pos] = bytes[0];
|
||||||
|
self.code[placeholder_pos + 1] = bytes[1];
|
||||||
|
self.code[placeholder_pos + 2] = bytes[2];
|
||||||
|
self.code[placeholder_pos + 3] = bytes[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_script(&mut self, script: &Script) -> Result<(), CompileError> {
|
||||||
|
// Register built-in variables
|
||||||
|
self.vars.push(("sample_rate".into(), VarLoc::BuiltinSampleRate));
|
||||||
|
self.vars.push(("buffer_size".into(), VarLoc::BuiltinBufferSize));
|
||||||
|
|
||||||
|
// Register inputs
|
||||||
|
for (i, input) in script.inputs.iter().enumerate() {
|
||||||
|
match input.signal {
|
||||||
|
SignalKind::Audio | SignalKind::Cv => {
|
||||||
|
self.vars.push((input.name.clone(), VarLoc::InputBuffer(i as u8)));
|
||||||
|
}
|
||||||
|
SignalKind::Midi => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register outputs
|
||||||
|
for (i, output) in script.outputs.iter().enumerate() {
|
||||||
|
match output.signal {
|
||||||
|
SignalKind::Audio | SignalKind::Cv => {
|
||||||
|
self.vars.push((output.name.clone(), VarLoc::OutputBuffer(i as u8)));
|
||||||
|
}
|
||||||
|
SignalKind::Midi => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register params
|
||||||
|
for (i, param) in script.params.iter().enumerate() {
|
||||||
|
self.vars.push((param.name.clone(), VarLoc::Param(i as u16)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register state variables
|
||||||
|
let mut scalar_idx: u16 = 0;
|
||||||
|
let mut array_idx: u16 = 0;
|
||||||
|
let mut sample_idx: u8 = 0;
|
||||||
|
for state in &script.state {
|
||||||
|
match &state.ty {
|
||||||
|
StateType::F32 => {
|
||||||
|
self.vars.push((state.name.clone(), VarLoc::StateScalar(scalar_idx, VType::F32)));
|
||||||
|
scalar_idx += 1;
|
||||||
|
}
|
||||||
|
StateType::Int => {
|
||||||
|
self.vars.push((state.name.clone(), VarLoc::StateScalar(scalar_idx, VType::Int)));
|
||||||
|
scalar_idx += 1;
|
||||||
|
}
|
||||||
|
StateType::Bool => {
|
||||||
|
self.vars.push((state.name.clone(), VarLoc::StateScalar(scalar_idx, VType::Bool)));
|
||||||
|
scalar_idx += 1;
|
||||||
|
}
|
||||||
|
StateType::ArrayF32(_) => {
|
||||||
|
self.vars.push((state.name.clone(), VarLoc::StateArray(array_idx, VType::F32)));
|
||||||
|
array_idx += 1;
|
||||||
|
}
|
||||||
|
StateType::ArrayInt(_) => {
|
||||||
|
self.vars.push((state.name.clone(), VarLoc::StateArray(array_idx, VType::Int)));
|
||||||
|
array_idx += 1;
|
||||||
|
}
|
||||||
|
StateType::Sample => {
|
||||||
|
self.vars.push((state.name.clone(), VarLoc::SampleSlot(sample_idx)));
|
||||||
|
sample_idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile process block
|
||||||
|
for stmt in &script.process {
|
||||||
|
self.compile_stmt(stmt)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.emit(OpCode::Halt);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_stmt(&mut self, stmt: &Stmt) -> Result<(), CompileError> {
|
||||||
|
match stmt {
|
||||||
|
Stmt::Let { name, init, .. } => {
|
||||||
|
let ty = self.infer_type(init)?;
|
||||||
|
self.compile_expr(init)?;
|
||||||
|
let _idx = self.alloc_local(name.clone(), ty);
|
||||||
|
self.emit(OpCode::StoreLocal);
|
||||||
|
self.emit_u16(_idx);
|
||||||
|
}
|
||||||
|
Stmt::Assign { target, value, span } => {
|
||||||
|
match target {
|
||||||
|
LValue::Ident(name, _) => {
|
||||||
|
let loc = self.lookup(name).ok_or_else(|| {
|
||||||
|
CompileError::new(format!("Undefined variable: {}", name), *span)
|
||||||
|
})?;
|
||||||
|
self.compile_expr(value)?;
|
||||||
|
match loc {
|
||||||
|
VarLoc::Local(idx, _) => {
|
||||||
|
self.emit(OpCode::StoreLocal);
|
||||||
|
self.emit_u16(idx);
|
||||||
|
}
|
||||||
|
VarLoc::StateScalar(idx, _) => {
|
||||||
|
self.emit(OpCode::StoreState);
|
||||||
|
self.emit_u16(idx);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
format!("Cannot assign to {}", name), *span,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LValue::Index(name, idx_expr, s) => {
|
||||||
|
let loc = self.lookup(name).ok_or_else(|| {
|
||||||
|
CompileError::new(format!("Undefined variable: {}", name), *s)
|
||||||
|
})?;
|
||||||
|
match loc {
|
||||||
|
VarLoc::OutputBuffer(port) => {
|
||||||
|
// StoreOutput: pops value then index
|
||||||
|
self.compile_expr(idx_expr)?;
|
||||||
|
self.compile_expr(value)?;
|
||||||
|
self.emit(OpCode::StoreOutput);
|
||||||
|
self.emit_u8(port);
|
||||||
|
}
|
||||||
|
VarLoc::StateArray(arr_id, _) => {
|
||||||
|
// StoreStateArray: pops value then index
|
||||||
|
self.compile_expr(idx_expr)?;
|
||||||
|
self.compile_expr(value)?;
|
||||||
|
self.emit(OpCode::StoreStateArray);
|
||||||
|
self.emit_u16(arr_id);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
format!("Cannot index-assign to {}", name), *s,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Stmt::If { cond, then_block, else_block, .. } => {
|
||||||
|
self.compile_expr(cond)?;
|
||||||
|
if let Some(else_b) = else_block {
|
||||||
|
// JumpIfFalse -> else
|
||||||
|
let else_jump = self.emit_jump_placeholder(OpCode::JumpIfFalse);
|
||||||
|
self.push_scope();
|
||||||
|
self.compile_block(then_block)?;
|
||||||
|
self.pop_scope();
|
||||||
|
// Jump -> end (skip else)
|
||||||
|
let end_jump = self.emit_jump_placeholder(OpCode::Jump);
|
||||||
|
self.patch_jump(else_jump);
|
||||||
|
self.push_scope();
|
||||||
|
self.compile_block(else_b)?;
|
||||||
|
self.pop_scope();
|
||||||
|
self.patch_jump(end_jump);
|
||||||
|
} else {
|
||||||
|
let end_jump = self.emit_jump_placeholder(OpCode::JumpIfFalse);
|
||||||
|
self.push_scope();
|
||||||
|
self.compile_block(then_block)?;
|
||||||
|
self.pop_scope();
|
||||||
|
self.patch_jump(end_jump);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Stmt::For { var, end, body, span: _ } => {
|
||||||
|
// Allocate loop variable as local
|
||||||
|
self.push_scope();
|
||||||
|
let loop_var = self.alloc_local(var.clone(), VType::Int);
|
||||||
|
|
||||||
|
// Initialize loop var to 0
|
||||||
|
let zero_idx = self.add_const_i32(0);
|
||||||
|
self.emit(OpCode::PushI32);
|
||||||
|
self.emit_u16(zero_idx);
|
||||||
|
self.emit(OpCode::StoreLocal);
|
||||||
|
self.emit_u16(loop_var);
|
||||||
|
|
||||||
|
// Loop start: check condition (i < end)
|
||||||
|
let loop_start = self.code.len();
|
||||||
|
self.emit(OpCode::LoadLocal);
|
||||||
|
self.emit_u16(loop_var);
|
||||||
|
self.compile_expr(end)?;
|
||||||
|
self.emit(OpCode::LtI);
|
||||||
|
|
||||||
|
let exit_jump = self.emit_jump_placeholder(OpCode::JumpIfFalse);
|
||||||
|
|
||||||
|
// Body
|
||||||
|
self.compile_block(body)?;
|
||||||
|
|
||||||
|
// Increment loop var
|
||||||
|
self.emit(OpCode::LoadLocal);
|
||||||
|
self.emit_u16(loop_var);
|
||||||
|
let one_idx = self.add_const_i32(1);
|
||||||
|
self.emit(OpCode::PushI32);
|
||||||
|
self.emit_u16(one_idx);
|
||||||
|
self.emit(OpCode::AddI);
|
||||||
|
self.emit(OpCode::StoreLocal);
|
||||||
|
self.emit_u16(loop_var);
|
||||||
|
|
||||||
|
// Jump back to loop start
|
||||||
|
self.emit(OpCode::Jump);
|
||||||
|
self.emit_u32(loop_start as u32);
|
||||||
|
|
||||||
|
// Patch exit
|
||||||
|
self.patch_jump(exit_jump);
|
||||||
|
self.pop_scope();
|
||||||
|
}
|
||||||
|
Stmt::ExprStmt(expr) => {
|
||||||
|
self.compile_expr(expr)?;
|
||||||
|
self.emit(OpCode::Pop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_block(&mut self, block: &[Stmt]) -> Result<(), CompileError> {
|
||||||
|
for stmt in block {
|
||||||
|
self.compile_stmt(stmt)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_expr(&mut self, expr: &Expr) -> Result<(), CompileError> {
|
||||||
|
match expr {
|
||||||
|
Expr::FloatLit(v, _) => {
|
||||||
|
let idx = self.add_const_f32(*v);
|
||||||
|
self.emit(OpCode::PushF32);
|
||||||
|
self.emit_u16(idx);
|
||||||
|
}
|
||||||
|
Expr::IntLit(v, _) => {
|
||||||
|
let idx = self.add_const_i32(*v);
|
||||||
|
self.emit(OpCode::PushI32);
|
||||||
|
self.emit_u16(idx);
|
||||||
|
}
|
||||||
|
Expr::BoolLit(v, _) => {
|
||||||
|
self.emit(OpCode::PushBool);
|
||||||
|
self.emit_u8(if *v { 1 } else { 0 });
|
||||||
|
}
|
||||||
|
Expr::Ident(name, span) => {
|
||||||
|
let loc = self.lookup(name).ok_or_else(|| {
|
||||||
|
CompileError::new(format!("Undefined variable: {}", name), *span)
|
||||||
|
})?;
|
||||||
|
match loc {
|
||||||
|
VarLoc::Local(idx, _) => {
|
||||||
|
self.emit(OpCode::LoadLocal);
|
||||||
|
self.emit_u16(idx);
|
||||||
|
}
|
||||||
|
VarLoc::Param(idx) => {
|
||||||
|
self.emit(OpCode::LoadParam);
|
||||||
|
self.emit_u16(idx);
|
||||||
|
}
|
||||||
|
VarLoc::StateScalar(idx, _) => {
|
||||||
|
self.emit(OpCode::LoadState);
|
||||||
|
self.emit_u16(idx);
|
||||||
|
}
|
||||||
|
VarLoc::BuiltinSampleRate => {
|
||||||
|
self.emit(OpCode::LoadSampleRate);
|
||||||
|
}
|
||||||
|
VarLoc::BuiltinBufferSize => {
|
||||||
|
self.emit(OpCode::LoadBufferSize);
|
||||||
|
}
|
||||||
|
// Arrays/buffers/samples used bare (for len(), etc.) — handled by call codegen
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::BinOp(left, op, right, _span) => {
|
||||||
|
let lt = self.infer_type(left)?;
|
||||||
|
let rt = self.infer_type(right)?;
|
||||||
|
self.compile_expr(left)?;
|
||||||
|
self.compile_expr(right)?;
|
||||||
|
|
||||||
|
match op {
|
||||||
|
BinOp::Add => {
|
||||||
|
if lt == VType::F32 || rt == VType::F32 {
|
||||||
|
self.emit(OpCode::AddF);
|
||||||
|
} else {
|
||||||
|
self.emit(OpCode::AddI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BinOp::Sub => {
|
||||||
|
if lt == VType::F32 || rt == VType::F32 {
|
||||||
|
self.emit(OpCode::SubF);
|
||||||
|
} else {
|
||||||
|
self.emit(OpCode::SubI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BinOp::Mul => {
|
||||||
|
if lt == VType::F32 || rt == VType::F32 {
|
||||||
|
self.emit(OpCode::MulF);
|
||||||
|
} else {
|
||||||
|
self.emit(OpCode::MulI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BinOp::Div => {
|
||||||
|
if lt == VType::F32 || rt == VType::F32 {
|
||||||
|
self.emit(OpCode::DivF);
|
||||||
|
} else {
|
||||||
|
self.emit(OpCode::DivI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BinOp::Mod => {
|
||||||
|
if lt == VType::F32 || rt == VType::F32 {
|
||||||
|
self.emit(OpCode::ModF);
|
||||||
|
} else {
|
||||||
|
self.emit(OpCode::ModI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BinOp::Eq => {
|
||||||
|
if lt == VType::F32 || rt == VType::F32 {
|
||||||
|
self.emit(OpCode::EqF);
|
||||||
|
} else if lt == VType::Int || rt == VType::Int {
|
||||||
|
self.emit(OpCode::EqI);
|
||||||
|
} else {
|
||||||
|
// bool comparison: treat as int
|
||||||
|
self.emit(OpCode::EqI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BinOp::Ne => {
|
||||||
|
if lt == VType::F32 || rt == VType::F32 {
|
||||||
|
self.emit(OpCode::NeF);
|
||||||
|
} else {
|
||||||
|
self.emit(OpCode::NeI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BinOp::Lt => {
|
||||||
|
if lt == VType::F32 || rt == VType::F32 {
|
||||||
|
self.emit(OpCode::LtF);
|
||||||
|
} else {
|
||||||
|
self.emit(OpCode::LtI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BinOp::Gt => {
|
||||||
|
if lt == VType::F32 || rt == VType::F32 {
|
||||||
|
self.emit(OpCode::GtF);
|
||||||
|
} else {
|
||||||
|
self.emit(OpCode::GtI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BinOp::Le => {
|
||||||
|
if lt == VType::F32 || rt == VType::F32 {
|
||||||
|
self.emit(OpCode::LeF);
|
||||||
|
} else {
|
||||||
|
self.emit(OpCode::LeI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BinOp::Ge => {
|
||||||
|
if lt == VType::F32 || rt == VType::F32 {
|
||||||
|
self.emit(OpCode::GeF);
|
||||||
|
} else {
|
||||||
|
self.emit(OpCode::GeI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BinOp::And => self.emit(OpCode::And),
|
||||||
|
BinOp::Or => self.emit(OpCode::Or),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::UnaryOp(op, inner, _) => {
|
||||||
|
let ty = self.infer_type(inner)?;
|
||||||
|
self.compile_expr(inner)?;
|
||||||
|
match op {
|
||||||
|
UnaryOp::Neg => {
|
||||||
|
if ty == VType::F32 {
|
||||||
|
self.emit(OpCode::NegF);
|
||||||
|
} else {
|
||||||
|
self.emit(OpCode::NegI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UnaryOp::Not => self.emit(OpCode::Not),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Cast(kind, inner, _) => {
|
||||||
|
self.compile_expr(inner)?;
|
||||||
|
match kind {
|
||||||
|
CastKind::ToInt => self.emit(OpCode::F32ToI32),
|
||||||
|
CastKind::ToFloat => self.emit(OpCode::I32ToF32),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Index(base, idx, span) => {
|
||||||
|
// base must be an Ident referencing an array/buffer
|
||||||
|
if let Expr::Ident(name, _) = base.as_ref() {
|
||||||
|
let loc = self.lookup(name).ok_or_else(|| {
|
||||||
|
CompileError::new(format!("Undefined variable: {}", name), *span)
|
||||||
|
})?;
|
||||||
|
match loc {
|
||||||
|
VarLoc::InputBuffer(port) => {
|
||||||
|
self.compile_expr(idx)?;
|
||||||
|
self.emit(OpCode::LoadInput);
|
||||||
|
self.emit_u8(port);
|
||||||
|
}
|
||||||
|
VarLoc::OutputBuffer(port) => {
|
||||||
|
self.compile_expr(idx)?;
|
||||||
|
self.emit(OpCode::LoadInput);
|
||||||
|
// Reading from output buffer — use same port but from outputs
|
||||||
|
// Actually outputs aren't readable in the VM. This would be
|
||||||
|
// an error in practice, but the validator should catch it.
|
||||||
|
// For now, treat as input read (will read zeros).
|
||||||
|
self.emit_u8(port);
|
||||||
|
}
|
||||||
|
VarLoc::StateArray(arr_id, _) => {
|
||||||
|
self.compile_expr(idx)?;
|
||||||
|
self.emit(OpCode::LoadStateArray);
|
||||||
|
self.emit_u16(arr_id);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
format!("Cannot index variable: {}", name), *span,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(CompileError::new("Index base must be an identifier", *span));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Call(name, args, span) => {
|
||||||
|
self.compile_call(name, args, *span)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_call(&mut self, name: &str, args: &[Expr], span: Span) -> Result<(), CompileError> {
|
||||||
|
match name {
|
||||||
|
// 1-arg math → push arg, emit opcode
|
||||||
|
"sin" => { self.compile_expr(&args[0])?; self.emit(OpCode::Sin); }
|
||||||
|
"cos" => { self.compile_expr(&args[0])?; self.emit(OpCode::Cos); }
|
||||||
|
"tan" => { self.compile_expr(&args[0])?; self.emit(OpCode::Tan); }
|
||||||
|
"asin" => { self.compile_expr(&args[0])?; self.emit(OpCode::Asin); }
|
||||||
|
"acos" => { self.compile_expr(&args[0])?; self.emit(OpCode::Acos); }
|
||||||
|
"atan" => { self.compile_expr(&args[0])?; self.emit(OpCode::Atan); }
|
||||||
|
"exp" => { self.compile_expr(&args[0])?; self.emit(OpCode::Exp); }
|
||||||
|
"log" => { self.compile_expr(&args[0])?; self.emit(OpCode::Log); }
|
||||||
|
"log2" => { self.compile_expr(&args[0])?; self.emit(OpCode::Log2); }
|
||||||
|
"sqrt" => { self.compile_expr(&args[0])?; self.emit(OpCode::Sqrt); }
|
||||||
|
"floor" => { self.compile_expr(&args[0])?; self.emit(OpCode::Floor); }
|
||||||
|
"ceil" => { self.compile_expr(&args[0])?; self.emit(OpCode::Ceil); }
|
||||||
|
"round" => { self.compile_expr(&args[0])?; self.emit(OpCode::Round); }
|
||||||
|
"trunc" => { self.compile_expr(&args[0])?; self.emit(OpCode::Trunc); }
|
||||||
|
"fract" => { self.compile_expr(&args[0])?; self.emit(OpCode::Fract); }
|
||||||
|
"abs" => { self.compile_expr(&args[0])?; self.emit(OpCode::Abs); }
|
||||||
|
"sign" => { self.compile_expr(&args[0])?; self.emit(OpCode::Sign); }
|
||||||
|
|
||||||
|
// 2-arg math
|
||||||
|
"atan2" => {
|
||||||
|
self.compile_expr(&args[0])?;
|
||||||
|
self.compile_expr(&args[1])?;
|
||||||
|
self.emit(OpCode::Atan2);
|
||||||
|
}
|
||||||
|
"pow" => {
|
||||||
|
self.compile_expr(&args[0])?;
|
||||||
|
self.compile_expr(&args[1])?;
|
||||||
|
self.emit(OpCode::Pow);
|
||||||
|
}
|
||||||
|
"min" => {
|
||||||
|
self.compile_expr(&args[0])?;
|
||||||
|
self.compile_expr(&args[1])?;
|
||||||
|
self.emit(OpCode::Min);
|
||||||
|
}
|
||||||
|
"max" => {
|
||||||
|
self.compile_expr(&args[0])?;
|
||||||
|
self.compile_expr(&args[1])?;
|
||||||
|
self.emit(OpCode::Max);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3-arg math
|
||||||
|
"clamp" => {
|
||||||
|
self.compile_expr(&args[0])?;
|
||||||
|
self.compile_expr(&args[1])?;
|
||||||
|
self.compile_expr(&args[2])?;
|
||||||
|
self.emit(OpCode::Clamp);
|
||||||
|
}
|
||||||
|
"mix" => {
|
||||||
|
self.compile_expr(&args[0])?;
|
||||||
|
self.compile_expr(&args[1])?;
|
||||||
|
self.compile_expr(&args[2])?;
|
||||||
|
self.emit(OpCode::Mix);
|
||||||
|
}
|
||||||
|
"smoothstep" => {
|
||||||
|
self.compile_expr(&args[0])?;
|
||||||
|
self.compile_expr(&args[1])?;
|
||||||
|
self.compile_expr(&args[2])?;
|
||||||
|
self.emit(OpCode::Smoothstep);
|
||||||
|
}
|
||||||
|
|
||||||
|
// cv_or(value, default) — if value is NaN, use default
|
||||||
|
"cv_or" => {
|
||||||
|
// Compile: push value, check IsNan, if true use default else keep value
|
||||||
|
// Strategy: push value, dup-like via local, IsNan, branch
|
||||||
|
// Simpler: push value, push value again, IsNan, JumpIfFalse skip, Pop, push default, skip:
|
||||||
|
// But we don't have Dup. Use a temp local instead.
|
||||||
|
let temp = self.next_local;
|
||||||
|
self.next_local += 1;
|
||||||
|
self.compile_expr(&args[0])?;
|
||||||
|
// Store to temp
|
||||||
|
self.emit(OpCode::StoreLocal);
|
||||||
|
self.emit_u16(temp);
|
||||||
|
// Load and check NaN
|
||||||
|
self.emit(OpCode::LoadLocal);
|
||||||
|
self.emit_u16(temp);
|
||||||
|
self.emit(OpCode::IsNan);
|
||||||
|
let skip_default = self.emit_jump_placeholder(OpCode::JumpIfFalse);
|
||||||
|
// NaN path: use default
|
||||||
|
self.compile_expr(&args[1])?;
|
||||||
|
let skip_end = self.emit_jump_placeholder(OpCode::Jump);
|
||||||
|
// Not NaN path: use original value
|
||||||
|
self.patch_jump(skip_default);
|
||||||
|
self.emit(OpCode::LoadLocal);
|
||||||
|
self.emit_u16(temp);
|
||||||
|
self.patch_jump(skip_end);
|
||||||
|
self.next_local -= 1; // release temp
|
||||||
|
}
|
||||||
|
|
||||||
|
// len(array) -> int
|
||||||
|
"len" => {
|
||||||
|
// Arg must be an ident referencing a state array or input/output buffer
|
||||||
|
if let Expr::Ident(arr_name, s) = &args[0] {
|
||||||
|
let loc = self.lookup(arr_name).ok_or_else(|| {
|
||||||
|
CompileError::new(format!("Undefined variable: {}", arr_name), *s)
|
||||||
|
})?;
|
||||||
|
match loc {
|
||||||
|
VarLoc::StateArray(arr_id, _) => {
|
||||||
|
self.emit(OpCode::ArrayLen);
|
||||||
|
self.emit_u16(arr_id);
|
||||||
|
}
|
||||||
|
VarLoc::InputBuffer(_) | VarLoc::OutputBuffer(_) => {
|
||||||
|
// Buffer length is buffer_size (for CV) or buffer_size*2 (for audio)
|
||||||
|
// We emit LoadBufferSize — scripts use buffer_size for iteration
|
||||||
|
self.emit(OpCode::LoadBufferSize);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(CompileError::new("len() argument must be an array", span));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(CompileError::new("len() argument must be an identifier", span));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sample_len(sample) -> int
|
||||||
|
"sample_len" => {
|
||||||
|
if let Expr::Ident(sname, s) = &args[0] {
|
||||||
|
let loc = self.lookup(sname).ok_or_else(|| {
|
||||||
|
CompileError::new(format!("Undefined: {}", sname), *s)
|
||||||
|
})?;
|
||||||
|
if let VarLoc::SampleSlot(slot) = loc {
|
||||||
|
self.emit(OpCode::SampleLen);
|
||||||
|
self.emit_u8(slot);
|
||||||
|
} else {
|
||||||
|
return Err(CompileError::new("sample_len() requires a sample", span));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(CompileError::new("sample_len() requires an identifier", span));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sample_read(sample, index) -> f32
|
||||||
|
"sample_read" => {
|
||||||
|
if let Expr::Ident(sname, s) = &args[0] {
|
||||||
|
let loc = self.lookup(sname).ok_or_else(|| {
|
||||||
|
CompileError::new(format!("Undefined: {}", sname), *s)
|
||||||
|
})?;
|
||||||
|
if let VarLoc::SampleSlot(slot) = loc {
|
||||||
|
self.compile_expr(&args[1])?;
|
||||||
|
self.emit(OpCode::SampleRead);
|
||||||
|
self.emit_u8(slot);
|
||||||
|
} else {
|
||||||
|
return Err(CompileError::new("sample_read() requires a sample", span));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(CompileError::new("sample_read() requires an identifier", span));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sample_rate_of(sample) -> int
|
||||||
|
"sample_rate_of" => {
|
||||||
|
if let Expr::Ident(sname, s) = &args[0] {
|
||||||
|
let loc = self.lookup(sname).ok_or_else(|| {
|
||||||
|
CompileError::new(format!("Undefined: {}", sname), *s)
|
||||||
|
})?;
|
||||||
|
if let VarLoc::SampleSlot(slot) = loc {
|
||||||
|
self.emit(OpCode::SampleRateOf);
|
||||||
|
self.emit_u8(slot);
|
||||||
|
} else {
|
||||||
|
return Err(CompileError::new("sample_rate_of() requires a sample", span));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(CompileError::new("sample_rate_of() requires an identifier", span));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
return Err(CompileError::new(format!("Unknown function: {}", name), span));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Infer the type of an expression (mirrors validator logic, needed for selecting typed opcodes)
|
||||||
|
fn infer_type(&self, expr: &Expr) -> Result<VType, CompileError> {
|
||||||
|
match expr {
|
||||||
|
Expr::FloatLit(_, _) => Ok(VType::F32),
|
||||||
|
Expr::IntLit(_, _) => Ok(VType::Int),
|
||||||
|
Expr::BoolLit(_, _) => Ok(VType::Bool),
|
||||||
|
Expr::Ident(name, span) => {
|
||||||
|
let loc = self.lookup(name).ok_or_else(|| {
|
||||||
|
CompileError::new(format!("Undefined variable: {}", name), *span)
|
||||||
|
})?;
|
||||||
|
match loc {
|
||||||
|
VarLoc::Local(_, ty) => Ok(ty),
|
||||||
|
VarLoc::Param(_) => Ok(VType::F32),
|
||||||
|
VarLoc::StateScalar(_, ty) => Ok(ty),
|
||||||
|
VarLoc::InputBuffer(_) => Ok(VType::ArrayF32),
|
||||||
|
VarLoc::OutputBuffer(_) => Ok(VType::ArrayF32),
|
||||||
|
VarLoc::StateArray(_, elem_ty) => {
|
||||||
|
if elem_ty == VType::Int { Ok(VType::ArrayInt) } else { Ok(VType::ArrayF32) }
|
||||||
|
}
|
||||||
|
VarLoc::SampleSlot(_) => Ok(VType::Sample),
|
||||||
|
VarLoc::BuiltinSampleRate => Ok(VType::Int),
|
||||||
|
VarLoc::BuiltinBufferSize => Ok(VType::Int),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::BinOp(left, op, right, _) => {
|
||||||
|
let lt = self.infer_type(left)?;
|
||||||
|
let rt = self.infer_type(right)?;
|
||||||
|
match op {
|
||||||
|
BinOp::And | BinOp::Or | BinOp::Eq | BinOp::Ne |
|
||||||
|
BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => Ok(VType::Bool),
|
||||||
|
_ => {
|
||||||
|
if lt == VType::F32 || rt == VType::F32 {
|
||||||
|
Ok(VType::F32)
|
||||||
|
} else {
|
||||||
|
Ok(VType::Int)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::UnaryOp(op, inner, _) => {
|
||||||
|
match op {
|
||||||
|
UnaryOp::Neg => self.infer_type(inner),
|
||||||
|
UnaryOp::Not => Ok(VType::Bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Cast(kind, _, _) => match kind {
|
||||||
|
CastKind::ToInt => Ok(VType::Int),
|
||||||
|
CastKind::ToFloat => Ok(VType::F32),
|
||||||
|
},
|
||||||
|
Expr::Index(base, _, _) => {
|
||||||
|
let base_ty = self.infer_type(base)?;
|
||||||
|
match base_ty {
|
||||||
|
VType::ArrayF32 => Ok(VType::F32),
|
||||||
|
VType::ArrayInt => Ok(VType::Int),
|
||||||
|
_ => Ok(VType::F32), // fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Call(name, _, _) => {
|
||||||
|
match name.as_str() {
|
||||||
|
"len" | "sample_len" | "sample_rate_of" => Ok(VType::Int),
|
||||||
|
"isnan" => Ok(VType::Bool),
|
||||||
|
_ => Ok(VType::F32), // all math functions return f32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile a validated AST into bytecode VM and UI declaration
|
||||||
|
pub fn compile(script: &Script) -> Result<(ScriptVM, UiDeclaration), CompileError> {
|
||||||
|
let mut compiler = Compiler::new();
|
||||||
|
compiler.compile_script(script)?;
|
||||||
|
|
||||||
|
// Collect state layout info
|
||||||
|
let mut num_state_scalars = 0usize;
|
||||||
|
let mut state_array_sizes = Vec::new();
|
||||||
|
let mut num_sample_slots = 0usize;
|
||||||
|
|
||||||
|
for state in &script.state {
|
||||||
|
match &state.ty {
|
||||||
|
StateType::F32 | StateType::Int | StateType::Bool => {
|
||||||
|
num_state_scalars += 1;
|
||||||
|
}
|
||||||
|
StateType::ArrayF32(sz) => state_array_sizes.push(*sz),
|
||||||
|
StateType::ArrayInt(sz) => state_array_sizes.push(*sz),
|
||||||
|
StateType::Sample => num_sample_slots += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let param_defaults: Vec<f32> = script.params.iter().map(|p| p.default).collect();
|
||||||
|
|
||||||
|
let vm = ScriptVM::new(
|
||||||
|
compiler.code,
|
||||||
|
compiler.constants_f32,
|
||||||
|
compiler.constants_i32,
|
||||||
|
script.params.len(),
|
||||||
|
¶m_defaults,
|
||||||
|
num_state_scalars,
|
||||||
|
&state_array_sizes,
|
||||||
|
num_sample_slots,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build UI declaration
|
||||||
|
let ui_decl = if let Some(elements) = &script.ui {
|
||||||
|
UiDeclaration { elements: elements.clone() }
|
||||||
|
} else {
|
||||||
|
// Auto-generate: sample pickers first, then all params
|
||||||
|
let mut elements = Vec::new();
|
||||||
|
for state in &script.state {
|
||||||
|
if state.ty == StateType::Sample {
|
||||||
|
elements.push(UiElement::Sample(state.name.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for param in &script.params {
|
||||||
|
elements.push(UiElement::Param(param.name.clone()));
|
||||||
|
}
|
||||||
|
UiDeclaration { elements }
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((vm, ui_decl))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::lexer::Lexer;
|
||||||
|
use crate::parser::Parser;
|
||||||
|
use crate::validator;
|
||||||
|
|
||||||
|
fn compile_source(src: &str) -> Result<(ScriptVM, UiDeclaration), CompileError> {
|
||||||
|
let mut lexer = Lexer::new(src);
|
||||||
|
let tokens = lexer.tokenize()?;
|
||||||
|
let mut parser = Parser::new(&tokens);
|
||||||
|
let script = parser.parse()?;
|
||||||
|
let validated = validator::validate(&script)?;
|
||||||
|
compile(validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_passthrough() {
|
||||||
|
let src = r#"
|
||||||
|
name "Pass"
|
||||||
|
category effect
|
||||||
|
inputs { audio_in: audio }
|
||||||
|
outputs { audio_out: audio }
|
||||||
|
process {
|
||||||
|
for i in 0..buffer_size {
|
||||||
|
audio_out[i] = audio_in[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let (mut vm, _) = compile_source(src).unwrap();
|
||||||
|
let input = vec![1.0f32, 2.0, 3.0, 4.0];
|
||||||
|
let mut output = vec![0.0f32; 4];
|
||||||
|
let inputs: Vec<&[f32]> = vec![&input];
|
||||||
|
let mut out_slice: Vec<&mut [f32]> = vec![&mut output];
|
||||||
|
vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap();
|
||||||
|
assert_eq!(output, vec![1.0, 2.0, 3.0, 4.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gain() {
|
||||||
|
let src = r#"
|
||||||
|
name "Gain"
|
||||||
|
category effect
|
||||||
|
inputs { audio_in: audio }
|
||||||
|
outputs { audio_out: audio }
|
||||||
|
params { gain: 0.5 [0.0, 1.0] "" }
|
||||||
|
process {
|
||||||
|
for i in 0..buffer_size {
|
||||||
|
audio_out[i] = audio_in[i] * gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let (mut vm, _) = compile_source(src).unwrap();
|
||||||
|
let input = vec![1.0f32, 2.0, 3.0, 4.0];
|
||||||
|
let mut output = vec![0.0f32; 4];
|
||||||
|
let inputs: Vec<&[f32]> = vec![&input];
|
||||||
|
let mut out_slice: Vec<&mut [f32]> = vec![&mut output];
|
||||||
|
vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap();
|
||||||
|
assert_eq!(output, vec![0.5, 1.0, 1.5, 2.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_array() {
|
||||||
|
let src = r#"
|
||||||
|
name "Delay"
|
||||||
|
category effect
|
||||||
|
inputs { audio_in: audio }
|
||||||
|
outputs { audio_out: audio }
|
||||||
|
state { buf: [8]f32 }
|
||||||
|
process {
|
||||||
|
for i in 0..buffer_size {
|
||||||
|
audio_out[i] = buf[i];
|
||||||
|
buf[i] = audio_in[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let (mut vm, _) = compile_source(src).unwrap();
|
||||||
|
|
||||||
|
// First call: output should be zeros (state initialized to 0), state gets input
|
||||||
|
let input = vec![10.0f32, 20.0, 30.0, 40.0];
|
||||||
|
let mut output = vec![0.0f32; 4];
|
||||||
|
{
|
||||||
|
let inputs: Vec<&[f32]> = vec![&input];
|
||||||
|
let mut out_slice: Vec<&mut [f32]> = vec![&mut output];
|
||||||
|
vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(output, vec![0.0, 0.0, 0.0, 0.0]);
|
||||||
|
|
||||||
|
// Second call: output should be previous input
|
||||||
|
let input2 = vec![50.0f32, 60.0, 70.0, 80.0];
|
||||||
|
let mut output2 = vec![0.0f32; 4];
|
||||||
|
{
|
||||||
|
let inputs: Vec<&[f32]> = vec![&input2];
|
||||||
|
let mut out_slice: Vec<&mut [f32]> = vec![&mut output2];
|
||||||
|
vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(output2, vec![10.0, 20.0, 30.0, 40.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_if_else() {
|
||||||
|
let src = r#"
|
||||||
|
name "Gate"
|
||||||
|
category effect
|
||||||
|
inputs { audio_in: audio }
|
||||||
|
outputs { audio_out: audio }
|
||||||
|
params { threshold: 0.5 [0.0, 1.0] "" }
|
||||||
|
process {
|
||||||
|
for i in 0..buffer_size {
|
||||||
|
if audio_in[i] >= threshold {
|
||||||
|
audio_out[i] = audio_in[i];
|
||||||
|
} else {
|
||||||
|
audio_out[i] = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let (mut vm, _) = compile_source(src).unwrap();
|
||||||
|
let input = vec![0.2f32, 0.8, 0.1, 0.9];
|
||||||
|
let mut output = vec![0.0f32; 4];
|
||||||
|
let inputs: Vec<&[f32]> = vec![&input];
|
||||||
|
let mut out_slice: Vec<&mut [f32]> = vec![&mut output];
|
||||||
|
vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap();
|
||||||
|
assert_eq!(output, vec![0.0, 0.8, 0.0, 0.9]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auto_ui() {
|
||||||
|
let src = r#"
|
||||||
|
name "Test"
|
||||||
|
category utility
|
||||||
|
params { gain: 1.0 [0.0, 2.0] "dB" }
|
||||||
|
state { clip: sample }
|
||||||
|
outputs { out: audio }
|
||||||
|
process {}
|
||||||
|
"#;
|
||||||
|
let (_, ui) = compile_source(src).unwrap();
|
||||||
|
// Auto-generated: sample first, then params
|
||||||
|
assert_eq!(ui.elements.len(), 2);
|
||||||
|
assert!(matches!(&ui.elements[0], UiElement::Sample(n) if n == "clip"));
|
||||||
|
assert!(matches!(&ui.elements[1], UiElement::Param(n) if n == "gain"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
use crate::token::Span;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Compile-time error with source location
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompileError {
|
||||||
|
pub message: String,
|
||||||
|
pub span: Span,
|
||||||
|
pub hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompileError {
|
||||||
|
pub fn new(message: impl Into<String>, span: Span) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
span,
|
||||||
|
hint: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||||
|
self.hint = Some(hint.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CompileError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "Error at line {}, col {}: {}", self.span.line, self.span.col, self.message)?;
|
||||||
|
if let Some(hint) = &self.hint {
|
||||||
|
write!(f, "\n Hint: {}", hint)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime error during VM execution
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ScriptError {
|
||||||
|
ExecutionLimitExceeded,
|
||||||
|
StackOverflow,
|
||||||
|
StackUnderflow,
|
||||||
|
DivisionByZero,
|
||||||
|
IndexOutOfBounds { index: i32, len: usize },
|
||||||
|
InvalidOpcode(u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ScriptError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
ScriptError::ExecutionLimitExceeded => write!(f, "Execution limit exceeded (possible infinite loop)"),
|
||||||
|
ScriptError::StackOverflow => write!(f, "Stack overflow"),
|
||||||
|
ScriptError::StackUnderflow => write!(f, "Stack underflow"),
|
||||||
|
ScriptError::DivisionByZero => write!(f, "Division by zero"),
|
||||||
|
ScriptError::IndexOutOfBounds { index, len } => {
|
||||||
|
write!(f, "Index {} out of bounds (length {})", index, len)
|
||||||
|
}
|
||||||
|
ScriptError::InvalidOpcode(op) => write!(f, "Invalid opcode: {}", op),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
use crate::error::CompileError;
|
||||||
|
use crate::token::{Span, Token, TokenKind};
|
||||||
|
|
||||||
|
pub struct Lexer<'a> {
|
||||||
|
source: &'a [u8],
|
||||||
|
pos: usize,
|
||||||
|
line: u32,
|
||||||
|
col: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Lexer<'a> {
|
||||||
|
pub fn new(source: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
source: source.as_bytes(),
|
||||||
|
pos: 0,
|
||||||
|
line: 1,
|
||||||
|
col: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tokenize(&mut self) -> Result<Vec<Token>, CompileError> {
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
loop {
|
||||||
|
self.skip_whitespace_and_comments();
|
||||||
|
if self.pos >= self.source.len() {
|
||||||
|
tokens.push(Token {
|
||||||
|
kind: TokenKind::Eof,
|
||||||
|
span: self.span(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokens.push(self.next_token()?);
|
||||||
|
}
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn span(&self) -> Span {
|
||||||
|
Span::new(self.line, self.col)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek(&self) -> Option<u8> {
|
||||||
|
self.source.get(self.pos).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek_next(&self) -> Option<u8> {
|
||||||
|
self.source.get(self.pos + 1).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance(&mut self) -> u8 {
|
||||||
|
let ch = self.source[self.pos];
|
||||||
|
self.pos += 1;
|
||||||
|
if ch == b'\n' {
|
||||||
|
self.line += 1;
|
||||||
|
self.col = 1;
|
||||||
|
} else {
|
||||||
|
self.col += 1;
|
||||||
|
}
|
||||||
|
ch
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_whitespace_and_comments(&mut self) {
|
||||||
|
loop {
|
||||||
|
// Skip whitespace
|
||||||
|
while self.pos < self.source.len() && self.source[self.pos].is_ascii_whitespace() {
|
||||||
|
self.advance();
|
||||||
|
}
|
||||||
|
// Skip line comments
|
||||||
|
if self.pos + 1 < self.source.len()
|
||||||
|
&& self.source[self.pos] == b'/'
|
||||||
|
&& self.source[self.pos + 1] == b'/'
|
||||||
|
{
|
||||||
|
while self.pos < self.source.len() && self.source[self.pos] != b'\n' {
|
||||||
|
self.advance();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_token(&mut self) -> Result<Token, CompileError> {
|
||||||
|
let span = self.span();
|
||||||
|
let ch = self.advance();
|
||||||
|
|
||||||
|
match ch {
|
||||||
|
b'{' => Ok(Token { kind: TokenKind::LBrace, span }),
|
||||||
|
b'}' => Ok(Token { kind: TokenKind::RBrace, span }),
|
||||||
|
b'[' => Ok(Token { kind: TokenKind::LBracket, span }),
|
||||||
|
b']' => Ok(Token { kind: TokenKind::RBracket, span }),
|
||||||
|
b'(' => Ok(Token { kind: TokenKind::LParen, span }),
|
||||||
|
b')' => Ok(Token { kind: TokenKind::RParen, span }),
|
||||||
|
b':' => Ok(Token { kind: TokenKind::Colon, span }),
|
||||||
|
b',' => Ok(Token { kind: TokenKind::Comma, span }),
|
||||||
|
b';' => Ok(Token { kind: TokenKind::Semicolon, span }),
|
||||||
|
b'+' => Ok(Token { kind: TokenKind::Plus, span }),
|
||||||
|
b'-' => Ok(Token { kind: TokenKind::Minus, span }),
|
||||||
|
b'*' => Ok(Token { kind: TokenKind::Star, span }),
|
||||||
|
b'/' => Ok(Token { kind: TokenKind::Slash, span }),
|
||||||
|
b'%' => Ok(Token { kind: TokenKind::Percent, span }),
|
||||||
|
|
||||||
|
b'.' if self.peek() == Some(b'.') => {
|
||||||
|
self.advance();
|
||||||
|
Ok(Token { kind: TokenKind::DotDot, span })
|
||||||
|
}
|
||||||
|
|
||||||
|
b'=' if self.peek() == Some(b'=') => {
|
||||||
|
self.advance();
|
||||||
|
Ok(Token { kind: TokenKind::EqEq, span })
|
||||||
|
}
|
||||||
|
b'=' => Ok(Token { kind: TokenKind::Eq, span }),
|
||||||
|
|
||||||
|
b'!' if self.peek() == Some(b'=') => {
|
||||||
|
self.advance();
|
||||||
|
Ok(Token { kind: TokenKind::BangEq, span })
|
||||||
|
}
|
||||||
|
b'!' => Ok(Token { kind: TokenKind::Bang, span }),
|
||||||
|
|
||||||
|
b'<' if self.peek() == Some(b'=') => {
|
||||||
|
self.advance();
|
||||||
|
Ok(Token { kind: TokenKind::LtEq, span })
|
||||||
|
}
|
||||||
|
b'<' => Ok(Token { kind: TokenKind::Lt, span }),
|
||||||
|
|
||||||
|
b'>' if self.peek() == Some(b'=') => {
|
||||||
|
self.advance();
|
||||||
|
Ok(Token { kind: TokenKind::GtEq, span })
|
||||||
|
}
|
||||||
|
b'>' => Ok(Token { kind: TokenKind::Gt, span }),
|
||||||
|
|
||||||
|
b'&' if self.peek() == Some(b'&') => {
|
||||||
|
self.advance();
|
||||||
|
Ok(Token { kind: TokenKind::AmpAmp, span })
|
||||||
|
}
|
||||||
|
|
||||||
|
b'|' if self.peek() == Some(b'|') => {
|
||||||
|
self.advance();
|
||||||
|
Ok(Token { kind: TokenKind::PipePipe, span })
|
||||||
|
}
|
||||||
|
|
||||||
|
b'"' => self.read_string(span),
|
||||||
|
|
||||||
|
ch if ch.is_ascii_digit() => self.read_number(ch, span),
|
||||||
|
|
||||||
|
ch if ch.is_ascii_alphabetic() || ch == b'_' => self.read_ident(ch, span),
|
||||||
|
|
||||||
|
_ => Err(CompileError::new(
|
||||||
|
format!("Unexpected character: '{}'", ch as char),
|
||||||
|
span,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_string(&mut self, span: Span) -> Result<Token, CompileError> {
|
||||||
|
let mut s = String::new();
|
||||||
|
loop {
|
||||||
|
match self.peek() {
|
||||||
|
Some(b'"') => {
|
||||||
|
self.advance();
|
||||||
|
return Ok(Token {
|
||||||
|
kind: TokenKind::StringLit(s),
|
||||||
|
span,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(b'\n') | None => {
|
||||||
|
return Err(CompileError::new("Unterminated string literal", span));
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
s.push(self.advance() as char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_number(&mut self, first: u8, span: Span) -> Result<Token, CompileError> {
|
||||||
|
let mut s = String::new();
|
||||||
|
s.push(first as char);
|
||||||
|
let mut is_float = false;
|
||||||
|
|
||||||
|
while let Some(ch) = self.peek() {
|
||||||
|
if ch.is_ascii_digit() {
|
||||||
|
s.push(self.advance() as char);
|
||||||
|
} else if ch == b'.' && self.peek_next() != Some(b'.') && !is_float {
|
||||||
|
is_float = true;
|
||||||
|
s.push(self.advance() as char);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_float {
|
||||||
|
let val: f32 = s
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| CompileError::new(format!("Invalid float literal: {}", s), span))?;
|
||||||
|
Ok(Token {
|
||||||
|
kind: TokenKind::FloatLit(val),
|
||||||
|
span,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let val: i32 = s
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| CompileError::new(format!("Invalid integer literal: {}", s), span))?;
|
||||||
|
// Check if this could be a float (e.g. 0 used in float context)
|
||||||
|
// For now, emit as IntLit; parser/validator handles coercion
|
||||||
|
Ok(Token {
|
||||||
|
kind: TokenKind::IntLit(val),
|
||||||
|
span,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_ident(&mut self, first: u8, span: Span) -> Result<Token, CompileError> {
|
||||||
|
let mut s = String::new();
|
||||||
|
s.push(first as char);
|
||||||
|
|
||||||
|
while let Some(ch) = self.peek() {
|
||||||
|
if ch.is_ascii_alphanumeric() || ch == b'_' {
|
||||||
|
s.push(self.advance() as char);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Token {
|
||||||
|
kind: TokenKind::from_ident(&s),
|
||||||
|
span,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_simple_tokens() {
|
||||||
|
let mut lexer = Lexer::new("name \"Test\" category effect");
|
||||||
|
let tokens = lexer.tokenize().unwrap();
|
||||||
|
assert_eq!(tokens[0].kind, TokenKind::Name);
|
||||||
|
assert_eq!(tokens[1].kind, TokenKind::StringLit("Test".into()));
|
||||||
|
assert_eq!(tokens[2].kind, TokenKind::Category);
|
||||||
|
assert_eq!(tokens[3].kind, TokenKind::Effect);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_numbers() {
|
||||||
|
let mut lexer = Lexer::new("42 3.14 0.5");
|
||||||
|
let tokens = lexer.tokenize().unwrap();
|
||||||
|
assert_eq!(tokens[0].kind, TokenKind::IntLit(42));
|
||||||
|
assert_eq!(tokens[1].kind, TokenKind::FloatLit(3.14));
|
||||||
|
assert_eq!(tokens[2].kind, TokenKind::FloatLit(0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_operators() {
|
||||||
|
let mut lexer = Lexer::new("== != <= >= && || ..");
|
||||||
|
let tokens = lexer.tokenize().unwrap();
|
||||||
|
assert_eq!(tokens[0].kind, TokenKind::EqEq);
|
||||||
|
assert_eq!(tokens[1].kind, TokenKind::BangEq);
|
||||||
|
assert_eq!(tokens[2].kind, TokenKind::LtEq);
|
||||||
|
assert_eq!(tokens[3].kind, TokenKind::GtEq);
|
||||||
|
assert_eq!(tokens[4].kind, TokenKind::AmpAmp);
|
||||||
|
assert_eq!(tokens[5].kind, TokenKind::PipePipe);
|
||||||
|
assert_eq!(tokens[6].kind, TokenKind::DotDot);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_comments() {
|
||||||
|
let mut lexer = Lexer::new("let x = 5; // comment\nlet y = 10;");
|
||||||
|
let tokens = lexer.tokenize().unwrap();
|
||||||
|
// Should skip the comment
|
||||||
|
assert_eq!(tokens[0].kind, TokenKind::Let);
|
||||||
|
assert_eq!(tokens[5].kind, TokenKind::Let);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_range_vs_float() {
|
||||||
|
// "0..10" should parse as IntLit(0), DotDot, IntLit(10), not as a float
|
||||||
|
let mut lexer = Lexer::new("0..10");
|
||||||
|
let tokens = lexer.tokenize().unwrap();
|
||||||
|
assert_eq!(tokens[0].kind, TokenKind::IntLit(0));
|
||||||
|
assert_eq!(tokens[1].kind, TokenKind::DotDot);
|
||||||
|
assert_eq!(tokens[2].kind, TokenKind::IntLit(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
pub mod ast;
|
||||||
|
pub mod error;
|
||||||
|
pub mod lexer;
|
||||||
|
pub mod token;
|
||||||
|
pub mod ui_decl;
|
||||||
|
pub mod parser;
|
||||||
|
pub mod validator;
|
||||||
|
pub mod opcodes;
|
||||||
|
pub mod codegen;
|
||||||
|
pub mod vm;
|
||||||
|
|
||||||
|
use error::CompileError;
|
||||||
|
use lexer::Lexer;
|
||||||
|
use parser::Parser;
|
||||||
|
|
||||||
|
pub use error::ScriptError;
|
||||||
|
pub use ui_decl::{UiDeclaration, UiElement};
|
||||||
|
pub use vm::{ScriptVM, SampleSlot};
|
||||||
|
|
||||||
|
/// Compiled script metadata — everything needed to create a ScriptNode
|
||||||
|
pub struct CompiledScript {
|
||||||
|
pub vm: ScriptVM,
|
||||||
|
pub name: String,
|
||||||
|
pub category: ast::CategoryKind,
|
||||||
|
pub input_ports: Vec<PortInfo>,
|
||||||
|
pub output_ports: Vec<PortInfo>,
|
||||||
|
pub parameters: Vec<ParamInfo>,
|
||||||
|
pub sample_slots: Vec<String>,
|
||||||
|
pub ui_declaration: UiDeclaration,
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PortInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub signal: ast::SignalKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ParamInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub min: f32,
|
||||||
|
pub max: f32,
|
||||||
|
pub default: f32,
|
||||||
|
pub unit: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile BeamDSP source code into a ready-to-run script
|
||||||
|
pub fn compile(source: &str) -> Result<CompiledScript, CompileError> {
|
||||||
|
let mut lexer = Lexer::new(source);
|
||||||
|
let tokens = lexer.tokenize()?;
|
||||||
|
|
||||||
|
let mut parser = Parser::new(&tokens);
|
||||||
|
let script = parser.parse()?;
|
||||||
|
|
||||||
|
let validated = validator::validate(&script)?;
|
||||||
|
|
||||||
|
let (vm, ui_decl) = codegen::compile(&validated)?;
|
||||||
|
|
||||||
|
let input_ports = script
|
||||||
|
.inputs
|
||||||
|
.iter()
|
||||||
|
.map(|p| PortInfo {
|
||||||
|
name: p.name.clone(),
|
||||||
|
signal: p.signal,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let output_ports = script
|
||||||
|
.outputs
|
||||||
|
.iter()
|
||||||
|
.map(|p| PortInfo {
|
||||||
|
name: p.name.clone(),
|
||||||
|
signal: p.signal,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let parameters = script
|
||||||
|
.params
|
||||||
|
.iter()
|
||||||
|
.map(|p| ParamInfo {
|
||||||
|
name: p.name.clone(),
|
||||||
|
min: p.min,
|
||||||
|
max: p.max,
|
||||||
|
default: p.default,
|
||||||
|
unit: p.unit.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let sample_slots = script
|
||||||
|
.state
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.ty == ast::StateType::Sample)
|
||||||
|
.map(|s| s.name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(CompiledScript {
|
||||||
|
vm,
|
||||||
|
name: script.name.clone(),
|
||||||
|
category: script.category,
|
||||||
|
input_ports,
|
||||||
|
output_ports,
|
||||||
|
parameters,
|
||||||
|
sample_slots,
|
||||||
|
ui_declaration: ui_decl,
|
||||||
|
source: source.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
/// Bytecode opcodes for the BeamDSP VM
|
||||||
|
#[repr(u8)]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum OpCode {
|
||||||
|
// Stack operations
|
||||||
|
PushF32 = 0, // next 4 bytes: f32 constant index (u16)
|
||||||
|
PushI32 = 1, // next 2 bytes: i32 constant index (u16)
|
||||||
|
PushBool = 2, // next 1 byte: 0 or 1
|
||||||
|
Pop = 3,
|
||||||
|
|
||||||
|
// Variable access (all use u16 index)
|
||||||
|
LoadLocal = 10,
|
||||||
|
StoreLocal = 11,
|
||||||
|
LoadParam = 12,
|
||||||
|
LoadState = 13,
|
||||||
|
StoreState = 14,
|
||||||
|
|
||||||
|
// Buffer access (u8 port index)
|
||||||
|
// LoadInput: pops index from stack, pushes input[port][index]
|
||||||
|
LoadInput = 20,
|
||||||
|
// StoreOutput: pops value then index, stores output[port][index] = value
|
||||||
|
StoreOutput = 21,
|
||||||
|
// State arrays (u16 array id)
|
||||||
|
LoadStateArray = 22, // pops index, pushes state_array[id][index]
|
||||||
|
StoreStateArray = 23, // pops value then index, stores state_array[id][index]
|
||||||
|
|
||||||
|
// Sample access (u8 slot index)
|
||||||
|
SampleLen = 25, // pushes frame count
|
||||||
|
SampleRead = 26, // pops index, pushes sample data
|
||||||
|
SampleRateOf = 27, // pushes sample rate
|
||||||
|
|
||||||
|
// Float arithmetic
|
||||||
|
AddF = 30,
|
||||||
|
SubF = 31,
|
||||||
|
MulF = 32,
|
||||||
|
DivF = 33,
|
||||||
|
ModF = 34,
|
||||||
|
NegF = 35,
|
||||||
|
|
||||||
|
// Int arithmetic
|
||||||
|
AddI = 40,
|
||||||
|
SubI = 41,
|
||||||
|
MulI = 42,
|
||||||
|
DivI = 43,
|
||||||
|
ModI = 44,
|
||||||
|
NegI = 45,
|
||||||
|
|
||||||
|
// Float comparison (push bool)
|
||||||
|
EqF = 50,
|
||||||
|
NeF = 51,
|
||||||
|
LtF = 52,
|
||||||
|
GtF = 53,
|
||||||
|
LeF = 54,
|
||||||
|
GeF = 55,
|
||||||
|
|
||||||
|
// Int comparison (push bool)
|
||||||
|
EqI = 60,
|
||||||
|
NeI = 61,
|
||||||
|
LtI = 62,
|
||||||
|
GtI = 63,
|
||||||
|
LeI = 64,
|
||||||
|
GeI = 65,
|
||||||
|
|
||||||
|
// Logical
|
||||||
|
And = 70,
|
||||||
|
Or = 71,
|
||||||
|
Not = 72,
|
||||||
|
|
||||||
|
// Type conversion
|
||||||
|
F32ToI32 = 80,
|
||||||
|
I32ToF32 = 81,
|
||||||
|
|
||||||
|
// Control flow (u32 offset)
|
||||||
|
Jump = 90,
|
||||||
|
JumpIfFalse = 91,
|
||||||
|
|
||||||
|
// Built-in math functions (operate on stack)
|
||||||
|
Sin = 100,
|
||||||
|
Cos = 101,
|
||||||
|
Tan = 102,
|
||||||
|
Asin = 103,
|
||||||
|
Acos = 104,
|
||||||
|
Atan = 105,
|
||||||
|
Atan2 = 106,
|
||||||
|
Exp = 107,
|
||||||
|
Log = 108,
|
||||||
|
Log2 = 109,
|
||||||
|
Pow = 110,
|
||||||
|
Sqrt = 111,
|
||||||
|
Floor = 112,
|
||||||
|
Ceil = 113,
|
||||||
|
Round = 114,
|
||||||
|
Trunc = 115,
|
||||||
|
Fract = 116,
|
||||||
|
Abs = 117,
|
||||||
|
Clamp = 118,
|
||||||
|
Min = 119,
|
||||||
|
Max = 120,
|
||||||
|
Sign = 121,
|
||||||
|
Mix = 122,
|
||||||
|
Smoothstep = 123,
|
||||||
|
IsNan = 124,
|
||||||
|
|
||||||
|
// Array/buffer info
|
||||||
|
ArrayLen = 130, // u16 array_id, pushes length as int
|
||||||
|
|
||||||
|
// Built-in constants
|
||||||
|
LoadSampleRate = 140,
|
||||||
|
LoadBufferSize = 141,
|
||||||
|
|
||||||
|
Halt = 255,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpCode {
|
||||||
|
pub fn from_u8(v: u8) -> Option<OpCode> {
|
||||||
|
// Safety: we validate the opcode values
|
||||||
|
match v {
|
||||||
|
0 => Some(OpCode::PushF32),
|
||||||
|
1 => Some(OpCode::PushI32),
|
||||||
|
2 => Some(OpCode::PushBool),
|
||||||
|
3 => Some(OpCode::Pop),
|
||||||
|
10 => Some(OpCode::LoadLocal),
|
||||||
|
11 => Some(OpCode::StoreLocal),
|
||||||
|
12 => Some(OpCode::LoadParam),
|
||||||
|
13 => Some(OpCode::LoadState),
|
||||||
|
14 => Some(OpCode::StoreState),
|
||||||
|
20 => Some(OpCode::LoadInput),
|
||||||
|
21 => Some(OpCode::StoreOutput),
|
||||||
|
22 => Some(OpCode::LoadStateArray),
|
||||||
|
23 => Some(OpCode::StoreStateArray),
|
||||||
|
25 => Some(OpCode::SampleLen),
|
||||||
|
26 => Some(OpCode::SampleRead),
|
||||||
|
27 => Some(OpCode::SampleRateOf),
|
||||||
|
30 => Some(OpCode::AddF),
|
||||||
|
31 => Some(OpCode::SubF),
|
||||||
|
32 => Some(OpCode::MulF),
|
||||||
|
33 => Some(OpCode::DivF),
|
||||||
|
34 => Some(OpCode::ModF),
|
||||||
|
35 => Some(OpCode::NegF),
|
||||||
|
40 => Some(OpCode::AddI),
|
||||||
|
41 => Some(OpCode::SubI),
|
||||||
|
42 => Some(OpCode::MulI),
|
||||||
|
43 => Some(OpCode::DivI),
|
||||||
|
44 => Some(OpCode::ModI),
|
||||||
|
45 => Some(OpCode::NegI),
|
||||||
|
50 => Some(OpCode::EqF),
|
||||||
|
51 => Some(OpCode::NeF),
|
||||||
|
52 => Some(OpCode::LtF),
|
||||||
|
53 => Some(OpCode::GtF),
|
||||||
|
54 => Some(OpCode::LeF),
|
||||||
|
55 => Some(OpCode::GeF),
|
||||||
|
60 => Some(OpCode::EqI),
|
||||||
|
61 => Some(OpCode::NeI),
|
||||||
|
62 => Some(OpCode::LtI),
|
||||||
|
63 => Some(OpCode::GtI),
|
||||||
|
64 => Some(OpCode::LeI),
|
||||||
|
65 => Some(OpCode::GeI),
|
||||||
|
70 => Some(OpCode::And),
|
||||||
|
71 => Some(OpCode::Or),
|
||||||
|
72 => Some(OpCode::Not),
|
||||||
|
80 => Some(OpCode::F32ToI32),
|
||||||
|
81 => Some(OpCode::I32ToF32),
|
||||||
|
90 => Some(OpCode::Jump),
|
||||||
|
91 => Some(OpCode::JumpIfFalse),
|
||||||
|
100 => Some(OpCode::Sin),
|
||||||
|
101 => Some(OpCode::Cos),
|
||||||
|
102 => Some(OpCode::Tan),
|
||||||
|
103 => Some(OpCode::Asin),
|
||||||
|
104 => Some(OpCode::Acos),
|
||||||
|
105 => Some(OpCode::Atan),
|
||||||
|
106 => Some(OpCode::Atan2),
|
||||||
|
107 => Some(OpCode::Exp),
|
||||||
|
108 => Some(OpCode::Log),
|
||||||
|
109 => Some(OpCode::Log2),
|
||||||
|
110 => Some(OpCode::Pow),
|
||||||
|
111 => Some(OpCode::Sqrt),
|
||||||
|
112 => Some(OpCode::Floor),
|
||||||
|
113 => Some(OpCode::Ceil),
|
||||||
|
114 => Some(OpCode::Round),
|
||||||
|
115 => Some(OpCode::Trunc),
|
||||||
|
116 => Some(OpCode::Fract),
|
||||||
|
117 => Some(OpCode::Abs),
|
||||||
|
118 => Some(OpCode::Clamp),
|
||||||
|
119 => Some(OpCode::Min),
|
||||||
|
120 => Some(OpCode::Max),
|
||||||
|
121 => Some(OpCode::Sign),
|
||||||
|
122 => Some(OpCode::Mix),
|
||||||
|
123 => Some(OpCode::Smoothstep),
|
||||||
|
124 => Some(OpCode::IsNan),
|
||||||
|
130 => Some(OpCode::ArrayLen),
|
||||||
|
140 => Some(OpCode::LoadSampleRate),
|
||||||
|
141 => Some(OpCode::LoadBufferSize),
|
||||||
|
255 => Some(OpCode::Halt),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,765 @@
|
||||||
|
use crate::ast::*;
|
||||||
|
use crate::error::CompileError;
|
||||||
|
use crate::token::{Span, Token, TokenKind};
|
||||||
|
use crate::ui_decl::UiElement;
|
||||||
|
|
||||||
|
pub struct Parser<'a> {
|
||||||
|
tokens: &'a [Token],
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Parser<'a> {
|
||||||
|
pub fn new(tokens: &'a [Token]) -> Self {
|
||||||
|
Self { tokens, pos: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek(&self) -> &TokenKind {
|
||||||
|
&self.tokens[self.pos].kind
|
||||||
|
}
|
||||||
|
|
||||||
|
fn span(&self) -> Span {
|
||||||
|
self.tokens[self.pos].span
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance(&mut self) -> &Token {
|
||||||
|
let tok = &self.tokens[self.pos];
|
||||||
|
if self.pos + 1 < self.tokens.len() {
|
||||||
|
self.pos += 1;
|
||||||
|
}
|
||||||
|
tok
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expect(&mut self, expected: &TokenKind) -> Result<&Token, CompileError> {
|
||||||
|
if std::mem::discriminant(self.peek()) == std::mem::discriminant(expected) {
|
||||||
|
Ok(self.advance())
|
||||||
|
} else {
|
||||||
|
Err(CompileError::new(
|
||||||
|
format!("Expected {:?}, found {:?}", expected, self.peek()),
|
||||||
|
self.span(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expect_ident(&mut self) -> Result<String, CompileError> {
|
||||||
|
match self.peek().clone() {
|
||||||
|
TokenKind::Ident(name) => {
|
||||||
|
let name = name.clone();
|
||||||
|
self.advance();
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
_ => Err(CompileError::new(
|
||||||
|
format!("Expected identifier, found {:?}", self.peek()),
|
||||||
|
self.span(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expect_string(&mut self) -> Result<String, CompileError> {
|
||||||
|
match self.peek().clone() {
|
||||||
|
TokenKind::StringLit(s) => {
|
||||||
|
let s = s.clone();
|
||||||
|
self.advance();
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
_ => Err(CompileError::new(
|
||||||
|
format!("Expected string literal, found {:?}", self.peek()),
|
||||||
|
self.span(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eat(&mut self, kind: &TokenKind) -> bool {
|
||||||
|
if std::mem::discriminant(self.peek()) == std::mem::discriminant(kind) {
|
||||||
|
self.advance();
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(&mut self) -> Result<Script, CompileError> {
|
||||||
|
let mut name = String::new();
|
||||||
|
let mut category = CategoryKind::Utility;
|
||||||
|
let mut inputs = Vec::new();
|
||||||
|
let mut outputs = Vec::new();
|
||||||
|
let mut params = Vec::new();
|
||||||
|
let mut state = Vec::new();
|
||||||
|
let mut ui = None;
|
||||||
|
let mut process = Vec::new();
|
||||||
|
|
||||||
|
while *self.peek() != TokenKind::Eof {
|
||||||
|
match self.peek() {
|
||||||
|
TokenKind::Name => {
|
||||||
|
self.advance();
|
||||||
|
name = self.expect_string()?;
|
||||||
|
}
|
||||||
|
TokenKind::Category => {
|
||||||
|
self.advance();
|
||||||
|
category = match self.peek() {
|
||||||
|
TokenKind::Generator => { self.advance(); CategoryKind::Generator }
|
||||||
|
TokenKind::Effect => { self.advance(); CategoryKind::Effect }
|
||||||
|
TokenKind::Utility => { self.advance(); CategoryKind::Utility }
|
||||||
|
_ => {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
"Expected generator, effect, or utility",
|
||||||
|
self.span(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
TokenKind::Inputs => {
|
||||||
|
self.advance();
|
||||||
|
inputs = self.parse_port_block()?;
|
||||||
|
}
|
||||||
|
TokenKind::Outputs => {
|
||||||
|
self.advance();
|
||||||
|
outputs = self.parse_port_block()?;
|
||||||
|
}
|
||||||
|
TokenKind::Params => {
|
||||||
|
self.advance();
|
||||||
|
params = self.parse_params_block()?;
|
||||||
|
}
|
||||||
|
TokenKind::State => {
|
||||||
|
self.advance();
|
||||||
|
state = self.parse_state_block()?;
|
||||||
|
}
|
||||||
|
TokenKind::Ui => {
|
||||||
|
self.advance();
|
||||||
|
ui = Some(self.parse_ui_block()?);
|
||||||
|
}
|
||||||
|
TokenKind::Process => {
|
||||||
|
self.advance();
|
||||||
|
process = self.parse_block()?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
format!("Unexpected token {:?} at top level", self.peek()),
|
||||||
|
self.span(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
"Script must have a name declaration",
|
||||||
|
Span::new(1, 1),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Script {
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
inputs,
|
||||||
|
outputs,
|
||||||
|
params,
|
||||||
|
state,
|
||||||
|
ui,
|
||||||
|
process,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_port_block(&mut self) -> Result<Vec<PortDecl>, CompileError> {
|
||||||
|
self.expect(&TokenKind::LBrace)?;
|
||||||
|
let mut ports = Vec::new();
|
||||||
|
while *self.peek() != TokenKind::RBrace {
|
||||||
|
let span = self.span();
|
||||||
|
let name = self.expect_ident()?;
|
||||||
|
self.expect(&TokenKind::Colon)?;
|
||||||
|
let signal = match self.peek() {
|
||||||
|
TokenKind::Audio => { self.advance(); SignalKind::Audio }
|
||||||
|
TokenKind::Cv => { self.advance(); SignalKind::Cv }
|
||||||
|
TokenKind::Midi => { self.advance(); SignalKind::Midi }
|
||||||
|
_ => {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
"Expected audio, cv, or midi",
|
||||||
|
self.span(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ports.push(PortDecl { name, signal, span });
|
||||||
|
}
|
||||||
|
self.expect(&TokenKind::RBrace)?;
|
||||||
|
Ok(ports)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_params_block(&mut self) -> Result<Vec<ParamDecl>, CompileError> {
|
||||||
|
self.expect(&TokenKind::LBrace)?;
|
||||||
|
let mut params = Vec::new();
|
||||||
|
while *self.peek() != TokenKind::RBrace {
|
||||||
|
let span = self.span();
|
||||||
|
let name = self.expect_ident()?;
|
||||||
|
self.expect(&TokenKind::Colon)?;
|
||||||
|
let default = self.parse_number()?;
|
||||||
|
self.expect(&TokenKind::LBracket)?;
|
||||||
|
let min = self.parse_number()?;
|
||||||
|
self.expect(&TokenKind::Comma)?;
|
||||||
|
let max = self.parse_number()?;
|
||||||
|
self.expect(&TokenKind::RBracket)?;
|
||||||
|
let unit = self.expect_string()?;
|
||||||
|
params.push(ParamDecl {
|
||||||
|
name,
|
||||||
|
default,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
unit,
|
||||||
|
span,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.expect(&TokenKind::RBrace)?;
|
||||||
|
Ok(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_number(&mut self) -> Result<f32, CompileError> {
|
||||||
|
let negative = self.eat(&TokenKind::Minus);
|
||||||
|
let val = match self.peek() {
|
||||||
|
TokenKind::FloatLit(v) => {
|
||||||
|
let v = *v;
|
||||||
|
self.advance();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
TokenKind::IntLit(v) => {
|
||||||
|
let v = *v as f32;
|
||||||
|
self.advance();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
format!("Expected number, found {:?}", self.peek()),
|
||||||
|
self.span(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(if negative { -val } else { val })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_state_block(&mut self) -> Result<Vec<StateDecl>, CompileError> {
|
||||||
|
self.expect(&TokenKind::LBrace)?;
|
||||||
|
let mut decls = Vec::new();
|
||||||
|
while *self.peek() != TokenKind::RBrace {
|
||||||
|
let span = self.span();
|
||||||
|
let name = self.expect_ident()?;
|
||||||
|
self.expect(&TokenKind::Colon)?;
|
||||||
|
let ty = self.parse_state_type()?;
|
||||||
|
decls.push(StateDecl { name, ty, span });
|
||||||
|
}
|
||||||
|
self.expect(&TokenKind::RBrace)?;
|
||||||
|
Ok(decls)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_state_type(&mut self) -> Result<StateType, CompileError> {
|
||||||
|
match self.peek() {
|
||||||
|
TokenKind::F32 => { self.advance(); Ok(StateType::F32) }
|
||||||
|
TokenKind::Int => { self.advance(); Ok(StateType::Int) }
|
||||||
|
TokenKind::Bool => { self.advance(); Ok(StateType::Bool) }
|
||||||
|
TokenKind::Sample => { self.advance(); Ok(StateType::Sample) }
|
||||||
|
TokenKind::LBracket => {
|
||||||
|
self.advance();
|
||||||
|
let size = match self.peek() {
|
||||||
|
TokenKind::IntLit(n) => {
|
||||||
|
let n = *n as usize;
|
||||||
|
self.advance();
|
||||||
|
n
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
"Expected integer size for array",
|
||||||
|
self.span(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.expect(&TokenKind::RBracket)?;
|
||||||
|
match self.peek() {
|
||||||
|
TokenKind::F32 => { self.advance(); Ok(StateType::ArrayF32(size)) }
|
||||||
|
TokenKind::Int => { self.advance(); Ok(StateType::ArrayInt(size)) }
|
||||||
|
_ => Err(CompileError::new("Expected f32 or int after array size", self.span())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Err(CompileError::new(
|
||||||
|
format!("Expected type (f32, int, bool, sample, [N]f32, [N]int), found {:?}", self.peek()),
|
||||||
|
self.span(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ui_block(&mut self) -> Result<Vec<UiElement>, CompileError> {
|
||||||
|
self.expect(&TokenKind::LBrace)?;
|
||||||
|
let mut elements = Vec::new();
|
||||||
|
while *self.peek() != TokenKind::RBrace {
|
||||||
|
elements.push(self.parse_ui_element()?);
|
||||||
|
}
|
||||||
|
self.expect(&TokenKind::RBrace)?;
|
||||||
|
Ok(elements)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ui_element(&mut self) -> Result<UiElement, CompileError> {
|
||||||
|
match self.peek() {
|
||||||
|
TokenKind::Param => {
|
||||||
|
self.advance();
|
||||||
|
let name = self.expect_ident()?;
|
||||||
|
Ok(UiElement::Param(name))
|
||||||
|
}
|
||||||
|
TokenKind::Sample => {
|
||||||
|
self.advance();
|
||||||
|
let name = self.expect_ident()?;
|
||||||
|
Ok(UiElement::Sample(name))
|
||||||
|
}
|
||||||
|
TokenKind::Group => {
|
||||||
|
self.advance();
|
||||||
|
let label = self.expect_string()?;
|
||||||
|
let children = self.parse_ui_block()?;
|
||||||
|
Ok(UiElement::Group { label, children })
|
||||||
|
}
|
||||||
|
TokenKind::Canvas => {
|
||||||
|
self.advance();
|
||||||
|
self.expect(&TokenKind::LBracket)?;
|
||||||
|
let width = self.parse_number()?;
|
||||||
|
self.expect(&TokenKind::Comma)?;
|
||||||
|
let height = self.parse_number()?;
|
||||||
|
self.expect(&TokenKind::RBracket)?;
|
||||||
|
Ok(UiElement::Canvas { width, height })
|
||||||
|
}
|
||||||
|
TokenKind::Spacer => {
|
||||||
|
self.advance();
|
||||||
|
let px = self.parse_number()?;
|
||||||
|
Ok(UiElement::Spacer(px))
|
||||||
|
}
|
||||||
|
_ => Err(CompileError::new(
|
||||||
|
format!("Expected UI element (param, sample, group, canvas, spacer), found {:?}", self.peek()),
|
||||||
|
self.span(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_block(&mut self) -> Result<Block, CompileError> {
|
||||||
|
self.expect(&TokenKind::LBrace)?;
|
||||||
|
let mut stmts = Vec::new();
|
||||||
|
while *self.peek() != TokenKind::RBrace {
|
||||||
|
stmts.push(self.parse_stmt()?);
|
||||||
|
}
|
||||||
|
self.expect(&TokenKind::RBrace)?;
|
||||||
|
Ok(stmts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_stmt(&mut self) -> Result<Stmt, CompileError> {
|
||||||
|
match self.peek() {
|
||||||
|
TokenKind::Let => self.parse_let(),
|
||||||
|
TokenKind::If => self.parse_if(),
|
||||||
|
TokenKind::For => self.parse_for(),
|
||||||
|
_ => {
|
||||||
|
// Assignment or expression statement
|
||||||
|
let span = self.span();
|
||||||
|
let expr = self.parse_expr()?;
|
||||||
|
|
||||||
|
if self.eat(&TokenKind::Eq) {
|
||||||
|
// This is an assignment: expr = value
|
||||||
|
let value = self.parse_expr()?;
|
||||||
|
self.eat(&TokenKind::Semicolon);
|
||||||
|
let target = self.expr_to_lvalue(expr, span)?;
|
||||||
|
Ok(Stmt::Assign { target, value, span })
|
||||||
|
} else {
|
||||||
|
self.eat(&TokenKind::Semicolon);
|
||||||
|
Ok(Stmt::ExprStmt(expr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expr_to_lvalue(&self, expr: Expr, span: Span) -> Result<LValue, CompileError> {
|
||||||
|
match expr {
|
||||||
|
Expr::Ident(name, s) => Ok(LValue::Ident(name, s)),
|
||||||
|
Expr::Index(base, idx, s) => {
|
||||||
|
if let Expr::Ident(name, _) = *base {
|
||||||
|
Ok(LValue::Index(name, idx, s))
|
||||||
|
} else {
|
||||||
|
Err(CompileError::new("Invalid assignment target", span))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Err(CompileError::new("Invalid assignment target", span)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_let(&mut self) -> Result<Stmt, CompileError> {
|
||||||
|
let span = self.span();
|
||||||
|
self.advance(); // consume 'let'
|
||||||
|
let mutable = self.eat(&TokenKind::Mut);
|
||||||
|
let name = self.expect_ident()?;
|
||||||
|
self.expect(&TokenKind::Eq)?;
|
||||||
|
let init = self.parse_expr()?;
|
||||||
|
self.eat(&TokenKind::Semicolon);
|
||||||
|
Ok(Stmt::Let {
|
||||||
|
name,
|
||||||
|
mutable,
|
||||||
|
init,
|
||||||
|
span,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_if(&mut self) -> Result<Stmt, CompileError> {
|
||||||
|
let span = self.span();
|
||||||
|
self.advance(); // consume 'if'
|
||||||
|
let cond = self.parse_expr()?;
|
||||||
|
let then_block = self.parse_block()?;
|
||||||
|
let else_block = if self.eat(&TokenKind::Else) {
|
||||||
|
if *self.peek() == TokenKind::If {
|
||||||
|
// else if -> wrap in a block with single if statement
|
||||||
|
Some(vec![self.parse_if()?])
|
||||||
|
} else {
|
||||||
|
Some(self.parse_block()?)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(Stmt::If {
|
||||||
|
cond,
|
||||||
|
then_block,
|
||||||
|
else_block,
|
||||||
|
span,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_for(&mut self) -> Result<Stmt, CompileError> {
|
||||||
|
let span = self.span();
|
||||||
|
self.advance(); // consume 'for'
|
||||||
|
let var = self.expect_ident()?;
|
||||||
|
self.expect(&TokenKind::In)?;
|
||||||
|
// Expect 0..end
|
||||||
|
let zero_span = self.span();
|
||||||
|
match self.peek() {
|
||||||
|
TokenKind::IntLit(0) => { self.advance(); }
|
||||||
|
_ => {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
"For loop range must start at 0 (e.g. 0..buffer_size)",
|
||||||
|
zero_span,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.expect(&TokenKind::DotDot)?;
|
||||||
|
let end = self.parse_expr()?;
|
||||||
|
let body = self.parse_block()?;
|
||||||
|
Ok(Stmt::For {
|
||||||
|
var,
|
||||||
|
end,
|
||||||
|
body,
|
||||||
|
span,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expression parsing with precedence climbing
|
||||||
|
|
||||||
|
fn parse_expr(&mut self) -> Result<Expr, CompileError> {
|
||||||
|
self.parse_or()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_or(&mut self) -> Result<Expr, CompileError> {
|
||||||
|
let mut left = self.parse_and()?;
|
||||||
|
while *self.peek() == TokenKind::PipePipe {
|
||||||
|
let span = self.span();
|
||||||
|
self.advance();
|
||||||
|
let right = self.parse_and()?;
|
||||||
|
left = Expr::BinOp(Box::new(left), BinOp::Or, Box::new(right), span);
|
||||||
|
}
|
||||||
|
Ok(left)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_and(&mut self) -> Result<Expr, CompileError> {
|
||||||
|
let mut left = self.parse_equality()?;
|
||||||
|
while *self.peek() == TokenKind::AmpAmp {
|
||||||
|
let span = self.span();
|
||||||
|
self.advance();
|
||||||
|
let right = self.parse_equality()?;
|
||||||
|
left = Expr::BinOp(Box::new(left), BinOp::And, Box::new(right), span);
|
||||||
|
}
|
||||||
|
Ok(left)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_equality(&mut self) -> Result<Expr, CompileError> {
|
||||||
|
let mut left = self.parse_comparison()?;
|
||||||
|
loop {
|
||||||
|
let op = match self.peek() {
|
||||||
|
TokenKind::EqEq => BinOp::Eq,
|
||||||
|
TokenKind::BangEq => BinOp::Ne,
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
let span = self.span();
|
||||||
|
self.advance();
|
||||||
|
let right = self.parse_comparison()?;
|
||||||
|
left = Expr::BinOp(Box::new(left), op, Box::new(right), span);
|
||||||
|
}
|
||||||
|
Ok(left)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_comparison(&mut self) -> Result<Expr, CompileError> {
|
||||||
|
let mut left = self.parse_additive()?;
|
||||||
|
loop {
|
||||||
|
let op = match self.peek() {
|
||||||
|
TokenKind::Lt => BinOp::Lt,
|
||||||
|
TokenKind::Gt => BinOp::Gt,
|
||||||
|
TokenKind::LtEq => BinOp::Le,
|
||||||
|
TokenKind::GtEq => BinOp::Ge,
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
let span = self.span();
|
||||||
|
self.advance();
|
||||||
|
let right = self.parse_additive()?;
|
||||||
|
left = Expr::BinOp(Box::new(left), op, Box::new(right), span);
|
||||||
|
}
|
||||||
|
Ok(left)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_additive(&mut self) -> Result<Expr, CompileError> {
|
||||||
|
let mut left = self.parse_multiplicative()?;
|
||||||
|
loop {
|
||||||
|
let op = match self.peek() {
|
||||||
|
TokenKind::Plus => BinOp::Add,
|
||||||
|
TokenKind::Minus => BinOp::Sub,
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
let span = self.span();
|
||||||
|
self.advance();
|
||||||
|
let right = self.parse_multiplicative()?;
|
||||||
|
left = Expr::BinOp(Box::new(left), op, Box::new(right), span);
|
||||||
|
}
|
||||||
|
Ok(left)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_multiplicative(&mut self) -> Result<Expr, CompileError> {
|
||||||
|
let mut left = self.parse_unary()?;
|
||||||
|
loop {
|
||||||
|
let op = match self.peek() {
|
||||||
|
TokenKind::Star => BinOp::Mul,
|
||||||
|
TokenKind::Slash => BinOp::Div,
|
||||||
|
TokenKind::Percent => BinOp::Mod,
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
let span = self.span();
|
||||||
|
self.advance();
|
||||||
|
let right = self.parse_unary()?;
|
||||||
|
left = Expr::BinOp(Box::new(left), op, Box::new(right), span);
|
||||||
|
}
|
||||||
|
Ok(left)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_unary(&mut self) -> Result<Expr, CompileError> {
|
||||||
|
match self.peek() {
|
||||||
|
TokenKind::Minus => {
|
||||||
|
let span = self.span();
|
||||||
|
self.advance();
|
||||||
|
let expr = self.parse_unary()?;
|
||||||
|
Ok(Expr::UnaryOp(UnaryOp::Neg, Box::new(expr), span))
|
||||||
|
}
|
||||||
|
TokenKind::Bang => {
|
||||||
|
let span = self.span();
|
||||||
|
self.advance();
|
||||||
|
let expr = self.parse_unary()?;
|
||||||
|
Ok(Expr::UnaryOp(UnaryOp::Not, Box::new(expr), span))
|
||||||
|
}
|
||||||
|
_ => self.parse_postfix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_postfix(&mut self) -> Result<Expr, CompileError> {
|
||||||
|
let mut expr = self.parse_primary()?;
|
||||||
|
|
||||||
|
// Handle indexing: expr[index]
|
||||||
|
while *self.peek() == TokenKind::LBracket {
|
||||||
|
let span = self.span();
|
||||||
|
self.advance();
|
||||||
|
let index = self.parse_expr()?;
|
||||||
|
self.expect(&TokenKind::RBracket)?;
|
||||||
|
expr = Expr::Index(Box::new(expr), Box::new(index), span);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_primary(&mut self) -> Result<Expr, CompileError> {
|
||||||
|
let span = self.span();
|
||||||
|
match self.peek().clone() {
|
||||||
|
TokenKind::FloatLit(v) => {
|
||||||
|
self.advance();
|
||||||
|
Ok(Expr::FloatLit(v, span))
|
||||||
|
}
|
||||||
|
TokenKind::IntLit(v) => {
|
||||||
|
self.advance();
|
||||||
|
Ok(Expr::IntLit(v, span))
|
||||||
|
}
|
||||||
|
TokenKind::True => {
|
||||||
|
self.advance();
|
||||||
|
Ok(Expr::BoolLit(true, span))
|
||||||
|
}
|
||||||
|
TokenKind::False => {
|
||||||
|
self.advance();
|
||||||
|
Ok(Expr::BoolLit(false, span))
|
||||||
|
}
|
||||||
|
TokenKind::LParen => {
|
||||||
|
self.advance();
|
||||||
|
let expr = self.parse_expr()?;
|
||||||
|
self.expect(&TokenKind::RParen)?;
|
||||||
|
Ok(expr)
|
||||||
|
}
|
||||||
|
// Cast: int(expr) or float(expr)
|
||||||
|
TokenKind::Int => {
|
||||||
|
self.advance();
|
||||||
|
self.expect(&TokenKind::LParen)?;
|
||||||
|
let expr = self.parse_expr()?;
|
||||||
|
self.expect(&TokenKind::RParen)?;
|
||||||
|
Ok(Expr::Cast(CastKind::ToInt, Box::new(expr), span))
|
||||||
|
}
|
||||||
|
TokenKind::F32 => {
|
||||||
|
self.advance();
|
||||||
|
self.expect(&TokenKind::LParen)?;
|
||||||
|
let expr = self.parse_expr()?;
|
||||||
|
self.expect(&TokenKind::RParen)?;
|
||||||
|
Ok(Expr::Cast(CastKind::ToFloat, Box::new(expr), span))
|
||||||
|
}
|
||||||
|
TokenKind::Ident(name) => {
|
||||||
|
let name = name.clone();
|
||||||
|
self.advance();
|
||||||
|
// Check if it's a function call
|
||||||
|
if *self.peek() == TokenKind::LParen {
|
||||||
|
self.advance();
|
||||||
|
let mut args = Vec::new();
|
||||||
|
if *self.peek() != TokenKind::RParen {
|
||||||
|
args.push(self.parse_expr()?);
|
||||||
|
while self.eat(&TokenKind::Comma) {
|
||||||
|
args.push(self.parse_expr()?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.expect(&TokenKind::RParen)?;
|
||||||
|
Ok(Expr::Call(name, args, span))
|
||||||
|
} else {
|
||||||
|
Ok(Expr::Ident(name, span))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Err(CompileError::new(
|
||||||
|
format!("Expected expression, found {:?}", self.peek()),
|
||||||
|
span,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::lexer::Lexer;
|
||||||
|
|
||||||
|
fn parse_script(source: &str) -> Result<Script, CompileError> {
|
||||||
|
let mut lexer = Lexer::new(source);
|
||||||
|
let tokens = lexer.tokenize()?;
|
||||||
|
let mut parser = Parser::new(&tokens);
|
||||||
|
parser.parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minimal_script() {
|
||||||
|
let script = parse_script(r#"
|
||||||
|
name "Test"
|
||||||
|
category utility
|
||||||
|
process {}
|
||||||
|
"#).unwrap();
|
||||||
|
assert_eq!(script.name, "Test");
|
||||||
|
assert_eq!(script.category, CategoryKind::Utility);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ports_and_params() {
|
||||||
|
let script = parse_script(r#"
|
||||||
|
name "Gain"
|
||||||
|
category effect
|
||||||
|
inputs {
|
||||||
|
audio_in: audio
|
||||||
|
cv_mod: cv
|
||||||
|
}
|
||||||
|
outputs {
|
||||||
|
audio_out: audio
|
||||||
|
}
|
||||||
|
params {
|
||||||
|
gain: 1.0 [0.0, 2.0] ""
|
||||||
|
}
|
||||||
|
process {}
|
||||||
|
"#).unwrap();
|
||||||
|
assert_eq!(script.inputs.len(), 2);
|
||||||
|
assert_eq!(script.outputs.len(), 1);
|
||||||
|
assert_eq!(script.params.len(), 1);
|
||||||
|
assert_eq!(script.params[0].name, "gain");
|
||||||
|
assert_eq!(script.params[0].default, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_with_sample() {
|
||||||
|
let script = parse_script(r#"
|
||||||
|
name "Sampler"
|
||||||
|
category generator
|
||||||
|
state {
|
||||||
|
clip: sample
|
||||||
|
phase: f32
|
||||||
|
buffer: [4096]f32
|
||||||
|
counter: int
|
||||||
|
}
|
||||||
|
process {}
|
||||||
|
"#).unwrap();
|
||||||
|
assert_eq!(script.state.len(), 4);
|
||||||
|
assert_eq!(script.state[0].ty, StateType::Sample);
|
||||||
|
assert_eq!(script.state[1].ty, StateType::F32);
|
||||||
|
assert_eq!(script.state[2].ty, StateType::ArrayF32(4096));
|
||||||
|
assert_eq!(script.state[3].ty, StateType::Int);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_process_with_for_loop() {
|
||||||
|
let script = parse_script(r#"
|
||||||
|
name "Pass"
|
||||||
|
category effect
|
||||||
|
inputs { audio_in: audio }
|
||||||
|
outputs { audio_out: audio }
|
||||||
|
process {
|
||||||
|
for i in 0..buffer_size {
|
||||||
|
audio_out[i * 2] = audio_in[i * 2];
|
||||||
|
audio_out[i * 2 + 1] = audio_in[i * 2 + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#).unwrap();
|
||||||
|
assert_eq!(script.process.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expressions() {
|
||||||
|
let script = parse_script(r#"
|
||||||
|
name "Expr"
|
||||||
|
category utility
|
||||||
|
process {
|
||||||
|
let x = 1.0 + 2.0 * 3.0;
|
||||||
|
let y = sin(x) + cos(3.14);
|
||||||
|
let z = int(x * 100.0);
|
||||||
|
}
|
||||||
|
"#).unwrap();
|
||||||
|
assert_eq!(script.process.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ui_block() {
|
||||||
|
let script = parse_script(r#"
|
||||||
|
name "UI Test"
|
||||||
|
category utility
|
||||||
|
params {
|
||||||
|
gain: 1.0 [0.0, 2.0] ""
|
||||||
|
mix: 0.5 [0.0, 1.0] ""
|
||||||
|
}
|
||||||
|
state {
|
||||||
|
clip: sample
|
||||||
|
}
|
||||||
|
ui {
|
||||||
|
sample clip
|
||||||
|
param gain
|
||||||
|
group "Advanced" {
|
||||||
|
param mix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process {}
|
||||||
|
"#).unwrap();
|
||||||
|
let ui = script.ui.unwrap();
|
||||||
|
assert_eq!(ui.len(), 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
/// Source location
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Span {
|
||||||
|
pub line: u32,
|
||||||
|
pub col: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Span {
|
||||||
|
pub fn new(line: u32, col: u32) -> Self {
|
||||||
|
Self { line, col }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token with source location
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Token {
|
||||||
|
pub kind: TokenKind,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum TokenKind {
|
||||||
|
// Header keywords
|
||||||
|
Name,
|
||||||
|
Category,
|
||||||
|
Inputs,
|
||||||
|
Outputs,
|
||||||
|
Params,
|
||||||
|
State,
|
||||||
|
Ui,
|
||||||
|
Process,
|
||||||
|
|
||||||
|
// Type keywords
|
||||||
|
Audio,
|
||||||
|
Cv,
|
||||||
|
Midi,
|
||||||
|
F32,
|
||||||
|
Int,
|
||||||
|
Bool,
|
||||||
|
Sample,
|
||||||
|
|
||||||
|
// Category values
|
||||||
|
Generator,
|
||||||
|
Effect,
|
||||||
|
Utility,
|
||||||
|
|
||||||
|
// Statement keywords
|
||||||
|
Let,
|
||||||
|
Mut,
|
||||||
|
If,
|
||||||
|
Else,
|
||||||
|
For,
|
||||||
|
In,
|
||||||
|
|
||||||
|
// UI keywords
|
||||||
|
Group,
|
||||||
|
Param,
|
||||||
|
Canvas,
|
||||||
|
Spacer,
|
||||||
|
|
||||||
|
// Literals
|
||||||
|
FloatLit(f32),
|
||||||
|
IntLit(i32),
|
||||||
|
StringLit(String),
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
|
||||||
|
// Identifiers
|
||||||
|
Ident(String),
|
||||||
|
|
||||||
|
// Operators
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
Star,
|
||||||
|
Slash,
|
||||||
|
Percent,
|
||||||
|
Eq, // =
|
||||||
|
EqEq, // ==
|
||||||
|
BangEq, // !=
|
||||||
|
Lt, // <
|
||||||
|
Gt, // >
|
||||||
|
LtEq, // <=
|
||||||
|
GtEq, // >=
|
||||||
|
AmpAmp, // &&
|
||||||
|
PipePipe, // ||
|
||||||
|
Bang, // !
|
||||||
|
|
||||||
|
// Delimiters
|
||||||
|
LBrace, // {
|
||||||
|
RBrace, // }
|
||||||
|
LBracket, // [
|
||||||
|
RBracket, // ]
|
||||||
|
LParen, // (
|
||||||
|
RParen, // )
|
||||||
|
Colon, // :
|
||||||
|
Comma, // ,
|
||||||
|
Semicolon, // ;
|
||||||
|
DotDot, // ..
|
||||||
|
|
||||||
|
// End of file
|
||||||
|
Eof,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenKind {
|
||||||
|
/// Try to match an identifier string to a keyword
|
||||||
|
pub fn from_ident(s: &str) -> TokenKind {
|
||||||
|
match s {
|
||||||
|
"name" => TokenKind::Name,
|
||||||
|
"category" => TokenKind::Category,
|
||||||
|
"inputs" => TokenKind::Inputs,
|
||||||
|
"outputs" => TokenKind::Outputs,
|
||||||
|
"params" => TokenKind::Params,
|
||||||
|
"state" => TokenKind::State,
|
||||||
|
"ui" => TokenKind::Ui,
|
||||||
|
"process" => TokenKind::Process,
|
||||||
|
"audio" => TokenKind::Audio,
|
||||||
|
"cv" => TokenKind::Cv,
|
||||||
|
"midi" => TokenKind::Midi,
|
||||||
|
"f32" => TokenKind::F32,
|
||||||
|
"int" => TokenKind::Int,
|
||||||
|
"bool" => TokenKind::Bool,
|
||||||
|
"sample" => TokenKind::Sample,
|
||||||
|
"generator" => TokenKind::Generator,
|
||||||
|
"effect" => TokenKind::Effect,
|
||||||
|
"utility" => TokenKind::Utility,
|
||||||
|
"let" => TokenKind::Let,
|
||||||
|
"mut" => TokenKind::Mut,
|
||||||
|
"if" => TokenKind::If,
|
||||||
|
"else" => TokenKind::Else,
|
||||||
|
"for" => TokenKind::For,
|
||||||
|
"in" => TokenKind::In,
|
||||||
|
"group" => TokenKind::Group,
|
||||||
|
"param" => TokenKind::Param,
|
||||||
|
"canvas" => TokenKind::Canvas,
|
||||||
|
"spacer" => TokenKind::Spacer,
|
||||||
|
"true" => TokenKind::True,
|
||||||
|
"false" => TokenKind::False,
|
||||||
|
_ => TokenKind::Ident(s.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Declarative UI layout for a script node, rendered in bottom_ui()
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct UiDeclaration {
|
||||||
|
pub elements: Vec<UiElement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum UiElement {
|
||||||
|
/// Render a parameter slider/knob
|
||||||
|
Param(String),
|
||||||
|
/// Render a sample picker dropdown
|
||||||
|
Sample(String),
|
||||||
|
/// Collapsible group with label
|
||||||
|
Group {
|
||||||
|
label: String,
|
||||||
|
children: Vec<UiElement>,
|
||||||
|
},
|
||||||
|
/// Drawable canvas area (phase 2)
|
||||||
|
Canvas {
|
||||||
|
width: f32,
|
||||||
|
height: f32,
|
||||||
|
},
|
||||||
|
/// Vertical spacer
|
||||||
|
Spacer(f32),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,388 @@
|
||||||
|
use crate::ast::*;
|
||||||
|
use crate::error::CompileError;
|
||||||
|
use crate::token::Span;
|
||||||
|
use crate::ui_decl::UiElement;
|
||||||
|
|
||||||
|
/// Type used during validation
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum VType {
|
||||||
|
F32,
|
||||||
|
Int,
|
||||||
|
Bool,
|
||||||
|
/// Array of f32 (state array or input/output buffer)
|
||||||
|
ArrayF32,
|
||||||
|
/// Array of int
|
||||||
|
ArrayInt,
|
||||||
|
/// Sample slot (accessed via sample_read/sample_len)
|
||||||
|
Sample,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VarInfo {
|
||||||
|
ty: VType,
|
||||||
|
mutable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Scope {
|
||||||
|
vars: Vec<(String, VarInfo)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scope {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self { vars: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn define(&mut self, name: String, ty: VType, mutable: bool) {
|
||||||
|
self.vars.push((name, VarInfo { ty, mutable }));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup(&self, name: &str) -> Option<&VarInfo> {
|
||||||
|
self.vars.iter().rev().find(|(n, _)| n == name).map(|(_, v)| v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Validator<'a> {
|
||||||
|
script: &'a Script,
|
||||||
|
scopes: Vec<Scope>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Validator<'a> {
|
||||||
|
fn new(script: &'a Script) -> Self {
|
||||||
|
Self {
|
||||||
|
script,
|
||||||
|
scopes: vec![Scope::new()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_scope(&mut self) -> &mut Scope {
|
||||||
|
self.scopes.last_mut().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_scope(&mut self) {
|
||||||
|
self.scopes.push(Scope::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pop_scope(&mut self) {
|
||||||
|
self.scopes.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup(&self, name: &str) -> Option<&VarInfo> {
|
||||||
|
for scope in self.scopes.iter().rev() {
|
||||||
|
if let Some(info) = scope.lookup(name) {
|
||||||
|
return Some(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn define(&mut self, name: String, ty: VType, mutable: bool) {
|
||||||
|
self.current_scope().define(name, ty, mutable);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&mut self) -> Result<(), CompileError> {
|
||||||
|
// Register built-in variables
|
||||||
|
self.define("sample_rate".into(), VType::Int, false);
|
||||||
|
self.define("buffer_size".into(), VType::Int, false);
|
||||||
|
|
||||||
|
// Register inputs as arrays
|
||||||
|
for input in &self.script.inputs {
|
||||||
|
let ty = match input.signal {
|
||||||
|
SignalKind::Audio | SignalKind::Cv => VType::ArrayF32,
|
||||||
|
SignalKind::Midi => continue, // MIDI not yet supported in process
|
||||||
|
};
|
||||||
|
self.define(input.name.clone(), ty, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register outputs as mutable arrays
|
||||||
|
for output in &self.script.outputs {
|
||||||
|
let ty = match output.signal {
|
||||||
|
SignalKind::Audio | SignalKind::Cv => VType::ArrayF32,
|
||||||
|
SignalKind::Midi => continue,
|
||||||
|
};
|
||||||
|
self.define(output.name.clone(), ty, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register params as f32
|
||||||
|
for param in &self.script.params {
|
||||||
|
self.define(param.name.clone(), VType::F32, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register state vars
|
||||||
|
for state in &self.script.state {
|
||||||
|
let (ty, mutable) = match &state.ty {
|
||||||
|
StateType::F32 => (VType::F32, true),
|
||||||
|
StateType::Int => (VType::Int, true),
|
||||||
|
StateType::Bool => (VType::Bool, true),
|
||||||
|
StateType::ArrayF32(_) => (VType::ArrayF32, true),
|
||||||
|
StateType::ArrayInt(_) => (VType::ArrayInt, true),
|
||||||
|
StateType::Sample => (VType::Sample, false),
|
||||||
|
};
|
||||||
|
self.define(state.name.clone(), ty, mutable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate process block
|
||||||
|
self.validate_block(&self.script.process)?;
|
||||||
|
|
||||||
|
// Validate UI references
|
||||||
|
if let Some(ui) = &self.script.ui {
|
||||||
|
self.validate_ui(ui)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_block(&mut self, block: &[Stmt]) -> Result<(), CompileError> {
|
||||||
|
for stmt in block {
|
||||||
|
self.validate_stmt(stmt)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_stmt(&mut self, stmt: &Stmt) -> Result<(), CompileError> {
|
||||||
|
match stmt {
|
||||||
|
Stmt::Let { name, mutable, init, span: _ } => {
|
||||||
|
let ty = self.infer_type(init)?;
|
||||||
|
self.define(name.clone(), ty, *mutable);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Stmt::Assign { target, value, span: _ } => {
|
||||||
|
match target {
|
||||||
|
LValue::Ident(name, s) => {
|
||||||
|
let info = self.lookup(name).ok_or_else(|| {
|
||||||
|
CompileError::new(format!("Undefined variable: {}", name), *s)
|
||||||
|
})?;
|
||||||
|
if !info.mutable {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
format!("Cannot assign to immutable variable: {}", name),
|
||||||
|
*s,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LValue::Index(name, idx, s) => {
|
||||||
|
let info = self.lookup(name).ok_or_else(|| {
|
||||||
|
CompileError::new(format!("Undefined variable: {}", name), *s)
|
||||||
|
})?;
|
||||||
|
if !info.mutable {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
format!("Cannot assign to immutable array: {}", name),
|
||||||
|
*s,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.infer_type(idx)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.infer_type(value)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Stmt::If { cond, then_block, else_block, .. } => {
|
||||||
|
self.infer_type(cond)?;
|
||||||
|
self.push_scope();
|
||||||
|
self.validate_block(then_block)?;
|
||||||
|
self.pop_scope();
|
||||||
|
if let Some(else_b) = else_block {
|
||||||
|
self.push_scope();
|
||||||
|
self.validate_block(else_b)?;
|
||||||
|
self.pop_scope();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Stmt::For { var, end, body, span } => {
|
||||||
|
let end_ty = self.infer_type(end)?;
|
||||||
|
if end_ty != VType::Int {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
"For loop bound must be an integer expression",
|
||||||
|
*span,
|
||||||
|
).with_hint("Use int(...) to convert, or use buffer_size / len(array)"));
|
||||||
|
}
|
||||||
|
self.push_scope();
|
||||||
|
self.define(var.clone(), VType::Int, false);
|
||||||
|
self.validate_block(body)?;
|
||||||
|
self.pop_scope();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Stmt::ExprStmt(expr) => {
|
||||||
|
self.infer_type(expr)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn infer_type(&self, expr: &Expr) -> Result<VType, CompileError> {
|
||||||
|
match expr {
|
||||||
|
Expr::FloatLit(_, _) => Ok(VType::F32),
|
||||||
|
Expr::IntLit(_, _) => Ok(VType::Int),
|
||||||
|
Expr::BoolLit(_, _) => Ok(VType::Bool),
|
||||||
|
Expr::Ident(name, span) => {
|
||||||
|
let info = self.lookup(name).ok_or_else(|| {
|
||||||
|
CompileError::new(format!("Undefined variable: {}", name), *span)
|
||||||
|
})?;
|
||||||
|
Ok(info.ty)
|
||||||
|
}
|
||||||
|
Expr::BinOp(left, op, right, span) => {
|
||||||
|
let lt = self.infer_type(left)?;
|
||||||
|
let rt = self.infer_type(right)?;
|
||||||
|
match op {
|
||||||
|
BinOp::And | BinOp::Or => Ok(VType::Bool),
|
||||||
|
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => {
|
||||||
|
Ok(VType::Bool)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Arithmetic: both sides should be same numeric type
|
||||||
|
if lt == VType::F32 || rt == VType::F32 {
|
||||||
|
Ok(VType::F32)
|
||||||
|
} else if lt == VType::Int && rt == VType::Int {
|
||||||
|
Ok(VType::Int)
|
||||||
|
} else {
|
||||||
|
Err(CompileError::new(
|
||||||
|
format!("Cannot apply {:?} to {:?} and {:?}", op, lt, rt),
|
||||||
|
*span,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::UnaryOp(op, inner, _) => {
|
||||||
|
let ty = self.infer_type(inner)?;
|
||||||
|
match op {
|
||||||
|
UnaryOp::Neg => Ok(ty),
|
||||||
|
UnaryOp::Not => Ok(VType::Bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Cast(kind, _, _) => match kind {
|
||||||
|
CastKind::ToInt => Ok(VType::Int),
|
||||||
|
CastKind::ToFloat => Ok(VType::F32),
|
||||||
|
},
|
||||||
|
Expr::Index(base, idx, span) => {
|
||||||
|
let base_ty = self.infer_type(base)?;
|
||||||
|
self.infer_type(idx)?;
|
||||||
|
match base_ty {
|
||||||
|
VType::ArrayF32 => Ok(VType::F32),
|
||||||
|
VType::ArrayInt => Ok(VType::Int),
|
||||||
|
_ => Err(CompileError::new("Cannot index non-array type", *span)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Call(name, args, span) => {
|
||||||
|
self.validate_call(name, args, *span)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_call(&self, name: &str, args: &[Expr], span: Span) -> Result<VType, CompileError> {
|
||||||
|
// Validate argument count and infer return type
|
||||||
|
match name {
|
||||||
|
// 1-arg math functions returning f32
|
||||||
|
"sin" | "cos" | "tan" | "asin" | "acos" | "atan" | "exp" | "log" | "log2"
|
||||||
|
| "sqrt" | "floor" | "ceil" | "round" | "trunc" | "fract" | "abs" | "sign" => {
|
||||||
|
if args.len() != 1 {
|
||||||
|
return Err(CompileError::new(format!("{}() takes 1 argument", name), span));
|
||||||
|
}
|
||||||
|
for arg in args { self.infer_type(arg)?; }
|
||||||
|
Ok(VType::F32)
|
||||||
|
}
|
||||||
|
// 2-arg math functions returning f32
|
||||||
|
"atan2" | "pow" | "min" | "max" => {
|
||||||
|
if args.len() != 2 {
|
||||||
|
return Err(CompileError::new(format!("{}() takes 2 arguments", name), span));
|
||||||
|
}
|
||||||
|
for arg in args { self.infer_type(arg)?; }
|
||||||
|
Ok(VType::F32)
|
||||||
|
}
|
||||||
|
// 3-arg functions
|
||||||
|
"clamp" | "mix" | "smoothstep" => {
|
||||||
|
if args.len() != 3 {
|
||||||
|
return Err(CompileError::new(format!("{}() takes 3 arguments", name), span));
|
||||||
|
}
|
||||||
|
for arg in args { self.infer_type(arg)?; }
|
||||||
|
Ok(VType::F32)
|
||||||
|
}
|
||||||
|
// cv_or(value, default) -> f32
|
||||||
|
"cv_or" => {
|
||||||
|
if args.len() != 2 {
|
||||||
|
return Err(CompileError::new("cv_or() takes 2 arguments", span));
|
||||||
|
}
|
||||||
|
for arg in args { self.infer_type(arg)?; }
|
||||||
|
Ok(VType::F32)
|
||||||
|
}
|
||||||
|
// len(array) -> int
|
||||||
|
"len" => {
|
||||||
|
if args.len() != 1 {
|
||||||
|
return Err(CompileError::new("len() takes 1 argument", span));
|
||||||
|
}
|
||||||
|
let ty = self.infer_type(&args[0])?;
|
||||||
|
if ty != VType::ArrayF32 && ty != VType::ArrayInt {
|
||||||
|
return Err(CompileError::new("len() requires an array argument", span));
|
||||||
|
}
|
||||||
|
Ok(VType::Int)
|
||||||
|
}
|
||||||
|
// sample_len(sample) -> int
|
||||||
|
"sample_len" => {
|
||||||
|
if args.len() != 1 {
|
||||||
|
return Err(CompileError::new("sample_len() takes 1 argument", span));
|
||||||
|
}
|
||||||
|
let ty = self.infer_type(&args[0])?;
|
||||||
|
if ty != VType::Sample {
|
||||||
|
return Err(CompileError::new("sample_len() requires a sample argument", span));
|
||||||
|
}
|
||||||
|
Ok(VType::Int)
|
||||||
|
}
|
||||||
|
// sample_read(sample, index) -> f32
|
||||||
|
"sample_read" => {
|
||||||
|
if args.len() != 2 {
|
||||||
|
return Err(CompileError::new("sample_read() takes 2 arguments", span));
|
||||||
|
}
|
||||||
|
let ty = self.infer_type(&args[0])?;
|
||||||
|
if ty != VType::Sample {
|
||||||
|
return Err(CompileError::new("sample_read() first argument must be a sample", span));
|
||||||
|
}
|
||||||
|
self.infer_type(&args[1])?;
|
||||||
|
Ok(VType::F32)
|
||||||
|
}
|
||||||
|
// sample_rate_of(sample) -> int
|
||||||
|
"sample_rate_of" => {
|
||||||
|
if args.len() != 1 {
|
||||||
|
return Err(CompileError::new("sample_rate_of() takes 1 argument", span));
|
||||||
|
}
|
||||||
|
let ty = self.infer_type(&args[0])?;
|
||||||
|
if ty != VType::Sample {
|
||||||
|
return Err(CompileError::new("sample_rate_of() requires a sample argument", span));
|
||||||
|
}
|
||||||
|
Ok(VType::Int)
|
||||||
|
}
|
||||||
|
_ => Err(CompileError::new(format!("Unknown function: {}", name), span)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_ui(&self, elements: &[UiElement]) -> Result<(), CompileError> {
|
||||||
|
for element in elements {
|
||||||
|
match element {
|
||||||
|
UiElement::Param(name) => {
|
||||||
|
if !self.script.params.iter().any(|p| p.name == *name) {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
format!("UI references unknown parameter: {}", name),
|
||||||
|
Span::new(0, 0),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiElement::Sample(name) => {
|
||||||
|
if !self.script.state.iter().any(|s| s.name == *name && s.ty == StateType::Sample) {
|
||||||
|
return Err(CompileError::new(
|
||||||
|
format!("UI references unknown sample: {}", name),
|
||||||
|
Span::new(0, 0),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiElement::Group { children, .. } => {
|
||||||
|
self.validate_ui(children)?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a parsed script. Returns Ok(()) if valid.
|
||||||
|
pub fn validate(script: &Script) -> Result<&Script, CompileError> {
|
||||||
|
let mut validator = Validator::new(script);
|
||||||
|
validator.validate()?;
|
||||||
|
Ok(script)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,451 @@
|
||||||
|
use crate::error::ScriptError;
|
||||||
|
use crate::opcodes::OpCode;
|
||||||
|
|
||||||
|
const STACK_SIZE: usize = 256;
|
||||||
|
const MAX_LOCALS: usize = 64;
|
||||||
|
const DEFAULT_INSTRUCTION_LIMIT: u64 = 10_000_000;
|
||||||
|
|
||||||
|
/// A value on the VM stack (tagged union)
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub union Value {
|
||||||
|
pub f: f32,
|
||||||
|
pub i: i32,
|
||||||
|
pub b: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Value {
|
||||||
|
fn default() -> Self {
|
||||||
|
Value { i: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A loaded audio sample slot
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SampleSlot {
|
||||||
|
pub data: Vec<f32>,
|
||||||
|
pub frame_count: usize,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SampleSlot {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
data: Vec::new(),
|
||||||
|
frame_count: 0,
|
||||||
|
sample_rate: 0,
|
||||||
|
name: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The BeamDSP virtual machine
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ScriptVM {
|
||||||
|
pub bytecode: Vec<u8>,
|
||||||
|
pub constants_f32: Vec<f32>,
|
||||||
|
pub constants_i32: Vec<i32>,
|
||||||
|
stack: Vec<Value>,
|
||||||
|
sp: usize,
|
||||||
|
locals: Vec<Value>,
|
||||||
|
pub params: Vec<f32>,
|
||||||
|
pub state_scalars: Vec<Value>,
|
||||||
|
pub state_arrays: Vec<Vec<f32>>,
|
||||||
|
pub sample_slots: Vec<SampleSlot>,
|
||||||
|
instruction_limit: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptVM {
|
||||||
|
pub fn new(
|
||||||
|
bytecode: Vec<u8>,
|
||||||
|
constants_f32: Vec<f32>,
|
||||||
|
constants_i32: Vec<i32>,
|
||||||
|
num_params: usize,
|
||||||
|
param_defaults: &[f32],
|
||||||
|
num_state_scalars: usize,
|
||||||
|
state_array_sizes: &[usize],
|
||||||
|
num_sample_slots: usize,
|
||||||
|
) -> Self {
|
||||||
|
let mut params = vec![0.0f32; num_params];
|
||||||
|
for (i, &d) in param_defaults.iter().enumerate() {
|
||||||
|
if i < params.len() {
|
||||||
|
params[i] = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
bytecode,
|
||||||
|
constants_f32,
|
||||||
|
constants_i32,
|
||||||
|
stack: vec![Value::default(); STACK_SIZE],
|
||||||
|
sp: 0,
|
||||||
|
locals: vec![Value::default(); MAX_LOCALS],
|
||||||
|
params,
|
||||||
|
state_scalars: vec![Value::default(); num_state_scalars],
|
||||||
|
state_arrays: state_array_sizes.iter().map(|&sz| vec![0.0f32; sz]).collect(),
|
||||||
|
sample_slots: (0..num_sample_slots).map(|_| SampleSlot::default()).collect(),
|
||||||
|
instruction_limit: DEFAULT_INSTRUCTION_LIMIT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all state (scalars + arrays) to zero. Called on node reset.
|
||||||
|
pub fn reset_state(&mut self) {
|
||||||
|
for s in &mut self.state_scalars {
|
||||||
|
*s = Value::default();
|
||||||
|
}
|
||||||
|
for arr in &mut self.state_arrays {
|
||||||
|
arr.fill(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute the bytecode with the given I/O buffers
|
||||||
|
pub fn execute(
|
||||||
|
&mut self,
|
||||||
|
inputs: &[&[f32]],
|
||||||
|
outputs: &mut [&mut [f32]],
|
||||||
|
sample_rate: u32,
|
||||||
|
buffer_size: usize,
|
||||||
|
) -> Result<(), ScriptError> {
|
||||||
|
self.sp = 0;
|
||||||
|
// Clear locals
|
||||||
|
for l in &mut self.locals {
|
||||||
|
*l = Value::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pc: usize = 0;
|
||||||
|
let mut ic: u64 = 0;
|
||||||
|
let limit = self.instruction_limit;
|
||||||
|
|
||||||
|
while pc < self.bytecode.len() {
|
||||||
|
ic += 1;
|
||||||
|
if ic > limit {
|
||||||
|
return Err(ScriptError::ExecutionLimitExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
let op = self.bytecode[pc];
|
||||||
|
pc += 1;
|
||||||
|
|
||||||
|
match OpCode::from_u8(op) {
|
||||||
|
Some(OpCode::Halt) => return Ok(()),
|
||||||
|
|
||||||
|
Some(OpCode::PushF32) => {
|
||||||
|
let idx = self.read_u16(&mut pc) as usize;
|
||||||
|
let v = self.constants_f32[idx];
|
||||||
|
self.push_f(v)?;
|
||||||
|
}
|
||||||
|
Some(OpCode::PushI32) => {
|
||||||
|
let idx = self.read_u16(&mut pc) as usize;
|
||||||
|
let v = self.constants_i32[idx];
|
||||||
|
self.push_i(v)?;
|
||||||
|
}
|
||||||
|
Some(OpCode::PushBool) => {
|
||||||
|
let v = self.bytecode[pc];
|
||||||
|
pc += 1;
|
||||||
|
self.push_b(v != 0)?;
|
||||||
|
}
|
||||||
|
Some(OpCode::Pop) => {
|
||||||
|
self.pop()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locals
|
||||||
|
Some(OpCode::LoadLocal) => {
|
||||||
|
let idx = self.read_u16(&mut pc) as usize;
|
||||||
|
let v = self.locals[idx];
|
||||||
|
self.push(v)?;
|
||||||
|
}
|
||||||
|
Some(OpCode::StoreLocal) => {
|
||||||
|
let idx = self.read_u16(&mut pc) as usize;
|
||||||
|
self.locals[idx] = self.pop()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params
|
||||||
|
Some(OpCode::LoadParam) => {
|
||||||
|
let idx = self.read_u16(&mut pc) as usize;
|
||||||
|
let v = self.params[idx];
|
||||||
|
self.push_f(v)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State scalars
|
||||||
|
Some(OpCode::LoadState) => {
|
||||||
|
let idx = self.read_u16(&mut pc) as usize;
|
||||||
|
let v = self.state_scalars[idx];
|
||||||
|
self.push(v)?;
|
||||||
|
}
|
||||||
|
Some(OpCode::StoreState) => {
|
||||||
|
let idx = self.read_u16(&mut pc) as usize;
|
||||||
|
self.state_scalars[idx] = self.pop()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input buffers
|
||||||
|
Some(OpCode::LoadInput) => {
|
||||||
|
let port = self.bytecode[pc] as usize;
|
||||||
|
pc += 1;
|
||||||
|
let idx = unsafe { self.pop()?.i } as usize;
|
||||||
|
let val = if port < inputs.len() && idx < inputs[port].len() {
|
||||||
|
inputs[port][idx]
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
self.push_f(val)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output buffers
|
||||||
|
Some(OpCode::StoreOutput) => {
|
||||||
|
let port = self.bytecode[pc] as usize;
|
||||||
|
pc += 1;
|
||||||
|
let val = unsafe { self.pop()?.f };
|
||||||
|
let idx = unsafe { self.pop()?.i } as usize;
|
||||||
|
if port < outputs.len() && idx < outputs[port].len() {
|
||||||
|
outputs[port][idx] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State arrays
|
||||||
|
Some(OpCode::LoadStateArray) => {
|
||||||
|
let arr_id = self.read_u16(&mut pc) as usize;
|
||||||
|
let idx = unsafe { self.pop()?.i };
|
||||||
|
let val = if arr_id < self.state_arrays.len() {
|
||||||
|
let arr_len = self.state_arrays[arr_id].len();
|
||||||
|
let idx = ((idx % arr_len as i32) + arr_len as i32) as usize % arr_len;
|
||||||
|
self.state_arrays[arr_id][idx]
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
self.push_f(val)?;
|
||||||
|
}
|
||||||
|
Some(OpCode::StoreStateArray) => {
|
||||||
|
let arr_id = self.read_u16(&mut pc) as usize;
|
||||||
|
let val = unsafe { self.pop()?.f };
|
||||||
|
let idx = unsafe { self.pop()?.i };
|
||||||
|
if arr_id < self.state_arrays.len() {
|
||||||
|
let arr_len = self.state_arrays[arr_id].len();
|
||||||
|
let idx = ((idx % arr_len as i32) + arr_len as i32) as usize % arr_len;
|
||||||
|
self.state_arrays[arr_id][idx] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample access
|
||||||
|
Some(OpCode::SampleLen) => {
|
||||||
|
let slot = self.bytecode[pc] as usize;
|
||||||
|
pc += 1;
|
||||||
|
let len = if slot < self.sample_slots.len() {
|
||||||
|
self.sample_slots[slot].frame_count as i32
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
self.push_i(len)?;
|
||||||
|
}
|
||||||
|
Some(OpCode::SampleRead) => {
|
||||||
|
let slot = self.bytecode[pc] as usize;
|
||||||
|
pc += 1;
|
||||||
|
let idx = unsafe { self.pop()?.i } as usize;
|
||||||
|
let val = if slot < self.sample_slots.len() && idx < self.sample_slots[slot].data.len() {
|
||||||
|
self.sample_slots[slot].data[idx]
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
self.push_f(val)?;
|
||||||
|
}
|
||||||
|
Some(OpCode::SampleRateOf) => {
|
||||||
|
let slot = self.bytecode[pc] as usize;
|
||||||
|
pc += 1;
|
||||||
|
let sr = if slot < self.sample_slots.len() {
|
||||||
|
self.sample_slots[slot].sample_rate as i32
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
self.push_i(sr)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Float arithmetic
|
||||||
|
Some(OpCode::AddF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a + b)?; }
|
||||||
|
Some(OpCode::SubF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a - b)?; }
|
||||||
|
Some(OpCode::MulF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a * b)?; }
|
||||||
|
Some(OpCode::DivF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(if b.abs() > 1e-30 { a / b } else { 0.0 })?; }
|
||||||
|
Some(OpCode::ModF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(if b.abs() > 1e-30 { a % b } else { 0.0 })?; }
|
||||||
|
Some(OpCode::NegF) => { let v = self.pop_f()?; self.push_f(-v)?; }
|
||||||
|
|
||||||
|
// Int arithmetic
|
||||||
|
Some(OpCode::AddI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(a.wrapping_add(b))?; }
|
||||||
|
Some(OpCode::SubI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(a.wrapping_sub(b))?; }
|
||||||
|
Some(OpCode::MulI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(a.wrapping_mul(b))?; }
|
||||||
|
Some(OpCode::DivI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(if b != 0 { a / b } else { 0 })?; }
|
||||||
|
Some(OpCode::ModI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(if b != 0 { a % b } else { 0 })?; }
|
||||||
|
Some(OpCode::NegI) => { let v = self.pop_i()?; self.push_i(-v)?; }
|
||||||
|
|
||||||
|
// Float comparison
|
||||||
|
Some(OpCode::EqF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a == b)?; }
|
||||||
|
Some(OpCode::NeF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a != b)?; }
|
||||||
|
Some(OpCode::LtF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a < b)?; }
|
||||||
|
Some(OpCode::GtF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a > b)?; }
|
||||||
|
Some(OpCode::LeF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a <= b)?; }
|
||||||
|
Some(OpCode::GeF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a >= b)?; }
|
||||||
|
|
||||||
|
// Int comparison
|
||||||
|
Some(OpCode::EqI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a == b)?; }
|
||||||
|
Some(OpCode::NeI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a != b)?; }
|
||||||
|
Some(OpCode::LtI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a < b)?; }
|
||||||
|
Some(OpCode::GtI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a > b)?; }
|
||||||
|
Some(OpCode::LeI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a <= b)?; }
|
||||||
|
Some(OpCode::GeI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a >= b)?; }
|
||||||
|
|
||||||
|
// Logical
|
||||||
|
Some(OpCode::And) => { let b = self.pop_b()?; let a = self.pop_b()?; self.push_b(a && b)?; }
|
||||||
|
Some(OpCode::Or) => { let b = self.pop_b()?; let a = self.pop_b()?; self.push_b(a || b)?; }
|
||||||
|
Some(OpCode::Not) => { let v = self.pop_b()?; self.push_b(!v)?; }
|
||||||
|
|
||||||
|
// Casts
|
||||||
|
Some(OpCode::F32ToI32) => { let v = self.pop_f()?; self.push_i(v as i32)?; }
|
||||||
|
Some(OpCode::I32ToF32) => { let v = self.pop_i()?; self.push_f(v as f32)?; }
|
||||||
|
|
||||||
|
// Control flow
|
||||||
|
Some(OpCode::Jump) => {
|
||||||
|
pc = self.read_u32(&mut pc) as usize;
|
||||||
|
}
|
||||||
|
Some(OpCode::JumpIfFalse) => {
|
||||||
|
let target = self.read_u32(&mut pc) as usize;
|
||||||
|
let cond = self.pop_b()?;
|
||||||
|
if !cond {
|
||||||
|
pc = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Math builtins
|
||||||
|
Some(OpCode::Sin) => { let v = self.pop_f()?; self.push_f(v.sin())?; }
|
||||||
|
Some(OpCode::Cos) => { let v = self.pop_f()?; self.push_f(v.cos())?; }
|
||||||
|
Some(OpCode::Tan) => { let v = self.pop_f()?; self.push_f(v.tan())?; }
|
||||||
|
Some(OpCode::Asin) => { let v = self.pop_f()?; self.push_f(v.asin())?; }
|
||||||
|
Some(OpCode::Acos) => { let v = self.pop_f()?; self.push_f(v.acos())?; }
|
||||||
|
Some(OpCode::Atan) => { let v = self.pop_f()?; self.push_f(v.atan())?; }
|
||||||
|
Some(OpCode::Atan2) => { let x = self.pop_f()?; let y = self.pop_f()?; self.push_f(y.atan2(x))?; }
|
||||||
|
Some(OpCode::Exp) => { let v = self.pop_f()?; self.push_f(v.exp())?; }
|
||||||
|
Some(OpCode::Log) => { let v = self.pop_f()?; self.push_f(v.ln())?; }
|
||||||
|
Some(OpCode::Log2) => { let v = self.pop_f()?; self.push_f(v.log2())?; }
|
||||||
|
Some(OpCode::Pow) => { let e = self.pop_f()?; let b = self.pop_f()?; self.push_f(b.powf(e))?; }
|
||||||
|
Some(OpCode::Sqrt) => { let v = self.pop_f()?; self.push_f(v.sqrt())?; }
|
||||||
|
Some(OpCode::Floor) => { let v = self.pop_f()?; self.push_f(v.floor())?; }
|
||||||
|
Some(OpCode::Ceil) => { let v = self.pop_f()?; self.push_f(v.ceil())?; }
|
||||||
|
Some(OpCode::Round) => { let v = self.pop_f()?; self.push_f(v.round())?; }
|
||||||
|
Some(OpCode::Trunc) => { let v = self.pop_f()?; self.push_f(v.trunc())?; }
|
||||||
|
Some(OpCode::Fract) => { let v = self.pop_f()?; self.push_f(v.fract())?; }
|
||||||
|
Some(OpCode::Abs) => { let v = self.pop_f()?; self.push_f(v.abs())?; }
|
||||||
|
Some(OpCode::Sign) => { let v = self.pop_f()?; self.push_f(v.signum())?; }
|
||||||
|
Some(OpCode::Clamp) => {
|
||||||
|
let hi = self.pop_f()?;
|
||||||
|
let lo = self.pop_f()?;
|
||||||
|
let v = self.pop_f()?;
|
||||||
|
self.push_f(v.clamp(lo, hi))?;
|
||||||
|
}
|
||||||
|
Some(OpCode::Min) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a.min(b))?; }
|
||||||
|
Some(OpCode::Max) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a.max(b))?; }
|
||||||
|
Some(OpCode::Mix) => {
|
||||||
|
let t = self.pop_f()?;
|
||||||
|
let b = self.pop_f()?;
|
||||||
|
let a = self.pop_f()?;
|
||||||
|
self.push_f(a + (b - a) * t)?;
|
||||||
|
}
|
||||||
|
Some(OpCode::Smoothstep) => {
|
||||||
|
let x = self.pop_f()?;
|
||||||
|
let e1 = self.pop_f()?;
|
||||||
|
let e0 = self.pop_f()?;
|
||||||
|
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
|
||||||
|
self.push_f(t * t * (3.0 - 2.0 * t))?;
|
||||||
|
}
|
||||||
|
Some(OpCode::IsNan) => {
|
||||||
|
let v = self.pop_f()?;
|
||||||
|
self.push_b(v.is_nan())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array length
|
||||||
|
Some(OpCode::ArrayLen) => {
|
||||||
|
let arr_id = self.read_u16(&mut pc) as usize;
|
||||||
|
let len = if arr_id < self.state_arrays.len() {
|
||||||
|
self.state_arrays[arr_id].len() as i32
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
self.push_i(len)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in constants
|
||||||
|
Some(OpCode::LoadSampleRate) => {
|
||||||
|
self.push_i(sample_rate as i32)?;
|
||||||
|
}
|
||||||
|
Some(OpCode::LoadBufferSize) => {
|
||||||
|
self.push_i(buffer_size as i32)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
None => return Err(ScriptError::InvalidOpcode(op)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stack helpers
|
||||||
|
#[inline]
|
||||||
|
fn push(&mut self, v: Value) -> Result<(), ScriptError> {
|
||||||
|
if self.sp >= STACK_SIZE {
|
||||||
|
return Err(ScriptError::StackOverflow);
|
||||||
|
}
|
||||||
|
self.stack[self.sp] = v;
|
||||||
|
self.sp += 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn push_f(&mut self, v: f32) -> Result<(), ScriptError> {
|
||||||
|
self.push(Value { f: v })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn push_i(&mut self, v: i32) -> Result<(), ScriptError> {
|
||||||
|
self.push(Value { i: v })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn push_b(&mut self, v: bool) -> Result<(), ScriptError> {
|
||||||
|
self.push(Value { b: v })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn pop(&mut self) -> Result<Value, ScriptError> {
|
||||||
|
if self.sp == 0 {
|
||||||
|
return Err(ScriptError::StackUnderflow);
|
||||||
|
}
|
||||||
|
self.sp -= 1;
|
||||||
|
Ok(self.stack[self.sp])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn pop_f(&mut self) -> Result<f32, ScriptError> {
|
||||||
|
Ok(unsafe { self.pop()?.f })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn pop_i(&mut self) -> Result<i32, ScriptError> {
|
||||||
|
Ok(unsafe { self.pop()?.i })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn pop_b(&mut self) -> Result<bool, ScriptError> {
|
||||||
|
Ok(unsafe { self.pop()?.b })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn read_u16(&self, pc: &mut usize) -> u16 {
|
||||||
|
let v = u16::from_le_bytes([self.bytecode[*pc], self.bytecode[*pc + 1]]);
|
||||||
|
*pc += 2;
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn read_u32(&self, pc: &mut usize) -> u32 {
|
||||||
|
let v = u32::from_le_bytes([
|
||||||
|
self.bytecode[*pc], self.bytecode[*pc + 1],
|
||||||
|
self.bytecode[*pc + 2], self.bytecode[*pc + 3],
|
||||||
|
]);
|
||||||
|
*pc += 4;
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ use crate::asset_folder::AssetFolderTree;
|
||||||
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
|
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
|
||||||
use crate::effect::EffectDefinition;
|
use crate::effect::EffectDefinition;
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
|
use crate::script::ScriptDefinition;
|
||||||
use crate::layout::LayoutNode;
|
use crate::layout::LayoutNode;
|
||||||
use crate::shape::ShapeColor;
|
use crate::shape::ShapeColor;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -146,6 +147,14 @@ pub struct Document {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub effect_folders: AssetFolderTree,
|
pub effect_folders: AssetFolderTree,
|
||||||
|
|
||||||
|
/// BeamDSP script definitions (audio DSP scripts for node graph)
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_definitions: HashMap<Uuid, ScriptDefinition>,
|
||||||
|
|
||||||
|
/// Folder organization for script definitions
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_folders: AssetFolderTree,
|
||||||
|
|
||||||
/// Current UI layout state (serialized for save/load)
|
/// Current UI layout state (serialized for save/load)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub ui_layout: Option<LayoutNode>,
|
pub ui_layout: Option<LayoutNode>,
|
||||||
|
|
@ -181,6 +190,8 @@ impl Default for Document {
|
||||||
audio_folders: AssetFolderTree::new(),
|
audio_folders: AssetFolderTree::new(),
|
||||||
image_folders: AssetFolderTree::new(),
|
image_folders: AssetFolderTree::new(),
|
||||||
effect_folders: AssetFolderTree::new(),
|
effect_folders: AssetFolderTree::new(),
|
||||||
|
script_definitions: HashMap::new(),
|
||||||
|
script_folders: AssetFolderTree::new(),
|
||||||
ui_layout: None,
|
ui_layout: None,
|
||||||
ui_layout_base: None,
|
ui_layout_base: None,
|
||||||
current_time: 0.0,
|
current_time: 0.0,
|
||||||
|
|
@ -494,6 +505,26 @@ impl Document {
|
||||||
self.effect_definitions.values()
|
self.effect_definitions.values()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === SCRIPT DEFINITION METHODS ===
|
||||||
|
|
||||||
|
pub fn add_script_definition(&mut self, definition: ScriptDefinition) -> Uuid {
|
||||||
|
let id = definition.id;
|
||||||
|
self.script_definitions.insert(id, definition);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_script_definition(&self, id: &Uuid) -> Option<&ScriptDefinition> {
|
||||||
|
self.script_definitions.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_script_definition_mut(&mut self, id: &Uuid) -> Option<&mut ScriptDefinition> {
|
||||||
|
self.script_definitions.get_mut(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn script_definitions(&self) -> impl Iterator<Item = &ScriptDefinition> {
|
||||||
|
self.script_definitions.values()
|
||||||
|
}
|
||||||
|
|
||||||
// === CLIP OVERLAP DETECTION METHODS ===
|
// === CLIP OVERLAP DETECTION METHODS ===
|
||||||
|
|
||||||
/// Get the duration of any clip type by ID
|
/// Get the duration of any clip type by ID
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ pub mod instance_group;
|
||||||
pub mod effect;
|
pub mod effect;
|
||||||
pub mod effect_layer;
|
pub mod effect_layer;
|
||||||
pub mod effect_registry;
|
pub mod effect_registry;
|
||||||
|
pub mod script;
|
||||||
pub mod document;
|
pub mod document;
|
||||||
pub mod renderer;
|
pub mod renderer;
|
||||||
pub mod video;
|
pub mod video;
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,9 @@ pub enum PaneType {
|
||||||
PresetBrowser,
|
PresetBrowser,
|
||||||
/// Asset library for browsing clips
|
/// Asset library for browsing clips
|
||||||
AssetLibrary,
|
AssetLibrary,
|
||||||
/// WGSL shader code editor for custom effects
|
/// Code editor for shaders and DSP scripts
|
||||||
ShaderEditor,
|
#[serde(alias = "shaderEditor")]
|
||||||
|
ScriptEditor,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PaneType {
|
impl PaneType {
|
||||||
|
|
@ -51,7 +52,7 @@ impl PaneType {
|
||||||
PaneType::NodeEditor => "Node Editor",
|
PaneType::NodeEditor => "Node Editor",
|
||||||
PaneType::PresetBrowser => "Instrument Browser",
|
PaneType::PresetBrowser => "Instrument Browser",
|
||||||
PaneType::AssetLibrary => "Asset Library",
|
PaneType::AssetLibrary => "Asset Library",
|
||||||
PaneType::ShaderEditor => "Shader Editor",
|
PaneType::ScriptEditor => "Script Editor",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +71,7 @@ impl PaneType {
|
||||||
PaneType::NodeEditor => "node-editor.svg",
|
PaneType::NodeEditor => "node-editor.svg",
|
||||||
PaneType::PresetBrowser => "stage.svg", // TODO: needs own icon
|
PaneType::PresetBrowser => "stage.svg", // TODO: needs own icon
|
||||||
PaneType::AssetLibrary => "stage.svg", // TODO: needs own icon
|
PaneType::AssetLibrary => "stage.svg", // TODO: needs own icon
|
||||||
PaneType::ShaderEditor => "node-editor.svg", // TODO: needs own icon
|
PaneType::ScriptEditor => "node-editor.svg", // TODO: needs own icon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,7 +89,7 @@ impl PaneType {
|
||||||
"nodeeditor" => Some(PaneType::NodeEditor),
|
"nodeeditor" => Some(PaneType::NodeEditor),
|
||||||
"presetbrowser" => Some(PaneType::PresetBrowser),
|
"presetbrowser" => Some(PaneType::PresetBrowser),
|
||||||
"assetlibrary" => Some(PaneType::AssetLibrary),
|
"assetlibrary" => Some(PaneType::AssetLibrary),
|
||||||
"shadereditor" => Some(PaneType::ShaderEditor),
|
"shadereditor" | "scripteditor" => Some(PaneType::ScriptEditor),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +107,7 @@ impl PaneType {
|
||||||
PaneType::VirtualPiano,
|
PaneType::VirtualPiano,
|
||||||
PaneType::PresetBrowser,
|
PaneType::PresetBrowser,
|
||||||
PaneType::AssetLibrary,
|
PaneType::AssetLibrary,
|
||||||
PaneType::ShaderEditor,
|
PaneType::ScriptEditor,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,7 +124,7 @@ impl PaneType {
|
||||||
PaneType::NodeEditor => "nodeEditor",
|
PaneType::NodeEditor => "nodeEditor",
|
||||||
PaneType::PresetBrowser => "presetBrowser",
|
PaneType::PresetBrowser => "presetBrowser",
|
||||||
PaneType::AssetLibrary => "assetLibrary",
|
PaneType::AssetLibrary => "assetLibrary",
|
||||||
PaneType::ShaderEditor => "shaderEditor",
|
PaneType::ScriptEditor => "scriptEditor",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
/// BeamDSP script definitions for the asset library
|
||||||
|
///
|
||||||
|
/// Scripts are audio DSP programs written in the BeamDSP language.
|
||||||
|
/// They live in the asset library and can be referenced by Script nodes.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// A BeamDSP script definition stored in the document
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ScriptDefinition {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub source: String,
|
||||||
|
/// Folder this script belongs to (None = root)
|
||||||
|
#[serde(default)]
|
||||||
|
pub folder_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptDefinition {
|
||||||
|
pub fn new(name: String, source: String) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name,
|
||||||
|
source,
|
||||||
|
folder_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_id(id: Uuid, name: String, source: String) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
source,
|
||||||
|
folder_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ edition = "2021"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
lightningbeam-core = { path = "../lightningbeam-core" }
|
lightningbeam-core = { path = "../lightningbeam-core" }
|
||||||
daw-backend = { path = "../../daw-backend" }
|
daw-backend = { path = "../../daw-backend" }
|
||||||
|
beamdsp = { path = "../beamdsp" }
|
||||||
rtrb = "0.3"
|
rtrb = "0.3"
|
||||||
cpal = "0.17"
|
cpal = "0.17"
|
||||||
ffmpeg-next = { version = "8.0", features = ["static"] }
|
ffmpeg-next = { version = "8.0", features = ["static"] }
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,7 @@ impl IconCache {
|
||||||
PaneType::Infopanel => pane_icons::INFOPANEL,
|
PaneType::Infopanel => pane_icons::INFOPANEL,
|
||||||
PaneType::PianoRoll => pane_icons::PIANO_ROLL,
|
PaneType::PianoRoll => pane_icons::PIANO_ROLL,
|
||||||
PaneType::VirtualPiano => pane_icons::PIANO,
|
PaneType::VirtualPiano => pane_icons::PIANO,
|
||||||
PaneType::NodeEditor | PaneType::ShaderEditor => pane_icons::NODE_EDITOR,
|
PaneType::NodeEditor | PaneType::ScriptEditor => pane_icons::NODE_EDITOR,
|
||||||
};
|
};
|
||||||
if let Some(texture) = rasterize_svg(svg_data, pane_type.icon_file(), 64, ctx) {
|
if let Some(texture) = rasterize_svg(svg_data, pane_type.icon_file(), 64, ctx) {
|
||||||
self.icons.insert(pane_type, texture);
|
self.icons.insert(pane_type, texture);
|
||||||
|
|
@ -644,8 +644,10 @@ struct EditorApp {
|
||||||
dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library
|
dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library
|
||||||
// Clipboard
|
// Clipboard
|
||||||
clipboard_manager: lightningbeam_core::clipboard::ClipboardManager,
|
clipboard_manager: lightningbeam_core::clipboard::ClipboardManager,
|
||||||
// Shader editor inter-pane communication
|
// Script editor inter-pane communication
|
||||||
effect_to_load: Option<Uuid>, // Effect ID to load into shader editor (set by asset library)
|
effect_to_load: Option<Uuid>, // Effect ID to load into shader editor (set by asset library)
|
||||||
|
script_to_edit: Option<Uuid>, // Script ID to open in editor (set by node graph)
|
||||||
|
script_saved: Option<Uuid>, // Script ID just saved (triggers auto-recompile)
|
||||||
// Effect thumbnail invalidation queue (persists across frames until processed)
|
// Effect thumbnail invalidation queue (persists across frames until processed)
|
||||||
effect_thumbnails_to_invalidate: Vec<Uuid>,
|
effect_thumbnails_to_invalidate: Vec<Uuid>,
|
||||||
// Import dialog state
|
// Import dialog state
|
||||||
|
|
@ -863,8 +865,10 @@ impl EditorApp {
|
||||||
recording_layer_id: None, // Will be set when recording starts
|
recording_layer_id: None, // Will be set when recording starts
|
||||||
dragging_asset: None, // No asset being dragged initially
|
dragging_asset: None, // No asset being dragged initially
|
||||||
clipboard_manager: lightningbeam_core::clipboard::ClipboardManager::new(),
|
clipboard_manager: lightningbeam_core::clipboard::ClipboardManager::new(),
|
||||||
effect_to_load: None, // No effect to load initially
|
effect_to_load: None,
|
||||||
effect_thumbnails_to_invalidate: Vec::new(), // No thumbnails to invalidate initially
|
script_to_edit: None,
|
||||||
|
script_saved: None,
|
||||||
|
effect_thumbnails_to_invalidate: Vec::new(),
|
||||||
last_import_filter: ImportFilter::default(), // Default to "All Supported"
|
last_import_filter: ImportFilter::default(), // Default to "All Supported"
|
||||||
stroke_width: 3.0, // Default stroke width
|
stroke_width: 3.0, // Default stroke width
|
||||||
fill_enabled: true, // Default to filling shapes
|
fill_enabled: true, // Default to filling shapes
|
||||||
|
|
@ -4455,6 +4459,8 @@ impl eframe::App for EditorApp {
|
||||||
clipboard_manager: &mut self.clipboard_manager,
|
clipboard_manager: &mut self.clipboard_manager,
|
||||||
waveform_stereo: self.config.waveform_stereo,
|
waveform_stereo: self.config.waveform_stereo,
|
||||||
project_generation: &mut self.project_generation,
|
project_generation: &mut self.project_generation,
|
||||||
|
script_to_edit: &mut self.script_to_edit,
|
||||||
|
script_saved: &mut self.script_saved,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_layout_node(
|
render_layout_node(
|
||||||
|
|
@ -4731,6 +4737,10 @@ struct RenderContext<'a> {
|
||||||
waveform_stereo: bool,
|
waveform_stereo: bool,
|
||||||
/// Project generation counter (incremented on load)
|
/// Project generation counter (incremented on load)
|
||||||
project_generation: &'a mut u64,
|
project_generation: &'a mut u64,
|
||||||
|
/// Script ID to open in the script editor (from node graph)
|
||||||
|
script_to_edit: &'a mut Option<Uuid>,
|
||||||
|
/// Script ID just saved (triggers auto-recompile of nodes using it)
|
||||||
|
script_saved: &'a mut Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively render a layout node with drag support
|
/// Recursively render a layout node with drag support
|
||||||
|
|
@ -5213,6 +5223,8 @@ fn render_pane(
|
||||||
clipboard_manager: ctx.clipboard_manager,
|
clipboard_manager: ctx.clipboard_manager,
|
||||||
waveform_stereo: ctx.waveform_stereo,
|
waveform_stereo: ctx.waveform_stereo,
|
||||||
project_generation: ctx.project_generation,
|
project_generation: ctx.project_generation,
|
||||||
|
script_to_edit: ctx.script_to_edit,
|
||||||
|
script_saved: ctx.script_saved,
|
||||||
};
|
};
|
||||||
pane_instance.render_header(&mut header_ui, &mut shared);
|
pane_instance.render_header(&mut header_ui, &mut shared);
|
||||||
}
|
}
|
||||||
|
|
@ -5284,6 +5296,8 @@ fn render_pane(
|
||||||
clipboard_manager: ctx.clipboard_manager,
|
clipboard_manager: ctx.clipboard_manager,
|
||||||
waveform_stereo: ctx.waveform_stereo,
|
waveform_stereo: ctx.waveform_stereo,
|
||||||
project_generation: ctx.project_generation,
|
project_generation: ctx.project_generation,
|
||||||
|
script_to_edit: ctx.script_to_edit,
|
||||||
|
script_saved: ctx.script_saved,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render pane content (header was already rendered above)
|
// Render pane content (header was already rendered above)
|
||||||
|
|
@ -5407,7 +5421,7 @@ fn pane_color(pane_type: PaneType) -> egui::Color32 {
|
||||||
PaneType::NodeEditor => egui::Color32::from_rgb(30, 45, 50),
|
PaneType::NodeEditor => egui::Color32::from_rgb(30, 45, 50),
|
||||||
PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30),
|
PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30),
|
||||||
PaneType::AssetLibrary => egui::Color32::from_rgb(45, 50, 35),
|
PaneType::AssetLibrary => egui::Color32::from_rgb(45, 50, 35),
|
||||||
PaneType::ShaderEditor => egui::Color32::from_rgb(35, 30, 55),
|
PaneType::ScriptEditor => egui::Color32::from_rgb(35, 30, 55),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,10 @@ pub struct SharedPaneState<'a> {
|
||||||
pub waveform_stereo: bool,
|
pub waveform_stereo: bool,
|
||||||
/// Generation counter - incremented on project load to force reloads
|
/// Generation counter - incremented on project load to force reloads
|
||||||
pub project_generation: &'a mut u64,
|
pub project_generation: &'a mut u64,
|
||||||
|
/// Script ID to open in the script editor (set by node graph "Edit Script" action)
|
||||||
|
pub script_to_edit: &'a mut Option<Uuid>,
|
||||||
|
/// Script ID that was just saved (triggers auto-recompile of nodes using it)
|
||||||
|
pub script_saved: &'a mut Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trait for pane rendering
|
/// Trait for pane rendering
|
||||||
|
|
@ -260,7 +264,7 @@ pub enum PaneInstance {
|
||||||
NodeEditor(node_editor::NodeEditorPane),
|
NodeEditor(node_editor::NodeEditorPane),
|
||||||
PresetBrowser(preset_browser::PresetBrowserPane),
|
PresetBrowser(preset_browser::PresetBrowserPane),
|
||||||
AssetLibrary(asset_library::AssetLibraryPane),
|
AssetLibrary(asset_library::AssetLibraryPane),
|
||||||
ShaderEditor(shader_editor::ShaderEditorPane),
|
ScriptEditor(shader_editor::ShaderEditorPane),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PaneInstance {
|
impl PaneInstance {
|
||||||
|
|
@ -281,8 +285,8 @@ impl PaneInstance {
|
||||||
PaneType::AssetLibrary => {
|
PaneType::AssetLibrary => {
|
||||||
PaneInstance::AssetLibrary(asset_library::AssetLibraryPane::new())
|
PaneInstance::AssetLibrary(asset_library::AssetLibraryPane::new())
|
||||||
}
|
}
|
||||||
PaneType::ShaderEditor => {
|
PaneType::ScriptEditor => {
|
||||||
PaneInstance::ShaderEditor(shader_editor::ShaderEditorPane::new())
|
PaneInstance::ScriptEditor(shader_editor::ShaderEditorPane::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -300,7 +304,7 @@ impl PaneInstance {
|
||||||
PaneInstance::NodeEditor(_) => PaneType::NodeEditor,
|
PaneInstance::NodeEditor(_) => PaneType::NodeEditor,
|
||||||
PaneInstance::PresetBrowser(_) => PaneType::PresetBrowser,
|
PaneInstance::PresetBrowser(_) => PaneType::PresetBrowser,
|
||||||
PaneInstance::AssetLibrary(_) => PaneType::AssetLibrary,
|
PaneInstance::AssetLibrary(_) => PaneType::AssetLibrary,
|
||||||
PaneInstance::ShaderEditor(_) => PaneType::ShaderEditor,
|
PaneInstance::ScriptEditor(_) => PaneType::ScriptEditor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -318,7 +322,7 @@ impl PaneRenderer for PaneInstance {
|
||||||
PaneInstance::NodeEditor(p) => p.render_header(ui, shared),
|
PaneInstance::NodeEditor(p) => p.render_header(ui, shared),
|
||||||
PaneInstance::PresetBrowser(p) => p.render_header(ui, shared),
|
PaneInstance::PresetBrowser(p) => p.render_header(ui, shared),
|
||||||
PaneInstance::AssetLibrary(p) => p.render_header(ui, shared),
|
PaneInstance::AssetLibrary(p) => p.render_header(ui, shared),
|
||||||
PaneInstance::ShaderEditor(p) => p.render_header(ui, shared),
|
PaneInstance::ScriptEditor(p) => p.render_header(ui, shared),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,7 +344,7 @@ impl PaneRenderer for PaneInstance {
|
||||||
PaneInstance::NodeEditor(p) => p.render_content(ui, rect, path, shared),
|
PaneInstance::NodeEditor(p) => p.render_content(ui, rect, path, shared),
|
||||||
PaneInstance::PresetBrowser(p) => p.render_content(ui, rect, path, shared),
|
PaneInstance::PresetBrowser(p) => p.render_content(ui, rect, path, shared),
|
||||||
PaneInstance::AssetLibrary(p) => p.render_content(ui, rect, path, shared),
|
PaneInstance::AssetLibrary(p) => p.render_content(ui, rect, path, shared),
|
||||||
PaneInstance::ShaderEditor(p) => p.render_content(ui, rect, path, shared),
|
PaneInstance::ScriptEditor(p) => p.render_content(ui, rect, path, shared),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,7 +360,7 @@ impl PaneRenderer for PaneInstance {
|
||||||
PaneInstance::NodeEditor(p) => p.name(),
|
PaneInstance::NodeEditor(p) => p.name(),
|
||||||
PaneInstance::PresetBrowser(p) => p.name(),
|
PaneInstance::PresetBrowser(p) => p.name(),
|
||||||
PaneInstance::AssetLibrary(p) => p.name(),
|
PaneInstance::AssetLibrary(p) => p.name(),
|
||||||
PaneInstance::ShaderEditor(p) => p.name(),
|
PaneInstance::ScriptEditor(p) => p.name(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,9 @@ pub enum NodeTemplate {
|
||||||
BpmDetector,
|
BpmDetector,
|
||||||
Mod,
|
Mod,
|
||||||
|
|
||||||
|
// Scripting
|
||||||
|
Script,
|
||||||
|
|
||||||
// Analysis
|
// Analysis
|
||||||
Oscilloscope,
|
Oscilloscope,
|
||||||
|
|
||||||
|
|
@ -127,6 +130,7 @@ impl NodeTemplate {
|
||||||
NodeTemplate::EnvelopeFollower => "EnvelopeFollower",
|
NodeTemplate::EnvelopeFollower => "EnvelopeFollower",
|
||||||
NodeTemplate::BpmDetector => "BpmDetector",
|
NodeTemplate::BpmDetector => "BpmDetector",
|
||||||
NodeTemplate::Beat => "Beat",
|
NodeTemplate::Beat => "Beat",
|
||||||
|
NodeTemplate::Script => "Script",
|
||||||
NodeTemplate::Mod => "Mod",
|
NodeTemplate::Mod => "Mod",
|
||||||
NodeTemplate::Oscilloscope => "Oscilloscope",
|
NodeTemplate::Oscilloscope => "Oscilloscope",
|
||||||
NodeTemplate::VoiceAllocator => "VoiceAllocator",
|
NodeTemplate::VoiceAllocator => "VoiceAllocator",
|
||||||
|
|
@ -148,6 +152,18 @@ pub struct NodeData {
|
||||||
/// Root note (MIDI note number) for original-pitch playback (default 69 = A4)
|
/// Root note (MIDI note number) for original-pitch playback (default 69 = A4)
|
||||||
#[serde(default = "default_root_note")]
|
#[serde(default = "default_root_note")]
|
||||||
pub root_note: u8,
|
pub root_note: u8,
|
||||||
|
/// BeamDSP script asset ID (for Script nodes — references a ScriptDefinition in the document)
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_id: Option<uuid::Uuid>,
|
||||||
|
/// Declarative UI from compiled BeamDSP script (for rendering sample pickers, groups)
|
||||||
|
#[serde(skip)]
|
||||||
|
pub ui_declaration: Option<beamdsp::UiDeclaration>,
|
||||||
|
/// Sample slot names from compiled script (index → name, for sample picker mapping)
|
||||||
|
#[serde(skip)]
|
||||||
|
pub sample_slot_names: Vec<String>,
|
||||||
|
/// Display names of loaded samples per slot (slot_index → display name)
|
||||||
|
#[serde(skip)]
|
||||||
|
pub script_sample_names: HashMap<usize, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_root_note() -> u8 { 69 }
|
fn default_root_note() -> u8 { 69 }
|
||||||
|
|
@ -172,6 +188,14 @@ pub struct SamplerFolderInfo {
|
||||||
pub clip_pool_indices: Vec<(String, usize)>,
|
pub clip_pool_indices: Vec<(String, usize)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pending script sample load request from bottom_ui(), handled by the node graph pane
|
||||||
|
pub enum PendingScriptSampleLoad {
|
||||||
|
/// Load from audio pool into a script sample slot
|
||||||
|
FromPool { node_id: NodeId, backend_node_id: u32, slot_index: usize, pool_index: usize, name: String },
|
||||||
|
/// Open file dialog to load into a script sample slot
|
||||||
|
FromFile { node_id: NodeId, backend_node_id: u32, slot_index: usize },
|
||||||
|
}
|
||||||
|
|
||||||
/// Pending sampler load request from bottom_ui(), handled by the node graph pane
|
/// Pending sampler load request from bottom_ui(), handled by the node graph pane
|
||||||
pub enum PendingSamplerLoad {
|
pub enum PendingSamplerLoad {
|
||||||
/// Load a single clip from the audio pool into a SimpleSampler
|
/// Load a single clip from the audio pool into a SimpleSampler
|
||||||
|
|
@ -207,6 +231,16 @@ pub struct GraphState {
|
||||||
pub pending_sequencer_changes: Vec<(NodeId, u32, f32)>,
|
pub pending_sequencer_changes: Vec<(NodeId, u32, f32)>,
|
||||||
/// Time scale per oscilloscope node (in milliseconds)
|
/// Time scale per oscilloscope node (in milliseconds)
|
||||||
pub oscilloscope_time_scale: HashMap<NodeId, f32>,
|
pub oscilloscope_time_scale: HashMap<NodeId, f32>,
|
||||||
|
/// Available scripts for Script node dropdown, populated before draw
|
||||||
|
pub available_scripts: Vec<(uuid::Uuid, String)>,
|
||||||
|
/// Pending script assignment from dropdown (node_id, script_id)
|
||||||
|
pub pending_script_assignment: Option<(NodeId, uuid::Uuid)>,
|
||||||
|
/// Pending "New script..." from dropdown (node_id) — create new script and open in editor
|
||||||
|
pub pending_new_script: Option<NodeId>,
|
||||||
|
/// Pending "Load from file..." from dropdown (node_id) — open file dialog for .bdsp
|
||||||
|
pub pending_load_script_file: Option<NodeId>,
|
||||||
|
/// Pending script sample load request from bottom_ui sample picker
|
||||||
|
pub pending_script_sample_load: Option<PendingScriptSampleLoad>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for GraphState {
|
impl Default for GraphState {
|
||||||
|
|
@ -222,6 +256,11 @@ impl Default for GraphState {
|
||||||
pending_root_note_changes: Vec::new(),
|
pending_root_note_changes: Vec::new(),
|
||||||
pending_sequencer_changes: Vec::new(),
|
pending_sequencer_changes: Vec::new(),
|
||||||
oscilloscope_time_scale: HashMap::new(),
|
oscilloscope_time_scale: HashMap::new(),
|
||||||
|
available_scripts: Vec::new(),
|
||||||
|
pending_script_assignment: None,
|
||||||
|
pending_new_script: None,
|
||||||
|
pending_load_script_file: None,
|
||||||
|
pending_script_sample_load: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -373,6 +412,8 @@ impl NodeTemplateTrait for NodeTemplate {
|
||||||
NodeTemplate::BpmDetector => "BPM Detector".into(),
|
NodeTemplate::BpmDetector => "BPM Detector".into(),
|
||||||
NodeTemplate::Beat => "Beat".into(),
|
NodeTemplate::Beat => "Beat".into(),
|
||||||
NodeTemplate::Mod => "Modulator".into(),
|
NodeTemplate::Mod => "Modulator".into(),
|
||||||
|
// Scripting
|
||||||
|
NodeTemplate::Script => "Script".into(),
|
||||||
// Analysis
|
// Analysis
|
||||||
NodeTemplate::Oscilloscope => "Oscilloscope".into(),
|
NodeTemplate::Oscilloscope => "Oscilloscope".into(),
|
||||||
// Advanced
|
// Advanced
|
||||||
|
|
@ -399,6 +440,7 @@ impl NodeTemplateTrait for NodeTemplate {
|
||||||
| NodeTemplate::Constant | NodeTemplate::MidiToCv | NodeTemplate::AudioToCv | NodeTemplate::Arpeggiator | NodeTemplate::Sequencer | NodeTemplate::Math
|
| NodeTemplate::Constant | NodeTemplate::MidiToCv | NodeTemplate::AudioToCv | NodeTemplate::Arpeggiator | NodeTemplate::Sequencer | NodeTemplate::Math
|
||||||
| NodeTemplate::SampleHold | NodeTemplate::SlewLimiter | NodeTemplate::Quantizer
|
| NodeTemplate::SampleHold | NodeTemplate::SlewLimiter | NodeTemplate::Quantizer
|
||||||
| NodeTemplate::EnvelopeFollower | NodeTemplate::BpmDetector | NodeTemplate::Mod => vec!["Utilities"],
|
| NodeTemplate::EnvelopeFollower | NodeTemplate::BpmDetector | NodeTemplate::Mod => vec!["Utilities"],
|
||||||
|
NodeTemplate::Script => vec!["Advanced"],
|
||||||
NodeTemplate::Oscilloscope => vec!["Analysis"],
|
NodeTemplate::Oscilloscope => vec!["Analysis"],
|
||||||
NodeTemplate::VoiceAllocator | NodeTemplate::Group => vec!["Advanced"],
|
NodeTemplate::VoiceAllocator | NodeTemplate::Group => vec!["Advanced"],
|
||||||
NodeTemplate::TemplateInput | NodeTemplate::TemplateOutput => vec!["Subgraph I/O"],
|
NodeTemplate::TemplateInput | NodeTemplate::TemplateOutput => vec!["Subgraph I/O"],
|
||||||
|
|
@ -411,7 +453,7 @@ impl NodeTemplateTrait for NodeTemplate {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn user_data(&self, _user_state: &mut Self::UserState) -> Self::NodeData {
|
fn user_data(&self, _user_state: &mut Self::UserState) -> Self::NodeData {
|
||||||
NodeData { template: *self, sample_display_name: None, root_note: 69 }
|
NodeData { template: *self, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_node(
|
fn build_node(
|
||||||
|
|
@ -856,6 +898,12 @@ impl NodeTemplateTrait for NodeTemplate {
|
||||||
// Inside a VA template: sends audio back to the allocator
|
// Inside a VA template: sends audio back to the allocator
|
||||||
graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||||
}
|
}
|
||||||
|
NodeTemplate::Script => {
|
||||||
|
// Default Script node: single audio in/out
|
||||||
|
// Ports will be rebuilt when a script is compiled
|
||||||
|
graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||||
|
graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1276,6 +1324,57 @@ impl NodeDataTrait for NodeData {
|
||||||
user_state.pending_sequencer_changes.push((node_id, param_id, new_bitmask as f32));
|
user_state.pending_sequencer_changes.push((node_id, param_id, new_bitmask as f32));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if self.template == NodeTemplate::Script {
|
||||||
|
let current_name = self.script_id
|
||||||
|
.and_then(|id| user_state.available_scripts.iter().find(|(sid, _)| *sid == id))
|
||||||
|
.map(|(_, name)| name.as_str())
|
||||||
|
.unwrap_or("No script");
|
||||||
|
|
||||||
|
let button = ui.button(current_name);
|
||||||
|
let popup_id = egui::Popup::default_response_id(&button);
|
||||||
|
let mut close_popup = false;
|
||||||
|
|
||||||
|
egui::Popup::from_toggle_button_response(&button)
|
||||||
|
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||||
|
.width(160.0)
|
||||||
|
.show(|ui| {
|
||||||
|
if widgets::list_item(ui, false, "New script...") {
|
||||||
|
user_state.pending_new_script = Some(node_id);
|
||||||
|
close_popup = true;
|
||||||
|
}
|
||||||
|
if widgets::list_item(ui, false, "Load from file...") {
|
||||||
|
user_state.pending_load_script_file = Some(node_id);
|
||||||
|
close_popup = true;
|
||||||
|
}
|
||||||
|
if !user_state.available_scripts.is_empty() {
|
||||||
|
ui.separator();
|
||||||
|
}
|
||||||
|
for (script_id, script_name) in &user_state.available_scripts {
|
||||||
|
let selected = self.script_id == Some(*script_id);
|
||||||
|
if widgets::list_item(ui, selected, script_name) {
|
||||||
|
user_state.pending_script_assignment = Some((node_id, *script_id));
|
||||||
|
close_popup = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if close_popup {
|
||||||
|
egui::Popup::close_id(ui.ctx(), popup_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render declarative UI elements (sample pickers, groups)
|
||||||
|
if let Some(ref ui_decl) = self.ui_declaration {
|
||||||
|
let backend_node_id = user_state.node_backend_ids.get(&node_id).copied().unwrap_or(0);
|
||||||
|
render_script_ui_elements(
|
||||||
|
ui, node_id, backend_node_id,
|
||||||
|
&ui_decl.elements,
|
||||||
|
&self.sample_slot_names,
|
||||||
|
&self.script_sample_names,
|
||||||
|
&user_state.available_clips,
|
||||||
|
&mut user_state.sampler_search_text,
|
||||||
|
&mut user_state.pending_script_sample_load,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ui.label("");
|
ui.label("");
|
||||||
}
|
}
|
||||||
|
|
@ -1283,6 +1382,91 @@ impl NodeDataTrait for NodeData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render UiDeclaration elements for Script nodes (sample pickers, groups, spacers)
|
||||||
|
fn render_script_ui_elements(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
node_id: NodeId,
|
||||||
|
backend_node_id: u32,
|
||||||
|
elements: &[beamdsp::UiElement],
|
||||||
|
sample_slot_names: &[String],
|
||||||
|
script_sample_names: &HashMap<usize, String>,
|
||||||
|
available_clips: &[SamplerClipInfo],
|
||||||
|
search_text: &mut String,
|
||||||
|
pending_load: &mut Option<PendingScriptSampleLoad>,
|
||||||
|
) {
|
||||||
|
for element in elements {
|
||||||
|
match element {
|
||||||
|
beamdsp::UiElement::Sample(slot_name) => {
|
||||||
|
// Find the slot index by name
|
||||||
|
let slot_index = sample_slot_names.iter().position(|n| n == slot_name);
|
||||||
|
let display = script_sample_names
|
||||||
|
.get(&slot_index.unwrap_or(usize::MAX))
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("No sample");
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(egui::RichText::new(slot_name).weak());
|
||||||
|
let button = ui.button(display);
|
||||||
|
if let Some(slot_idx) = slot_index {
|
||||||
|
let popup_id = egui::Popup::default_response_id(&button);
|
||||||
|
let mut close = false;
|
||||||
|
egui::Popup::from_toggle_button_response(&button)
|
||||||
|
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||||
|
.width(160.0)
|
||||||
|
.show(|ui| {
|
||||||
|
let search = search_text.to_lowercase();
|
||||||
|
let filtered: Vec<&SamplerClipInfo> = available_clips.iter()
|
||||||
|
.filter(|c| search.is_empty() || c.name.to_lowercase().contains(&search))
|
||||||
|
.collect();
|
||||||
|
let items = filtered.iter().map(|c| (false, c.name.as_str()));
|
||||||
|
if let Some(idx) = widgets::scrollable_list(ui, 200.0, items) {
|
||||||
|
let clip = filtered[idx];
|
||||||
|
*pending_load = Some(PendingScriptSampleLoad::FromPool {
|
||||||
|
node_id,
|
||||||
|
backend_node_id,
|
||||||
|
slot_index: slot_idx,
|
||||||
|
pool_index: clip.pool_index,
|
||||||
|
name: clip.name.clone(),
|
||||||
|
});
|
||||||
|
close = true;
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("Open...").clicked() {
|
||||||
|
*pending_load = Some(PendingScriptSampleLoad::FromFile {
|
||||||
|
node_id,
|
||||||
|
backend_node_id,
|
||||||
|
slot_index: slot_idx,
|
||||||
|
});
|
||||||
|
close = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if close {
|
||||||
|
egui::Popup::close_id(ui.ctx(), popup_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
beamdsp::UiElement::Group { label, children } => {
|
||||||
|
egui::CollapsingHeader::new(egui::RichText::new(label).weak())
|
||||||
|
.default_open(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
render_script_ui_elements(
|
||||||
|
ui, node_id, backend_node_id,
|
||||||
|
children, sample_slot_names, script_sample_names,
|
||||||
|
available_clips, search_text, pending_load,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
beamdsp::UiElement::Spacer(height) => {
|
||||||
|
ui.add_space(*height);
|
||||||
|
}
|
||||||
|
beamdsp::UiElement::Param(_) | beamdsp::UiElement::Canvas { .. } => {
|
||||||
|
// Params are handled as inline input ports; Canvas is phase 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Iterator for all node templates (track-level graph)
|
// Iterator for all node templates (track-level graph)
|
||||||
pub struct AllNodeTemplates;
|
pub struct AllNodeTemplates;
|
||||||
|
|
||||||
|
|
@ -1370,6 +1554,7 @@ impl NodeTemplateIter for AllNodeTemplates {
|
||||||
NodeTemplate::Oscilloscope,
|
NodeTemplate::Oscilloscope,
|
||||||
// Advanced
|
// Advanced
|
||||||
NodeTemplate::VoiceAllocator,
|
NodeTemplate::VoiceAllocator,
|
||||||
|
NodeTemplate::Script,
|
||||||
// Note: Group is not in the node finder — groups are created via Ctrl+G selection.
|
// Note: Group is not in the node finder — groups are created via Ctrl+G selection.
|
||||||
// Note: TemplateInput/TemplateOutput are excluded from the default finder.
|
// Note: TemplateInput/TemplateOutput are excluded from the default finder.
|
||||||
// They are added dynamically when editing inside a subgraph.
|
// They are added dynamically when editing inside a subgraph.
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,10 @@ pub struct NodeGraphPane {
|
||||||
/// Cached node screen rects from last frame (for hit-testing)
|
/// Cached node screen rects from last frame (for hit-testing)
|
||||||
last_node_rects: std::collections::HashMap<NodeId, egui::Rect>,
|
last_node_rects: std::collections::HashMap<NodeId, egui::Rect>,
|
||||||
|
|
||||||
|
/// Script nodes loaded from preset that need script_id resolution
|
||||||
|
/// (frontend_node_id, script_source) — processed in render loop where document is available
|
||||||
|
pending_script_resolutions: Vec<(NodeId, String)>,
|
||||||
|
|
||||||
/// Last time we polled oscilloscope data (~20 FPS)
|
/// Last time we polled oscilloscope data (~20 FPS)
|
||||||
last_oscilloscope_poll: std::time::Instant,
|
last_oscilloscope_poll: std::time::Instant,
|
||||||
/// Backend track ID (u32) for oscilloscope queries
|
/// Backend track ID (u32) for oscilloscope queries
|
||||||
|
|
@ -167,6 +171,7 @@ impl NodeGraphPane {
|
||||||
renaming_group: None,
|
renaming_group: None,
|
||||||
node_context_menu: None,
|
node_context_menu: None,
|
||||||
last_node_rects: HashMap::new(),
|
last_node_rects: HashMap::new(),
|
||||||
|
pending_script_resolutions: Vec::new(),
|
||||||
last_oscilloscope_poll: std::time::Instant::now(),
|
last_oscilloscope_poll: std::time::Instant::now(),
|
||||||
backend_track_id: None,
|
backend_track_id: None,
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +208,7 @@ impl NodeGraphPane {
|
||||||
renaming_group: None,
|
renaming_group: None,
|
||||||
node_context_menu: None,
|
node_context_menu: None,
|
||||||
last_node_rects: HashMap::new(),
|
last_node_rects: HashMap::new(),
|
||||||
|
pending_script_resolutions: Vec::new(),
|
||||||
last_oscilloscope_poll: std::time::Instant::now(),
|
last_oscilloscope_poll: std::time::Instant::now(),
|
||||||
backend_track_id: Some(backend_track_id),
|
backend_track_id: Some(backend_track_id),
|
||||||
};
|
};
|
||||||
|
|
@ -226,6 +232,167 @@ impl NodeGraphPane {
|
||||||
self.load_graph_from_json(&json)
|
self.load_graph_from_json(&json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rebuild a Script node's ports and parameters to match a compiled script.
|
||||||
|
/// Performs a diff: ports with matching name+type keep their connections,
|
||||||
|
/// removed ports lose connections, new ports are added.
|
||||||
|
/// Parameters are added as ConnectionOrConstant inputs with inline widgets.
|
||||||
|
fn rebuild_script_node_ports(&mut self, node_id: NodeId, compiled: &beamdsp::CompiledScript) {
|
||||||
|
let signal_to_data_type = |sig: beamdsp::ast::SignalKind| match sig {
|
||||||
|
beamdsp::ast::SignalKind::Audio => DataType::Audio,
|
||||||
|
beamdsp::ast::SignalKind::Cv => DataType::CV,
|
||||||
|
beamdsp::ast::SignalKind::Midi => DataType::Midi,
|
||||||
|
};
|
||||||
|
|
||||||
|
let unit_str = |u: &str| -> &'static str {
|
||||||
|
match u { "Hz" => " Hz", "s" => " s", "dB" => " dB", "%" => "%", _ => "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect what the new inputs should be: signal ports + param ports
|
||||||
|
// Signal ports use DataType matching their signal kind, ConnectionOnly
|
||||||
|
// Param ports use DataType::CV, ConnectionOrConstant with float_param value
|
||||||
|
let num_signal_inputs = compiled.input_ports.len();
|
||||||
|
let num_params = compiled.parameters.len();
|
||||||
|
let num_signal_outputs = compiled.output_ports.len();
|
||||||
|
|
||||||
|
// Check if everything already matches (ports + params + outputs)
|
||||||
|
let already_matches = if let Some(node) = self.state.graph.nodes.get(node_id) {
|
||||||
|
let expected_inputs = num_signal_inputs + num_params;
|
||||||
|
if node.inputs.len() != expected_inputs || node.outputs.len() != num_signal_outputs {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
// Check signal inputs
|
||||||
|
let signals_match = node.inputs[..num_signal_inputs].iter()
|
||||||
|
.zip(&compiled.input_ports)
|
||||||
|
.all(|((name, id), port)| {
|
||||||
|
name == &port.name
|
||||||
|
&& self.state.graph.inputs.get(*id)
|
||||||
|
.map_or(false, |p| p.typ == signal_to_data_type(port.signal))
|
||||||
|
});
|
||||||
|
// Check param inputs
|
||||||
|
let params_match = node.inputs[num_signal_inputs..].iter()
|
||||||
|
.zip(&compiled.parameters)
|
||||||
|
.all(|((name, id), param)| {
|
||||||
|
name == ¶m.name
|
||||||
|
&& self.state.graph.inputs.get(*id)
|
||||||
|
.map_or(false, |p| p.typ == DataType::CV)
|
||||||
|
});
|
||||||
|
// Check outputs
|
||||||
|
let outputs_match = node.outputs.iter()
|
||||||
|
.zip(&compiled.output_ports)
|
||||||
|
.all(|((name, id), port)| {
|
||||||
|
name == &port.name
|
||||||
|
&& self.state.graph.outputs.get(*id)
|
||||||
|
.map_or(false, |p| p.typ == signal_to_data_type(port.signal))
|
||||||
|
});
|
||||||
|
signals_match && params_match && outputs_match
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if already_matches {
|
||||||
|
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
||||||
|
node.label = compiled.name.clone();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build lookup of existing inputs: (name, type, kind) → InputId
|
||||||
|
let old_inputs: Vec<(String, InputId, DataType, InputParamKind)> = self.state.graph.nodes.get(node_id)
|
||||||
|
.map(|n| n.inputs.iter().filter_map(|(name, id)| {
|
||||||
|
let p = self.state.graph.inputs.get(*id)?;
|
||||||
|
Some((name.clone(), *id, p.typ, p.kind))
|
||||||
|
}).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let old_outputs: Vec<(String, OutputId, DataType)> = self.state.graph.nodes.get(node_id)
|
||||||
|
.map(|n| n.outputs.iter().filter_map(|(name, id)| {
|
||||||
|
let typ = self.state.graph.outputs.get(*id)?.typ;
|
||||||
|
Some((name.clone(), *id, typ))
|
||||||
|
}).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Match signal inputs
|
||||||
|
let mut used_old_inputs: HashSet<InputId> = HashSet::new();
|
||||||
|
let mut new_input_ids: Vec<(String, InputId)> = Vec::new();
|
||||||
|
|
||||||
|
for port in &compiled.input_ports {
|
||||||
|
let dt = signal_to_data_type(port.signal);
|
||||||
|
if let Some((_, old_id, _, _)) = old_inputs.iter().find(|(name, id, typ, kind)| {
|
||||||
|
name == &port.name && *typ == dt
|
||||||
|
&& matches!(kind, InputParamKind::ConnectionOnly)
|
||||||
|
&& !used_old_inputs.contains(id)
|
||||||
|
}) {
|
||||||
|
used_old_inputs.insert(*old_id);
|
||||||
|
new_input_ids.push((port.name.clone(), *old_id));
|
||||||
|
} else {
|
||||||
|
let id = self.state.graph.add_input_param(
|
||||||
|
node_id, port.name.clone(), dt,
|
||||||
|
ValueType::float(0.0), InputParamKind::ConnectionOnly, true,
|
||||||
|
);
|
||||||
|
new_input_ids.push((port.name.clone(), id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match param inputs
|
||||||
|
for (i, param) in compiled.parameters.iter().enumerate() {
|
||||||
|
if let Some((_, old_id, _, _)) = old_inputs.iter().find(|(name, id, typ, kind)| {
|
||||||
|
name == ¶m.name && *typ == DataType::CV
|
||||||
|
&& matches!(kind, InputParamKind::ConnectionOrConstant)
|
||||||
|
&& !used_old_inputs.contains(id)
|
||||||
|
}) {
|
||||||
|
used_old_inputs.insert(*old_id);
|
||||||
|
new_input_ids.push((param.name.clone(), *old_id));
|
||||||
|
} else {
|
||||||
|
let id = self.state.graph.add_input_param(
|
||||||
|
node_id, param.name.clone(), DataType::CV,
|
||||||
|
ValueType::float_param(param.default, param.min, param.max, unit_str(¶m.unit), i as u32, None),
|
||||||
|
InputParamKind::ConnectionOrConstant, true,
|
||||||
|
);
|
||||||
|
new_input_ids.push((param.name.clone(), id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old inputs that weren't reused
|
||||||
|
for (_, old_id, _, _) in &old_inputs {
|
||||||
|
if !used_old_inputs.contains(old_id) {
|
||||||
|
self.state.graph.remove_input_param(*old_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match outputs
|
||||||
|
let mut used_old_outputs: HashSet<OutputId> = HashSet::new();
|
||||||
|
let mut new_output_ids: Vec<(String, OutputId)> = Vec::new();
|
||||||
|
|
||||||
|
for port in &compiled.output_ports {
|
||||||
|
let dt = signal_to_data_type(port.signal);
|
||||||
|
if let Some((_, old_id, _)) = old_outputs.iter().find(|(name, id, typ)| {
|
||||||
|
name == &port.name && *typ == dt && !used_old_outputs.contains(id)
|
||||||
|
}) {
|
||||||
|
used_old_outputs.insert(*old_id);
|
||||||
|
new_output_ids.push((port.name.clone(), *old_id));
|
||||||
|
} else {
|
||||||
|
let id = self.state.graph.add_output_param(node_id, port.name.clone(), dt);
|
||||||
|
new_output_ids.push((port.name.clone(), id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (_, old_id, _) in &old_outputs {
|
||||||
|
if !used_old_outputs.contains(old_id) {
|
||||||
|
self.state.graph.remove_output_param(*old_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the node's port ordering and UI metadata
|
||||||
|
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
||||||
|
node.inputs = new_input_ids;
|
||||||
|
node.outputs = new_output_ids;
|
||||||
|
node.label = compiled.name.clone();
|
||||||
|
node.user_data.ui_declaration = Some(compiled.ui_declaration.clone());
|
||||||
|
node.user_data.sample_slot_names = compiled.sample_slots.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_graph_response(
|
fn handle_graph_response(
|
||||||
&mut self,
|
&mut self,
|
||||||
response: egui_node_graph2::GraphResponse<
|
response: egui_node_graph2::GraphResponse<
|
||||||
|
|
@ -680,6 +847,82 @@ impl NodeGraphPane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_pending_script_sample_load(
|
||||||
|
&mut self,
|
||||||
|
load: graph_data::PendingScriptSampleLoad,
|
||||||
|
shared: &mut crate::panes::SharedPaneState,
|
||||||
|
) {
|
||||||
|
let backend_track_id = match self.backend_track_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let controller_arc = match &shared.audio_controller {
|
||||||
|
Some(c) => std::sync::Arc::clone(c),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
match load {
|
||||||
|
graph_data::PendingScriptSampleLoad::FromPool { node_id, backend_node_id, slot_index, pool_index, name } => {
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
match controller.get_pool_audio_samples(pool_index) {
|
||||||
|
Ok((samples, sample_rate, _channels)) => {
|
||||||
|
controller.send_command(daw_backend::Command::GraphSetScriptSample(
|
||||||
|
backend_track_id, backend_node_id, slot_index,
|
||||||
|
samples, sample_rate, name.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to get pool audio for script sample: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
||||||
|
node.user_data.script_sample_names.insert(slot_index, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graph_data::PendingScriptSampleLoad::FromFile { node_id, backend_node_id, slot_index } => {
|
||||||
|
if let Some(path) = rfd::FileDialog::new()
|
||||||
|
.add_filter("Audio", &["wav", "flac", "mp3", "ogg", "aiff"])
|
||||||
|
.pick_file()
|
||||||
|
{
|
||||||
|
let file_name = path.file_stem()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "Sample".to_string());
|
||||||
|
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
match controller.import_audio_sync(path.to_path_buf()) {
|
||||||
|
Ok(pool_index) => {
|
||||||
|
// Add to document asset library
|
||||||
|
let metadata = daw_backend::io::read_metadata(&path).ok();
|
||||||
|
let duration = metadata.as_ref().map(|m| m.duration).unwrap_or(0.0);
|
||||||
|
let clip = lightningbeam_core::clip::AudioClip::new_sampled(&file_name, pool_index, duration);
|
||||||
|
shared.action_executor.document_mut().add_audio_clip(clip);
|
||||||
|
|
||||||
|
// Get the audio data and send to script node
|
||||||
|
match controller.get_pool_audio_samples(pool_index) {
|
||||||
|
Ok((samples, sample_rate, _channels)) => {
|
||||||
|
controller.send_command(daw_backend::Command::GraphSetScriptSample(
|
||||||
|
backend_track_id, backend_node_id, slot_index,
|
||||||
|
samples, sample_rate, file_name.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to get pool audio for script sample: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to import audio '{}': {}", path.display(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
||||||
|
node.user_data.script_sample_names.insert(slot_index, file_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn check_parameter_changes(&mut self, shared: &mut crate::panes::SharedPaneState) {
|
fn check_parameter_changes(&mut self, shared: &mut crate::panes::SharedPaneState) {
|
||||||
// Check all input parameters for value changes
|
// Check all input parameters for value changes
|
||||||
let mut _checked_count = 0;
|
let mut _checked_count = 0;
|
||||||
|
|
@ -1217,6 +1460,7 @@ impl NodeGraphPane {
|
||||||
self.backend_to_frontend_map.clear();
|
self.backend_to_frontend_map.clear();
|
||||||
|
|
||||||
// Create nodes in frontend
|
// Create nodes in frontend
|
||||||
|
self.pending_script_resolutions.clear();
|
||||||
for node in &graph_state.nodes {
|
for node in &graph_state.nodes {
|
||||||
let node_template = match Self::backend_type_to_template(&node.node_type) {
|
let node_template = match Self::backend_type_to_template(&node.node_type) {
|
||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
|
|
@ -1226,7 +1470,21 @@ impl NodeGraphPane {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.add_node_to_editor(node_template, &node.node_type, node.position, node.id, &node.parameters);
|
let frontend_id = self.add_node_to_editor(node_template, &node.node_type, node.position, node.id, &node.parameters);
|
||||||
|
|
||||||
|
// For Script nodes: rebuild ports now (before connections), defer script_id resolution
|
||||||
|
if node.node_type == "Script" {
|
||||||
|
if let Some(ref source) = node.script_source {
|
||||||
|
if let Some(fid) = frontend_id {
|
||||||
|
// Rebuild ports/params immediately so connections map correctly
|
||||||
|
if let Ok(compiled) = beamdsp::compile(source) {
|
||||||
|
self.rebuild_script_node_ports(fid, &compiled);
|
||||||
|
}
|
||||||
|
// Defer script_id resolution to render loop (needs document access)
|
||||||
|
self.pending_script_resolutions.push((fid, source.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create connections in frontend
|
// Create connections in frontend
|
||||||
|
|
@ -1674,7 +1932,7 @@ impl NodeGraphPane {
|
||||||
label: group.name.clone(),
|
label: group.name.clone(),
|
||||||
inputs: vec![],
|
inputs: vec![],
|
||||||
outputs: vec![],
|
outputs: vec![],
|
||||||
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 },
|
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add dynamic input ports based on boundary inputs
|
// Add dynamic input ports based on boundary inputs
|
||||||
|
|
@ -1746,7 +2004,7 @@ impl NodeGraphPane {
|
||||||
label: "Group Input".to_string(),
|
label: "Group Input".to_string(),
|
||||||
inputs: vec![],
|
inputs: vec![],
|
||||||
outputs: vec![],
|
outputs: vec![],
|
||||||
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 },
|
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() },
|
||||||
});
|
});
|
||||||
|
|
||||||
for bc in &scope_group.boundary_inputs {
|
for bc in &scope_group.boundary_inputs {
|
||||||
|
|
@ -1793,7 +2051,7 @@ impl NodeGraphPane {
|
||||||
label: "Group Output".to_string(),
|
label: "Group Output".to_string(),
|
||||||
inputs: vec![],
|
inputs: vec![],
|
||||||
outputs: vec![],
|
outputs: vec![],
|
||||||
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 },
|
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() },
|
||||||
});
|
});
|
||||||
|
|
||||||
for bc in &scope_group.boundary_outputs {
|
for bc in &scope_group.boundary_outputs {
|
||||||
|
|
@ -1965,6 +2223,7 @@ impl NodeGraphPane {
|
||||||
"Oscilloscope" => Some(NodeTemplate::Oscilloscope),
|
"Oscilloscope" => Some(NodeTemplate::Oscilloscope),
|
||||||
"Arpeggiator" => Some(NodeTemplate::Arpeggiator),
|
"Arpeggiator" => Some(NodeTemplate::Arpeggiator),
|
||||||
"Sequencer" => Some(NodeTemplate::Sequencer),
|
"Sequencer" => Some(NodeTemplate::Sequencer),
|
||||||
|
"Script" => Some(NodeTemplate::Script),
|
||||||
"Beat" => Some(NodeTemplate::Beat),
|
"Beat" => Some(NodeTemplate::Beat),
|
||||||
"VoiceAllocator" => Some(NodeTemplate::VoiceAllocator),
|
"VoiceAllocator" => Some(NodeTemplate::VoiceAllocator),
|
||||||
"Group" => Some(NodeTemplate::Group),
|
"Group" => Some(NodeTemplate::Group),
|
||||||
|
|
@ -1989,7 +2248,7 @@ impl NodeGraphPane {
|
||||||
label: label.to_string(),
|
label: label.to_string(),
|
||||||
inputs: vec![],
|
inputs: vec![],
|
||||||
outputs: vec![],
|
outputs: vec![],
|
||||||
user_data: NodeData { template: node_template, sample_display_name: None, root_note: 69 },
|
user_data: NodeData { template: node_template, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() },
|
||||||
});
|
});
|
||||||
|
|
||||||
node_template.build_node(&mut self.state.graph, &mut self.user_state, frontend_id);
|
node_template.build_node(&mut self.state.graph, &mut self.user_state, frontend_id);
|
||||||
|
|
@ -2337,6 +2596,12 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
.collect();
|
.collect();
|
||||||
self.user_state.available_folders.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
self.user_state.available_folders.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||||
|
|
||||||
|
// Available scripts for Script node dropdown
|
||||||
|
self.user_state.available_scripts = doc.script_definitions()
|
||||||
|
.map(|s| (s.id, s.name.clone()))
|
||||||
|
.collect();
|
||||||
|
self.user_state.available_scripts.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
|
||||||
|
|
||||||
// Node backend ID map
|
// Node backend ID map
|
||||||
self.user_state.node_backend_ids = self.node_id_map.iter()
|
self.user_state.node_backend_ids = self.node_id_map.iter()
|
||||||
.map(|(&node_id, backend_id)| {
|
.map(|(&node_id, backend_id)| {
|
||||||
|
|
@ -2385,6 +2650,11 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
self.handle_pending_sampler_load(load, shared);
|
self.handle_pending_sampler_load(load, shared);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle pending script sample load requests from bottom_ui()
|
||||||
|
if let Some(load) = self.user_state.pending_script_sample_load.take() {
|
||||||
|
self.handle_pending_script_sample_load(load, shared);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle pending root note changes
|
// Handle pending root note changes
|
||||||
if !self.user_state.pending_root_note_changes.is_empty() {
|
if !self.user_state.pending_root_note_changes.is_empty() {
|
||||||
let changes: Vec<_> = self.user_state.pending_root_note_changes.drain(..).collect();
|
let changes: Vec<_> = self.user_state.pending_root_note_changes.drain(..).collect();
|
||||||
|
|
@ -2425,6 +2695,160 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve Script nodes loaded from preset: find or create ScriptDefinitions
|
||||||
|
// (ports were already rebuilt during load_graph_from_json, this just sets script_id)
|
||||||
|
if !self.pending_script_resolutions.is_empty() {
|
||||||
|
let resolutions = std::mem::take(&mut self.pending_script_resolutions);
|
||||||
|
for (node_id, source) in resolutions {
|
||||||
|
// Try to find an existing ScriptDefinition with matching source
|
||||||
|
let existing_id = shared.action_executor.document()
|
||||||
|
.script_definitions()
|
||||||
|
.find(|s| s.source == source)
|
||||||
|
.map(|s| s.id);
|
||||||
|
|
||||||
|
let script_id = if let Some(id) = existing_id {
|
||||||
|
id
|
||||||
|
} else {
|
||||||
|
// Create a new ScriptDefinition from the source
|
||||||
|
use lightningbeam_core::script::ScriptDefinition;
|
||||||
|
let name = beamdsp::compile(&source)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.unwrap_or_else(|_| "Imported Script".to_string());
|
||||||
|
let script = ScriptDefinition::new(name, source.clone());
|
||||||
|
let id = script.id;
|
||||||
|
shared.action_executor.document_mut().add_script_definition(script);
|
||||||
|
id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set script_id on the node
|
||||||
|
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
||||||
|
node.user_data.script_id = Some(script_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle pending script assignment from Script node dropdown
|
||||||
|
if let Some((node_id, script_id)) = self.user_state.pending_script_assignment.take() {
|
||||||
|
// Update the node's script_id
|
||||||
|
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
||||||
|
node.user_data.script_id = Some(script_id);
|
||||||
|
}
|
||||||
|
// Look up script source, compile locally to rebuild ports, and send to backend
|
||||||
|
let source = shared.action_executor.document()
|
||||||
|
.get_script_definition(&script_id)
|
||||||
|
.map(|s| s.source.clone());
|
||||||
|
if let Some(source) = source {
|
||||||
|
// Compile locally to get port info and rebuild the node UI
|
||||||
|
if let Ok(compiled) = beamdsp::compile(&source) {
|
||||||
|
self.rebuild_script_node_ports(node_id, &compiled);
|
||||||
|
}
|
||||||
|
if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) {
|
||||||
|
if let Some(&backend_id) = self.node_id_map.get(&node_id) {
|
||||||
|
let BackendNodeId::Audio(node_idx) = backend_id;
|
||||||
|
if let Some(controller_arc) = &shared.audio_controller {
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
controller.send_command(daw_backend::Command::GraphSetScript(
|
||||||
|
backend_track_id, node_idx.index() as u32, source,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "New script..." from dropdown
|
||||||
|
if let Some(node_id) = self.user_state.pending_new_script.take() {
|
||||||
|
use lightningbeam_core::script::ScriptDefinition;
|
||||||
|
let script = ScriptDefinition::new(
|
||||||
|
"New Script".to_string(),
|
||||||
|
"name \"New Script\"\ncategory effect\n\ninputs {\n audio_in: audio\n}\n\noutputs {\n audio_out: audio\n}\n\nprocess {\n for i in 0..buffer_size {\n audio_out[i * 2] = audio_in[i * 2];\n audio_out[i * 2 + 1] = audio_in[i * 2 + 1];\n }\n}\n".to_string(),
|
||||||
|
);
|
||||||
|
let script_id = script.id;
|
||||||
|
shared.action_executor.document_mut().add_script_definition(script);
|
||||||
|
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
||||||
|
node.user_data.script_id = Some(script_id);
|
||||||
|
}
|
||||||
|
// Open in editor
|
||||||
|
*shared.script_to_edit = Some(script_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "Load from file..." from dropdown
|
||||||
|
if let Some(node_id) = self.user_state.pending_load_script_file.take() {
|
||||||
|
if let Some(path) = rfd::FileDialog::new()
|
||||||
|
.set_title("Load BeamDSP Script")
|
||||||
|
.add_filter("BeamDSP Script", &["bdsp"])
|
||||||
|
.pick_file()
|
||||||
|
{
|
||||||
|
if let Ok(source) = std::fs::read_to_string(&path) {
|
||||||
|
use lightningbeam_core::script::ScriptDefinition;
|
||||||
|
let name = path.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("Imported Script")
|
||||||
|
.to_string();
|
||||||
|
let script = ScriptDefinition::new(name, source.clone());
|
||||||
|
let script_id = script.id;
|
||||||
|
shared.action_executor.document_mut().add_script_definition(script);
|
||||||
|
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
||||||
|
node.user_data.script_id = Some(script_id);
|
||||||
|
}
|
||||||
|
// Compile locally to rebuild ports, then send to backend
|
||||||
|
if let Ok(compiled) = beamdsp::compile(&source) {
|
||||||
|
self.rebuild_script_node_ports(node_id, &compiled);
|
||||||
|
}
|
||||||
|
if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) {
|
||||||
|
if let Some(&backend_id) = self.node_id_map.get(&node_id) {
|
||||||
|
let BackendNodeId::Audio(node_idx) = backend_id;
|
||||||
|
if let Some(controller_arc) = &shared.audio_controller {
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
controller.send_command(daw_backend::Command::GraphSetScript(
|
||||||
|
backend_track_id, node_idx.index() as u32, source,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle script_saved: auto-recompile all Script nodes using that script
|
||||||
|
if let Some(saved_script_id) = shared.script_saved.take() {
|
||||||
|
let source = shared.action_executor.document()
|
||||||
|
.get_script_definition(&saved_script_id)
|
||||||
|
.map(|s| s.source.clone());
|
||||||
|
if let Some(source) = source {
|
||||||
|
// Compile locally to get updated port info
|
||||||
|
let compiled = beamdsp::compile(&source).ok();
|
||||||
|
|
||||||
|
// Collect matching node IDs first (can't mutate graph while iterating)
|
||||||
|
let matching_nodes: Vec<NodeId> = self.state.graph.nodes.iter()
|
||||||
|
.filter(|(_, node)| node.user_data.script_id == Some(saved_script_id))
|
||||||
|
.map(|(id, _)| id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Rebuild ports for all matching nodes
|
||||||
|
if let Some(ref compiled) = compiled {
|
||||||
|
for &node_id in &matching_nodes {
|
||||||
|
self.rebuild_script_node_ports(node_id, compiled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to backend
|
||||||
|
if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) {
|
||||||
|
if let Some(controller_arc) = &shared.audio_controller {
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
for &node_id in &matching_nodes {
|
||||||
|
if let Some(&backend_id) = self.node_id_map.get(&node_id) {
|
||||||
|
let BackendNodeId::Audio(node_idx) = backend_id;
|
||||||
|
controller.send_command(daw_backend::Command::GraphSetScript(
|
||||||
|
backend_track_id, node_idx.index() as u32, source.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Detect right-click on nodes — intercept the library's node finder and show our context menu instead
|
// Detect right-click on nodes — intercept the library's node finder and show our context menu instead
|
||||||
{
|
{
|
||||||
let secondary_clicked = ui.input(|i| i.pointer.secondary_released());
|
let secondary_clicked = ui.input(|i| i.pointer.secondary_released());
|
||||||
|
|
@ -2450,6 +2874,11 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
let mut action_delete = false;
|
let mut action_delete = false;
|
||||||
let mut action_ungroup = false;
|
let mut action_ungroup = false;
|
||||||
let mut action_rename = false;
|
let mut action_rename = false;
|
||||||
|
let mut action_edit_script = false;
|
||||||
|
|
||||||
|
let is_script_node = self.state.graph.nodes.get(ctx_node_id)
|
||||||
|
.map(|n| n.user_data.template == NodeTemplate::Script)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
let menu_response = egui::Area::new(ui.id().with("node_context_menu"))
|
let menu_response = egui::Area::new(ui.id().with("node_context_menu"))
|
||||||
.fixed_pos(menu_pos)
|
.fixed_pos(menu_pos)
|
||||||
|
|
@ -2468,6 +2897,13 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
}
|
}
|
||||||
|
if is_script_node {
|
||||||
|
if ui.button("Edit Script").clicked() {
|
||||||
|
action_edit_script = true;
|
||||||
|
close_menu = true;
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
}
|
||||||
if ui.button("Delete").clicked() {
|
if ui.button("Delete").clicked() {
|
||||||
action_delete = true;
|
action_delete = true;
|
||||||
close_menu = true;
|
close_menu = true;
|
||||||
|
|
@ -2534,6 +2970,13 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if action_edit_script {
|
||||||
|
if let Some(script_id) = self.state.graph.nodes.get(ctx_node_id)
|
||||||
|
.and_then(|n| n.user_data.script_id)
|
||||||
|
{
|
||||||
|
*shared.script_to_edit = Some(script_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
if close_menu {
|
if close_menu {
|
||||||
self.node_context_menu = None;
|
self.node_context_menu = None;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue