diff --git a/daw-backend/Cargo.toml b/daw-backend/Cargo.toml index 8242b60..4537fcb 100644 --- a/daw-backend/Cargo.toml +++ b/daw-backend/Cargo.toml @@ -36,6 +36,9 @@ dasp_rms = "0.11" petgraph = "0.6" serde_json = "1.0" +# BeamDSP scripting engine +beamdsp = { path = "../lightningbeam-ui/beamdsp" } + [dev-dependencies] [profile.release] diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index f735ea5..2bb7c53 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1169,6 +1169,7 @@ impl Engine { "Beat" => Box::new(BeatNode::new("Beat".to_string())), "Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".to_string())), "Sequencer" => Box::new(SequencerNode::new("Sequencer".to_string())), + "Script" => Box::new(ScriptNode::new("Script".to_string())), "EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())), "Limiter" => Box::new(LimiterNode::new("Limiter".to_string())), "Math" => Box::new(MathNode::new("Math".to_string())), @@ -1259,6 +1260,7 @@ impl Engine { "Beat" => Box::new(BeatNode::new("Beat".to_string())), "Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".to_string())), "Sequencer" => Box::new(SequencerNode::new("Sequencer".to_string())), + "Script" => Box::new(ScriptNode::new("Script".to_string())), "EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())), "Limiter" => Box::new(LimiterNode::new("Limiter".to_string())), "Math" => Box::new(MathNode::new("Math".to_string())), @@ -1657,6 +1659,58 @@ impl Engine { } } + Command::GraphSetScript(track_id, node_id, source) => { + use crate::audio::node_graph::nodes::ScriptNode; + + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(script_node) = graph_node.node.as_any_mut().downcast_mut::() { + 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::() { + script_node.set_sample(slot_index, data, sample_rate, name); + } + } + } + } + Command::SamplerLoadSample(track_id, node_id, file_path) => { use crate::audio::node_graph::nodes::SimpleSamplerNode; diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs index edaf375..4cd1f46 100644 --- a/daw-backend/src/audio/node_graph/graph.rs +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -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::() { + let source = script_node.source_code(); + if !source.is_empty() { + serialized.script_source = Some(source.to_string()); + } + } + } + // Save position if available if let Some(pos) = self.get_node_position(node_idx) { serialized.set_position(pos.0, pos.1); @@ -992,6 +1003,7 @@ impl AudioGraph { "Beat" => Box::new(BeatNode::new("Beat")), "Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator")), "Sequencer" => Box::new(SequencerNode::new("Sequencer")), + "Script" => Box::new(ScriptNode::new("Script")), "EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower")), "Limiter" => Box::new(LimiterNode::new("Limiter")), "Math" => Box::new(MathNode::new("Math")), @@ -1034,7 +1046,22 @@ impl AudioGraph { let node_idx = graph.add_node(node); index_map.insert(serialized_node.id, node_idx); - // Set parameters + // Restore script source for Script nodes (must come before parameter setting + // since set_script rebuilds parameters) + if let Some(ref source) = serialized_node.script_source { + if serialized_node.node_type == "Script" { + use crate::audio::node_graph::nodes::ScriptNode; + if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) { + if let Some(script_node) = graph_node.node.as_any_mut().downcast_mut::() { + if let Err(e) = script_node.set_script(source) { + eprintln!("Warning: failed to compile script for node {}: {}", serialized_node.id, e); + } + } + } + } + } + + // Set parameters (after script compilation so param slots exist) for (¶m_id, &value) in &serialized_node.parameters { if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) { graph_node.node.set_parameter(param_id, value); diff --git a/daw-backend/src/audio/node_graph/nodes/mod.rs b/daw-backend/src/audio/node_graph/nodes/mod.rs index 2dcd5eb..d37f4d3 100644 --- a/daw-backend/src/audio/node_graph/nodes/mod.rs +++ b/daw-backend/src/audio/node_graph/nodes/mod.rs @@ -34,6 +34,7 @@ mod quantizer; mod reverb; mod ring_modulator; mod sample_hold; +mod script_node; mod sequencer; mod simple_sampler; mod slew_limiter; @@ -79,6 +80,7 @@ pub use quantizer::QuantizerNode; pub use reverb::ReverbNode; pub use ring_modulator::RingModulatorNode; pub use sample_hold::SampleHoldNode; +pub use script_node::ScriptNode; pub use sequencer::SequencerNode; pub use simple_sampler::SimpleSamplerNode; pub use slew_limiter::SlewLimiterNode; diff --git a/daw-backend/src/audio/node_graph/nodes/script_node.rs b/daw-backend/src/audio/node_graph/nodes/script_node.rs new file mode 100644 index 0000000..f4dcab9 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/script_node.rs @@ -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, + outputs: Vec, + parameters: Vec, + 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) -> 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 { + 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, 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 { + 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], + 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 { + 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 + } +} diff --git a/daw-backend/src/audio/node_graph/preset.rs b/daw-backend/src/audio/node_graph/preset.rs index fd760be..60d7811 100644 --- a/daw-backend/src/audio/node_graph/preset.rs +++ b/daw-backend/src/audio/node_graph/preset.rs @@ -123,6 +123,10 @@ pub struct SerializedNode { /// For sampler nodes: loaded sample data #[serde(skip_serializing_if = "Option::is_none")] pub sample_data: Option, + + /// For Script nodes: BeamDSP source code + #[serde(skip_serializing_if = "Option::is_none")] + pub script_source: Option, } /// Serialized group definition (frontend-only visual grouping, stored opaquely by backend) @@ -217,6 +221,7 @@ impl SerializedNode { position: (0.0, 0.0), template_graph: None, sample_data: None, + script_source: None, } } diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 1a70a5a..42ee4e8 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -175,6 +175,11 @@ pub enum Command { /// Save a VoiceAllocator's template graph as a preset (track_id, voice_allocator_id, preset_path, preset_name) GraphSaveTemplatePreset(TrackId, u32, String, String), + /// Compile and set a BeamDSP script on a Script node (track_id, node_id, source_code) + GraphSetScript(TrackId, u32, String), + /// Load audio sample data into a Script node's sample slot (track_id, node_id, slot_index, audio_data, sample_rate, name) + GraphSetScriptSample(TrackId, u32, usize, Vec, u32, String), + /// Load a sample into a SimpleSampler node (track_id, node_id, file_path) SamplerLoadSample(TrackId, u32, String), /// Load a sample from the audio pool into a SimpleSampler node (track_id, node_id, pool_index) @@ -266,6 +271,16 @@ pub enum AudioEvent { GraphPresetLoaded(TrackId), /// Preset has been saved to file (track_id, preset_path) GraphPresetSaved(TrackId, String), + /// Script compilation result (track_id, node_id, success, error, ui_declaration, source) + ScriptCompiled { + track_id: TrackId, + node_id: u32, + success: bool, + error: Option, + ui_declaration: Option, + source: String, + }, + /// Export progress (frames_rendered, total_frames) ExportProgress { frames_rendered: usize, diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index b00ae4a..60d7ef4 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -678,6 +678,13 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "beamdsp" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "bincode" version = "1.3.3" @@ -1655,6 +1662,7 @@ name = "daw-backend" version = "0.1.0" dependencies = [ "base64 0.22.1", + "beamdsp", "cpal", "crossterm", "dasp_envelope", @@ -3438,6 +3446,7 @@ dependencies = [ name = "lightningbeam-editor" version = "0.1.0" dependencies = [ + "beamdsp", "bytemuck", "clap", "cpal", diff --git a/lightningbeam-ui/Cargo.toml b/lightningbeam-ui/Cargo.toml index e5a67a5..ae6a194 100644 --- a/lightningbeam-ui/Cargo.toml +++ b/lightningbeam-ui/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "lightningbeam-editor", "lightningbeam-core", + "beamdsp", ] [workspace.dependencies] @@ -49,6 +50,9 @@ notify-rust = "4.11" [profile.dev.package.daw-backend] opt-level = 2 +[profile.dev.package.beamdsp] +opt-level = 2 + # Also optimize symphonia (audio decoder) and cpal (audio I/O) — these # run in the audio callback path and are heavily numeric. [profile.dev.package.symphonia] diff --git a/lightningbeam-ui/beamdsp/BEAMDSP.md b/lightningbeam-ui/beamdsp/BEAMDSP.md new file mode 100644 index 0000000..3fccdea --- /dev/null +++ b/lightningbeam-ui/beamdsp/BEAMDSP.md @@ -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. diff --git a/lightningbeam-ui/beamdsp/Cargo.toml b/lightningbeam-ui/beamdsp/Cargo.toml new file mode 100644 index 0000000..7b1f574 --- /dev/null +++ b/lightningbeam-ui/beamdsp/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "beamdsp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/lightningbeam-ui/beamdsp/src/ast.rs b/lightningbeam-ui/beamdsp/src/ast.rs new file mode 100644 index 0000000..42afe2e --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/ast.rs @@ -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, + pub outputs: Vec, + pub params: Vec, + pub state: Vec, + pub ui: Option>, + 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; + +#[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, + 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, Span), +} + +#[derive(Debug, Clone)] +pub enum Expr { + FloatLit(f32, Span), + IntLit(i32, Span), + BoolLit(bool, Span), + Ident(String, Span), + BinOp(Box, BinOp, Box, Span), + UnaryOp(UnaryOp, Box, Span), + Call(String, Vec, Span), + Index(Box, Box, Span), + Cast(CastKind, Box, 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, +} diff --git a/lightningbeam-ui/beamdsp/src/codegen.rs b/lightningbeam-ui/beamdsp/src/codegen.rs new file mode 100644 index 0000000..b51aca4 --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/codegen.rs @@ -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, + constants_f32: Vec, + constants_i32: Vec, + vars: Vec<(String, VarLoc)>, + next_local: u16, + scope_stack: Vec, // 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 { + 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 { + 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 = script.params.iter().map(|p| p.default).collect(); + + let vm = ScriptVM::new( + compiler.code, + compiler.constants_f32, + compiler.constants_i32, + script.params.len(), + ¶m_defaults, + num_state_scalars, + &state_array_sizes, + num_sample_slots, + ); + + // Build UI declaration + let ui_decl = if let Some(elements) = &script.ui { + UiDeclaration { elements: elements.clone() } + } else { + // Auto-generate: sample pickers first, then all params + let mut elements = Vec::new(); + for state in &script.state { + if state.ty == StateType::Sample { + elements.push(UiElement::Sample(state.name.clone())); + } + } + for param in &script.params { + elements.push(UiElement::Param(param.name.clone())); + } + UiDeclaration { elements } + }; + + Ok((vm, ui_decl)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::Lexer; + use crate::parser::Parser; + use crate::validator; + + fn compile_source(src: &str) -> Result<(ScriptVM, UiDeclaration), CompileError> { + let mut lexer = Lexer::new(src); + let tokens = lexer.tokenize()?; + let mut parser = Parser::new(&tokens); + let script = parser.parse()?; + let validated = validator::validate(&script)?; + compile(validated) + } + + #[test] + fn test_passthrough() { + let src = r#" + name "Pass" + category effect + inputs { audio_in: audio } + outputs { audio_out: audio } + process { + for i in 0..buffer_size { + audio_out[i] = audio_in[i]; + } + } + "#; + let (mut vm, _) = compile_source(src).unwrap(); + let input = vec![1.0f32, 2.0, 3.0, 4.0]; + let mut output = vec![0.0f32; 4]; + let inputs: Vec<&[f32]> = vec![&input]; + let mut out_slice: Vec<&mut [f32]> = vec![&mut output]; + vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap(); + assert_eq!(output, vec![1.0, 2.0, 3.0, 4.0]); + } + + #[test] + fn test_gain() { + let src = r#" + name "Gain" + category effect + inputs { audio_in: audio } + outputs { audio_out: audio } + params { gain: 0.5 [0.0, 1.0] "" } + process { + for i in 0..buffer_size { + audio_out[i] = audio_in[i] * gain; + } + } + "#; + let (mut vm, _) = compile_source(src).unwrap(); + let input = vec![1.0f32, 2.0, 3.0, 4.0]; + let mut output = vec![0.0f32; 4]; + let inputs: Vec<&[f32]> = vec![&input]; + let mut out_slice: Vec<&mut [f32]> = vec![&mut output]; + vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap(); + assert_eq!(output, vec![0.5, 1.0, 1.5, 2.0]); + } + + #[test] + fn test_state_array() { + let src = r#" + name "Delay" + category effect + inputs { audio_in: audio } + outputs { audio_out: audio } + state { buf: [8]f32 } + process { + for i in 0..buffer_size { + audio_out[i] = buf[i]; + buf[i] = audio_in[i]; + } + } + "#; + let (mut vm, _) = compile_source(src).unwrap(); + + // First call: output should be zeros (state initialized to 0), state gets input + let input = vec![10.0f32, 20.0, 30.0, 40.0]; + let mut output = vec![0.0f32; 4]; + { + let inputs: Vec<&[f32]> = vec![&input]; + let mut out_slice: Vec<&mut [f32]> = vec![&mut output]; + vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap(); + } + assert_eq!(output, vec![0.0, 0.0, 0.0, 0.0]); + + // Second call: output should be previous input + let input2 = vec![50.0f32, 60.0, 70.0, 80.0]; + let mut output2 = vec![0.0f32; 4]; + { + let inputs: Vec<&[f32]> = vec![&input2]; + let mut out_slice: Vec<&mut [f32]> = vec![&mut output2]; + vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap(); + } + assert_eq!(output2, vec![10.0, 20.0, 30.0, 40.0]); + } + + #[test] + fn test_if_else() { + let src = r#" + name "Gate" + category effect + inputs { audio_in: audio } + outputs { audio_out: audio } + params { threshold: 0.5 [0.0, 1.0] "" } + process { + for i in 0..buffer_size { + if audio_in[i] >= threshold { + audio_out[i] = audio_in[i]; + } else { + audio_out[i] = 0.0; + } + } + } + "#; + let (mut vm, _) = compile_source(src).unwrap(); + let input = vec![0.2f32, 0.8, 0.1, 0.9]; + let mut output = vec![0.0f32; 4]; + let inputs: Vec<&[f32]> = vec![&input]; + let mut out_slice: Vec<&mut [f32]> = vec![&mut output]; + vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap(); + assert_eq!(output, vec![0.0, 0.8, 0.0, 0.9]); + } + + #[test] + fn test_auto_ui() { + let src = r#" + name "Test" + category utility + params { gain: 1.0 [0.0, 2.0] "dB" } + state { clip: sample } + outputs { out: audio } + process {} + "#; + let (_, ui) = compile_source(src).unwrap(); + // Auto-generated: sample first, then params + assert_eq!(ui.elements.len(), 2); + assert!(matches!(&ui.elements[0], UiElement::Sample(n) if n == "clip")); + assert!(matches!(&ui.elements[1], UiElement::Param(n) if n == "gain")); + } +} diff --git a/lightningbeam-ui/beamdsp/src/error.rs b/lightningbeam-ui/beamdsp/src/error.rs new file mode 100644 index 0000000..10c331e --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/error.rs @@ -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, +} + +impl CompileError { + pub fn new(message: impl Into, span: Span) -> Self { + Self { + message: message.into(), + span, + hint: None, + } + } + + pub fn with_hint(mut self, hint: impl Into) -> 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), + } + } +} diff --git a/lightningbeam-ui/beamdsp/src/lexer.rs b/lightningbeam-ui/beamdsp/src/lexer.rs new file mode 100644 index 0000000..b5677e8 --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/lexer.rs @@ -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, 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 { + self.source.get(self.pos).copied() + } + + fn peek_next(&self) -> Option { + 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 { + 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 { + 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 { + 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 { + 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)); + } +} diff --git a/lightningbeam-ui/beamdsp/src/lib.rs b/lightningbeam-ui/beamdsp/src/lib.rs new file mode 100644 index 0000000..9b7fd4b --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/lib.rs @@ -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, + pub output_ports: Vec, + pub parameters: Vec, + pub sample_slots: Vec, + 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 { + 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(), + }) +} diff --git a/lightningbeam-ui/beamdsp/src/opcodes.rs b/lightningbeam-ui/beamdsp/src/opcodes.rs new file mode 100644 index 0000000..73041b7 --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/opcodes.rs @@ -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 { + // 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, + } + } +} diff --git a/lightningbeam-ui/beamdsp/src/parser.rs b/lightningbeam-ui/beamdsp/src/parser.rs new file mode 100644 index 0000000..22d39f2 --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/parser.rs @@ -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 { + 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 { + 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 { + 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, 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, 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 { + 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, 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 { + 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + self.parse_or() + } + + fn parse_or(&mut self) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/lightningbeam-ui/beamdsp/src/token.rs b/lightningbeam-ui/beamdsp/src/token.rs new file mode 100644 index 0000000..222528a --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/token.rs @@ -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()), + } + } +} diff --git a/lightningbeam-ui/beamdsp/src/ui_decl.rs b/lightningbeam-ui/beamdsp/src/ui_decl.rs new file mode 100644 index 0000000..0a11b5c --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/ui_decl.rs @@ -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, +} + +#[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, + }, + /// Drawable canvas area (phase 2) + Canvas { + width: f32, + height: f32, + }, + /// Vertical spacer + Spacer(f32), +} diff --git a/lightningbeam-ui/beamdsp/src/validator.rs b/lightningbeam-ui/beamdsp/src/validator.rs new file mode 100644 index 0000000..fb759bf --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/validator.rs @@ -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, +} + +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 { + 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 { + // 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) +} diff --git a/lightningbeam-ui/beamdsp/src/vm.rs b/lightningbeam-ui/beamdsp/src/vm.rs new file mode 100644 index 0000000..7e7058e --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/vm.rs @@ -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, + 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, + pub constants_f32: Vec, + pub constants_i32: Vec, + stack: Vec, + sp: usize, + locals: Vec, + pub params: Vec, + pub state_scalars: Vec, + pub state_arrays: Vec>, + pub sample_slots: Vec, + instruction_limit: u64, +} + +impl ScriptVM { + pub fn new( + bytecode: Vec, + constants_f32: Vec, + constants_i32: Vec, + 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 { + if self.sp == 0 { + return Err(ScriptError::StackUnderflow); + } + self.sp -= 1; + Ok(self.stack[self.sp]) + } + + #[inline] + fn pop_f(&mut self) -> Result { + Ok(unsafe { self.pop()?.f }) + } + + #[inline] + fn pop_i(&mut self) -> Result { + Ok(unsafe { self.pop()?.i }) + } + + #[inline] + fn pop_b(&mut self) -> Result { + 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 + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 85cf1b6..16ae16e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -7,6 +7,7 @@ use crate::asset_folder::AssetFolderTree; use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip}; use crate::effect::EffectDefinition; use crate::layer::AnyLayer; +use crate::script::ScriptDefinition; use crate::layout::LayoutNode; use crate::shape::ShapeColor; use serde::{Deserialize, Serialize}; @@ -146,6 +147,14 @@ pub struct Document { #[serde(default)] pub effect_folders: AssetFolderTree, + /// BeamDSP script definitions (audio DSP scripts for node graph) + #[serde(default)] + pub script_definitions: HashMap, + + /// Folder organization for script definitions + #[serde(default)] + pub script_folders: AssetFolderTree, + /// Current UI layout state (serialized for save/load) #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_layout: Option, @@ -181,6 +190,8 @@ impl Default for Document { audio_folders: AssetFolderTree::new(), image_folders: AssetFolderTree::new(), effect_folders: AssetFolderTree::new(), + script_definitions: HashMap::new(), + script_folders: AssetFolderTree::new(), ui_layout: None, ui_layout_base: None, current_time: 0.0, @@ -494,6 +505,26 @@ impl Document { self.effect_definitions.values() } + // === SCRIPT DEFINITION METHODS === + + pub fn add_script_definition(&mut self, definition: ScriptDefinition) -> Uuid { + let id = definition.id; + self.script_definitions.insert(id, definition); + id + } + + pub fn get_script_definition(&self, id: &Uuid) -> Option<&ScriptDefinition> { + self.script_definitions.get(id) + } + + pub fn get_script_definition_mut(&mut self, id: &Uuid) -> Option<&mut ScriptDefinition> { + self.script_definitions.get_mut(id) + } + + pub fn script_definitions(&self) -> impl Iterator { + self.script_definitions.values() + } + // === CLIP OVERLAP DETECTION METHODS === /// Get the duration of any clip type by ID diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 8d07f67..5934c8b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod instance_group; pub mod effect; pub mod effect_layer; pub mod effect_registry; +pub mod script; pub mod document; pub mod renderer; pub mod video; diff --git a/lightningbeam-ui/lightningbeam-core/src/pane.rs b/lightningbeam-ui/lightningbeam-core/src/pane.rs index c2308e3..9b06352 100644 --- a/lightningbeam-ui/lightningbeam-core/src/pane.rs +++ b/lightningbeam-ui/lightningbeam-core/src/pane.rs @@ -33,8 +33,9 @@ pub enum PaneType { PresetBrowser, /// Asset library for browsing clips AssetLibrary, - /// WGSL shader code editor for custom effects - ShaderEditor, + /// Code editor for shaders and DSP scripts + #[serde(alias = "shaderEditor")] + ScriptEditor, } impl PaneType { @@ -51,7 +52,7 @@ impl PaneType { PaneType::NodeEditor => "Node Editor", PaneType::PresetBrowser => "Instrument Browser", PaneType::AssetLibrary => "Asset Library", - PaneType::ShaderEditor => "Shader Editor", + PaneType::ScriptEditor => "Script Editor", } } @@ -70,7 +71,7 @@ impl PaneType { PaneType::NodeEditor => "node-editor.svg", PaneType::PresetBrowser => "stage.svg", // TODO: needs own icon PaneType::AssetLibrary => "stage.svg", // TODO: needs own icon - PaneType::ShaderEditor => "node-editor.svg", // TODO: needs own icon + PaneType::ScriptEditor => "node-editor.svg", // TODO: needs own icon } } @@ -88,7 +89,7 @@ impl PaneType { "nodeeditor" => Some(PaneType::NodeEditor), "presetbrowser" => Some(PaneType::PresetBrowser), "assetlibrary" => Some(PaneType::AssetLibrary), - "shadereditor" => Some(PaneType::ShaderEditor), + "shadereditor" | "scripteditor" => Some(PaneType::ScriptEditor), _ => None, } } @@ -106,7 +107,7 @@ impl PaneType { PaneType::VirtualPiano, PaneType::PresetBrowser, PaneType::AssetLibrary, - PaneType::ShaderEditor, + PaneType::ScriptEditor, ] } @@ -123,7 +124,7 @@ impl PaneType { PaneType::NodeEditor => "nodeEditor", PaneType::PresetBrowser => "presetBrowser", PaneType::AssetLibrary => "assetLibrary", - PaneType::ShaderEditor => "shaderEditor", + PaneType::ScriptEditor => "scriptEditor", } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/script.rs b/lightningbeam-ui/lightningbeam-core/src/script.rs new file mode 100644 index 0000000..b432e92 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/script.rs @@ -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, +} + +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, + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index 5c94b34..19304dc 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] lightningbeam-core = { path = "../lightningbeam-core" } daw-backend = { path = "../../daw-backend" } +beamdsp = { path = "../beamdsp" } rtrb = "0.3" cpal = "0.17" ffmpeg-next = { version = "8.0", features = ["static"] } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 0ee0fbc..e925355 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -263,7 +263,7 @@ impl IconCache { PaneType::Infopanel => pane_icons::INFOPANEL, PaneType::PianoRoll => pane_icons::PIANO_ROLL, PaneType::VirtualPiano => pane_icons::PIANO, - PaneType::NodeEditor | PaneType::ShaderEditor => pane_icons::NODE_EDITOR, + PaneType::NodeEditor | PaneType::ScriptEditor => pane_icons::NODE_EDITOR, }; if let Some(texture) = rasterize_svg(svg_data, pane_type.icon_file(), 64, ctx) { self.icons.insert(pane_type, texture); @@ -644,8 +644,10 @@ struct EditorApp { dragging_asset: Option, // Asset being dragged from Asset Library // Clipboard clipboard_manager: lightningbeam_core::clipboard::ClipboardManager, - // Shader editor inter-pane communication + // Script editor inter-pane communication effect_to_load: Option, // Effect ID to load into shader editor (set by asset library) + script_to_edit: Option, // Script ID to open in editor (set by node graph) + script_saved: Option, // Script ID just saved (triggers auto-recompile) // Effect thumbnail invalidation queue (persists across frames until processed) effect_thumbnails_to_invalidate: Vec, // Import dialog state @@ -863,8 +865,10 @@ impl EditorApp { recording_layer_id: None, // Will be set when recording starts dragging_asset: None, // No asset being dragged initially clipboard_manager: lightningbeam_core::clipboard::ClipboardManager::new(), - effect_to_load: None, // No effect to load initially - effect_thumbnails_to_invalidate: Vec::new(), // No thumbnails to invalidate initially + effect_to_load: None, + script_to_edit: None, + script_saved: None, + effect_thumbnails_to_invalidate: Vec::new(), last_import_filter: ImportFilter::default(), // Default to "All Supported" stroke_width: 3.0, // Default stroke width fill_enabled: true, // Default to filling shapes @@ -4455,6 +4459,8 @@ impl eframe::App for EditorApp { clipboard_manager: &mut self.clipboard_manager, waveform_stereo: self.config.waveform_stereo, project_generation: &mut self.project_generation, + script_to_edit: &mut self.script_to_edit, + script_saved: &mut self.script_saved, }; render_layout_node( @@ -4731,6 +4737,10 @@ struct RenderContext<'a> { waveform_stereo: bool, /// Project generation counter (incremented on load) project_generation: &'a mut u64, + /// Script ID to open in the script editor (from node graph) + script_to_edit: &'a mut Option, + /// Script ID just saved (triggers auto-recompile of nodes using it) + script_saved: &'a mut Option, } /// Recursively render a layout node with drag support @@ -5213,6 +5223,8 @@ fn render_pane( clipboard_manager: ctx.clipboard_manager, waveform_stereo: ctx.waveform_stereo, project_generation: ctx.project_generation, + script_to_edit: ctx.script_to_edit, + script_saved: ctx.script_saved, }; pane_instance.render_header(&mut header_ui, &mut shared); } @@ -5284,6 +5296,8 @@ fn render_pane( clipboard_manager: ctx.clipboard_manager, waveform_stereo: ctx.waveform_stereo, project_generation: ctx.project_generation, + script_to_edit: ctx.script_to_edit, + script_saved: ctx.script_saved, }; // Render pane content (header was already rendered above) @@ -5407,7 +5421,7 @@ fn pane_color(pane_type: PaneType) -> egui::Color32 { PaneType::NodeEditor => egui::Color32::from_rgb(30, 45, 50), PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30), PaneType::AssetLibrary => egui::Color32::from_rgb(45, 50, 35), - PaneType::ShaderEditor => egui::Color32::from_rgb(35, 30, 55), + PaneType::ScriptEditor => egui::Color32::from_rgb(35, 30, 55), } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 0b45b08..67afd34 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -219,6 +219,10 @@ pub struct SharedPaneState<'a> { pub waveform_stereo: bool, /// Generation counter - incremented on project load to force reloads pub project_generation: &'a mut u64, + /// Script ID to open in the script editor (set by node graph "Edit Script" action) + pub script_to_edit: &'a mut Option, + /// Script ID that was just saved (triggers auto-recompile of nodes using it) + pub script_saved: &'a mut Option, } /// Trait for pane rendering @@ -260,7 +264,7 @@ pub enum PaneInstance { NodeEditor(node_editor::NodeEditorPane), PresetBrowser(preset_browser::PresetBrowserPane), AssetLibrary(asset_library::AssetLibraryPane), - ShaderEditor(shader_editor::ShaderEditorPane), + ScriptEditor(shader_editor::ShaderEditorPane), } impl PaneInstance { @@ -281,8 +285,8 @@ impl PaneInstance { PaneType::AssetLibrary => { PaneInstance::AssetLibrary(asset_library::AssetLibraryPane::new()) } - PaneType::ShaderEditor => { - PaneInstance::ShaderEditor(shader_editor::ShaderEditorPane::new()) + PaneType::ScriptEditor => { + PaneInstance::ScriptEditor(shader_editor::ShaderEditorPane::new()) } } } @@ -300,7 +304,7 @@ impl PaneInstance { PaneInstance::NodeEditor(_) => PaneType::NodeEditor, PaneInstance::PresetBrowser(_) => PaneType::PresetBrowser, PaneInstance::AssetLibrary(_) => PaneType::AssetLibrary, - PaneInstance::ShaderEditor(_) => PaneType::ShaderEditor, + PaneInstance::ScriptEditor(_) => PaneType::ScriptEditor, } } } @@ -318,7 +322,7 @@ impl PaneRenderer for PaneInstance { PaneInstance::NodeEditor(p) => p.render_header(ui, shared), PaneInstance::PresetBrowser(p) => p.render_header(ui, shared), PaneInstance::AssetLibrary(p) => p.render_header(ui, shared), - PaneInstance::ShaderEditor(p) => p.render_header(ui, shared), + PaneInstance::ScriptEditor(p) => p.render_header(ui, shared), } } @@ -340,7 +344,7 @@ impl PaneRenderer for PaneInstance { PaneInstance::NodeEditor(p) => p.render_content(ui, rect, path, shared), PaneInstance::PresetBrowser(p) => p.render_content(ui, rect, path, shared), PaneInstance::AssetLibrary(p) => p.render_content(ui, rect, path, shared), - PaneInstance::ShaderEditor(p) => p.render_content(ui, rect, path, shared), + PaneInstance::ScriptEditor(p) => p.render_content(ui, rect, path, shared), } } @@ -356,7 +360,7 @@ impl PaneRenderer for PaneInstance { PaneInstance::NodeEditor(p) => p.name(), PaneInstance::PresetBrowser(p) => p.name(), PaneInstance::AssetLibrary(p) => p.name(), - PaneInstance::ShaderEditor(p) => p.name(), + PaneInstance::ScriptEditor(p) => p.name(), } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs index 3d28ed1..19bc42e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs @@ -68,6 +68,9 @@ pub enum NodeTemplate { BpmDetector, Mod, + // Scripting + Script, + // Analysis Oscilloscope, @@ -127,6 +130,7 @@ impl NodeTemplate { NodeTemplate::EnvelopeFollower => "EnvelopeFollower", NodeTemplate::BpmDetector => "BpmDetector", NodeTemplate::Beat => "Beat", + NodeTemplate::Script => "Script", NodeTemplate::Mod => "Mod", NodeTemplate::Oscilloscope => "Oscilloscope", NodeTemplate::VoiceAllocator => "VoiceAllocator", @@ -148,6 +152,18 @@ pub struct NodeData { /// Root note (MIDI note number) for original-pitch playback (default 69 = A4) #[serde(default = "default_root_note")] pub root_note: u8, + /// BeamDSP script asset ID (for Script nodes — references a ScriptDefinition in the document) + #[serde(default)] + pub script_id: Option, + /// Declarative UI from compiled BeamDSP script (for rendering sample pickers, groups) + #[serde(skip)] + pub ui_declaration: Option, + /// Sample slot names from compiled script (index → name, for sample picker mapping) + #[serde(skip)] + pub sample_slot_names: Vec, + /// Display names of loaded samples per slot (slot_index → display name) + #[serde(skip)] + pub script_sample_names: HashMap, } fn default_root_note() -> u8 { 69 } @@ -172,6 +188,14 @@ pub struct SamplerFolderInfo { pub clip_pool_indices: Vec<(String, usize)>, } +/// Pending script sample load request from bottom_ui(), handled by the node graph pane +pub enum PendingScriptSampleLoad { + /// Load from audio pool into a script sample slot + FromPool { node_id: NodeId, backend_node_id: u32, slot_index: usize, pool_index: usize, name: String }, + /// Open file dialog to load into a script sample slot + FromFile { node_id: NodeId, backend_node_id: u32, slot_index: usize }, +} + /// Pending sampler load request from bottom_ui(), handled by the node graph pane pub enum PendingSamplerLoad { /// Load a single clip from the audio pool into a SimpleSampler @@ -207,6 +231,16 @@ pub struct GraphState { pub pending_sequencer_changes: Vec<(NodeId, u32, f32)>, /// Time scale per oscilloscope node (in milliseconds) pub oscilloscope_time_scale: HashMap, + /// 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, + /// Pending "Load from file..." from dropdown (node_id) — open file dialog for .bdsp + pub pending_load_script_file: Option, + /// Pending script sample load request from bottom_ui sample picker + pub pending_script_sample_load: Option, } impl Default for GraphState { @@ -222,6 +256,11 @@ impl Default for GraphState { pending_root_note_changes: Vec::new(), pending_sequencer_changes: Vec::new(), oscilloscope_time_scale: HashMap::new(), + available_scripts: Vec::new(), + pending_script_assignment: None, + pending_new_script: None, + pending_load_script_file: None, + pending_script_sample_load: None, } } } @@ -373,6 +412,8 @@ impl NodeTemplateTrait for NodeTemplate { NodeTemplate::BpmDetector => "BPM Detector".into(), NodeTemplate::Beat => "Beat".into(), NodeTemplate::Mod => "Modulator".into(), + // Scripting + NodeTemplate::Script => "Script".into(), // Analysis NodeTemplate::Oscilloscope => "Oscilloscope".into(), // Advanced @@ -399,6 +440,7 @@ impl NodeTemplateTrait for NodeTemplate { | NodeTemplate::Constant | NodeTemplate::MidiToCv | NodeTemplate::AudioToCv | NodeTemplate::Arpeggiator | NodeTemplate::Sequencer | NodeTemplate::Math | NodeTemplate::SampleHold | NodeTemplate::SlewLimiter | NodeTemplate::Quantizer | NodeTemplate::EnvelopeFollower | NodeTemplate::BpmDetector | NodeTemplate::Mod => vec!["Utilities"], + NodeTemplate::Script => vec!["Advanced"], NodeTemplate::Oscilloscope => vec!["Analysis"], NodeTemplate::VoiceAllocator | NodeTemplate::Group => vec!["Advanced"], NodeTemplate::TemplateInput | NodeTemplate::TemplateOutput => vec!["Subgraph I/O"], @@ -411,7 +453,7 @@ impl NodeTemplateTrait for NodeTemplate { } fn user_data(&self, _user_state: &mut Self::UserState) -> Self::NodeData { - NodeData { template: *self, sample_display_name: None, root_note: 69 } + NodeData { template: *self, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() } } fn build_node( @@ -856,6 +898,12 @@ impl NodeTemplateTrait for NodeTemplate { // Inside a VA template: sends audio back to the allocator graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true); } + NodeTemplate::Script => { + // Default Script node: single audio in/out + // Ports will be rebuilt when a script is compiled + graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true); + graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio); + } } } } @@ -1276,6 +1324,57 @@ impl NodeDataTrait for NodeData { user_state.pending_sequencer_changes.push((node_id, param_id, new_bitmask as f32)); } } + } else if self.template == NodeTemplate::Script { + let current_name = self.script_id + .and_then(|id| user_state.available_scripts.iter().find(|(sid, _)| *sid == id)) + .map(|(_, name)| name.as_str()) + .unwrap_or("No script"); + + let button = ui.button(current_name); + let popup_id = egui::Popup::default_response_id(&button); + let mut close_popup = false; + + egui::Popup::from_toggle_button_response(&button) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .width(160.0) + .show(|ui| { + if widgets::list_item(ui, false, "New script...") { + user_state.pending_new_script = Some(node_id); + close_popup = true; + } + if widgets::list_item(ui, false, "Load from file...") { + user_state.pending_load_script_file = Some(node_id); + close_popup = true; + } + if !user_state.available_scripts.is_empty() { + ui.separator(); + } + for (script_id, script_name) in &user_state.available_scripts { + let selected = self.script_id == Some(*script_id); + if widgets::list_item(ui, selected, script_name) { + user_state.pending_script_assignment = Some((node_id, *script_id)); + close_popup = true; + } + } + }); + + if close_popup { + egui::Popup::close_id(ui.ctx(), popup_id); + } + + // Render declarative UI elements (sample pickers, groups) + if let Some(ref ui_decl) = self.ui_declaration { + let backend_node_id = user_state.node_backend_ids.get(&node_id).copied().unwrap_or(0); + render_script_ui_elements( + ui, node_id, backend_node_id, + &ui_decl.elements, + &self.sample_slot_names, + &self.script_sample_names, + &user_state.available_clips, + &mut user_state.sampler_search_text, + &mut user_state.pending_script_sample_load, + ); + } } else { ui.label(""); } @@ -1283,6 +1382,91 @@ impl NodeDataTrait for NodeData { } } +/// Render UiDeclaration elements for Script nodes (sample pickers, groups, spacers) +fn render_script_ui_elements( + ui: &mut egui::Ui, + node_id: NodeId, + backend_node_id: u32, + elements: &[beamdsp::UiElement], + sample_slot_names: &[String], + script_sample_names: &HashMap, + available_clips: &[SamplerClipInfo], + search_text: &mut String, + pending_load: &mut Option, +) { + for element in elements { + match element { + beamdsp::UiElement::Sample(slot_name) => { + // Find the slot index by name + let slot_index = sample_slot_names.iter().position(|n| n == slot_name); + let display = script_sample_names + .get(&slot_index.unwrap_or(usize::MAX)) + .map(|s| s.as_str()) + .unwrap_or("No sample"); + + ui.horizontal(|ui| { + ui.label(egui::RichText::new(slot_name).weak()); + let button = ui.button(display); + if let Some(slot_idx) = slot_index { + let popup_id = egui::Popup::default_response_id(&button); + let mut close = false; + egui::Popup::from_toggle_button_response(&button) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .width(160.0) + .show(|ui| { + let search = search_text.to_lowercase(); + let filtered: Vec<&SamplerClipInfo> = available_clips.iter() + .filter(|c| search.is_empty() || c.name.to_lowercase().contains(&search)) + .collect(); + let items = filtered.iter().map(|c| (false, c.name.as_str())); + if let Some(idx) = widgets::scrollable_list(ui, 200.0, items) { + let clip = filtered[idx]; + *pending_load = Some(PendingScriptSampleLoad::FromPool { + node_id, + backend_node_id, + slot_index: slot_idx, + pool_index: clip.pool_index, + name: clip.name.clone(), + }); + close = true; + } + ui.separator(); + if ui.button("Open...").clicked() { + *pending_load = Some(PendingScriptSampleLoad::FromFile { + node_id, + backend_node_id, + slot_index: slot_idx, + }); + close = true; + } + }); + if close { + egui::Popup::close_id(ui.ctx(), popup_id); + } + } + }); + } + beamdsp::UiElement::Group { label, children } => { + egui::CollapsingHeader::new(egui::RichText::new(label).weak()) + .default_open(true) + .show(ui, |ui| { + render_script_ui_elements( + ui, node_id, backend_node_id, + children, sample_slot_names, script_sample_names, + available_clips, search_text, pending_load, + ); + }); + } + beamdsp::UiElement::Spacer(height) => { + ui.add_space(*height); + } + beamdsp::UiElement::Param(_) | beamdsp::UiElement::Canvas { .. } => { + // Params are handled as inline input ports; Canvas is phase 6 + } + } + } +} + // Iterator for all node templates (track-level graph) pub struct AllNodeTemplates; @@ -1370,6 +1554,7 @@ impl NodeTemplateIter for AllNodeTemplates { NodeTemplate::Oscilloscope, // Advanced NodeTemplate::VoiceAllocator, + NodeTemplate::Script, // Note: Group is not in the node finder — groups are created via Ctrl+G selection. // Note: TemplateInput/TemplateOutput are excluded from the default finder. // They are added dynamically when editing inside a subgraph. diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs index eeacd2c..b4478ac 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -137,6 +137,10 @@ pub struct NodeGraphPane { /// Cached node screen rects from last frame (for hit-testing) last_node_rects: std::collections::HashMap, + /// Script nodes loaded from preset that need script_id resolution + /// (frontend_node_id, script_source) — processed in render loop where document is available + pending_script_resolutions: Vec<(NodeId, String)>, + /// Last time we polled oscilloscope data (~20 FPS) last_oscilloscope_poll: std::time::Instant, /// Backend track ID (u32) for oscilloscope queries @@ -167,6 +171,7 @@ impl NodeGraphPane { renaming_group: None, node_context_menu: None, last_node_rects: HashMap::new(), + pending_script_resolutions: Vec::new(), last_oscilloscope_poll: std::time::Instant::now(), backend_track_id: None, } @@ -203,6 +208,7 @@ impl NodeGraphPane { renaming_group: None, node_context_menu: None, last_node_rects: HashMap::new(), + pending_script_resolutions: Vec::new(), last_oscilloscope_poll: std::time::Instant::now(), backend_track_id: Some(backend_track_id), }; @@ -226,6 +232,167 @@ impl NodeGraphPane { self.load_graph_from_json(&json) } + /// Rebuild a Script node's ports and parameters to match a compiled script. + /// Performs a diff: ports with matching name+type keep their connections, + /// removed ports lose connections, new ports are added. + /// Parameters are added as ConnectionOrConstant inputs with inline widgets. + fn rebuild_script_node_ports(&mut self, node_id: NodeId, compiled: &beamdsp::CompiledScript) { + let signal_to_data_type = |sig: beamdsp::ast::SignalKind| match sig { + beamdsp::ast::SignalKind::Audio => DataType::Audio, + beamdsp::ast::SignalKind::Cv => DataType::CV, + beamdsp::ast::SignalKind::Midi => DataType::Midi, + }; + + let unit_str = |u: &str| -> &'static str { + match u { "Hz" => " Hz", "s" => " s", "dB" => " dB", "%" => "%", _ => "" } + }; + + // Collect what the new inputs should be: signal ports + param ports + // Signal ports use DataType matching their signal kind, ConnectionOnly + // Param ports use DataType::CV, ConnectionOrConstant with float_param value + let num_signal_inputs = compiled.input_ports.len(); + let num_params = compiled.parameters.len(); + let num_signal_outputs = compiled.output_ports.len(); + + // Check if everything already matches (ports + params + outputs) + let already_matches = if let Some(node) = self.state.graph.nodes.get(node_id) { + let expected_inputs = num_signal_inputs + num_params; + if node.inputs.len() != expected_inputs || node.outputs.len() != num_signal_outputs { + false + } else { + // Check signal inputs + let signals_match = node.inputs[..num_signal_inputs].iter() + .zip(&compiled.input_ports) + .all(|((name, id), port)| { + name == &port.name + && self.state.graph.inputs.get(*id) + .map_or(false, |p| p.typ == signal_to_data_type(port.signal)) + }); + // Check param inputs + let params_match = node.inputs[num_signal_inputs..].iter() + .zip(&compiled.parameters) + .all(|((name, id), param)| { + name == ¶m.name + && self.state.graph.inputs.get(*id) + .map_or(false, |p| p.typ == DataType::CV) + }); + // Check outputs + let outputs_match = node.outputs.iter() + .zip(&compiled.output_ports) + .all(|((name, id), port)| { + name == &port.name + && self.state.graph.outputs.get(*id) + .map_or(false, |p| p.typ == signal_to_data_type(port.signal)) + }); + signals_match && params_match && outputs_match + } + } else { + return; + }; + + if already_matches { + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.label = compiled.name.clone(); + } + return; + } + + // Build lookup of existing inputs: (name, type, kind) → InputId + let old_inputs: Vec<(String, InputId, DataType, InputParamKind)> = self.state.graph.nodes.get(node_id) + .map(|n| n.inputs.iter().filter_map(|(name, id)| { + let p = self.state.graph.inputs.get(*id)?; + Some((name.clone(), *id, p.typ, p.kind)) + }).collect()) + .unwrap_or_default(); + + let old_outputs: Vec<(String, OutputId, DataType)> = self.state.graph.nodes.get(node_id) + .map(|n| n.outputs.iter().filter_map(|(name, id)| { + let typ = self.state.graph.outputs.get(*id)?.typ; + Some((name.clone(), *id, typ)) + }).collect()) + .unwrap_or_default(); + + // Match signal inputs + let mut used_old_inputs: HashSet = HashSet::new(); + let mut new_input_ids: Vec<(String, InputId)> = Vec::new(); + + for port in &compiled.input_ports { + let dt = signal_to_data_type(port.signal); + if let Some((_, old_id, _, _)) = old_inputs.iter().find(|(name, id, typ, kind)| { + name == &port.name && *typ == dt + && matches!(kind, InputParamKind::ConnectionOnly) + && !used_old_inputs.contains(id) + }) { + used_old_inputs.insert(*old_id); + new_input_ids.push((port.name.clone(), *old_id)); + } else { + let id = self.state.graph.add_input_param( + node_id, port.name.clone(), dt, + ValueType::float(0.0), InputParamKind::ConnectionOnly, true, + ); + new_input_ids.push((port.name.clone(), id)); + } + } + + // Match param inputs + for (i, param) in compiled.parameters.iter().enumerate() { + if let Some((_, old_id, _, _)) = old_inputs.iter().find(|(name, id, typ, kind)| { + name == ¶m.name && *typ == DataType::CV + && matches!(kind, InputParamKind::ConnectionOrConstant) + && !used_old_inputs.contains(id) + }) { + used_old_inputs.insert(*old_id); + new_input_ids.push((param.name.clone(), *old_id)); + } else { + let id = self.state.graph.add_input_param( + node_id, param.name.clone(), DataType::CV, + ValueType::float_param(param.default, param.min, param.max, unit_str(¶m.unit), i as u32, None), + InputParamKind::ConnectionOrConstant, true, + ); + new_input_ids.push((param.name.clone(), id)); + } + } + + // Remove old inputs that weren't reused + for (_, old_id, _, _) in &old_inputs { + if !used_old_inputs.contains(old_id) { + self.state.graph.remove_input_param(*old_id); + } + } + + // Match outputs + let mut used_old_outputs: HashSet = HashSet::new(); + let mut new_output_ids: Vec<(String, OutputId)> = Vec::new(); + + for port in &compiled.output_ports { + let dt = signal_to_data_type(port.signal); + if let Some((_, old_id, _)) = old_outputs.iter().find(|(name, id, typ)| { + name == &port.name && *typ == dt && !used_old_outputs.contains(id) + }) { + used_old_outputs.insert(*old_id); + new_output_ids.push((port.name.clone(), *old_id)); + } else { + let id = self.state.graph.add_output_param(node_id, port.name.clone(), dt); + new_output_ids.push((port.name.clone(), id)); + } + } + + for (_, old_id, _) in &old_outputs { + if !used_old_outputs.contains(old_id) { + self.state.graph.remove_output_param(*old_id); + } + } + + // Set the node's port ordering and UI metadata + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.inputs = new_input_ids; + node.outputs = new_output_ids; + node.label = compiled.name.clone(); + node.user_data.ui_declaration = Some(compiled.ui_declaration.clone()); + node.user_data.sample_slot_names = compiled.sample_slots.clone(); + } + } + fn handle_graph_response( &mut self, response: egui_node_graph2::GraphResponse< @@ -680,6 +847,82 @@ impl NodeGraphPane { } } + fn handle_pending_script_sample_load( + &mut self, + load: graph_data::PendingScriptSampleLoad, + shared: &mut crate::panes::SharedPaneState, + ) { + let backend_track_id = match self.backend_track_id { + Some(id) => id, + None => return, + }; + let controller_arc = match &shared.audio_controller { + Some(c) => std::sync::Arc::clone(c), + None => return, + }; + + match load { + graph_data::PendingScriptSampleLoad::FromPool { node_id, backend_node_id, slot_index, pool_index, name } => { + let mut controller = controller_arc.lock().unwrap(); + match controller.get_pool_audio_samples(pool_index) { + Ok((samples, sample_rate, _channels)) => { + controller.send_command(daw_backend::Command::GraphSetScriptSample( + backend_track_id, backend_node_id, slot_index, + samples, sample_rate, name.clone(), + )); + } + Err(e) => { + eprintln!("Failed to get pool audio for script sample: {}", e); + return; + } + } + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.script_sample_names.insert(slot_index, name); + } + } + graph_data::PendingScriptSampleLoad::FromFile { node_id, backend_node_id, slot_index } => { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Audio", &["wav", "flac", "mp3", "ogg", "aiff"]) + .pick_file() + { + let file_name = path.file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Sample".to_string()); + + let mut controller = controller_arc.lock().unwrap(); + match controller.import_audio_sync(path.to_path_buf()) { + Ok(pool_index) => { + // Add to document asset library + let metadata = daw_backend::io::read_metadata(&path).ok(); + let duration = metadata.as_ref().map(|m| m.duration).unwrap_or(0.0); + let clip = lightningbeam_core::clip::AudioClip::new_sampled(&file_name, pool_index, duration); + shared.action_executor.document_mut().add_audio_clip(clip); + + // Get the audio data and send to script node + match controller.get_pool_audio_samples(pool_index) { + Ok((samples, sample_rate, _channels)) => { + controller.send_command(daw_backend::Command::GraphSetScriptSample( + backend_track_id, backend_node_id, slot_index, + samples, sample_rate, file_name.clone(), + )); + } + Err(e) => { + eprintln!("Failed to get pool audio for script sample: {}", e); + } + } + } + Err(e) => { + eprintln!("Failed to import audio '{}': {}", path.display(), e); + } + } + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.script_sample_names.insert(slot_index, file_name); + } + } + } + } + } + fn check_parameter_changes(&mut self, shared: &mut crate::panes::SharedPaneState) { // Check all input parameters for value changes let mut _checked_count = 0; @@ -1217,6 +1460,7 @@ impl NodeGraphPane { self.backend_to_frontend_map.clear(); // Create nodes in frontend + self.pending_script_resolutions.clear(); for node in &graph_state.nodes { let node_template = match Self::backend_type_to_template(&node.node_type) { Some(t) => t, @@ -1226,7 +1470,21 @@ impl NodeGraphPane { } }; - self.add_node_to_editor(node_template, &node.node_type, node.position, node.id, &node.parameters); + let frontend_id = self.add_node_to_editor(node_template, &node.node_type, node.position, node.id, &node.parameters); + + // For Script nodes: rebuild ports now (before connections), defer script_id resolution + if node.node_type == "Script" { + if let Some(ref source) = node.script_source { + if let Some(fid) = frontend_id { + // Rebuild ports/params immediately so connections map correctly + if let Ok(compiled) = beamdsp::compile(source) { + self.rebuild_script_node_ports(fid, &compiled); + } + // Defer script_id resolution to render loop (needs document access) + self.pending_script_resolutions.push((fid, source.clone())); + } + } + } } // Create connections in frontend @@ -1674,7 +1932,7 @@ impl NodeGraphPane { label: group.name.clone(), inputs: vec![], outputs: vec![], - user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 }, + user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() }, }); // Add dynamic input ports based on boundary inputs @@ -1746,7 +2004,7 @@ impl NodeGraphPane { label: "Group Input".to_string(), inputs: vec![], outputs: vec![], - user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 }, + user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() }, }); for bc in &scope_group.boundary_inputs { @@ -1793,7 +2051,7 @@ impl NodeGraphPane { label: "Group Output".to_string(), inputs: vec![], outputs: vec![], - user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 }, + user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() }, }); for bc in &scope_group.boundary_outputs { @@ -1965,6 +2223,7 @@ impl NodeGraphPane { "Oscilloscope" => Some(NodeTemplate::Oscilloscope), "Arpeggiator" => Some(NodeTemplate::Arpeggiator), "Sequencer" => Some(NodeTemplate::Sequencer), + "Script" => Some(NodeTemplate::Script), "Beat" => Some(NodeTemplate::Beat), "VoiceAllocator" => Some(NodeTemplate::VoiceAllocator), "Group" => Some(NodeTemplate::Group), @@ -1989,7 +2248,7 @@ impl NodeGraphPane { label: label.to_string(), inputs: vec![], outputs: vec![], - user_data: NodeData { template: node_template, sample_display_name: None, root_note: 69 }, + user_data: NodeData { template: node_template, sample_display_name: None, root_note: 69, script_id: None, ui_declaration: None, sample_slot_names: Vec::new(), script_sample_names: HashMap::new() }, }); node_template.build_node(&mut self.state.graph, &mut self.user_state, frontend_id); @@ -2337,6 +2596,12 @@ impl crate::panes::PaneRenderer for NodeGraphPane { .collect(); self.user_state.available_folders.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + // Available scripts for Script node dropdown + self.user_state.available_scripts = doc.script_definitions() + .map(|s| (s.id, s.name.clone())) + .collect(); + self.user_state.available_scripts.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase())); + // Node backend ID map self.user_state.node_backend_ids = self.node_id_map.iter() .map(|(&node_id, backend_id)| { @@ -2385,6 +2650,11 @@ impl crate::panes::PaneRenderer for NodeGraphPane { self.handle_pending_sampler_load(load, shared); } + // Handle pending script sample load requests from bottom_ui() + if let Some(load) = self.user_state.pending_script_sample_load.take() { + self.handle_pending_script_sample_load(load, shared); + } + // Handle pending root note changes if !self.user_state.pending_root_note_changes.is_empty() { let changes: Vec<_> = self.user_state.pending_root_note_changes.drain(..).collect(); @@ -2425,6 +2695,160 @@ impl crate::panes::PaneRenderer for NodeGraphPane { } } + // Resolve Script nodes loaded from preset: find or create ScriptDefinitions + // (ports were already rebuilt during load_graph_from_json, this just sets script_id) + if !self.pending_script_resolutions.is_empty() { + let resolutions = std::mem::take(&mut self.pending_script_resolutions); + for (node_id, source) in resolutions { + // Try to find an existing ScriptDefinition with matching source + let existing_id = shared.action_executor.document() + .script_definitions() + .find(|s| s.source == source) + .map(|s| s.id); + + let script_id = if let Some(id) = existing_id { + id + } else { + // Create a new ScriptDefinition from the source + use lightningbeam_core::script::ScriptDefinition; + let name = beamdsp::compile(&source) + .map(|c| c.name.clone()) + .unwrap_or_else(|_| "Imported Script".to_string()); + let script = ScriptDefinition::new(name, source.clone()); + let id = script.id; + shared.action_executor.document_mut().add_script_definition(script); + id + }; + + // Set script_id on the node + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.script_id = Some(script_id); + } + } + } + + // Handle pending script assignment from Script node dropdown + if let Some((node_id, script_id)) = self.user_state.pending_script_assignment.take() { + // Update the node's script_id + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.script_id = Some(script_id); + } + // Look up script source, compile locally to rebuild ports, and send to backend + let source = shared.action_executor.document() + .get_script_definition(&script_id) + .map(|s| s.source.clone()); + if let Some(source) = source { + // Compile locally to get port info and rebuild the node UI + if let Ok(compiled) = beamdsp::compile(&source) { + self.rebuild_script_node_ports(node_id, &compiled); + } + if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) { + if let Some(&backend_id) = self.node_id_map.get(&node_id) { + let BackendNodeId::Audio(node_idx) = backend_id; + if let Some(controller_arc) = &shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.send_command(daw_backend::Command::GraphSetScript( + backend_track_id, node_idx.index() as u32, source, + )); + } + } + } + } + } + + // Handle "New script..." from dropdown + if let Some(node_id) = self.user_state.pending_new_script.take() { + use lightningbeam_core::script::ScriptDefinition; + let script = ScriptDefinition::new( + "New Script".to_string(), + "name \"New Script\"\ncategory effect\n\ninputs {\n audio_in: audio\n}\n\noutputs {\n audio_out: audio\n}\n\nprocess {\n for i in 0..buffer_size {\n audio_out[i * 2] = audio_in[i * 2];\n audio_out[i * 2 + 1] = audio_in[i * 2 + 1];\n }\n}\n".to_string(), + ); + let script_id = script.id; + shared.action_executor.document_mut().add_script_definition(script); + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.script_id = Some(script_id); + } + // Open in editor + *shared.script_to_edit = Some(script_id); + } + + // Handle "Load from file..." from dropdown + if let Some(node_id) = self.user_state.pending_load_script_file.take() { + if let Some(path) = rfd::FileDialog::new() + .set_title("Load BeamDSP Script") + .add_filter("BeamDSP Script", &["bdsp"]) + .pick_file() + { + if let Ok(source) = std::fs::read_to_string(&path) { + use lightningbeam_core::script::ScriptDefinition; + let name = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Imported Script") + .to_string(); + let script = ScriptDefinition::new(name, source.clone()); + let script_id = script.id; + shared.action_executor.document_mut().add_script_definition(script); + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.script_id = Some(script_id); + } + // Compile locally to rebuild ports, then send to backend + if let Ok(compiled) = beamdsp::compile(&source) { + self.rebuild_script_node_ports(node_id, &compiled); + } + if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) { + if let Some(&backend_id) = self.node_id_map.get(&node_id) { + let BackendNodeId::Audio(node_idx) = backend_id; + if let Some(controller_arc) = &shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.send_command(daw_backend::Command::GraphSetScript( + backend_track_id, node_idx.index() as u32, source, + )); + } + } + } + } + } + } + + // Handle script_saved: auto-recompile all Script nodes using that script + if let Some(saved_script_id) = shared.script_saved.take() { + let source = shared.action_executor.document() + .get_script_definition(&saved_script_id) + .map(|s| s.source.clone()); + if let Some(source) = source { + // Compile locally to get updated port info + let compiled = beamdsp::compile(&source).ok(); + + // Collect matching node IDs first (can't mutate graph while iterating) + let matching_nodes: Vec = self.state.graph.nodes.iter() + .filter(|(_, node)| node.user_data.script_id == Some(saved_script_id)) + .map(|(id, _)| id) + .collect(); + + // Rebuild ports for all matching nodes + if let Some(ref compiled) = compiled { + for &node_id in &matching_nodes { + self.rebuild_script_node_ports(node_id, compiled); + } + } + + // Send to backend + if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) { + if let Some(controller_arc) = &shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + for &node_id in &matching_nodes { + if let Some(&backend_id) = self.node_id_map.get(&node_id) { + let BackendNodeId::Audio(node_idx) = backend_id; + controller.send_command(daw_backend::Command::GraphSetScript( + backend_track_id, node_idx.index() as u32, source.clone(), + )); + } + } + } + } + } + } + // Detect right-click on nodes — intercept the library's node finder and show our context menu instead { let secondary_clicked = ui.input(|i| i.pointer.secondary_released()); @@ -2450,6 +2874,11 @@ impl crate::panes::PaneRenderer for NodeGraphPane { let mut action_delete = false; let mut action_ungroup = false; let mut action_rename = false; + let mut action_edit_script = false; + + let is_script_node = self.state.graph.nodes.get(ctx_node_id) + .map(|n| n.user_data.template == NodeTemplate::Script) + .unwrap_or(false); let menu_response = egui::Area::new(ui.id().with("node_context_menu")) .fixed_pos(menu_pos) @@ -2468,6 +2897,13 @@ impl crate::panes::PaneRenderer for NodeGraphPane { } ui.separator(); } + if is_script_node { + if ui.button("Edit Script").clicked() { + action_edit_script = true; + close_menu = true; + } + ui.separator(); + } if ui.button("Delete").clicked() { action_delete = true; close_menu = true; @@ -2534,6 +2970,13 @@ impl crate::panes::PaneRenderer for NodeGraphPane { } } } + if action_edit_script { + if let Some(script_id) = self.state.graph.nodes.get(ctx_node_id) + .and_then(|n| n.user_data.script_id) + { + *shared.script_to_edit = Some(script_id); + } + } if close_menu { self.node_context_menu = None; } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs index 239efc4..9c60149 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs @@ -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. -/// Features: -/// - Syntax highlighting for WGSL -/// - Line numbers -/// - Basic validation feedback -/// - Template shader insertion +/// Supports multiple editor modes: +/// - Shader: WGSL shader code for custom visual effects +/// - BeamDSP: Audio DSP scripts for scriptable audio nodes +/// +/// Both modes use the same save/load workflow through the asset library. use eframe::egui::{self, Ui}; use egui_code_editor::{CodeEditor, ColorTheme, Syntax}; use lightningbeam_core::effect::{EffectCategory, EffectDefinition}; -use lightningbeam_core::effect_registry::EffectRegistry; +use lightningbeam_core::script::ScriptDefinition; use uuid::Uuid; 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 #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UnsavedDialogResult { @@ -22,7 +30,8 @@ enum UnsavedDialogResult { SaveAndContinue, } -/// Custom syntax definition for WGSL (WebGPU Shading Language) +// ── Syntax definitions ────────────────────────────────────────────── + fn wgsl_syntax() -> Syntax { Syntax { language: "WGSL", @@ -31,51 +40,35 @@ fn wgsl_syntax() -> Syntax { comment_multiline: ["/*", "*/"], hyperlinks: std::collections::BTreeSet::new(), keywords: std::collections::BTreeSet::from([ - // Control flow "if", "else", "for", "while", "loop", "break", "continue", "return", "switch", "case", "default", "discard", - // Declarations "fn", "let", "var", "const", "struct", "alias", "type", - // Storage classes and access modes "function", "private", "workgroup", "uniform", "storage", "read", "write", "read_write", - // Shader stages "vertex", "fragment", "compute", - // Attributes "location", "builtin", "group", "binding", - // Built-in values "position", "vertex_index", "instance_index", "front_facing", "frag_depth", "local_invocation_id", "local_invocation_index", "global_invocation_id", "workgroup_id", "num_workgroups", "sample_index", "sample_mask", ]), types: std::collections::BTreeSet::from([ - // Scalar types "bool", "i32", "u32", "f32", "f16", - // Vector types "vec2", "vec3", "vec4", - "vec2i", "vec3i", "vec4i", - "vec2u", "vec3u", "vec4u", - "vec2f", "vec3f", "vec4f", - "vec2h", "vec3h", "vec4h", - // Matrix types - "mat2x2", "mat2x3", "mat2x4", - "mat3x2", "mat3x3", "mat3x4", - "mat4x2", "mat4x3", "mat4x4", - "mat2x2f", "mat3x3f", "mat4x4f", - // Texture types + "vec2i", "vec3i", "vec4i", "vec2u", "vec3u", "vec4u", + "vec2f", "vec3f", "vec4f", "vec2h", "vec3h", "vec4h", + "mat2x2", "mat2x3", "mat2x4", "mat3x2", "mat3x3", "mat3x4", + "mat4x2", "mat4x3", "mat4x4", "mat2x2f", "mat3x3f", "mat4x4f", "texture_1d", "texture_2d", "texture_2d_array", "texture_3d", "texture_cube", "texture_cube_array", "texture_multisampled_2d", "texture_storage_1d", "texture_storage_2d", "texture_storage_2d_array", "texture_storage_3d", "texture_depth_2d", "texture_depth_2d_array", - "texture_depth_cube", "texture_depth_cube_array", "texture_depth_multisampled_2d", - // Sampler types + "texture_depth_cube", "texture_depth_cube_array", + "texture_depth_multisampled_2d", "sampler", "sampler_comparison", - // Array and pointer "array", "ptr", ]), special: std::collections::BTreeSet::from([ - // Built-in functions (subset) "abs", "acos", "all", "any", "asin", "atan", "atan2", "ceil", "clamp", "cos", "cosh", "cross", "degrees", "determinant", "distance", "dot", @@ -85,27 +78,53 @@ fn wgsl_syntax() -> Syntax { "pow", "radians", "reflect", "refract", "round", "saturate", "sign", "sin", "sinh", "smoothstep", "sqrt", "step", "tan", "tanh", "transpose", "trunc", - // Texture functions "textureSample", "textureSampleLevel", "textureSampleBias", "textureSampleGrad", "textureSampleCompare", "textureLoad", "textureStore", "textureDimensions", "textureNumLayers", "textureNumLevels", "textureNumSamples", - // Atomic functions "atomicLoad", "atomicStore", "atomicAdd", "atomicSub", "atomicMax", "atomicMin", "atomicAnd", "atomicOr", "atomicXor", "atomicExchange", "atomicCompareExchangeWeak", - // Data packing "pack4x8snorm", "pack4x8unorm", "pack2x16snorm", "pack2x16unorm", "unpack4x8snorm", "unpack4x8unorm", "unpack2x16snorm", "unpack2x16unorm", - // Synchronization "storageBarrier", "workgroupBarrier", "workgroupUniformLoad", - // Type constructors "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 // Input: source_tex (the layer content) // Output: vec4 color at each pixel @@ -118,7 +137,6 @@ struct VertexOutput { @location(0) uv: vec2, } -// Fullscreen triangle strip @vertex fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { var out: VertexOutput; @@ -131,19 +149,12 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { - // Sample the source texture 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; } "#; -/// Grayscale effect shader template const GRAYSCALE_TEMPLATE: &str = r#"// Grayscale Effect -// Converts the image to grayscale using luminance weights - @group(0) @binding(0) var source_tex: texture_2d; @group(0) @binding(1) var source_sampler: sampler; @@ -165,18 +176,12 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { let color = textureSample(source_tex, source_sampler, in.uv); - - // ITU-R BT.709 luminance coefficients let luminance = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722)); - return vec4(luminance, luminance, luminance, color.a); } "#; -/// Vignette effect shader template const VIGNETTE_TEMPLATE: &str = r#"// Vignette Effect -// Darkens the edges of the image - @group(0) @binding(0) var source_tex: texture_2d; @group(0) @binding(1) var source_sampler: sampler; @@ -198,41 +203,169 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { let color = textureSample(source_tex, source_sampler, in.uv); - - // Calculate distance from center (0.5, 0.5) let center = vec2(0.5, 0.5); let dist = distance(in.uv, center); - - // Vignette parameters - let radius = 0.7; // Inner radius (no darkening) - let softness = 0.4; // Transition softness - - // Calculate vignette factor + let radius = 0.7; + let softness = 0.4; let vignette = smoothstep(radius + softness, radius, dist); - return vec4(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 { - /// The shader source code being edited - shader_code: String, - /// Whether to show the template selector - #[allow(dead_code)] - show_templates: bool, - /// Error message from last compilation attempt (if any) + /// Current editor mode + mode: EditorMode, + /// The source code being edited + code: String, + /// Display name for the asset being edited + asset_name: String, + /// Error message from last compilation attempt compile_error: Option, - /// Name for the shader/effect - shader_name: String, + + // ── Shader mode state ─────────────────────────────────── /// ID of effect being edited (None = new effect) editing_effect_id: Option, - /// Original code when effect was loaded (for dirty checking) + + // ── BeamDSP mode state ────────────────────────────────── + /// ID of script being edited (None = new script) + editing_script_id: Option, + + // ── Shared state ──────────────────────────────────────── + /// Original code when asset was loaded (for dirty checking) original_code: Option, - /// Original name when effect was loaded (for dirty checking) + /// Original name when asset was loaded (for dirty checking) original_name: Option, /// Effect awaiting confirmation to load (when there are unsaved changes) pending_load_effect: Option, + /// Script awaiting confirmation to load (when there are unsaved changes) + pending_load_script: Option, /// Whether to show the unsaved changes confirmation dialog show_unsaved_dialog: bool, } @@ -240,37 +373,76 @@ pub struct ShaderEditorPane { impl ShaderEditorPane { pub fn new() -> Self { Self { - shader_code: DEFAULT_SHADER_TEMPLATE.to_string(), - show_templates: false, + mode: EditorMode::Shader, + code: DEFAULT_SHADER_TEMPLATE.to_string(), + asset_name: "Custom Effect".to_string(), compile_error: None, - shader_name: "Custom Effect".to_string(), editing_effect_id: None, + editing_script_id: None, original_code: None, original_name: None, pending_load_effect: None, + pending_load_script: None, show_unsaved_dialog: false, } } - /// Check if there are unsaved changes - pub fn has_unsaved_changes(&self) -> bool { - match (&self.original_code, &self.original_name) { - (Some(orig_code), Some(orig_name)) => { - 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 + fn default_code(&self) -> &str { + match self.mode { + EditorMode::Shader => DEFAULT_SHADER_TEMPLATE, + EditorMode::BeamDSP => BEAMDSP_PASSTHROUGH, } } - /// Load an effect into the editor - pub fn load_effect(&mut self, effect: &EffectDefinition) { - self.shader_name = effect.name.clone(); - self.shader_code = effect.shader_code.clone(); - // For built-in effects, don't set editing_effect_id (editing creates a copy) + fn default_name(&self) -> &str { + match self.mode { + EditorMode::Shader => "Custom Effect", + EditorMode::BeamDSP => "New Script", + } + } + + 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 { self.editing_effect_id = Some(effect.id); } else { @@ -281,56 +453,102 @@ impl ShaderEditorPane { 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( &self, effect_id: Uuid, document: &lightningbeam_core::document::Document, ) -> Option { - // First check custom effects in document + use lightningbeam_core::effect_registry::EffectRegistry; if let Some(def) = document.effect_definitions.get(&effect_id) { return Some(def.clone()); } - // Then check built-in effects 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 { let mut result = None; - if self.show_unsaved_dialog { - let window_id = egui::Id::new("shader_unsaved_dialog"); - egui::Window::new("Unsaved Changes") - .id(window_id) + .id(egui::Id::new("script_unsaved_dialog")) .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) .show(ui.ctx(), |ui| { ui.set_min_width(300.0); - - ui.label("You have unsaved changes to this shader."); + let label = match self.mode { + 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.add_space(12.0); - ui.horizontal(|ui| { if ui.button("Cancel").clicked() { result = Some(UnsavedDialogResult::Cancel); @@ -344,74 +562,109 @@ impl ShaderEditorPane { }); }); } - result } - /// Render the toolbar with template selection and actions - /// Returns true if Save was clicked - fn render_toolbar(&mut self, ui: &mut Ui, _path: &NodePath) -> bool { + // ── Toolbar rendering ─────────────────────────────────── + + fn render_toolbar( + &mut self, + ui: &mut Ui, + available_scripts: &[(Uuid, String)], + ) -> (bool, bool, Option) { let mut save_clicked = false; + let mut export_clicked = false; + let mut open_script_id = None; ui.horizontal(|ui| { - // New button if ui.button("New").clicked() { - // TODO: Check for unsaved changes first - self.new_effect(); + self.new_asset(); } - ui.separator(); - - // Shader name input - ui.label("Name:"); - ui.add(egui::TextEdit::singleline(&mut self.shader_name).desired_width(150.0)); - - ui.separator(); - - // Template dropdown - egui::ComboBox::from_label("Template") - .selected_text("Insert Template") - .show_ui(ui, |ui| { - if ui.selectable_label(false, "Basic (Passthrough)").clicked() { - self.shader_code = DEFAULT_SHADER_TEMPLATE.to_string(); - } - if ui.selectable_label(false, "Grayscale").clicked() { - self.shader_code = GRAYSCALE_TEMPLATE.to_string(); - } - if ui.selectable_label(false, "Vignette").clicked() { - self.shader_code = VIGNETTE_TEMPLATE.to_string(); + // 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(); - // 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; - } + ui.label("Name:"); + ui.add(egui::TextEdit::singleline(&mut self.asset_name).desired_width(150.0)); + ui.separator(); + + // Mode-specific templates + match self.mode { + EditorMode::Shader => { + egui::ComboBox::from_label("Template") + .selected_text("Insert Template") + .show_ui(ui, |ui| { + if ui.selectable_label(false, "Basic (Passthrough)").clicked() { + self.code = DEFAULT_SHADER_TEMPLATE.to_string(); + } + if ui.selectable_label(false, "Grayscale").clicked() { + self.code = GRAYSCALE_TEMPLATE.to_string(); + } + if ui.selectable_label(false, "Vignette").clicked() { + 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(); - // Save button if ui.button("Save").clicked() { save_clicked = true; } - // Show dirty indicator + if self.mode == EditorMode::BeamDSP { + if ui.button("Export").clicked() { + export_clicked = true; + } + } + if self.has_unsaved_changes() { ui.label(egui::RichText::new("*").color(egui::Color32::YELLOW)); } - - // Show editing mode - if let Some(_) = self.editing_effect_id { + if self.is_editing_existing() { ui.label(egui::RichText::new("(Editing)").weak()); } else { 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) { if let Some(error) = &self.compile_error { ui.horizontal(|ui| { @@ -433,150 +686,161 @@ impl PaneRenderer for ShaderEditorPane { ) { // Handle effect loading request from asset library 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 self.has_unsaved_changes() { - // Store effect to load and show dialog self.pending_load_effect = Some(effect); self.show_unsaved_dialog = true; } else { - // No unsaved changes, load immediately 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 if let Some(result) = self.render_unsaved_dialog(ui) { match result { UnsavedDialogResult::Cancel => { - // Cancel the load, keep current state self.pending_load_effect = None; + self.pending_load_script = None; self.show_unsaved_dialog = false; } UnsavedDialogResult::Discard => { - // Discard changes and load the new effect if let Some(effect) = self.pending_load_effect.take() { self.load_effect(&effect); } + if let Some(script) = self.pending_load_script.take() { + self.load_script(&script); + } self.show_unsaved_dialog = false; } UnsavedDialogResult::SaveAndContinue => { - // Save current work first - if !self.shader_name.trim().is_empty() { - let effect = if let Some(existing_id) = self.editing_effect_id { - 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); + match self.mode { + EditorMode::Shader => { self.save_effect(shared); } + EditorMode::BeamDSP => { self.save_script(shared); } } - // Then load the new effect if let Some(effect) = self.pending_load_effect.take() { self.load_effect(&effect); } + if let Some(script) = self.pending_load_script.take() { + self.load_script(&script); + } self.show_unsaved_dialog = false; } } } // Background - ui.painter().rect_filled( - rect, - 0.0, - egui::Color32::from_rgb(25, 25, 30), - ); + ui.painter().rect_filled(rect, 0.0, egui::Color32::from_rgb(25, 25, 30)); - // Create content area let content_rect = rect.shrink(8.0); let mut content_ui = ui.new_child( egui::UiBuilder::new() .max_rect(content_rect) .layout(egui::Layout::top_down(egui::Align::LEFT)), ); - 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 - 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.separator(); content_ui.add_space(4.0); - // Handle save action - if save_clicked { - if self.shader_name.trim().is_empty() { - self.compile_error = Some("Name cannot be empty".to_string()); - } else { - // Create or update EffectDefinition - 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 - ) + // Handle open script + if let Some(script_id) = open_script_id { + 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 { - // Create new custom effect - EffectDefinition::new( - self.shader_name.clone(), - EffectCategory::Custom, - self.shader_code.clone(), - vec![], // No parameters for now - ) - }; + self.load_script(&script); + } + } + } - 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); - self.compile_error = None; + // Handle save + if save_clicked { + match self.mode { + EditorMode::Shader => { self.save_effect(shared); } + EditorMode::BeamDSP => { self.save_script(shared); } + } + } + + // 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 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 + 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() - .id_salt(("shader_editor_scroll", path)) + .id_salt(("script_editor_scroll", path)) .auto_shrink([false, false]) .show(&mut content_ui, |ui| { ui.set_min_size(remaining_rect.size()); - CodeEditor::default() - .id_source("shader_code_editor") + .id_source("script_code_editor") .with_rows(50) .with_fontsize(13.0) .with_theme(ColorTheme::GRUVBOX_DARK) - .with_syntax(wgsl_syntax()) + .with_syntax(syntax) .with_numlines(true) - .show(ui, &mut self.shader_code); + .show(ui, &mut self.code); }); } fn name(&self) -> &str { - "Shader Editor" + match self.mode { + EditorMode::Shader => "Shader Editor", + EditorMode::BeamDSP => "Script Editor", + } } }