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" petgraph = "0.6"
serde_json = "1.0" serde_json = "1.0"
# BeamDSP scripting engine
beamdsp = { path = "../lightningbeam-ui/beamdsp" }
[dev-dependencies] [dev-dependencies]
[profile.release] [profile.release]

View File

@ -1169,6 +1169,7 @@ impl Engine {
"Beat" => Box::new(BeatNode::new("Beat".to_string())), "Beat" => Box::new(BeatNode::new("Beat".to_string())),
"Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".to_string())), "Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".to_string())),
"Sequencer" => Box::new(SequencerNode::new("Sequencer".to_string())), "Sequencer" => Box::new(SequencerNode::new("Sequencer".to_string())),
"Script" => Box::new(ScriptNode::new("Script".to_string())),
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())), "EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())),
"Limiter" => Box::new(LimiterNode::new("Limiter".to_string())), "Limiter" => Box::new(LimiterNode::new("Limiter".to_string())),
"Math" => Box::new(MathNode::new("Math".to_string())), "Math" => Box::new(MathNode::new("Math".to_string())),
@ -1259,6 +1260,7 @@ impl Engine {
"Beat" => Box::new(BeatNode::new("Beat".to_string())), "Beat" => Box::new(BeatNode::new("Beat".to_string())),
"Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".to_string())), "Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".to_string())),
"Sequencer" => Box::new(SequencerNode::new("Sequencer".to_string())), "Sequencer" => Box::new(SequencerNode::new("Sequencer".to_string())),
"Script" => Box::new(ScriptNode::new("Script".to_string())),
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())), "EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())),
"Limiter" => Box::new(LimiterNode::new("Limiter".to_string())), "Limiter" => Box::new(LimiterNode::new("Limiter".to_string())),
"Math" => Box::new(MathNode::new("Math".to_string())), "Math" => Box::new(MathNode::new("Math".to_string())),
@ -1657,6 +1659,58 @@ impl Engine {
} }
} }
Command::GraphSetScript(track_id, node_id, source) => {
use crate::audio::node_graph::nodes::ScriptNode;
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
let graph = &mut track.instrument_graph;
let node_idx = NodeIndex::new(node_id as usize);
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
if let Some(script_node) = graph_node.node.as_any_mut().downcast_mut::<ScriptNode>() {
match script_node.set_script(&source) {
Ok(ui_decl) => {
// Send compile success event back to frontend
let _ = self.event_tx.push(AudioEvent::ScriptCompiled {
track_id,
node_id,
success: true,
error: None,
ui_declaration: Some(ui_decl),
source: source.clone(),
});
}
Err(e) => {
let _ = self.event_tx.push(AudioEvent::ScriptCompiled {
track_id,
node_id,
success: false,
error: Some(e),
ui_declaration: None,
source,
});
}
}
}
}
}
}
Command::GraphSetScriptSample(track_id, node_id, slot_index, data, sample_rate, name) => {
use crate::audio::node_graph::nodes::ScriptNode;
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
let graph = &mut track.instrument_graph;
let node_idx = NodeIndex::new(node_id as usize);
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
if let Some(script_node) = graph_node.node.as_any_mut().downcast_mut::<ScriptNode>() {
script_node.set_sample(slot_index, data, sample_rate, name);
}
}
}
}
Command::SamplerLoadSample(track_id, node_id, file_path) => { Command::SamplerLoadSample(track_id, node_id, file_path) => {
use crate::audio::node_graph::nodes::SimpleSamplerNode; use crate::audio::node_graph::nodes::SimpleSamplerNode;

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 // Save position if available
if let Some(pos) = self.get_node_position(node_idx) { if let Some(pos) = self.get_node_position(node_idx) {
serialized.set_position(pos.0, pos.1); serialized.set_position(pos.0, pos.1);
@ -992,6 +1003,7 @@ impl AudioGraph {
"Beat" => Box::new(BeatNode::new("Beat")), "Beat" => Box::new(BeatNode::new("Beat")),
"Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator")), "Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator")),
"Sequencer" => Box::new(SequencerNode::new("Sequencer")), "Sequencer" => Box::new(SequencerNode::new("Sequencer")),
"Script" => Box::new(ScriptNode::new("Script")),
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower")), "EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower")),
"Limiter" => Box::new(LimiterNode::new("Limiter")), "Limiter" => Box::new(LimiterNode::new("Limiter")),
"Math" => Box::new(MathNode::new("Math")), "Math" => Box::new(MathNode::new("Math")),
@ -1034,7 +1046,22 @@ impl AudioGraph {
let node_idx = graph.add_node(node); let node_idx = graph.add_node(node);
index_map.insert(serialized_node.id, node_idx); index_map.insert(serialized_node.id, node_idx);
// Set parameters // Restore script source for Script nodes (must come before parameter setting
// since set_script rebuilds parameters)
if let Some(ref source) = serialized_node.script_source {
if serialized_node.node_type == "Script" {
use crate::audio::node_graph::nodes::ScriptNode;
if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) {
if let Some(script_node) = graph_node.node.as_any_mut().downcast_mut::<ScriptNode>() {
if let Err(e) = script_node.set_script(source) {
eprintln!("Warning: failed to compile script for node {}: {}", serialized_node.id, e);
}
}
}
}
}
// Set parameters (after script compilation so param slots exist)
for (&param_id, &value) in &serialized_node.parameters { for (&param_id, &value) in &serialized_node.parameters {
if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) { if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) {
graph_node.node.set_parameter(param_id, value); graph_node.node.set_parameter(param_id, value);

View File

@ -34,6 +34,7 @@ mod quantizer;
mod reverb; mod reverb;
mod ring_modulator; mod ring_modulator;
mod sample_hold; mod sample_hold;
mod script_node;
mod sequencer; mod sequencer;
mod simple_sampler; mod simple_sampler;
mod slew_limiter; mod slew_limiter;
@ -79,6 +80,7 @@ pub use quantizer::QuantizerNode;
pub use reverb::ReverbNode; pub use reverb::ReverbNode;
pub use ring_modulator::RingModulatorNode; pub use ring_modulator::RingModulatorNode;
pub use sample_hold::SampleHoldNode; pub use sample_hold::SampleHoldNode;
pub use script_node::ScriptNode;
pub use sequencer::SequencerNode; pub use sequencer::SequencerNode;
pub use simple_sampler::SimpleSamplerNode; pub use simple_sampler::SimpleSamplerNode;
pub use slew_limiter::SlewLimiterNode; pub use slew_limiter::SlewLimiterNode;

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 /// For sampler nodes: loaded sample data
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub sample_data: Option<SampleData>, pub sample_data: Option<SampleData>,
/// For Script nodes: BeamDSP source code
#[serde(skip_serializing_if = "Option::is_none")]
pub script_source: Option<String>,
} }
/// Serialized group definition (frontend-only visual grouping, stored opaquely by backend) /// Serialized group definition (frontend-only visual grouping, stored opaquely by backend)
@ -217,6 +221,7 @@ impl SerializedNode {
position: (0.0, 0.0), position: (0.0, 0.0),
template_graph: None, template_graph: None,
sample_data: None, sample_data: None,
script_source: None,
} }
} }

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) /// Save a VoiceAllocator's template graph as a preset (track_id, voice_allocator_id, preset_path, preset_name)
GraphSaveTemplatePreset(TrackId, u32, String, String), GraphSaveTemplatePreset(TrackId, u32, String, String),
/// Compile and set a BeamDSP script on a Script node (track_id, node_id, source_code)
GraphSetScript(TrackId, u32, String),
/// Load audio sample data into a Script node's sample slot (track_id, node_id, slot_index, audio_data, sample_rate, name)
GraphSetScriptSample(TrackId, u32, usize, Vec<f32>, u32, String),
/// Load a sample into a SimpleSampler node (track_id, node_id, file_path) /// Load a sample into a SimpleSampler node (track_id, node_id, file_path)
SamplerLoadSample(TrackId, u32, String), SamplerLoadSample(TrackId, u32, String),
/// Load a sample from the audio pool into a SimpleSampler node (track_id, node_id, pool_index) /// Load a sample from the audio pool into a SimpleSampler node (track_id, node_id, pool_index)
@ -266,6 +271,16 @@ pub enum AudioEvent {
GraphPresetLoaded(TrackId), GraphPresetLoaded(TrackId),
/// Preset has been saved to file (track_id, preset_path) /// Preset has been saved to file (track_id, preset_path)
GraphPresetSaved(TrackId, String), GraphPresetSaved(TrackId, String),
/// Script compilation result (track_id, node_id, success, error, ui_declaration, source)
ScriptCompiled {
track_id: TrackId,
node_id: u32,
success: bool,
error: Option<String>,
ui_declaration: Option<beamdsp::UiDeclaration>,
source: String,
},
/// Export progress (frames_rendered, total_frames) /// Export progress (frames_rendered, total_frames)
ExportProgress { ExportProgress {
frames_rendered: usize, frames_rendered: usize,

View File

@ -678,6 +678,13 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "beamdsp"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bincode" name = "bincode"
version = "1.3.3" version = "1.3.3"
@ -1655,6 +1662,7 @@ name = "daw-backend"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"beamdsp",
"cpal", "cpal",
"crossterm", "crossterm",
"dasp_envelope", "dasp_envelope",
@ -3438,6 +3446,7 @@ dependencies = [
name = "lightningbeam-editor" name = "lightningbeam-editor"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"beamdsp",
"bytemuck", "bytemuck",
"clap", "clap",
"cpal", "cpal",

View File

@ -3,6 +3,7 @@ resolver = "2"
members = [ members = [
"lightningbeam-editor", "lightningbeam-editor",
"lightningbeam-core", "lightningbeam-core",
"beamdsp",
] ]
[workspace.dependencies] [workspace.dependencies]
@ -49,6 +50,9 @@ notify-rust = "4.11"
[profile.dev.package.daw-backend] [profile.dev.package.daw-backend]
opt-level = 2 opt-level = 2
[profile.dev.package.beamdsp]
opt-level = 2
# Also optimize symphonia (audio decoder) and cpal (audio I/O) — these # Also optimize symphonia (audio decoder) and cpal (audio I/O) — these
# run in the audio callback path and are heavily numeric. # run in the audio callback path and are heavily numeric.
[profile.dev.package.symphonia] [profile.dev.package.symphonia]

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::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
use crate::effect::EffectDefinition; use crate::effect::EffectDefinition;
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::script::ScriptDefinition;
use crate::layout::LayoutNode; use crate::layout::LayoutNode;
use crate::shape::ShapeColor; use crate::shape::ShapeColor;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -146,6 +147,14 @@ pub struct Document {
#[serde(default)] #[serde(default)]
pub effect_folders: AssetFolderTree, pub effect_folders: AssetFolderTree,
/// BeamDSP script definitions (audio DSP scripts for node graph)
#[serde(default)]
pub script_definitions: HashMap<Uuid, ScriptDefinition>,
/// Folder organization for script definitions
#[serde(default)]
pub script_folders: AssetFolderTree,
/// Current UI layout state (serialized for save/load) /// Current UI layout state (serialized for save/load)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_layout: Option<LayoutNode>, pub ui_layout: Option<LayoutNode>,
@ -181,6 +190,8 @@ impl Default for Document {
audio_folders: AssetFolderTree::new(), audio_folders: AssetFolderTree::new(),
image_folders: AssetFolderTree::new(), image_folders: AssetFolderTree::new(),
effect_folders: AssetFolderTree::new(), effect_folders: AssetFolderTree::new(),
script_definitions: HashMap::new(),
script_folders: AssetFolderTree::new(),
ui_layout: None, ui_layout: None,
ui_layout_base: None, ui_layout_base: None,
current_time: 0.0, current_time: 0.0,
@ -494,6 +505,26 @@ impl Document {
self.effect_definitions.values() self.effect_definitions.values()
} }
// === SCRIPT DEFINITION METHODS ===
pub fn add_script_definition(&mut self, definition: ScriptDefinition) -> Uuid {
let id = definition.id;
self.script_definitions.insert(id, definition);
id
}
pub fn get_script_definition(&self, id: &Uuid) -> Option<&ScriptDefinition> {
self.script_definitions.get(id)
}
pub fn get_script_definition_mut(&mut self, id: &Uuid) -> Option<&mut ScriptDefinition> {
self.script_definitions.get_mut(id)
}
pub fn script_definitions(&self) -> impl Iterator<Item = &ScriptDefinition> {
self.script_definitions.values()
}
// === CLIP OVERLAP DETECTION METHODS === // === CLIP OVERLAP DETECTION METHODS ===
/// Get the duration of any clip type by ID /// Get the duration of any clip type by ID

View File

@ -20,6 +20,7 @@ pub mod instance_group;
pub mod effect; pub mod effect;
pub mod effect_layer; pub mod effect_layer;
pub mod effect_registry; pub mod effect_registry;
pub mod script;
pub mod document; pub mod document;
pub mod renderer; pub mod renderer;
pub mod video; pub mod video;

View File

@ -33,8 +33,9 @@ pub enum PaneType {
PresetBrowser, PresetBrowser,
/// Asset library for browsing clips /// Asset library for browsing clips
AssetLibrary, AssetLibrary,
/// WGSL shader code editor for custom effects /// Code editor for shaders and DSP scripts
ShaderEditor, #[serde(alias = "shaderEditor")]
ScriptEditor,
} }
impl PaneType { impl PaneType {
@ -51,7 +52,7 @@ impl PaneType {
PaneType::NodeEditor => "Node Editor", PaneType::NodeEditor => "Node Editor",
PaneType::PresetBrowser => "Instrument Browser", PaneType::PresetBrowser => "Instrument Browser",
PaneType::AssetLibrary => "Asset Library", PaneType::AssetLibrary => "Asset Library",
PaneType::ShaderEditor => "Shader Editor", PaneType::ScriptEditor => "Script Editor",
} }
} }
@ -70,7 +71,7 @@ impl PaneType {
PaneType::NodeEditor => "node-editor.svg", PaneType::NodeEditor => "node-editor.svg",
PaneType::PresetBrowser => "stage.svg", // TODO: needs own icon PaneType::PresetBrowser => "stage.svg", // TODO: needs own icon
PaneType::AssetLibrary => "stage.svg", // TODO: needs own icon PaneType::AssetLibrary => "stage.svg", // TODO: needs own icon
PaneType::ShaderEditor => "node-editor.svg", // TODO: needs own icon PaneType::ScriptEditor => "node-editor.svg", // TODO: needs own icon
} }
} }
@ -88,7 +89,7 @@ impl PaneType {
"nodeeditor" => Some(PaneType::NodeEditor), "nodeeditor" => Some(PaneType::NodeEditor),
"presetbrowser" => Some(PaneType::PresetBrowser), "presetbrowser" => Some(PaneType::PresetBrowser),
"assetlibrary" => Some(PaneType::AssetLibrary), "assetlibrary" => Some(PaneType::AssetLibrary),
"shadereditor" => Some(PaneType::ShaderEditor), "shadereditor" | "scripteditor" => Some(PaneType::ScriptEditor),
_ => None, _ => None,
} }
} }
@ -106,7 +107,7 @@ impl PaneType {
PaneType::VirtualPiano, PaneType::VirtualPiano,
PaneType::PresetBrowser, PaneType::PresetBrowser,
PaneType::AssetLibrary, PaneType::AssetLibrary,
PaneType::ShaderEditor, PaneType::ScriptEditor,
] ]
} }
@ -123,7 +124,7 @@ impl PaneType {
PaneType::NodeEditor => "nodeEditor", PaneType::NodeEditor => "nodeEditor",
PaneType::PresetBrowser => "presetBrowser", PaneType::PresetBrowser => "presetBrowser",
PaneType::AssetLibrary => "assetLibrary", PaneType::AssetLibrary => "assetLibrary",
PaneType::ShaderEditor => "shaderEditor", PaneType::ScriptEditor => "scriptEditor",
} }
} }
} }

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] [dependencies]
lightningbeam-core = { path = "../lightningbeam-core" } lightningbeam-core = { path = "../lightningbeam-core" }
daw-backend = { path = "../../daw-backend" } daw-backend = { path = "../../daw-backend" }
beamdsp = { path = "../beamdsp" }
rtrb = "0.3" rtrb = "0.3"
cpal = "0.17" cpal = "0.17"
ffmpeg-next = { version = "8.0", features = ["static"] } ffmpeg-next = { version = "8.0", features = ["static"] }

View File

@ -263,7 +263,7 @@ impl IconCache {
PaneType::Infopanel => pane_icons::INFOPANEL, PaneType::Infopanel => pane_icons::INFOPANEL,
PaneType::PianoRoll => pane_icons::PIANO_ROLL, PaneType::PianoRoll => pane_icons::PIANO_ROLL,
PaneType::VirtualPiano => pane_icons::PIANO, PaneType::VirtualPiano => pane_icons::PIANO,
PaneType::NodeEditor | PaneType::ShaderEditor => pane_icons::NODE_EDITOR, PaneType::NodeEditor | PaneType::ScriptEditor => pane_icons::NODE_EDITOR,
}; };
if let Some(texture) = rasterize_svg(svg_data, pane_type.icon_file(), 64, ctx) { if let Some(texture) = rasterize_svg(svg_data, pane_type.icon_file(), 64, ctx) {
self.icons.insert(pane_type, texture); self.icons.insert(pane_type, texture);
@ -644,8 +644,10 @@ struct EditorApp {
dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library
// Clipboard // Clipboard
clipboard_manager: lightningbeam_core::clipboard::ClipboardManager, clipboard_manager: lightningbeam_core::clipboard::ClipboardManager,
// Shader editor inter-pane communication // Script editor inter-pane communication
effect_to_load: Option<Uuid>, // Effect ID to load into shader editor (set by asset library) effect_to_load: Option<Uuid>, // Effect ID to load into shader editor (set by asset library)
script_to_edit: Option<Uuid>, // Script ID to open in editor (set by node graph)
script_saved: Option<Uuid>, // Script ID just saved (triggers auto-recompile)
// Effect thumbnail invalidation queue (persists across frames until processed) // Effect thumbnail invalidation queue (persists across frames until processed)
effect_thumbnails_to_invalidate: Vec<Uuid>, effect_thumbnails_to_invalidate: Vec<Uuid>,
// Import dialog state // Import dialog state
@ -863,8 +865,10 @@ impl EditorApp {
recording_layer_id: None, // Will be set when recording starts recording_layer_id: None, // Will be set when recording starts
dragging_asset: None, // No asset being dragged initially dragging_asset: None, // No asset being dragged initially
clipboard_manager: lightningbeam_core::clipboard::ClipboardManager::new(), clipboard_manager: lightningbeam_core::clipboard::ClipboardManager::new(),
effect_to_load: None, // No effect to load initially effect_to_load: None,
effect_thumbnails_to_invalidate: Vec::new(), // No thumbnails to invalidate initially script_to_edit: None,
script_saved: None,
effect_thumbnails_to_invalidate: Vec::new(),
last_import_filter: ImportFilter::default(), // Default to "All Supported" last_import_filter: ImportFilter::default(), // Default to "All Supported"
stroke_width: 3.0, // Default stroke width stroke_width: 3.0, // Default stroke width
fill_enabled: true, // Default to filling shapes fill_enabled: true, // Default to filling shapes
@ -4455,6 +4459,8 @@ impl eframe::App for EditorApp {
clipboard_manager: &mut self.clipboard_manager, clipboard_manager: &mut self.clipboard_manager,
waveform_stereo: self.config.waveform_stereo, waveform_stereo: self.config.waveform_stereo,
project_generation: &mut self.project_generation, project_generation: &mut self.project_generation,
script_to_edit: &mut self.script_to_edit,
script_saved: &mut self.script_saved,
}; };
render_layout_node( render_layout_node(
@ -4731,6 +4737,10 @@ struct RenderContext<'a> {
waveform_stereo: bool, waveform_stereo: bool,
/// Project generation counter (incremented on load) /// Project generation counter (incremented on load)
project_generation: &'a mut u64, project_generation: &'a mut u64,
/// Script ID to open in the script editor (from node graph)
script_to_edit: &'a mut Option<Uuid>,
/// Script ID just saved (triggers auto-recompile of nodes using it)
script_saved: &'a mut Option<Uuid>,
} }
/// Recursively render a layout node with drag support /// Recursively render a layout node with drag support
@ -5213,6 +5223,8 @@ fn render_pane(
clipboard_manager: ctx.clipboard_manager, clipboard_manager: ctx.clipboard_manager,
waveform_stereo: ctx.waveform_stereo, waveform_stereo: ctx.waveform_stereo,
project_generation: ctx.project_generation, project_generation: ctx.project_generation,
script_to_edit: ctx.script_to_edit,
script_saved: ctx.script_saved,
}; };
pane_instance.render_header(&mut header_ui, &mut shared); pane_instance.render_header(&mut header_ui, &mut shared);
} }
@ -5284,6 +5296,8 @@ fn render_pane(
clipboard_manager: ctx.clipboard_manager, clipboard_manager: ctx.clipboard_manager,
waveform_stereo: ctx.waveform_stereo, waveform_stereo: ctx.waveform_stereo,
project_generation: ctx.project_generation, project_generation: ctx.project_generation,
script_to_edit: ctx.script_to_edit,
script_saved: ctx.script_saved,
}; };
// Render pane content (header was already rendered above) // Render pane content (header was already rendered above)
@ -5407,7 +5421,7 @@ fn pane_color(pane_type: PaneType) -> egui::Color32 {
PaneType::NodeEditor => egui::Color32::from_rgb(30, 45, 50), PaneType::NodeEditor => egui::Color32::from_rgb(30, 45, 50),
PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30), PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30),
PaneType::AssetLibrary => egui::Color32::from_rgb(45, 50, 35), PaneType::AssetLibrary => egui::Color32::from_rgb(45, 50, 35),
PaneType::ShaderEditor => egui::Color32::from_rgb(35, 30, 55), PaneType::ScriptEditor => egui::Color32::from_rgb(35, 30, 55),
} }
} }

View File

@ -219,6 +219,10 @@ pub struct SharedPaneState<'a> {
pub waveform_stereo: bool, pub waveform_stereo: bool,
/// Generation counter - incremented on project load to force reloads /// Generation counter - incremented on project load to force reloads
pub project_generation: &'a mut u64, pub project_generation: &'a mut u64,
/// Script ID to open in the script editor (set by node graph "Edit Script" action)
pub script_to_edit: &'a mut Option<Uuid>,
/// Script ID that was just saved (triggers auto-recompile of nodes using it)
pub script_saved: &'a mut Option<Uuid>,
} }
/// Trait for pane rendering /// Trait for pane rendering
@ -260,7 +264,7 @@ pub enum PaneInstance {
NodeEditor(node_editor::NodeEditorPane), NodeEditor(node_editor::NodeEditorPane),
PresetBrowser(preset_browser::PresetBrowserPane), PresetBrowser(preset_browser::PresetBrowserPane),
AssetLibrary(asset_library::AssetLibraryPane), AssetLibrary(asset_library::AssetLibraryPane),
ShaderEditor(shader_editor::ShaderEditorPane), ScriptEditor(shader_editor::ShaderEditorPane),
} }
impl PaneInstance { impl PaneInstance {
@ -281,8 +285,8 @@ impl PaneInstance {
PaneType::AssetLibrary => { PaneType::AssetLibrary => {
PaneInstance::AssetLibrary(asset_library::AssetLibraryPane::new()) PaneInstance::AssetLibrary(asset_library::AssetLibraryPane::new())
} }
PaneType::ShaderEditor => { PaneType::ScriptEditor => {
PaneInstance::ShaderEditor(shader_editor::ShaderEditorPane::new()) PaneInstance::ScriptEditor(shader_editor::ShaderEditorPane::new())
} }
} }
} }
@ -300,7 +304,7 @@ impl PaneInstance {
PaneInstance::NodeEditor(_) => PaneType::NodeEditor, PaneInstance::NodeEditor(_) => PaneType::NodeEditor,
PaneInstance::PresetBrowser(_) => PaneType::PresetBrowser, PaneInstance::PresetBrowser(_) => PaneType::PresetBrowser,
PaneInstance::AssetLibrary(_) => PaneType::AssetLibrary, PaneInstance::AssetLibrary(_) => PaneType::AssetLibrary,
PaneInstance::ShaderEditor(_) => PaneType::ShaderEditor, PaneInstance::ScriptEditor(_) => PaneType::ScriptEditor,
} }
} }
} }
@ -318,7 +322,7 @@ impl PaneRenderer for PaneInstance {
PaneInstance::NodeEditor(p) => p.render_header(ui, shared), PaneInstance::NodeEditor(p) => p.render_header(ui, shared),
PaneInstance::PresetBrowser(p) => p.render_header(ui, shared), PaneInstance::PresetBrowser(p) => p.render_header(ui, shared),
PaneInstance::AssetLibrary(p) => p.render_header(ui, shared), PaneInstance::AssetLibrary(p) => p.render_header(ui, shared),
PaneInstance::ShaderEditor(p) => p.render_header(ui, shared), PaneInstance::ScriptEditor(p) => p.render_header(ui, shared),
} }
} }
@ -340,7 +344,7 @@ impl PaneRenderer for PaneInstance {
PaneInstance::NodeEditor(p) => p.render_content(ui, rect, path, shared), PaneInstance::NodeEditor(p) => p.render_content(ui, rect, path, shared),
PaneInstance::PresetBrowser(p) => p.render_content(ui, rect, path, shared), PaneInstance::PresetBrowser(p) => p.render_content(ui, rect, path, shared),
PaneInstance::AssetLibrary(p) => p.render_content(ui, rect, path, shared), PaneInstance::AssetLibrary(p) => p.render_content(ui, rect, path, shared),
PaneInstance::ShaderEditor(p) => p.render_content(ui, rect, path, shared), PaneInstance::ScriptEditor(p) => p.render_content(ui, rect, path, shared),
} }
} }
@ -356,7 +360,7 @@ impl PaneRenderer for PaneInstance {
PaneInstance::NodeEditor(p) => p.name(), PaneInstance::NodeEditor(p) => p.name(),
PaneInstance::PresetBrowser(p) => p.name(), PaneInstance::PresetBrowser(p) => p.name(),
PaneInstance::AssetLibrary(p) => p.name(), PaneInstance::AssetLibrary(p) => p.name(),
PaneInstance::ShaderEditor(p) => p.name(), PaneInstance::ScriptEditor(p) => p.name(),
} }
} }
} }

View File

@ -68,6 +68,9 @@ pub enum NodeTemplate {
BpmDetector, BpmDetector,
Mod, Mod,
// Scripting
Script,
// Analysis // Analysis
Oscilloscope, Oscilloscope,
@ -127,6 +130,7 @@ impl NodeTemplate {
NodeTemplate::EnvelopeFollower => "EnvelopeFollower", NodeTemplate::EnvelopeFollower => "EnvelopeFollower",
NodeTemplate::BpmDetector => "BpmDetector", NodeTemplate::BpmDetector => "BpmDetector",
NodeTemplate::Beat => "Beat", NodeTemplate::Beat => "Beat",
NodeTemplate::Script => "Script",
NodeTemplate::Mod => "Mod", NodeTemplate::Mod => "Mod",
NodeTemplate::Oscilloscope => "Oscilloscope", NodeTemplate::Oscilloscope => "Oscilloscope",
NodeTemplate::VoiceAllocator => "VoiceAllocator", NodeTemplate::VoiceAllocator => "VoiceAllocator",
@ -148,6 +152,18 @@ pub struct NodeData {
/// Root note (MIDI note number) for original-pitch playback (default 69 = A4) /// Root note (MIDI note number) for original-pitch playback (default 69 = A4)
#[serde(default = "default_root_note")] #[serde(default = "default_root_note")]
pub root_note: u8, pub root_note: u8,
/// BeamDSP script asset ID (for Script nodes — references a ScriptDefinition in the document)
#[serde(default)]
pub script_id: Option<uuid::Uuid>,
/// Declarative UI from compiled BeamDSP script (for rendering sample pickers, groups)
#[serde(skip)]
pub ui_declaration: Option<beamdsp::UiDeclaration>,
/// Sample slot names from compiled script (index → name, for sample picker mapping)
#[serde(skip)]
pub sample_slot_names: Vec<String>,
/// Display names of loaded samples per slot (slot_index → display name)
#[serde(skip)]
pub script_sample_names: HashMap<usize, String>,
} }
fn default_root_note() -> u8 { 69 } fn default_root_note() -> u8 { 69 }
@ -172,6 +188,14 @@ pub struct SamplerFolderInfo {
pub clip_pool_indices: Vec<(String, usize)>, pub clip_pool_indices: Vec<(String, usize)>,
} }
/// Pending script sample load request from bottom_ui(), handled by the node graph pane
pub enum PendingScriptSampleLoad {
/// Load from audio pool into a script sample slot
FromPool { node_id: NodeId, backend_node_id: u32, slot_index: usize, pool_index: usize, name: String },
/// Open file dialog to load into a script sample slot
FromFile { node_id: NodeId, backend_node_id: u32, slot_index: usize },
}
/// Pending sampler load request from bottom_ui(), handled by the node graph pane /// Pending sampler load request from bottom_ui(), handled by the node graph pane
pub enum PendingSamplerLoad { pub enum PendingSamplerLoad {
/// Load a single clip from the audio pool into a SimpleSampler /// Load a single clip from the audio pool into a SimpleSampler
@ -207,6 +231,16 @@ pub struct GraphState {
pub pending_sequencer_changes: Vec<(NodeId, u32, f32)>, pub pending_sequencer_changes: Vec<(NodeId, u32, f32)>,
/// Time scale per oscilloscope node (in milliseconds) /// Time scale per oscilloscope node (in milliseconds)
pub oscilloscope_time_scale: HashMap<NodeId, f32>, pub oscilloscope_time_scale: HashMap<NodeId, f32>,
/// Available scripts for Script node dropdown, populated before draw
pub available_scripts: Vec<(uuid::Uuid, String)>,
/// Pending script assignment from dropdown (node_id, script_id)
pub pending_script_assignment: Option<(NodeId, uuid::Uuid)>,
/// Pending "New script..." from dropdown (node_id) — create new script and open in editor
pub pending_new_script: Option<NodeId>,
/// Pending "Load from file..." from dropdown (node_id) — open file dialog for .bdsp
pub pending_load_script_file: Option<NodeId>,
/// Pending script sample load request from bottom_ui sample picker
pub pending_script_sample_load: Option<PendingScriptSampleLoad>,
} }
impl Default for GraphState { impl Default for GraphState {
@ -222,6 +256,11 @@ impl Default for GraphState {
pending_root_note_changes: Vec::new(), pending_root_note_changes: Vec::new(),
pending_sequencer_changes: Vec::new(), pending_sequencer_changes: Vec::new(),
oscilloscope_time_scale: HashMap::new(), oscilloscope_time_scale: HashMap::new(),
available_scripts: Vec::new(),
pending_script_assignment: None,
pending_new_script: None,
pending_load_script_file: None,
pending_script_sample_load: None,
} }
} }
} }
@ -373,6 +412,8 @@ impl NodeTemplateTrait for NodeTemplate {
NodeTemplate::BpmDetector => "BPM Detector".into(), NodeTemplate::BpmDetector => "BPM Detector".into(),
NodeTemplate::Beat => "Beat".into(), NodeTemplate::Beat => "Beat".into(),
NodeTemplate::Mod => "Modulator".into(), NodeTemplate::Mod => "Modulator".into(),
// Scripting
NodeTemplate::Script => "Script".into(),
// Analysis // Analysis
NodeTemplate::Oscilloscope => "Oscilloscope".into(), NodeTemplate::Oscilloscope => "Oscilloscope".into(),
// Advanced // Advanced
@ -399,6 +440,7 @@ impl NodeTemplateTrait for NodeTemplate {
| NodeTemplate::Constant | NodeTemplate::MidiToCv | NodeTemplate::AudioToCv | NodeTemplate::Arpeggiator | NodeTemplate::Sequencer | NodeTemplate::Math | NodeTemplate::Constant | NodeTemplate::MidiToCv | NodeTemplate::AudioToCv | NodeTemplate::Arpeggiator | NodeTemplate::Sequencer | NodeTemplate::Math
| NodeTemplate::SampleHold | NodeTemplate::SlewLimiter | NodeTemplate::Quantizer | NodeTemplate::SampleHold | NodeTemplate::SlewLimiter | NodeTemplate::Quantizer
| NodeTemplate::EnvelopeFollower | NodeTemplate::BpmDetector | NodeTemplate::Mod => vec!["Utilities"], | NodeTemplate::EnvelopeFollower | NodeTemplate::BpmDetector | NodeTemplate::Mod => vec!["Utilities"],
NodeTemplate::Script => vec!["Advanced"],
NodeTemplate::Oscilloscope => vec!["Analysis"], NodeTemplate::Oscilloscope => vec!["Analysis"],
NodeTemplate::VoiceAllocator | NodeTemplate::Group => vec!["Advanced"], NodeTemplate::VoiceAllocator | NodeTemplate::Group => vec!["Advanced"],
NodeTemplate::TemplateInput | NodeTemplate::TemplateOutput => vec!["Subgraph I/O"], NodeTemplate::TemplateInput | NodeTemplate::TemplateOutput => vec!["Subgraph I/O"],
@ -411,7 +453,7 @@ impl NodeTemplateTrait for NodeTemplate {
} }
fn user_data(&self, _user_state: &mut Self::UserState) -> Self::NodeData { fn user_data(&self, _user_state: &mut Self::UserState) -> Self::NodeData {
NodeData { template: *self, sample_display_name: None, root_note: 69 } NodeData { template: *self, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() }
} }
fn build_node( fn build_node(
@ -856,6 +898,12 @@ impl NodeTemplateTrait for NodeTemplate {
// Inside a VA template: sends audio back to the allocator // Inside a VA template: sends audio back to the allocator
graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true); graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
} }
NodeTemplate::Script => {
// Default Script node: single audio in/out
// Ports will be rebuilt when a script is compiled
graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio);
}
} }
} }
} }
@ -1276,6 +1324,57 @@ impl NodeDataTrait for NodeData {
user_state.pending_sequencer_changes.push((node_id, param_id, new_bitmask as f32)); user_state.pending_sequencer_changes.push((node_id, param_id, new_bitmask as f32));
} }
} }
} else if self.template == NodeTemplate::Script {
let current_name = self.script_id
.and_then(|id| user_state.available_scripts.iter().find(|(sid, _)| *sid == id))
.map(|(_, name)| name.as_str())
.unwrap_or("No script");
let button = ui.button(current_name);
let popup_id = egui::Popup::default_response_id(&button);
let mut close_popup = false;
egui::Popup::from_toggle_button_response(&button)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.width(160.0)
.show(|ui| {
if widgets::list_item(ui, false, "New script...") {
user_state.pending_new_script = Some(node_id);
close_popup = true;
}
if widgets::list_item(ui, false, "Load from file...") {
user_state.pending_load_script_file = Some(node_id);
close_popup = true;
}
if !user_state.available_scripts.is_empty() {
ui.separator();
}
for (script_id, script_name) in &user_state.available_scripts {
let selected = self.script_id == Some(*script_id);
if widgets::list_item(ui, selected, script_name) {
user_state.pending_script_assignment = Some((node_id, *script_id));
close_popup = true;
}
}
});
if close_popup {
egui::Popup::close_id(ui.ctx(), popup_id);
}
// Render declarative UI elements (sample pickers, groups)
if let Some(ref ui_decl) = self.ui_declaration {
let backend_node_id = user_state.node_backend_ids.get(&node_id).copied().unwrap_or(0);
render_script_ui_elements(
ui, node_id, backend_node_id,
&ui_decl.elements,
&self.sample_slot_names,
&self.script_sample_names,
&user_state.available_clips,
&mut user_state.sampler_search_text,
&mut user_state.pending_script_sample_load,
);
}
} else { } else {
ui.label(""); ui.label("");
} }
@ -1283,6 +1382,91 @@ impl NodeDataTrait for NodeData {
} }
} }
/// Render UiDeclaration elements for Script nodes (sample pickers, groups, spacers)
fn render_script_ui_elements(
ui: &mut egui::Ui,
node_id: NodeId,
backend_node_id: u32,
elements: &[beamdsp::UiElement],
sample_slot_names: &[String],
script_sample_names: &HashMap<usize, String>,
available_clips: &[SamplerClipInfo],
search_text: &mut String,
pending_load: &mut Option<PendingScriptSampleLoad>,
) {
for element in elements {
match element {
beamdsp::UiElement::Sample(slot_name) => {
// Find the slot index by name
let slot_index = sample_slot_names.iter().position(|n| n == slot_name);
let display = script_sample_names
.get(&slot_index.unwrap_or(usize::MAX))
.map(|s| s.as_str())
.unwrap_or("No sample");
ui.horizontal(|ui| {
ui.label(egui::RichText::new(slot_name).weak());
let button = ui.button(display);
if let Some(slot_idx) = slot_index {
let popup_id = egui::Popup::default_response_id(&button);
let mut close = false;
egui::Popup::from_toggle_button_response(&button)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.width(160.0)
.show(|ui| {
let search = search_text.to_lowercase();
let filtered: Vec<&SamplerClipInfo> = available_clips.iter()
.filter(|c| search.is_empty() || c.name.to_lowercase().contains(&search))
.collect();
let items = filtered.iter().map(|c| (false, c.name.as_str()));
if let Some(idx) = widgets::scrollable_list(ui, 200.0, items) {
let clip = filtered[idx];
*pending_load = Some(PendingScriptSampleLoad::FromPool {
node_id,
backend_node_id,
slot_index: slot_idx,
pool_index: clip.pool_index,
name: clip.name.clone(),
});
close = true;
}
ui.separator();
if ui.button("Open...").clicked() {
*pending_load = Some(PendingScriptSampleLoad::FromFile {
node_id,
backend_node_id,
slot_index: slot_idx,
});
close = true;
}
});
if close {
egui::Popup::close_id(ui.ctx(), popup_id);
}
}
});
}
beamdsp::UiElement::Group { label, children } => {
egui::CollapsingHeader::new(egui::RichText::new(label).weak())
.default_open(true)
.show(ui, |ui| {
render_script_ui_elements(
ui, node_id, backend_node_id,
children, sample_slot_names, script_sample_names,
available_clips, search_text, pending_load,
);
});
}
beamdsp::UiElement::Spacer(height) => {
ui.add_space(*height);
}
beamdsp::UiElement::Param(_) | beamdsp::UiElement::Canvas { .. } => {
// Params are handled as inline input ports; Canvas is phase 6
}
}
}
}
// Iterator for all node templates (track-level graph) // Iterator for all node templates (track-level graph)
pub struct AllNodeTemplates; pub struct AllNodeTemplates;
@ -1370,6 +1554,7 @@ impl NodeTemplateIter for AllNodeTemplates {
NodeTemplate::Oscilloscope, NodeTemplate::Oscilloscope,
// Advanced // Advanced
NodeTemplate::VoiceAllocator, NodeTemplate::VoiceAllocator,
NodeTemplate::Script,
// Note: Group is not in the node finder — groups are created via Ctrl+G selection. // Note: Group is not in the node finder — groups are created via Ctrl+G selection.
// Note: TemplateInput/TemplateOutput are excluded from the default finder. // Note: TemplateInput/TemplateOutput are excluded from the default finder.
// They are added dynamically when editing inside a subgraph. // They are added dynamically when editing inside a subgraph.

View File

@ -137,6 +137,10 @@ pub struct NodeGraphPane {
/// Cached node screen rects from last frame (for hit-testing) /// Cached node screen rects from last frame (for hit-testing)
last_node_rects: std::collections::HashMap<NodeId, egui::Rect>, last_node_rects: std::collections::HashMap<NodeId, egui::Rect>,
/// Script nodes loaded from preset that need script_id resolution
/// (frontend_node_id, script_source) — processed in render loop where document is available
pending_script_resolutions: Vec<(NodeId, String)>,
/// Last time we polled oscilloscope data (~20 FPS) /// Last time we polled oscilloscope data (~20 FPS)
last_oscilloscope_poll: std::time::Instant, last_oscilloscope_poll: std::time::Instant,
/// Backend track ID (u32) for oscilloscope queries /// Backend track ID (u32) for oscilloscope queries
@ -167,6 +171,7 @@ impl NodeGraphPane {
renaming_group: None, renaming_group: None,
node_context_menu: None, node_context_menu: None,
last_node_rects: HashMap::new(), last_node_rects: HashMap::new(),
pending_script_resolutions: Vec::new(),
last_oscilloscope_poll: std::time::Instant::now(), last_oscilloscope_poll: std::time::Instant::now(),
backend_track_id: None, backend_track_id: None,
} }
@ -203,6 +208,7 @@ impl NodeGraphPane {
renaming_group: None, renaming_group: None,
node_context_menu: None, node_context_menu: None,
last_node_rects: HashMap::new(), last_node_rects: HashMap::new(),
pending_script_resolutions: Vec::new(),
last_oscilloscope_poll: std::time::Instant::now(), last_oscilloscope_poll: std::time::Instant::now(),
backend_track_id: Some(backend_track_id), backend_track_id: Some(backend_track_id),
}; };
@ -226,6 +232,167 @@ impl NodeGraphPane {
self.load_graph_from_json(&json) self.load_graph_from_json(&json)
} }
/// Rebuild a Script node's ports and parameters to match a compiled script.
/// Performs a diff: ports with matching name+type keep their connections,
/// removed ports lose connections, new ports are added.
/// Parameters are added as ConnectionOrConstant inputs with inline widgets.
fn rebuild_script_node_ports(&mut self, node_id: NodeId, compiled: &beamdsp::CompiledScript) {
let signal_to_data_type = |sig: beamdsp::ast::SignalKind| match sig {
beamdsp::ast::SignalKind::Audio => DataType::Audio,
beamdsp::ast::SignalKind::Cv => DataType::CV,
beamdsp::ast::SignalKind::Midi => DataType::Midi,
};
let unit_str = |u: &str| -> &'static str {
match u { "Hz" => " Hz", "s" => " s", "dB" => " dB", "%" => "%", _ => "" }
};
// Collect what the new inputs should be: signal ports + param ports
// Signal ports use DataType matching their signal kind, ConnectionOnly
// Param ports use DataType::CV, ConnectionOrConstant with float_param value
let num_signal_inputs = compiled.input_ports.len();
let num_params = compiled.parameters.len();
let num_signal_outputs = compiled.output_ports.len();
// Check if everything already matches (ports + params + outputs)
let already_matches = if let Some(node) = self.state.graph.nodes.get(node_id) {
let expected_inputs = num_signal_inputs + num_params;
if node.inputs.len() != expected_inputs || node.outputs.len() != num_signal_outputs {
false
} else {
// Check signal inputs
let signals_match = node.inputs[..num_signal_inputs].iter()
.zip(&compiled.input_ports)
.all(|((name, id), port)| {
name == &port.name
&& self.state.graph.inputs.get(*id)
.map_or(false, |p| p.typ == signal_to_data_type(port.signal))
});
// Check param inputs
let params_match = node.inputs[num_signal_inputs..].iter()
.zip(&compiled.parameters)
.all(|((name, id), param)| {
name == &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( fn handle_graph_response(
&mut self, &mut self,
response: egui_node_graph2::GraphResponse< response: egui_node_graph2::GraphResponse<
@ -680,6 +847,82 @@ impl NodeGraphPane {
} }
} }
fn handle_pending_script_sample_load(
&mut self,
load: graph_data::PendingScriptSampleLoad,
shared: &mut crate::panes::SharedPaneState,
) {
let backend_track_id = match self.backend_track_id {
Some(id) => id,
None => return,
};
let controller_arc = match &shared.audio_controller {
Some(c) => std::sync::Arc::clone(c),
None => return,
};
match load {
graph_data::PendingScriptSampleLoad::FromPool { node_id, backend_node_id, slot_index, pool_index, name } => {
let mut controller = controller_arc.lock().unwrap();
match controller.get_pool_audio_samples(pool_index) {
Ok((samples, sample_rate, _channels)) => {
controller.send_command(daw_backend::Command::GraphSetScriptSample(
backend_track_id, backend_node_id, slot_index,
samples, sample_rate, name.clone(),
));
}
Err(e) => {
eprintln!("Failed to get pool audio for script sample: {}", e);
return;
}
}
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
node.user_data.script_sample_names.insert(slot_index, name);
}
}
graph_data::PendingScriptSampleLoad::FromFile { node_id, backend_node_id, slot_index } => {
if let Some(path) = rfd::FileDialog::new()
.add_filter("Audio", &["wav", "flac", "mp3", "ogg", "aiff"])
.pick_file()
{
let file_name = path.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Sample".to_string());
let mut controller = controller_arc.lock().unwrap();
match controller.import_audio_sync(path.to_path_buf()) {
Ok(pool_index) => {
// Add to document asset library
let metadata = daw_backend::io::read_metadata(&path).ok();
let duration = metadata.as_ref().map(|m| m.duration).unwrap_or(0.0);
let clip = lightningbeam_core::clip::AudioClip::new_sampled(&file_name, pool_index, duration);
shared.action_executor.document_mut().add_audio_clip(clip);
// Get the audio data and send to script node
match controller.get_pool_audio_samples(pool_index) {
Ok((samples, sample_rate, _channels)) => {
controller.send_command(daw_backend::Command::GraphSetScriptSample(
backend_track_id, backend_node_id, slot_index,
samples, sample_rate, file_name.clone(),
));
}
Err(e) => {
eprintln!("Failed to get pool audio for script sample: {}", e);
}
}
}
Err(e) => {
eprintln!("Failed to import audio '{}': {}", path.display(), e);
}
}
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
node.user_data.script_sample_names.insert(slot_index, file_name);
}
}
}
}
}
fn check_parameter_changes(&mut self, shared: &mut crate::panes::SharedPaneState) { fn check_parameter_changes(&mut self, shared: &mut crate::panes::SharedPaneState) {
// Check all input parameters for value changes // Check all input parameters for value changes
let mut _checked_count = 0; let mut _checked_count = 0;
@ -1217,6 +1460,7 @@ impl NodeGraphPane {
self.backend_to_frontend_map.clear(); self.backend_to_frontend_map.clear();
// Create nodes in frontend // Create nodes in frontend
self.pending_script_resolutions.clear();
for node in &graph_state.nodes { for node in &graph_state.nodes {
let node_template = match Self::backend_type_to_template(&node.node_type) { let node_template = match Self::backend_type_to_template(&node.node_type) {
Some(t) => t, Some(t) => t,
@ -1226,7 +1470,21 @@ impl NodeGraphPane {
} }
}; };
self.add_node_to_editor(node_template, &node.node_type, node.position, node.id, &node.parameters); let frontend_id = self.add_node_to_editor(node_template, &node.node_type, node.position, node.id, &node.parameters);
// For Script nodes: rebuild ports now (before connections), defer script_id resolution
if node.node_type == "Script" {
if let Some(ref source) = node.script_source {
if let Some(fid) = frontend_id {
// Rebuild ports/params immediately so connections map correctly
if let Ok(compiled) = beamdsp::compile(source) {
self.rebuild_script_node_ports(fid, &compiled);
}
// Defer script_id resolution to render loop (needs document access)
self.pending_script_resolutions.push((fid, source.clone()));
}
}
}
} }
// Create connections in frontend // Create connections in frontend
@ -1674,7 +1932,7 @@ impl NodeGraphPane {
label: group.name.clone(), label: group.name.clone(),
inputs: vec![], inputs: vec![],
outputs: vec![], outputs: vec![],
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 }, user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() },
}); });
// Add dynamic input ports based on boundary inputs // Add dynamic input ports based on boundary inputs
@ -1746,7 +2004,7 @@ impl NodeGraphPane {
label: "Group Input".to_string(), label: "Group Input".to_string(),
inputs: vec![], inputs: vec![],
outputs: vec![], outputs: vec![],
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 }, user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() },
}); });
for bc in &scope_group.boundary_inputs { for bc in &scope_group.boundary_inputs {
@ -1793,7 +2051,7 @@ impl NodeGraphPane {
label: "Group Output".to_string(), label: "Group Output".to_string(),
inputs: vec![], inputs: vec![],
outputs: vec![], outputs: vec![],
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 }, user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() },
}); });
for bc in &scope_group.boundary_outputs { for bc in &scope_group.boundary_outputs {
@ -1965,6 +2223,7 @@ impl NodeGraphPane {
"Oscilloscope" => Some(NodeTemplate::Oscilloscope), "Oscilloscope" => Some(NodeTemplate::Oscilloscope),
"Arpeggiator" => Some(NodeTemplate::Arpeggiator), "Arpeggiator" => Some(NodeTemplate::Arpeggiator),
"Sequencer" => Some(NodeTemplate::Sequencer), "Sequencer" => Some(NodeTemplate::Sequencer),
"Script" => Some(NodeTemplate::Script),
"Beat" => Some(NodeTemplate::Beat), "Beat" => Some(NodeTemplate::Beat),
"VoiceAllocator" => Some(NodeTemplate::VoiceAllocator), "VoiceAllocator" => Some(NodeTemplate::VoiceAllocator),
"Group" => Some(NodeTemplate::Group), "Group" => Some(NodeTemplate::Group),
@ -1989,7 +2248,7 @@ impl NodeGraphPane {
label: label.to_string(), label: label.to_string(),
inputs: vec![], inputs: vec![],
outputs: vec![], outputs: vec![],
user_data: NodeData { template: node_template, sample_display_name: None, root_note: 69 }, user_data: NodeData { template: node_template, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() },
}); });
node_template.build_node(&mut self.state.graph, &mut self.user_state, frontend_id); node_template.build_node(&mut self.state.graph, &mut self.user_state, frontend_id);
@ -2337,6 +2596,12 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
.collect(); .collect();
self.user_state.available_folders.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); self.user_state.available_folders.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
// Available scripts for Script node dropdown
self.user_state.available_scripts = doc.script_definitions()
.map(|s| (s.id, s.name.clone()))
.collect();
self.user_state.available_scripts.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
// Node backend ID map // Node backend ID map
self.user_state.node_backend_ids = self.node_id_map.iter() self.user_state.node_backend_ids = self.node_id_map.iter()
.map(|(&node_id, backend_id)| { .map(|(&node_id, backend_id)| {
@ -2385,6 +2650,11 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
self.handle_pending_sampler_load(load, shared); self.handle_pending_sampler_load(load, shared);
} }
// Handle pending script sample load requests from bottom_ui()
if let Some(load) = self.user_state.pending_script_sample_load.take() {
self.handle_pending_script_sample_load(load, shared);
}
// Handle pending root note changes // Handle pending root note changes
if !self.user_state.pending_root_note_changes.is_empty() { if !self.user_state.pending_root_note_changes.is_empty() {
let changes: Vec<_> = self.user_state.pending_root_note_changes.drain(..).collect(); let changes: Vec<_> = self.user_state.pending_root_note_changes.drain(..).collect();
@ -2425,6 +2695,160 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
} }
} }
// Resolve Script nodes loaded from preset: find or create ScriptDefinitions
// (ports were already rebuilt during load_graph_from_json, this just sets script_id)
if !self.pending_script_resolutions.is_empty() {
let resolutions = std::mem::take(&mut self.pending_script_resolutions);
for (node_id, source) in resolutions {
// Try to find an existing ScriptDefinition with matching source
let existing_id = shared.action_executor.document()
.script_definitions()
.find(|s| s.source == source)
.map(|s| s.id);
let script_id = if let Some(id) = existing_id {
id
} else {
// Create a new ScriptDefinition from the source
use lightningbeam_core::script::ScriptDefinition;
let name = beamdsp::compile(&source)
.map(|c| c.name.clone())
.unwrap_or_else(|_| "Imported Script".to_string());
let script = ScriptDefinition::new(name, source.clone());
let id = script.id;
shared.action_executor.document_mut().add_script_definition(script);
id
};
// Set script_id on the node
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
node.user_data.script_id = Some(script_id);
}
}
}
// Handle pending script assignment from Script node dropdown
if let Some((node_id, script_id)) = self.user_state.pending_script_assignment.take() {
// Update the node's script_id
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
node.user_data.script_id = Some(script_id);
}
// Look up script source, compile locally to rebuild ports, and send to backend
let source = shared.action_executor.document()
.get_script_definition(&script_id)
.map(|s| s.source.clone());
if let Some(source) = source {
// Compile locally to get port info and rebuild the node UI
if let Ok(compiled) = beamdsp::compile(&source) {
self.rebuild_script_node_ports(node_id, &compiled);
}
if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) {
if let Some(&backend_id) = self.node_id_map.get(&node_id) {
let BackendNodeId::Audio(node_idx) = backend_id;
if let Some(controller_arc) = &shared.audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.send_command(daw_backend::Command::GraphSetScript(
backend_track_id, node_idx.index() as u32, source,
));
}
}
}
}
}
// Handle "New script..." from dropdown
if let Some(node_id) = self.user_state.pending_new_script.take() {
use lightningbeam_core::script::ScriptDefinition;
let script = ScriptDefinition::new(
"New Script".to_string(),
"name \"New Script\"\ncategory effect\n\ninputs {\n audio_in: audio\n}\n\noutputs {\n audio_out: audio\n}\n\nprocess {\n for i in 0..buffer_size {\n audio_out[i * 2] = audio_in[i * 2];\n audio_out[i * 2 + 1] = audio_in[i * 2 + 1];\n }\n}\n".to_string(),
);
let script_id = script.id;
shared.action_executor.document_mut().add_script_definition(script);
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
node.user_data.script_id = Some(script_id);
}
// Open in editor
*shared.script_to_edit = Some(script_id);
}
// Handle "Load from file..." from dropdown
if let Some(node_id) = self.user_state.pending_load_script_file.take() {
if let Some(path) = rfd::FileDialog::new()
.set_title("Load BeamDSP Script")
.add_filter("BeamDSP Script", &["bdsp"])
.pick_file()
{
if let Ok(source) = std::fs::read_to_string(&path) {
use lightningbeam_core::script::ScriptDefinition;
let name = path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Imported Script")
.to_string();
let script = ScriptDefinition::new(name, source.clone());
let script_id = script.id;
shared.action_executor.document_mut().add_script_definition(script);
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
node.user_data.script_id = Some(script_id);
}
// Compile locally to rebuild ports, then send to backend
if let Ok(compiled) = beamdsp::compile(&source) {
self.rebuild_script_node_ports(node_id, &compiled);
}
if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) {
if let Some(&backend_id) = self.node_id_map.get(&node_id) {
let BackendNodeId::Audio(node_idx) = backend_id;
if let Some(controller_arc) = &shared.audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.send_command(daw_backend::Command::GraphSetScript(
backend_track_id, node_idx.index() as u32, source,
));
}
}
}
}
}
}
// Handle script_saved: auto-recompile all Script nodes using that script
if let Some(saved_script_id) = shared.script_saved.take() {
let source = shared.action_executor.document()
.get_script_definition(&saved_script_id)
.map(|s| s.source.clone());
if let Some(source) = source {
// Compile locally to get updated port info
let compiled = beamdsp::compile(&source).ok();
// Collect matching node IDs first (can't mutate graph while iterating)
let matching_nodes: Vec<NodeId> = self.state.graph.nodes.iter()
.filter(|(_, node)| node.user_data.script_id == Some(saved_script_id))
.map(|(id, _)| id)
.collect();
// Rebuild ports for all matching nodes
if let Some(ref compiled) = compiled {
for &node_id in &matching_nodes {
self.rebuild_script_node_ports(node_id, compiled);
}
}
// Send to backend
if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) {
if let Some(controller_arc) = &shared.audio_controller {
let mut controller = controller_arc.lock().unwrap();
for &node_id in &matching_nodes {
if let Some(&backend_id) = self.node_id_map.get(&node_id) {
let BackendNodeId::Audio(node_idx) = backend_id;
controller.send_command(daw_backend::Command::GraphSetScript(
backend_track_id, node_idx.index() as u32, source.clone(),
));
}
}
}
}
}
}
// Detect right-click on nodes — intercept the library's node finder and show our context menu instead // Detect right-click on nodes — intercept the library's node finder and show our context menu instead
{ {
let secondary_clicked = ui.input(|i| i.pointer.secondary_released()); let secondary_clicked = ui.input(|i| i.pointer.secondary_released());
@ -2450,6 +2874,11 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
let mut action_delete = false; let mut action_delete = false;
let mut action_ungroup = false; let mut action_ungroup = false;
let mut action_rename = false; let mut action_rename = false;
let mut action_edit_script = false;
let is_script_node = self.state.graph.nodes.get(ctx_node_id)
.map(|n| n.user_data.template == NodeTemplate::Script)
.unwrap_or(false);
let menu_response = egui::Area::new(ui.id().with("node_context_menu")) let menu_response = egui::Area::new(ui.id().with("node_context_menu"))
.fixed_pos(menu_pos) .fixed_pos(menu_pos)
@ -2468,6 +2897,13 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
} }
ui.separator(); ui.separator();
} }
if is_script_node {
if ui.button("Edit Script").clicked() {
action_edit_script = true;
close_menu = true;
}
ui.separator();
}
if ui.button("Delete").clicked() { if ui.button("Delete").clicked() {
action_delete = true; action_delete = true;
close_menu = true; close_menu = true;
@ -2534,6 +2970,13 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
} }
} }
} }
if action_edit_script {
if let Some(script_id) = self.state.graph.nodes.get(ctx_node_id)
.and_then(|n| n.user_data.script_id)
{
*shared.script_to_edit = Some(script_id);
}
}
if close_menu { if close_menu {
self.node_context_menu = None; self.node_context_menu = None;
} }

View File

@ -1,19 +1,27 @@
/// Shader Editor pane - WGSL shader code editor with syntax highlighting /// Script Editor pane - unified code editor for WGSL shaders and BeamDSP scripts
/// ///
/// Provides a code editor for creating and editing custom effect shaders. /// Supports multiple editor modes:
/// Features: /// - Shader: WGSL shader code for custom visual effects
/// - Syntax highlighting for WGSL /// - BeamDSP: Audio DSP scripts for scriptable audio nodes
/// - Line numbers ///
/// - Basic validation feedback /// Both modes use the same save/load workflow through the asset library.
/// - Template shader insertion
use eframe::egui::{self, Ui}; use eframe::egui::{self, Ui};
use egui_code_editor::{CodeEditor, ColorTheme, Syntax}; use egui_code_editor::{CodeEditor, ColorTheme, Syntax};
use lightningbeam_core::effect::{EffectCategory, EffectDefinition}; use lightningbeam_core::effect::{EffectCategory, EffectDefinition};
use lightningbeam_core::effect_registry::EffectRegistry; use lightningbeam_core::script::ScriptDefinition;
use uuid::Uuid; use uuid::Uuid;
use super::{NodePath, PaneRenderer, SharedPaneState}; use super::{NodePath, PaneRenderer, SharedPaneState};
/// Editor mode determines syntax, templates, and compile/save behavior
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditorMode {
/// WGSL shader for visual effects
Shader,
/// BeamDSP script for audio processing nodes
BeamDSP,
}
/// Result from the unsaved changes dialog /// Result from the unsaved changes dialog
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UnsavedDialogResult { enum UnsavedDialogResult {
@ -22,7 +30,8 @@ enum UnsavedDialogResult {
SaveAndContinue, SaveAndContinue,
} }
/// Custom syntax definition for WGSL (WebGPU Shading Language) // ── Syntax definitions ──────────────────────────────────────────────
fn wgsl_syntax() -> Syntax { fn wgsl_syntax() -> Syntax {
Syntax { Syntax {
language: "WGSL", language: "WGSL",
@ -31,51 +40,35 @@ fn wgsl_syntax() -> Syntax {
comment_multiline: ["/*", "*/"], comment_multiline: ["/*", "*/"],
hyperlinks: std::collections::BTreeSet::new(), hyperlinks: std::collections::BTreeSet::new(),
keywords: std::collections::BTreeSet::from([ keywords: std::collections::BTreeSet::from([
// Control flow
"if", "else", "for", "while", "loop", "break", "continue", "return", "if", "else", "for", "while", "loop", "break", "continue", "return",
"switch", "case", "default", "discard", "switch", "case", "default", "discard",
// Declarations
"fn", "let", "var", "const", "struct", "alias", "type", "fn", "let", "var", "const", "struct", "alias", "type",
// Storage classes and access modes
"function", "private", "workgroup", "uniform", "storage", "function", "private", "workgroup", "uniform", "storage",
"read", "write", "read_write", "read", "write", "read_write",
// Shader stages
"vertex", "fragment", "compute", "vertex", "fragment", "compute",
// Attributes
"location", "builtin", "group", "binding", "location", "builtin", "group", "binding",
// Built-in values
"position", "vertex_index", "instance_index", "front_facing", "position", "vertex_index", "instance_index", "front_facing",
"frag_depth", "local_invocation_id", "local_invocation_index", "frag_depth", "local_invocation_id", "local_invocation_index",
"global_invocation_id", "workgroup_id", "num_workgroups", "global_invocation_id", "workgroup_id", "num_workgroups",
"sample_index", "sample_mask", "sample_index", "sample_mask",
]), ]),
types: std::collections::BTreeSet::from([ types: std::collections::BTreeSet::from([
// Scalar types
"bool", "i32", "u32", "f32", "f16", "bool", "i32", "u32", "f32", "f16",
// Vector types
"vec2", "vec3", "vec4", "vec2", "vec3", "vec4",
"vec2i", "vec3i", "vec4i", "vec2i", "vec3i", "vec4i", "vec2u", "vec3u", "vec4u",
"vec2u", "vec3u", "vec4u", "vec2f", "vec3f", "vec4f", "vec2h", "vec3h", "vec4h",
"vec2f", "vec3f", "vec4f", "mat2x2", "mat2x3", "mat2x4", "mat3x2", "mat3x3", "mat3x4",
"vec2h", "vec3h", "vec4h", "mat4x2", "mat4x3", "mat4x4", "mat2x2f", "mat3x3f", "mat4x4f",
// Matrix types
"mat2x2", "mat2x3", "mat2x4",
"mat3x2", "mat3x3", "mat3x4",
"mat4x2", "mat4x3", "mat4x4",
"mat2x2f", "mat3x3f", "mat4x4f",
// Texture types
"texture_1d", "texture_2d", "texture_2d_array", "texture_3d", "texture_1d", "texture_2d", "texture_2d_array", "texture_3d",
"texture_cube", "texture_cube_array", "texture_multisampled_2d", "texture_cube", "texture_cube_array", "texture_multisampled_2d",
"texture_storage_1d", "texture_storage_2d", "texture_storage_2d_array", "texture_storage_1d", "texture_storage_2d", "texture_storage_2d_array",
"texture_storage_3d", "texture_depth_2d", "texture_depth_2d_array", "texture_storage_3d", "texture_depth_2d", "texture_depth_2d_array",
"texture_depth_cube", "texture_depth_cube_array", "texture_depth_multisampled_2d", "texture_depth_cube", "texture_depth_cube_array",
// Sampler types "texture_depth_multisampled_2d",
"sampler", "sampler_comparison", "sampler", "sampler_comparison",
// Array and pointer
"array", "ptr", "array", "ptr",
]), ]),
special: std::collections::BTreeSet::from([ special: std::collections::BTreeSet::from([
// Built-in functions (subset)
"abs", "acos", "all", "any", "asin", "atan", "atan2", "abs", "acos", "all", "any", "asin", "atan", "atan2",
"ceil", "clamp", "cos", "cosh", "cross", "ceil", "clamp", "cos", "cosh", "cross",
"degrees", "determinant", "distance", "dot", "degrees", "determinant", "distance", "dot",
@ -85,27 +78,53 @@ fn wgsl_syntax() -> Syntax {
"pow", "radians", "reflect", "refract", "round", "pow", "radians", "reflect", "refract", "round",
"saturate", "sign", "sin", "sinh", "smoothstep", "sqrt", "step", "saturate", "sign", "sin", "sinh", "smoothstep", "sqrt", "step",
"tan", "tanh", "transpose", "trunc", "tan", "tanh", "transpose", "trunc",
// Texture functions
"textureSample", "textureSampleLevel", "textureSampleBias", "textureSample", "textureSampleLevel", "textureSampleBias",
"textureSampleGrad", "textureSampleCompare", "textureLoad", "textureSampleGrad", "textureSampleCompare", "textureLoad",
"textureStore", "textureDimensions", "textureNumLayers", "textureStore", "textureDimensions", "textureNumLayers",
"textureNumLevels", "textureNumSamples", "textureNumLevels", "textureNumSamples",
// Atomic functions
"atomicLoad", "atomicStore", "atomicAdd", "atomicSub", "atomicLoad", "atomicStore", "atomicAdd", "atomicSub",
"atomicMax", "atomicMin", "atomicAnd", "atomicOr", "atomicXor", "atomicMax", "atomicMin", "atomicAnd", "atomicOr", "atomicXor",
"atomicExchange", "atomicCompareExchangeWeak", "atomicExchange", "atomicCompareExchangeWeak",
// Data packing
"pack4x8snorm", "pack4x8unorm", "pack2x16snorm", "pack2x16unorm", "pack4x8snorm", "pack4x8unorm", "pack2x16snorm", "pack2x16unorm",
"unpack4x8snorm", "unpack4x8unorm", "unpack2x16snorm", "unpack2x16unorm", "unpack4x8snorm", "unpack4x8unorm", "unpack2x16snorm", "unpack2x16unorm",
// Synchronization
"storageBarrier", "workgroupBarrier", "workgroupUniformLoad", "storageBarrier", "workgroupBarrier", "workgroupUniformLoad",
// Type constructors
"select", "bitcast", "select", "bitcast",
]), ]),
} }
} }
/// Default WGSL shader template for custom effects fn beamdsp_syntax() -> Syntax {
Syntax {
language: "BeamDSP",
case_sensitive: true,
comment: "//",
comment_multiline: ["/*", "*/"],
hyperlinks: std::collections::BTreeSet::new(),
keywords: std::collections::BTreeSet::from([
"name", "category", "inputs", "outputs", "params", "state", "ui", "process",
"if", "else", "for", "in", "let", "mut",
"generator", "effect", "utility",
"audio", "cv", "midi",
"param", "sample", "group", "canvas", "spacer",
]),
types: std::collections::BTreeSet::from([
"f32", "int", "bool",
]),
special: std::collections::BTreeSet::from([
"sin", "cos", "tan", "asin", "acos", "atan", "atan2",
"exp", "log", "log2", "pow", "sqrt",
"floor", "ceil", "round", "trunc", "fract",
"abs", "clamp", "min", "max", "sign",
"mix", "smoothstep",
"len", "cv_or", "float",
"sample_len", "sample_read", "sample_rate_of",
"sample_rate", "buffer_size",
]),
}
}
// ── Templates ───────────────────────────────────────────────────────
const DEFAULT_SHADER_TEMPLATE: &str = r#"// Custom Effect Shader const DEFAULT_SHADER_TEMPLATE: &str = r#"// Custom Effect Shader
// Input: source_tex (the layer content) // Input: source_tex (the layer content)
// Output: vec4<f32> color at each pixel // Output: vec4<f32> color at each pixel
@ -118,7 +137,6 @@ struct VertexOutput {
@location(0) uv: vec2<f32>, @location(0) uv: vec2<f32>,
} }
// Fullscreen triangle strip
@vertex @vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var out: VertexOutput; var out: VertexOutput;
@ -131,19 +149,12 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Sample the source texture
let color = textureSample(source_tex, source_sampler, in.uv); let color = textureSample(source_tex, source_sampler, in.uv);
// Your effect code here - modify 'color' as desired
// Example: Return the color unchanged (passthrough)
return color; return color;
} }
"#; "#;
/// Grayscale effect shader template
const GRAYSCALE_TEMPLATE: &str = r#"// Grayscale Effect const GRAYSCALE_TEMPLATE: &str = r#"// Grayscale Effect
// Converts the image to grayscale using luminance weights
@group(0) @binding(0) var source_tex: texture_2d<f32>; @group(0) @binding(0) var source_tex: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler; @group(0) @binding(1) var source_sampler: sampler;
@ -165,18 +176,12 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let color = textureSample(source_tex, source_sampler, in.uv); let color = textureSample(source_tex, source_sampler, in.uv);
// ITU-R BT.709 luminance coefficients
let luminance = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722)); let luminance = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
return vec4<f32>(luminance, luminance, luminance, color.a); return vec4<f32>(luminance, luminance, luminance, color.a);
} }
"#; "#;
/// Vignette effect shader template
const VIGNETTE_TEMPLATE: &str = r#"// Vignette Effect const VIGNETTE_TEMPLATE: &str = r#"// Vignette Effect
// Darkens the edges of the image
@group(0) @binding(0) var source_tex: texture_2d<f32>; @group(0) @binding(0) var source_tex: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler; @group(0) @binding(1) var source_sampler: sampler;
@ -198,41 +203,169 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let color = textureSample(source_tex, source_sampler, in.uv); let color = textureSample(source_tex, source_sampler, in.uv);
// Calculate distance from center (0.5, 0.5)
let center = vec2<f32>(0.5, 0.5); let center = vec2<f32>(0.5, 0.5);
let dist = distance(in.uv, center); let dist = distance(in.uv, center);
let radius = 0.7;
// Vignette parameters let softness = 0.4;
let radius = 0.7; // Inner radius (no darkening)
let softness = 0.4; // Transition softness
// Calculate vignette factor
let vignette = smoothstep(radius + softness, radius, dist); let vignette = smoothstep(radius + softness, radius, dist);
return vec4<f32>(color.rgb * vignette, color.a); return vec4<f32>(color.rgb * vignette, color.a);
} }
"#; "#;
/// Shader Editor pane state const BEAMDSP_PASSTHROUGH: &str = r#"name "Passthrough"
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];
}
}
"#;
const BEAMDSP_GAIN: &str = r#"name "Simple Gain"
category effect
inputs {
audio_in: audio
}
outputs {
audio_out: audio
}
params {
gain: 1.0 [0.0, 2.0] ""
}
ui {
param gain
}
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;
}
}
"#;
const BEAMDSP_STEREO_DELAY: &str = r#"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;
}
}
"#;
const BEAMDSP_LFO: &str = r#"name "Custom 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;
}
}
}
"#;
// ── Pane state ──────────────────────────────────────────────────────
/// Script Editor pane state — unified editor for shaders and DSP scripts
pub struct ShaderEditorPane { pub struct ShaderEditorPane {
/// The shader source code being edited /// Current editor mode
shader_code: String, mode: EditorMode,
/// Whether to show the template selector /// The source code being edited
#[allow(dead_code)] code: String,
show_templates: bool, /// Display name for the asset being edited
/// Error message from last compilation attempt (if any) asset_name: String,
/// Error message from last compilation attempt
compile_error: Option<String>, compile_error: Option<String>,
/// Name for the shader/effect
shader_name: String, // ── Shader mode state ───────────────────────────────────
/// ID of effect being edited (None = new effect) /// ID of effect being edited (None = new effect)
editing_effect_id: Option<Uuid>, editing_effect_id: Option<Uuid>,
/// Original code when effect was loaded (for dirty checking)
// ── BeamDSP mode state ──────────────────────────────────
/// ID of script being edited (None = new script)
editing_script_id: Option<Uuid>,
// ── Shared state ────────────────────────────────────────
/// Original code when asset was loaded (for dirty checking)
original_code: Option<String>, original_code: Option<String>,
/// Original name when effect was loaded (for dirty checking) /// Original name when asset was loaded (for dirty checking)
original_name: Option<String>, original_name: Option<String>,
/// Effect awaiting confirmation to load (when there are unsaved changes) /// Effect awaiting confirmation to load (when there are unsaved changes)
pending_load_effect: Option<EffectDefinition>, pending_load_effect: Option<EffectDefinition>,
/// Script awaiting confirmation to load (when there are unsaved changes)
pending_load_script: Option<ScriptDefinition>,
/// Whether to show the unsaved changes confirmation dialog /// Whether to show the unsaved changes confirmation dialog
show_unsaved_dialog: bool, show_unsaved_dialog: bool,
} }
@ -240,37 +373,76 @@ pub struct ShaderEditorPane {
impl ShaderEditorPane { impl ShaderEditorPane {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
shader_code: DEFAULT_SHADER_TEMPLATE.to_string(), mode: EditorMode::Shader,
show_templates: false, code: DEFAULT_SHADER_TEMPLATE.to_string(),
asset_name: "Custom Effect".to_string(),
compile_error: None, compile_error: None,
shader_name: "Custom Effect".to_string(),
editing_effect_id: None, editing_effect_id: None,
editing_script_id: None,
original_code: None, original_code: None,
original_name: None, original_name: None,
pending_load_effect: None, pending_load_effect: None,
pending_load_script: None,
show_unsaved_dialog: false, show_unsaved_dialog: false,
} }
} }
/// Check if there are unsaved changes fn default_code(&self) -> &str {
pub fn has_unsaved_changes(&self) -> bool { match self.mode {
match (&self.original_code, &self.original_name) { EditorMode::Shader => DEFAULT_SHADER_TEMPLATE,
(Some(orig_code), Some(orig_name)) => { EditorMode::BeamDSP => BEAMDSP_PASSTHROUGH,
self.shader_code != *orig_code || self.shader_name != *orig_name
}
// If no original, check if we've modified from default
(None, None) => {
self.shader_code != DEFAULT_SHADER_TEMPLATE || self.shader_name != "Custom Effect"
}
_ => true, // Inconsistent state, assume dirty
} }
} }
/// Load an effect into the editor fn default_name(&self) -> &str {
pub fn load_effect(&mut self, effect: &EffectDefinition) { match self.mode {
self.shader_name = effect.name.clone(); EditorMode::Shader => "Custom Effect",
self.shader_code = effect.shader_code.clone(); EditorMode::BeamDSP => "New Script",
// For built-in effects, don't set editing_effect_id (editing creates a copy) }
}
fn has_unsaved_changes(&self) -> bool {
match (&self.original_code, &self.original_name) {
(Some(orig_code), Some(orig_name)) => {
self.code != *orig_code || self.asset_name != *orig_name
}
(None, None) => {
self.code != self.default_code() || self.asset_name != self.default_name()
}
_ => true,
}
}
fn is_editing_existing(&self) -> bool {
match self.mode {
EditorMode::Shader => self.editing_effect_id.is_some(),
EditorMode::BeamDSP => self.editing_script_id.is_some(),
}
}
fn mark_saved_state(&mut self) {
self.original_code = Some(self.code.clone());
self.original_name = Some(self.asset_name.clone());
}
fn new_asset(&mut self) {
self.asset_name = self.default_name().to_string();
self.code = self.default_code().to_string();
match self.mode {
EditorMode::Shader => self.editing_effect_id = None,
EditorMode::BeamDSP => self.editing_script_id = None,
}
self.original_code = None;
self.original_name = None;
self.compile_error = None;
}
// ── Shader-specific ─────────────────────────────────────
fn load_effect(&mut self, effect: &EffectDefinition) {
self.mode = EditorMode::Shader;
self.asset_name = effect.name.clone();
self.code = effect.shader_code.clone();
if effect.category == EffectCategory::Custom { if effect.category == EffectCategory::Custom {
self.editing_effect_id = Some(effect.id); self.editing_effect_id = Some(effect.id);
} else { } else {
@ -281,56 +453,102 @@ impl ShaderEditorPane {
self.compile_error = None; self.compile_error = None;
} }
/// Reset to a new blank effect
pub fn new_effect(&mut self) {
self.shader_name = "Custom Effect".to_string();
self.shader_code = DEFAULT_SHADER_TEMPLATE.to_string();
self.editing_effect_id = None;
self.original_code = None;
self.original_name = None;
self.compile_error = None;
}
/// Mark the current state as saved
pub fn mark_saved(&mut self, effect_id: Uuid) {
self.editing_effect_id = Some(effect_id);
self.original_code = Some(self.shader_code.clone());
self.original_name = Some(self.shader_name.clone());
}
/// Look up an effect by ID (checks document first, then built-in registry)
fn lookup_effect( fn lookup_effect(
&self, &self,
effect_id: Uuid, effect_id: Uuid,
document: &lightningbeam_core::document::Document, document: &lightningbeam_core::document::Document,
) -> Option<EffectDefinition> { ) -> Option<EffectDefinition> {
// First check custom effects in document use lightningbeam_core::effect_registry::EffectRegistry;
if let Some(def) = document.effect_definitions.get(&effect_id) { if let Some(def) = document.effect_definitions.get(&effect_id) {
return Some(def.clone()); return Some(def.clone());
} }
// Then check built-in effects
EffectRegistry::get_by_id(&effect_id) EffectRegistry::get_by_id(&effect_id)
} }
/// Render the unsaved changes confirmation dialog fn save_effect(&mut self, shared: &mut SharedPaneState) -> bool {
if self.asset_name.trim().is_empty() {
self.compile_error = Some("Name cannot be empty".to_string());
return false;
}
let effect = if let Some(existing_id) = self.editing_effect_id {
EffectDefinition::with_id(
existing_id, self.asset_name.clone(),
EffectCategory::Custom, self.code.clone(), vec![],
)
} else {
EffectDefinition::new(
self.asset_name.clone(), EffectCategory::Custom,
self.code.clone(), vec![],
)
};
let effect_id = effect.id;
shared.action_executor.document_mut().add_effect_definition(effect);
self.editing_effect_id = Some(effect_id);
self.mark_saved_state();
shared.effect_thumbnails_to_invalidate.push(effect_id);
self.compile_error = None;
true
}
// ── BeamDSP-specific ────────────────────────────────────
fn load_script(&mut self, script: &ScriptDefinition) {
self.mode = EditorMode::BeamDSP;
self.asset_name = script.name.clone();
self.code = script.source.clone();
self.editing_script_id = Some(script.id);
self.original_code = Some(script.source.clone());
self.original_name = Some(script.name.clone());
self.compile_error = None;
}
fn save_script(&mut self, shared: &mut SharedPaneState) -> bool {
if self.asset_name.trim().is_empty() {
self.compile_error = Some("Name cannot be empty".to_string());
return false;
}
// Compile first — reject if invalid
if let Err(err) = beamdsp::compile(&self.code) {
self.compile_error = Some(format!("{}", err));
return false;
}
let script = if let Some(existing_id) = self.editing_script_id {
ScriptDefinition::with_id(existing_id, self.asset_name.clone(), self.code.clone())
} else {
ScriptDefinition::new(self.asset_name.clone(), self.code.clone())
};
let script_id = script.id;
shared.action_executor.document_mut().add_script_definition(script);
self.editing_script_id = Some(script_id);
self.mark_saved_state();
self.compile_error = None;
// Auto-recompile: notify all Script nodes referencing this script
*shared.script_saved = Some(script_id);
true
}
// ── Dialog rendering ────────────────────────────────────
fn render_unsaved_dialog(&mut self, ui: &mut egui::Ui) -> Option<UnsavedDialogResult> { fn render_unsaved_dialog(&mut self, ui: &mut egui::Ui) -> Option<UnsavedDialogResult> {
let mut result = None; let mut result = None;
if self.show_unsaved_dialog { if self.show_unsaved_dialog {
let window_id = egui::Id::new("shader_unsaved_dialog");
egui::Window::new("Unsaved Changes") egui::Window::new("Unsaved Changes")
.id(window_id) .id(egui::Id::new("script_unsaved_dialog"))
.collapsible(false) .collapsible(false)
.resizable(false) .resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.show(ui.ctx(), |ui| { .show(ui.ctx(), |ui| {
ui.set_min_width(300.0); ui.set_min_width(300.0);
let label = match self.mode {
ui.label("You have unsaved changes to this shader."); EditorMode::Shader => "You have unsaved changes to this shader.",
EditorMode::BeamDSP => "You have unsaved changes to this script.",
};
ui.label(label);
ui.label("What would you like to do?"); ui.label("What would you like to do?");
ui.add_space(12.0); ui.add_space(12.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button("Cancel").clicked() { if ui.button("Cancel").clicked() {
result = Some(UnsavedDialogResult::Cancel); result = Some(UnsavedDialogResult::Cancel);
@ -344,74 +562,109 @@ impl ShaderEditorPane {
}); });
}); });
} }
result result
} }
/// Render the toolbar with template selection and actions // ── Toolbar rendering ───────────────────────────────────
/// Returns true if Save was clicked
fn render_toolbar(&mut self, ui: &mut Ui, _path: &NodePath) -> bool { fn render_toolbar(
&mut self,
ui: &mut Ui,
available_scripts: &[(Uuid, String)],
) -> (bool, bool, Option<Uuid>) {
let mut save_clicked = false; let mut save_clicked = false;
let mut export_clicked = false;
let mut open_script_id = None;
ui.horizontal(|ui| { ui.horizontal(|ui| {
// New button
if ui.button("New").clicked() { if ui.button("New").clicked() {
// TODO: Check for unsaved changes first self.new_asset();
self.new_effect(); }
// Open dropdown for existing scripts (BeamDSP mode)
if self.mode == EditorMode::BeamDSP && !available_scripts.is_empty() {
let open_btn = ui.button("Open");
let popup_id = egui::Id::new("script_editor_open_popup");
if open_btn.clicked() {
ui.memory_mut(|m| m.toggle_popup(popup_id));
}
egui::popup_below_widget(ui, popup_id, &open_btn, egui::PopupCloseBehavior::CloseOnClickOutside, |ui| {
ui.set_min_width(160.0);
for (id, name) in available_scripts {
let is_current = self.editing_script_id == Some(*id);
if ui.selectable_label(is_current, name).clicked() {
open_script_id = Some(*id);
ui.memory_mut(|m| m.close_popup(popup_id));
}
}
});
} }
ui.separator(); ui.separator();
// Shader name input
ui.label("Name:"); ui.label("Name:");
ui.add(egui::TextEdit::singleline(&mut self.shader_name).desired_width(150.0)); ui.add(egui::TextEdit::singleline(&mut self.asset_name).desired_width(150.0));
ui.separator(); ui.separator();
// Template dropdown // Mode-specific templates
match self.mode {
EditorMode::Shader => {
egui::ComboBox::from_label("Template") egui::ComboBox::from_label("Template")
.selected_text("Insert Template") .selected_text("Insert Template")
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
if ui.selectable_label(false, "Basic (Passthrough)").clicked() { if ui.selectable_label(false, "Basic (Passthrough)").clicked() {
self.shader_code = DEFAULT_SHADER_TEMPLATE.to_string(); self.code = DEFAULT_SHADER_TEMPLATE.to_string();
} }
if ui.selectable_label(false, "Grayscale").clicked() { if ui.selectable_label(false, "Grayscale").clicked() {
self.shader_code = GRAYSCALE_TEMPLATE.to_string(); self.code = GRAYSCALE_TEMPLATE.to_string();
} }
if ui.selectable_label(false, "Vignette").clicked() { if ui.selectable_label(false, "Vignette").clicked() {
self.shader_code = VIGNETTE_TEMPLATE.to_string(); self.code = VIGNETTE_TEMPLATE.to_string();
} }
}); });
}
EditorMode::BeamDSP => {
egui::ComboBox::from_label("Template")
.selected_text("Insert Template")
.show_ui(ui, |ui| {
if ui.selectable_label(false, "Passthrough").clicked() {
self.code = BEAMDSP_PASSTHROUGH.to_string();
}
if ui.selectable_label(false, "Simple Gain").clicked() {
self.code = BEAMDSP_GAIN.to_string();
}
if ui.selectable_label(false, "Stereo Delay").clicked() {
self.code = BEAMDSP_STEREO_DELAY.to_string();
}
if ui.selectable_label(false, "Custom LFO").clicked() {
self.code = BEAMDSP_LFO.to_string();
}
});
}
}
ui.separator(); ui.separator();
// Compile button (placeholder for now)
if ui.button("Validate").clicked() {
// TODO: Integrate with wgpu shader validation
// For now, just clear any previous error
self.compile_error = None;
}
// Save button
if ui.button("Save").clicked() { if ui.button("Save").clicked() {
save_clicked = true; save_clicked = true;
} }
// Show dirty indicator if self.mode == EditorMode::BeamDSP {
if ui.button("Export").clicked() {
export_clicked = true;
}
}
if self.has_unsaved_changes() { if self.has_unsaved_changes() {
ui.label(egui::RichText::new("*").color(egui::Color32::YELLOW)); ui.label(egui::RichText::new("*").color(egui::Color32::YELLOW));
} }
if self.is_editing_existing() {
// Show editing mode
if let Some(_) = self.editing_effect_id {
ui.label(egui::RichText::new("(Editing)").weak()); ui.label(egui::RichText::new("(Editing)").weak());
} else { } else {
ui.label(egui::RichText::new("(New)").weak()); ui.label(egui::RichText::new("(New)").weak());
} }
}); });
save_clicked (save_clicked, export_clicked, open_script_id)
} }
/// Render the error panel if there's a compile error
fn render_error_panel(&self, ui: &mut Ui) { fn render_error_panel(&self, ui: &mut Ui) {
if let Some(error) = &self.compile_error { if let Some(error) = &self.compile_error {
ui.horizontal(|ui| { ui.horizontal(|ui| {
@ -433,150 +686,161 @@ impl PaneRenderer for ShaderEditorPane {
) { ) {
// Handle effect loading request from asset library // Handle effect loading request from asset library
if let Some(effect_id) = shared.effect_to_load.take() { if let Some(effect_id) = shared.effect_to_load.take() {
// Look up the effect
if let Some(effect) = self.lookup_effect(effect_id, shared.action_executor.document()) { if let Some(effect) = self.lookup_effect(effect_id, shared.action_executor.document()) {
if self.has_unsaved_changes() { if self.has_unsaved_changes() {
// Store effect to load and show dialog
self.pending_load_effect = Some(effect); self.pending_load_effect = Some(effect);
self.show_unsaved_dialog = true; self.show_unsaved_dialog = true;
} else { } else {
// No unsaved changes, load immediately
self.load_effect(&effect); self.load_effect(&effect);
} }
} }
} }
// Handle script loading request from node graph
if let Some(script_id) = shared.script_to_edit.take() {
if let Some(script) = shared.action_executor.document().get_script_definition(&script_id).cloned() {
if self.has_unsaved_changes() {
self.pending_load_script = Some(script);
self.show_unsaved_dialog = true;
} else {
self.load_script(&script);
}
}
}
// Handle unsaved changes dialog // Handle unsaved changes dialog
if let Some(result) = self.render_unsaved_dialog(ui) { if let Some(result) = self.render_unsaved_dialog(ui) {
match result { match result {
UnsavedDialogResult::Cancel => { UnsavedDialogResult::Cancel => {
// Cancel the load, keep current state
self.pending_load_effect = None; self.pending_load_effect = None;
self.pending_load_script = None;
self.show_unsaved_dialog = false; self.show_unsaved_dialog = false;
} }
UnsavedDialogResult::Discard => { UnsavedDialogResult::Discard => {
// Discard changes and load the new effect
if let Some(effect) = self.pending_load_effect.take() { if let Some(effect) = self.pending_load_effect.take() {
self.load_effect(&effect); self.load_effect(&effect);
} }
if let Some(script) = self.pending_load_script.take() {
self.load_script(&script);
}
self.show_unsaved_dialog = false; self.show_unsaved_dialog = false;
} }
UnsavedDialogResult::SaveAndContinue => { UnsavedDialogResult::SaveAndContinue => {
// Save current work first match self.mode {
if !self.shader_name.trim().is_empty() { EditorMode::Shader => { self.save_effect(shared); }
let effect = if let Some(existing_id) = self.editing_effect_id { EditorMode::BeamDSP => { self.save_script(shared); }
EffectDefinition::with_id(
existing_id,
self.shader_name.clone(),
EffectCategory::Custom,
self.shader_code.clone(),
vec![],
)
} else {
EffectDefinition::new(
self.shader_name.clone(),
EffectCategory::Custom,
self.shader_code.clone(),
vec![],
)
};
let effect_id = effect.id;
shared.action_executor.document_mut().add_effect_definition(effect);
self.mark_saved(effect_id);
// Invalidate thumbnail so it regenerates with new shader
shared.effect_thumbnails_to_invalidate.push(effect_id);
} }
// Then load the new effect
if let Some(effect) = self.pending_load_effect.take() { if let Some(effect) = self.pending_load_effect.take() {
self.load_effect(&effect); self.load_effect(&effect);
} }
if let Some(script) = self.pending_load_script.take() {
self.load_script(&script);
}
self.show_unsaved_dialog = false; self.show_unsaved_dialog = false;
} }
} }
} }
// Background // Background
ui.painter().rect_filled( ui.painter().rect_filled(rect, 0.0, egui::Color32::from_rgb(25, 25, 30));
rect,
0.0,
egui::Color32::from_rgb(25, 25, 30),
);
// Create content area
let content_rect = rect.shrink(8.0); let content_rect = rect.shrink(8.0);
let mut content_ui = ui.new_child( let mut content_ui = ui.new_child(
egui::UiBuilder::new() egui::UiBuilder::new()
.max_rect(content_rect) .max_rect(content_rect)
.layout(egui::Layout::top_down(egui::Align::LEFT)), .layout(egui::Layout::top_down(egui::Align::LEFT)),
); );
content_ui.set_min_width(content_rect.width() - 16.0); content_ui.set_min_width(content_rect.width() - 16.0);
// Mode selector
content_ui.horizontal(|ui| {
if ui.selectable_value(&mut self.mode, EditorMode::Shader, "Shader").changed() {
// Switching modes - reset to defaults for the new mode
self.new_asset();
}
if ui.selectable_value(&mut self.mode, EditorMode::BeamDSP, "BeamDSP").changed() {
self.new_asset();
}
});
content_ui.add_space(2.0);
// Collect available scripts for the Open dropdown
let available_scripts: Vec<(Uuid, String)> = shared.action_executor.document()
.script_definitions()
.map(|s| (s.id, s.name.clone()))
.collect();
// Toolbar // Toolbar
let save_clicked = self.render_toolbar(&mut content_ui, path); let (save_clicked, export_clicked, open_script_id) = self.render_toolbar(&mut content_ui, &available_scripts);
content_ui.add_space(4.0); content_ui.add_space(4.0);
content_ui.separator(); content_ui.separator();
content_ui.add_space(4.0); content_ui.add_space(4.0);
// Handle save action // Handle open script
if save_clicked { if let Some(script_id) = open_script_id {
if self.shader_name.trim().is_empty() { if let Some(script) = shared.action_executor.document().get_script_definition(&script_id).cloned() {
self.compile_error = Some("Name cannot be empty".to_string()); if self.has_unsaved_changes() {
self.pending_load_script = Some(script);
self.show_unsaved_dialog = true;
} else { } else {
// Create or update EffectDefinition self.load_script(&script);
let effect = if let Some(existing_id) = self.editing_effect_id { }
// Update existing custom effect }
EffectDefinition::with_id( }
existing_id,
self.shader_name.clone(),
EffectCategory::Custom,
self.shader_code.clone(),
vec![], // No parameters for now
)
} else {
// Create new custom effect
EffectDefinition::new(
self.shader_name.clone(),
EffectCategory::Custom,
self.shader_code.clone(),
vec![], // No parameters for now
)
};
let effect_id = effect.id; // Handle save
shared.action_executor.document_mut().add_effect_definition(effect); if save_clicked {
self.mark_saved(effect_id); match self.mode {
// Invalidate thumbnail so it regenerates with new shader EditorMode::Shader => { self.save_effect(shared); }
shared.effect_thumbnails_to_invalidate.push(effect_id); EditorMode::BeamDSP => { self.save_script(shared); }
self.compile_error = None; }
}
// Handle export (.bdsp)
if export_clicked {
let default_name = format!("{}.bdsp", self.asset_name.trim());
if let Some(path) = rfd::FileDialog::new()
.set_title("Export BeamDSP Script")
.set_file_name(&default_name)
.add_filter("BeamDSP Script", &["bdsp"])
.save_file()
{
if let Err(e) = std::fs::write(&path, &self.code) {
self.compile_error = Some(format!("Export failed: {}", e));
}
} }
} }
// Error panel // Error panel
self.render_error_panel(&mut content_ui); self.render_error_panel(&mut content_ui);
// Calculate remaining height for the code editor
let remaining_rect = content_ui.available_rect_before_wrap();
// Code editor // Code editor
let remaining_rect = content_ui.available_rect_before_wrap();
let syntax = match self.mode {
EditorMode::Shader => wgsl_syntax(),
EditorMode::BeamDSP => beamdsp_syntax(),
};
egui::ScrollArea::both() egui::ScrollArea::both()
.id_salt(("shader_editor_scroll", path)) .id_salt(("script_editor_scroll", path))
.auto_shrink([false, false]) .auto_shrink([false, false])
.show(&mut content_ui, |ui| { .show(&mut content_ui, |ui| {
ui.set_min_size(remaining_rect.size()); ui.set_min_size(remaining_rect.size());
CodeEditor::default() CodeEditor::default()
.id_source("shader_code_editor") .id_source("script_code_editor")
.with_rows(50) .with_rows(50)
.with_fontsize(13.0) .with_fontsize(13.0)
.with_theme(ColorTheme::GRUVBOX_DARK) .with_theme(ColorTheme::GRUVBOX_DARK)
.with_syntax(wgsl_syntax()) .with_syntax(syntax)
.with_numlines(true) .with_numlines(true)
.show(ui, &mut self.shader_code); .show(ui, &mut self.code);
}); });
} }
fn name(&self) -> &str { fn name(&self) -> &str {
"Shader Editor" match self.mode {
EditorMode::Shader => "Shader Editor",
EditorMode::BeamDSP => "Script Editor",
}
} }
} }