Add script node

This commit is contained in:
Skyler Lehmkuhl 2026-02-19 09:29:14 -05:00
parent ac575482f3
commit c66487b25e
32 changed files with 5777 additions and 259 deletions

View File

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

View File

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

View File

@ -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 (&param_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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
[package]
name = "beamdsp"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }

View File

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

View File

@ -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(),
&param_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"));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"] }

View File

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

View File

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

View File

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

View File

@ -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 == &param.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 == &param.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(&param.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;
}