Add script node
This commit is contained in:
parent
ac575482f3
commit
c66487b25e
|
|
@ -36,6 +36,9 @@ dasp_rms = "0.11"
|
|||
petgraph = "0.6"
|
||||
serde_json = "1.0"
|
||||
|
||||
# BeamDSP scripting engine
|
||||
beamdsp = { path = "../lightningbeam-ui/beamdsp" }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
[profile.release]
|
||||
|
|
|
|||
|
|
@ -1169,6 +1169,7 @@ impl Engine {
|
|||
"Beat" => Box::new(BeatNode::new("Beat".to_string())),
|
||||
"Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".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())),
|
||||
"Limiter" => Box::new(LimiterNode::new("Limiter".to_string())),
|
||||
"Math" => Box::new(MathNode::new("Math".to_string())),
|
||||
|
|
@ -1259,6 +1260,7 @@ impl Engine {
|
|||
"Beat" => Box::new(BeatNode::new("Beat".to_string())),
|
||||
"Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".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())),
|
||||
"Limiter" => Box::new(LimiterNode::new("Limiter".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) => {
|
||||
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
|
||||
if let Some(pos) = self.get_node_position(node_idx) {
|
||||
serialized.set_position(pos.0, pos.1);
|
||||
|
|
@ -992,6 +1003,7 @@ impl AudioGraph {
|
|||
"Beat" => Box::new(BeatNode::new("Beat")),
|
||||
"Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator")),
|
||||
"Sequencer" => Box::new(SequencerNode::new("Sequencer")),
|
||||
"Script" => Box::new(ScriptNode::new("Script")),
|
||||
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower")),
|
||||
"Limiter" => Box::new(LimiterNode::new("Limiter")),
|
||||
"Math" => Box::new(MathNode::new("Math")),
|
||||
|
|
@ -1034,7 +1046,22 @@ impl AudioGraph {
|
|||
let node_idx = graph.add_node(node);
|
||||
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 {
|
||||
if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) {
|
||||
graph_node.node.set_parameter(param_id, value);
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ mod quantizer;
|
|||
mod reverb;
|
||||
mod ring_modulator;
|
||||
mod sample_hold;
|
||||
mod script_node;
|
||||
mod sequencer;
|
||||
mod simple_sampler;
|
||||
mod slew_limiter;
|
||||
|
|
@ -79,6 +80,7 @@ pub use quantizer::QuantizerNode;
|
|||
pub use reverb::ReverbNode;
|
||||
pub use ring_modulator::RingModulatorNode;
|
||||
pub use sample_hold::SampleHoldNode;
|
||||
pub use script_node::ScriptNode;
|
||||
pub use sequencer::SequencerNode;
|
||||
pub use simple_sampler::SimpleSamplerNode;
|
||||
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
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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)
|
||||
|
|
@ -217,6 +221,7 @@ impl SerializedNode {
|
|||
position: (0.0, 0.0),
|
||||
template_graph: 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)
|
||||
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)
|
||||
SamplerLoadSample(TrackId, u32, String),
|
||||
/// 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),
|
||||
/// Preset has been saved to file (track_id, preset_path)
|
||||
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)
|
||||
ExportProgress {
|
||||
frames_rendered: usize,
|
||||
|
|
|
|||
|
|
@ -678,6 +678,13 @@ version = "1.8.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
||||
|
||||
[[package]]
|
||||
name = "beamdsp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
|
|
@ -1655,6 +1662,7 @@ name = "daw-backend"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"beamdsp",
|
||||
"cpal",
|
||||
"crossterm",
|
||||
"dasp_envelope",
|
||||
|
|
@ -3438,6 +3446,7 @@ dependencies = [
|
|||
name = "lightningbeam-editor"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"beamdsp",
|
||||
"bytemuck",
|
||||
"clap",
|
||||
"cpal",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ resolver = "2"
|
|||
members = [
|
||||
"lightningbeam-editor",
|
||||
"lightningbeam-core",
|
||||
"beamdsp",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
@ -49,6 +50,9 @@ notify-rust = "4.11"
|
|||
[profile.dev.package.daw-backend]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package.beamdsp]
|
||||
opt-level = 2
|
||||
|
||||
# Also optimize symphonia (audio decoder) and cpal (audio I/O) — these
|
||||
# run in the audio callback path and are heavily numeric.
|
||||
[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::effect::EffectDefinition;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::script::ScriptDefinition;
|
||||
use crate::layout::LayoutNode;
|
||||
use crate::shape::ShapeColor;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -146,6 +147,14 @@ pub struct Document {
|
|||
#[serde(default)]
|
||||
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)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ui_layout: Option<LayoutNode>,
|
||||
|
|
@ -181,6 +190,8 @@ impl Default for Document {
|
|||
audio_folders: AssetFolderTree::new(),
|
||||
image_folders: AssetFolderTree::new(),
|
||||
effect_folders: AssetFolderTree::new(),
|
||||
script_definitions: HashMap::new(),
|
||||
script_folders: AssetFolderTree::new(),
|
||||
ui_layout: None,
|
||||
ui_layout_base: None,
|
||||
current_time: 0.0,
|
||||
|
|
@ -494,6 +505,26 @@ impl Document {
|
|||
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 ===
|
||||
|
||||
/// Get the duration of any clip type by ID
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ pub mod instance_group;
|
|||
pub mod effect;
|
||||
pub mod effect_layer;
|
||||
pub mod effect_registry;
|
||||
pub mod script;
|
||||
pub mod document;
|
||||
pub mod renderer;
|
||||
pub mod video;
|
||||
|
|
|
|||
|
|
@ -33,8 +33,9 @@ pub enum PaneType {
|
|||
PresetBrowser,
|
||||
/// Asset library for browsing clips
|
||||
AssetLibrary,
|
||||
/// WGSL shader code editor for custom effects
|
||||
ShaderEditor,
|
||||
/// Code editor for shaders and DSP scripts
|
||||
#[serde(alias = "shaderEditor")]
|
||||
ScriptEditor,
|
||||
}
|
||||
|
||||
impl PaneType {
|
||||
|
|
@ -51,7 +52,7 @@ impl PaneType {
|
|||
PaneType::NodeEditor => "Node Editor",
|
||||
PaneType::PresetBrowser => "Instrument Browser",
|
||||
PaneType::AssetLibrary => "Asset Library",
|
||||
PaneType::ShaderEditor => "Shader Editor",
|
||||
PaneType::ScriptEditor => "Script Editor",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +71,7 @@ impl PaneType {
|
|||
PaneType::NodeEditor => "node-editor.svg",
|
||||
PaneType::PresetBrowser => "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),
|
||||
"presetbrowser" => Some(PaneType::PresetBrowser),
|
||||
"assetlibrary" => Some(PaneType::AssetLibrary),
|
||||
"shadereditor" => Some(PaneType::ShaderEditor),
|
||||
"shadereditor" | "scripteditor" => Some(PaneType::ScriptEditor),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -106,7 +107,7 @@ impl PaneType {
|
|||
PaneType::VirtualPiano,
|
||||
PaneType::PresetBrowser,
|
||||
PaneType::AssetLibrary,
|
||||
PaneType::ShaderEditor,
|
||||
PaneType::ScriptEditor,
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +124,7 @@ impl PaneType {
|
|||
PaneType::NodeEditor => "nodeEditor",
|
||||
PaneType::PresetBrowser => "presetBrowser",
|
||||
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]
|
||||
lightningbeam-core = { path = "../lightningbeam-core" }
|
||||
daw-backend = { path = "../../daw-backend" }
|
||||
beamdsp = { path = "../beamdsp" }
|
||||
rtrb = "0.3"
|
||||
cpal = "0.17"
|
||||
ffmpeg-next = { version = "8.0", features = ["static"] }
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ impl IconCache {
|
|||
PaneType::Infopanel => pane_icons::INFOPANEL,
|
||||
PaneType::PianoRoll => pane_icons::PIANO_ROLL,
|
||||
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) {
|
||||
self.icons.insert(pane_type, texture);
|
||||
|
|
@ -644,8 +644,10 @@ struct EditorApp {
|
|||
dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library
|
||||
// Clipboard
|
||||
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)
|
||||
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_thumbnails_to_invalidate: Vec<Uuid>,
|
||||
// Import dialog state
|
||||
|
|
@ -863,8 +865,10 @@ impl EditorApp {
|
|||
recording_layer_id: None, // Will be set when recording starts
|
||||
dragging_asset: None, // No asset being dragged initially
|
||||
clipboard_manager: lightningbeam_core::clipboard::ClipboardManager::new(),
|
||||
effect_to_load: None, // No effect to load initially
|
||||
effect_thumbnails_to_invalidate: Vec::new(), // No thumbnails to invalidate initially
|
||||
effect_to_load: None,
|
||||
script_to_edit: None,
|
||||
script_saved: None,
|
||||
effect_thumbnails_to_invalidate: Vec::new(),
|
||||
last_import_filter: ImportFilter::default(), // Default to "All Supported"
|
||||
stroke_width: 3.0, // Default stroke width
|
||||
fill_enabled: true, // Default to filling shapes
|
||||
|
|
@ -4455,6 +4459,8 @@ impl eframe::App for EditorApp {
|
|||
clipboard_manager: &mut self.clipboard_manager,
|
||||
waveform_stereo: self.config.waveform_stereo,
|
||||
project_generation: &mut self.project_generation,
|
||||
script_to_edit: &mut self.script_to_edit,
|
||||
script_saved: &mut self.script_saved,
|
||||
};
|
||||
|
||||
render_layout_node(
|
||||
|
|
@ -4731,6 +4737,10 @@ struct RenderContext<'a> {
|
|||
waveform_stereo: bool,
|
||||
/// Project generation counter (incremented on load)
|
||||
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
|
||||
|
|
@ -5213,6 +5223,8 @@ fn render_pane(
|
|||
clipboard_manager: ctx.clipboard_manager,
|
||||
waveform_stereo: ctx.waveform_stereo,
|
||||
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);
|
||||
}
|
||||
|
|
@ -5284,6 +5296,8 @@ fn render_pane(
|
|||
clipboard_manager: ctx.clipboard_manager,
|
||||
waveform_stereo: ctx.waveform_stereo,
|
||||
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)
|
||||
|
|
@ -5407,7 +5421,7 @@ fn pane_color(pane_type: PaneType) -> egui::Color32 {
|
|||
PaneType::NodeEditor => egui::Color32::from_rgb(30, 45, 50),
|
||||
PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30),
|
||||
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,
|
||||
/// Generation counter - incremented on project load to force reloads
|
||||
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
|
||||
|
|
@ -260,7 +264,7 @@ pub enum PaneInstance {
|
|||
NodeEditor(node_editor::NodeEditorPane),
|
||||
PresetBrowser(preset_browser::PresetBrowserPane),
|
||||
AssetLibrary(asset_library::AssetLibraryPane),
|
||||
ShaderEditor(shader_editor::ShaderEditorPane),
|
||||
ScriptEditor(shader_editor::ShaderEditorPane),
|
||||
}
|
||||
|
||||
impl PaneInstance {
|
||||
|
|
@ -281,8 +285,8 @@ impl PaneInstance {
|
|||
PaneType::AssetLibrary => {
|
||||
PaneInstance::AssetLibrary(asset_library::AssetLibraryPane::new())
|
||||
}
|
||||
PaneType::ShaderEditor => {
|
||||
PaneInstance::ShaderEditor(shader_editor::ShaderEditorPane::new())
|
||||
PaneType::ScriptEditor => {
|
||||
PaneInstance::ScriptEditor(shader_editor::ShaderEditorPane::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -300,7 +304,7 @@ impl PaneInstance {
|
|||
PaneInstance::NodeEditor(_) => PaneType::NodeEditor,
|
||||
PaneInstance::PresetBrowser(_) => PaneType::PresetBrowser,
|
||||
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::PresetBrowser(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::PresetBrowser(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::PresetBrowser(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,
|
||||
Mod,
|
||||
|
||||
// Scripting
|
||||
Script,
|
||||
|
||||
// Analysis
|
||||
Oscilloscope,
|
||||
|
||||
|
|
@ -127,6 +130,7 @@ impl NodeTemplate {
|
|||
NodeTemplate::EnvelopeFollower => "EnvelopeFollower",
|
||||
NodeTemplate::BpmDetector => "BpmDetector",
|
||||
NodeTemplate::Beat => "Beat",
|
||||
NodeTemplate::Script => "Script",
|
||||
NodeTemplate::Mod => "Mod",
|
||||
NodeTemplate::Oscilloscope => "Oscilloscope",
|
||||
NodeTemplate::VoiceAllocator => "VoiceAllocator",
|
||||
|
|
@ -148,6 +152,18 @@ pub struct NodeData {
|
|||
/// Root note (MIDI note number) for original-pitch playback (default 69 = A4)
|
||||
#[serde(default = "default_root_note")]
|
||||
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 }
|
||||
|
|
@ -172,6 +188,14 @@ pub struct SamplerFolderInfo {
|
|||
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
|
||||
pub enum PendingSamplerLoad {
|
||||
/// 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)>,
|
||||
/// Time scale per oscilloscope node (in milliseconds)
|
||||
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 {
|
||||
|
|
@ -222,6 +256,11 @@ impl Default for GraphState {
|
|||
pending_root_note_changes: Vec::new(),
|
||||
pending_sequencer_changes: Vec::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::Beat => "Beat".into(),
|
||||
NodeTemplate::Mod => "Modulator".into(),
|
||||
// Scripting
|
||||
NodeTemplate::Script => "Script".into(),
|
||||
// Analysis
|
||||
NodeTemplate::Oscilloscope => "Oscilloscope".into(),
|
||||
// Advanced
|
||||
|
|
@ -399,6 +440,7 @@ impl NodeTemplateTrait for NodeTemplate {
|
|||
| NodeTemplate::Constant | NodeTemplate::MidiToCv | NodeTemplate::AudioToCv | NodeTemplate::Arpeggiator | NodeTemplate::Sequencer | NodeTemplate::Math
|
||||
| NodeTemplate::SampleHold | NodeTemplate::SlewLimiter | NodeTemplate::Quantizer
|
||||
| NodeTemplate::EnvelopeFollower | NodeTemplate::BpmDetector | NodeTemplate::Mod => vec!["Utilities"],
|
||||
NodeTemplate::Script => vec!["Advanced"],
|
||||
NodeTemplate::Oscilloscope => vec!["Analysis"],
|
||||
NodeTemplate::VoiceAllocator | NodeTemplate::Group => vec!["Advanced"],
|
||||
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 {
|
||||
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(
|
||||
|
|
@ -856,6 +898,12 @@ impl NodeTemplateTrait for NodeTemplate {
|
|||
// 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);
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
} 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 {
|
||||
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)
|
||||
pub struct AllNodeTemplates;
|
||||
|
||||
|
|
@ -1370,6 +1554,7 @@ impl NodeTemplateIter for AllNodeTemplates {
|
|||
NodeTemplate::Oscilloscope,
|
||||
// Advanced
|
||||
NodeTemplate::VoiceAllocator,
|
||||
NodeTemplate::Script,
|
||||
// Note: Group is not in the node finder — groups are created via Ctrl+G selection.
|
||||
// Note: TemplateInput/TemplateOutput are excluded from the default finder.
|
||||
// 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)
|
||||
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_oscilloscope_poll: std::time::Instant,
|
||||
/// Backend track ID (u32) for oscilloscope queries
|
||||
|
|
@ -167,6 +171,7 @@ impl NodeGraphPane {
|
|||
renaming_group: None,
|
||||
node_context_menu: None,
|
||||
last_node_rects: HashMap::new(),
|
||||
pending_script_resolutions: Vec::new(),
|
||||
last_oscilloscope_poll: std::time::Instant::now(),
|
||||
backend_track_id: None,
|
||||
}
|
||||
|
|
@ -203,6 +208,7 @@ impl NodeGraphPane {
|
|||
renaming_group: None,
|
||||
node_context_menu: None,
|
||||
last_node_rects: HashMap::new(),
|
||||
pending_script_resolutions: Vec::new(),
|
||||
last_oscilloscope_poll: std::time::Instant::now(),
|
||||
backend_track_id: Some(backend_track_id),
|
||||
};
|
||||
|
|
@ -226,6 +232,167 @@ impl NodeGraphPane {
|
|||
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(
|
||||
&mut self,
|
||||
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) {
|
||||
// Check all input parameters for value changes
|
||||
let mut _checked_count = 0;
|
||||
|
|
@ -1217,6 +1460,7 @@ impl NodeGraphPane {
|
|||
self.backend_to_frontend_map.clear();
|
||||
|
||||
// Create nodes in frontend
|
||||
self.pending_script_resolutions.clear();
|
||||
for node in &graph_state.nodes {
|
||||
let node_template = match Self::backend_type_to_template(&node.node_type) {
|
||||
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
|
||||
|
|
@ -1674,7 +1932,7 @@ impl NodeGraphPane {
|
|||
label: group.name.clone(),
|
||||
inputs: 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
|
||||
|
|
@ -1746,7 +2004,7 @@ impl NodeGraphPane {
|
|||
label: "Group Input".to_string(),
|
||||
inputs: 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 {
|
||||
|
|
@ -1793,7 +2051,7 @@ impl NodeGraphPane {
|
|||
label: "Group Output".to_string(),
|
||||
inputs: 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 {
|
||||
|
|
@ -1965,6 +2223,7 @@ impl NodeGraphPane {
|
|||
"Oscilloscope" => Some(NodeTemplate::Oscilloscope),
|
||||
"Arpeggiator" => Some(NodeTemplate::Arpeggiator),
|
||||
"Sequencer" => Some(NodeTemplate::Sequencer),
|
||||
"Script" => Some(NodeTemplate::Script),
|
||||
"Beat" => Some(NodeTemplate::Beat),
|
||||
"VoiceAllocator" => Some(NodeTemplate::VoiceAllocator),
|
||||
"Group" => Some(NodeTemplate::Group),
|
||||
|
|
@ -1989,7 +2248,7 @@ impl NodeGraphPane {
|
|||
label: label.to_string(),
|
||||
inputs: 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);
|
||||
|
|
@ -2337,6 +2596,12 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
|||
.collect();
|
||||
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
|
||||
self.user_state.node_backend_ids = self.node_id_map.iter()
|
||||
.map(|(&node_id, backend_id)| {
|
||||
|
|
@ -2385,6 +2650,11 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
|||
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
|
||||
if !self.user_state.pending_root_note_changes.is_empty() {
|
||||
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
|
||||
{
|
||||
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_ungroup = 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"))
|
||||
.fixed_pos(menu_pos)
|
||||
|
|
@ -2468,6 +2897,13 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
|||
}
|
||||
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() {
|
||||
action_delete = 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 {
|
||||
self.node_context_menu = None;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue