Add UI to script node

This commit is contained in:
Skyler Lehmkuhl 2026-02-19 10:16:05 -05:00
parent 2804c2bd5d
commit 92dffbaa4e
11 changed files with 819 additions and 11 deletions

View File

@ -12,6 +12,7 @@ pub struct Script {
pub state: Vec<StateDecl>, pub state: Vec<StateDecl>,
pub ui: Option<Vec<UiElement>>, pub ui: Option<Vec<UiElement>>,
pub process: Block, pub process: Block,
pub draw: Option<Block>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@ -37,6 +37,7 @@ struct Compiler {
vars: Vec<(String, VarLoc)>, vars: Vec<(String, VarLoc)>,
next_local: u16, next_local: u16,
scope_stack: Vec<u16>, // local count at scope entry scope_stack: Vec<u16>, // local count at scope entry
draw_context: bool, // true when compiling a draw {} block
} }
impl Compiler { impl Compiler {
@ -48,6 +49,7 @@ impl Compiler {
vars: Vec::new(), vars: Vec::new(),
next_local: 0, next_local: 0,
scope_stack: Vec::new(), scope_stack: Vec::new(),
draw_context: false,
} }
} }
@ -210,6 +212,55 @@ impl Compiler {
Ok(()) Ok(())
} }
/// Compile the draw block into separate bytecode (for the DrawVM)
fn compile_draw(&mut self, script: &Script) -> Result<(), CompileError> {
self.draw_context = true;
// Register params (same as process)
for (i, param) in script.params.iter().enumerate() {
self.vars.push((param.name.clone(), VarLoc::Param(i as u16)));
}
// Register state variables (draw gets its own copy)
let mut scalar_idx: u16 = 0;
let mut array_idx: u16 = 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 => {} // no samples in draw context
}
}
// Compile draw block
if let Some(draw) = &script.draw {
for stmt in draw {
self.compile_stmt(stmt)?;
}
}
self.emit(OpCode::Halt);
Ok(())
}
fn compile_stmt(&mut self, stmt: &Stmt) -> Result<(), CompileError> { fn compile_stmt(&mut self, stmt: &Stmt) -> Result<(), CompileError> {
match stmt { match stmt {
Stmt::Let { name, init, .. } => { Stmt::Let { name, init, .. } => {
@ -235,6 +286,10 @@ impl Compiler {
self.emit(OpCode::StoreState); self.emit(OpCode::StoreState);
self.emit_u16(idx); self.emit_u16(idx);
} }
VarLoc::Param(idx) if self.draw_context => {
self.emit(OpCode::StoreParam);
self.emit_u16(idx);
}
_ => { _ => {
return Err(CompileError::new( return Err(CompileError::new(
format!("Cannot assign to {}", name), *span, format!("Cannot assign to {}", name), *span,
@ -336,10 +391,13 @@ impl Compiler {
self.pop_scope(); self.pop_scope();
} }
Stmt::ExprStmt(expr) => { Stmt::ExprStmt(expr) => {
let is_void = self.is_void_call(expr);
self.compile_expr(expr)?; self.compile_expr(expr)?;
if !is_void {
self.emit(OpCode::Pop); self.emit(OpCode::Pop);
} }
} }
}
Ok(()) Ok(())
} }
@ -548,6 +606,18 @@ impl Compiler {
Ok(()) Ok(())
} }
/// Returns true if the expression is a call to a void function (no return value).
fn is_void_call(&self, expr: &Expr) -> bool {
if let Expr::Call(name, _, _) = expr {
matches!(name.as_str(),
"fill_circle" | "stroke_circle" | "stroke_arc" |
"line" | "fill_rect" | "stroke_rect"
)
} else {
false
}
}
fn compile_call(&mut self, name: &str, args: &[Expr], span: Span) -> Result<(), CompileError> { fn compile_call(&mut self, name: &str, args: &[Expr], span: Span) -> Result<(), CompileError> {
match name { match name {
// 1-arg math → push arg, emit opcode // 1-arg math → push arg, emit opcode
@ -717,6 +787,48 @@ impl Compiler {
} }
} }
// Draw builtins (only valid in draw context)
"fill_circle" | "stroke_circle" | "stroke_arc" | "line" |
"fill_rect" | "stroke_rect" |
"mouse_x" | "mouse_y" | "mouse_down" if self.draw_context => {
match name {
"fill_circle" => {
// fill_circle(cx, cy, r, color)
for arg in args { self.compile_expr(arg)?; }
self.emit(OpCode::DrawFillCircle);
}
"stroke_circle" => {
// stroke_circle(cx, cy, r, color, width)
for arg in args { self.compile_expr(arg)?; }
self.emit(OpCode::DrawStrokeCircle);
}
"stroke_arc" => {
// stroke_arc(cx, cy, r, start_deg, end_deg, color, width)
for arg in args { self.compile_expr(arg)?; }
self.emit(OpCode::DrawStrokeArc);
}
"line" => {
// line(x1, y1, x2, y2, color, width)
for arg in args { self.compile_expr(arg)?; }
self.emit(OpCode::DrawLine);
}
"fill_rect" => {
// fill_rect(x, y, w, h, color)
for arg in args { self.compile_expr(arg)?; }
self.emit(OpCode::DrawFillRect);
}
"stroke_rect" => {
// stroke_rect(x, y, w, h, color, width)
for arg in args { self.compile_expr(arg)?; }
self.emit(OpCode::DrawStrokeRect);
}
"mouse_x" => { self.emit(OpCode::MouseX); }
"mouse_y" => { self.emit(OpCode::MouseY); }
"mouse_down" => { self.emit(OpCode::MouseDown); }
_ => unreachable!(),
}
}
_ => { _ => {
return Err(CompileError::new(format!("Unknown function: {}", name), span)); return Err(CompileError::new(format!("Unknown function: {}", name), span));
} }
@ -793,7 +905,7 @@ impl Compiler {
} }
/// Compile a validated AST into bytecode VM and UI declaration /// Compile a validated AST into bytecode VM and UI declaration
pub fn compile(script: &Script) -> Result<(ScriptVM, UiDeclaration), CompileError> { pub fn compile(script: &Script) -> Result<(ScriptVM, UiDeclaration, Option<crate::vm::DrawVM>), CompileError> {
let mut compiler = Compiler::new(); let mut compiler = Compiler::new();
compiler.compile_script(script)?; compiler.compile_script(script)?;
@ -843,7 +955,24 @@ pub fn compile(script: &Script) -> Result<(ScriptVM, UiDeclaration), CompileErro
UiDeclaration { elements } UiDeclaration { elements }
}; };
Ok((vm, ui_decl)) // Compile draw block if present
let draw_vm = if script.draw.is_some() {
let mut draw_compiler = Compiler::new();
draw_compiler.compile_draw(script)?;
Some(crate::vm::DrawVM::new(
draw_compiler.code,
draw_compiler.constants_f32,
draw_compiler.constants_i32,
script.params.len(),
&param_defaults,
num_state_scalars,
&state_array_sizes,
))
} else {
None
};
Ok((vm, ui_decl, draw_vm))
} }
#[cfg(test)] #[cfg(test)]
@ -859,7 +988,8 @@ mod tests {
let mut parser = Parser::new(&tokens); let mut parser = Parser::new(&tokens);
let script = parser.parse()?; let script = parser.parse()?;
let validated = validator::validate(&script)?; let validated = validator::validate(&script)?;
compile(validated) let (vm, ui, _draw_vm) = compile(validated)?;
Ok((vm, ui))
} }
#[test] #[test]
@ -988,4 +1118,43 @@ mod tests {
assert!(matches!(&ui.elements[0], UiElement::Sample(n) if n == "clip")); assert!(matches!(&ui.elements[0], UiElement::Sample(n) if n == "clip"));
assert!(matches!(&ui.elements[1], UiElement::Param(n) if n == "gain")); assert!(matches!(&ui.elements[1], UiElement::Param(n) if n == "gain"));
} }
#[test]
fn test_draw_block() {
let src = r#"
name "Knob"
category utility
params { volume: 0.75 [0.0, 1.0] "" }
outputs { out: audio }
ui { canvas [80, 80] }
draw {
let cx = 40.0;
let cy = 40.0;
fill_circle(cx, cy, 35.0, 0x333333FF);
let angle = volume * 270.0 - 135.0;
stroke_arc(cx, cy, 30.0, -135.0, angle, 0x4488FFFF, 3.0);
}
process {
for i in 0..buffer_size {
out[i] = 0.0;
}
}
"#;
let mut lexer = Lexer::new(src);
let tokens = lexer.tokenize().unwrap();
let mut parser = Parser::new(&tokens);
let script = parser.parse().unwrap();
let validated = validator::validate(&script).unwrap();
let (_vm, _ui, draw_vm) = compile(validated).unwrap();
// Draw VM should exist
let mut dvm = draw_vm.expect("draw_vm should be Some");
assert!(!dvm.bytecode.is_empty());
// Execute should succeed without stack errors
dvm.execute().unwrap();
// Should have produced draw commands
assert_eq!(dvm.draw_commands.len(), 2); // fill_circle + stroke_arc
}
} }

View File

@ -172,6 +172,28 @@ impl<'a> Lexer<'a> {
} }
fn read_number(&mut self, first: u8, span: Span) -> Result<Token, CompileError> { fn read_number(&mut self, first: u8, span: Span) -> Result<Token, CompileError> {
// Check for hex literal: 0x...
if first == b'0' && self.peek() == Some(b'x') {
self.advance(); // skip 'x'
let mut hex = String::new();
while let Some(ch) = self.peek() {
if ch.is_ascii_hexdigit() {
hex.push(self.advance() as char);
} else {
break;
}
}
if hex.is_empty() {
return Err(CompileError::new("Expected hex digits after 0x", span));
}
let val = u32::from_str_radix(&hex, 16)
.map_err(|_| CompileError::new(format!("Invalid hex literal: 0x{}", hex), span))?;
return Ok(Token {
kind: TokenKind::IntLit(val as i32),
span,
});
}
let mut s = String::new(); let mut s = String::new();
s.push(first as char); s.push(first as char);
let mut is_float = false; let mut is_float = false;

View File

@ -15,7 +15,7 @@ use parser::Parser;
pub use error::ScriptError; pub use error::ScriptError;
pub use ui_decl::{UiDeclaration, UiElement}; pub use ui_decl::{UiDeclaration, UiElement};
pub use vm::{ScriptVM, SampleSlot}; pub use vm::{ScriptVM, SampleSlot, DrawVM, DrawCommand, MouseState};
/// Compiled script metadata — everything needed to create a ScriptNode /// Compiled script metadata — everything needed to create a ScriptNode
pub struct CompiledScript { pub struct CompiledScript {
@ -28,6 +28,7 @@ pub struct CompiledScript {
pub sample_slots: Vec<String>, pub sample_slots: Vec<String>,
pub ui_declaration: UiDeclaration, pub ui_declaration: UiDeclaration,
pub source: String, pub source: String,
pub draw_vm: Option<DrawVM>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -55,7 +56,7 @@ pub fn compile(source: &str) -> Result<CompiledScript, CompileError> {
let validated = validator::validate(&script)?; let validated = validator::validate(&script)?;
let (vm, ui_decl) = codegen::compile(&validated)?; let (vm, ui_decl, draw_vm) = codegen::compile(&validated)?;
let input_ports = script let input_ports = script
.inputs .inputs
@ -104,5 +105,6 @@ pub fn compile(source: &str) -> Result<CompiledScript, CompileError> {
sample_slots, sample_slots,
ui_declaration: ui_decl, ui_declaration: ui_decl,
source: source.to_string(), source: source.to_string(),
draw_vm,
}) })
} }

View File

@ -108,6 +108,22 @@ pub enum OpCode {
LoadSampleRate = 140, LoadSampleRate = 140,
LoadBufferSize = 141, LoadBufferSize = 141,
// Draw commands (pop args from stack, push to draw command buffer)
DrawFillCircle = 150, // pops: color(i32), r, cy, cx
DrawStrokeCircle = 151, // pops: width, color(i32), r, cy, cx
DrawStrokeArc = 152, // pops: width, color(i32), end_deg, start_deg, r, cy, cx
DrawLine = 153, // pops: width, color(i32), y2, x2, y1, x1
DrawFillRect = 154, // pops: color(i32), h, w, y, x
DrawStrokeRect = 155, // pops: width, color(i32), h, w, y, x
// Mouse input (push onto stack)
MouseX = 160, // pushes canvas-relative X as f32
MouseY = 161, // pushes canvas-relative Y as f32
MouseDown = 162, // pushes 1.0 if pressed, 0.0 if not
// Param write (draw context only)
StoreParam = 170, // u16 param index, pops value from stack
Halt = 255, Halt = 255,
} }
@ -190,6 +206,16 @@ impl OpCode {
130 => Some(OpCode::ArrayLen), 130 => Some(OpCode::ArrayLen),
140 => Some(OpCode::LoadSampleRate), 140 => Some(OpCode::LoadSampleRate),
141 => Some(OpCode::LoadBufferSize), 141 => Some(OpCode::LoadBufferSize),
150 => Some(OpCode::DrawFillCircle),
151 => Some(OpCode::DrawStrokeCircle),
152 => Some(OpCode::DrawStrokeArc),
153 => Some(OpCode::DrawLine),
154 => Some(OpCode::DrawFillRect),
155 => Some(OpCode::DrawStrokeRect),
160 => Some(OpCode::MouseX),
161 => Some(OpCode::MouseY),
162 => Some(OpCode::MouseDown),
170 => Some(OpCode::StoreParam),
255 => Some(OpCode::Halt), 255 => Some(OpCode::Halt),
_ => None, _ => None,
} }

View File

@ -86,6 +86,7 @@ impl<'a> Parser<'a> {
let mut state = Vec::new(); let mut state = Vec::new();
let mut ui = None; let mut ui = None;
let mut process = Vec::new(); let mut process = Vec::new();
let mut draw = None;
while *self.peek() != TokenKind::Eof { while *self.peek() != TokenKind::Eof {
match self.peek() { match self.peek() {
@ -131,6 +132,10 @@ impl<'a> Parser<'a> {
self.advance(); self.advance();
process = self.parse_block()?; process = self.parse_block()?;
} }
TokenKind::Draw => {
self.advance();
draw = Some(self.parse_block()?);
}
_ => { _ => {
return Err(CompileError::new( return Err(CompileError::new(
format!("Unexpected token {:?} at top level", self.peek()), format!("Unexpected token {:?} at top level", self.peek()),
@ -156,6 +161,7 @@ impl<'a> Parser<'a> {
state, state,
ui, ui,
process, process,
draw,
}) })
} }

View File

@ -58,6 +58,9 @@ pub enum TokenKind {
Canvas, Canvas,
Spacer, Spacer,
// Draw block
Draw,
// Literals // Literals
FloatLit(f32), FloatLit(f32),
IntLit(i32), IntLit(i32),
@ -133,6 +136,7 @@ impl TokenKind {
"param" => TokenKind::Param, "param" => TokenKind::Param,
"canvas" => TokenKind::Canvas, "canvas" => TokenKind::Canvas,
"spacer" => TokenKind::Spacer, "spacer" => TokenKind::Spacer,
"draw" => TokenKind::Draw,
"true" => TokenKind::True, "true" => TokenKind::True,
"false" => TokenKind::False, "false" => TokenKind::False,
_ => TokenKind::Ident(s.to_string()), _ => TokenKind::Ident(s.to_string()),

View File

@ -375,6 +375,15 @@ impl ScriptVM {
self.push_i(buffer_size as i32)?; self.push_i(buffer_size as i32)?;
} }
// Draw/mouse opcodes are not valid in the audio ScriptVM
Some(OpCode::DrawFillCircle) | Some(OpCode::DrawStrokeCircle) |
Some(OpCode::DrawStrokeArc) | Some(OpCode::DrawLine) |
Some(OpCode::DrawFillRect) | Some(OpCode::DrawStrokeRect) |
Some(OpCode::MouseX) | Some(OpCode::MouseY) | Some(OpCode::MouseDown) |
Some(OpCode::StoreParam) => {
return Err(ScriptError::InvalidOpcode(op));
}
None => return Err(ScriptError::InvalidOpcode(op)), None => return Err(ScriptError::InvalidOpcode(op)),
} }
} }
@ -449,3 +458,391 @@ impl ScriptVM {
v v
} }
} }
// ---- Draw VM (runs on UI thread, produces draw commands) ----
/// A draw command produced by the draw block
#[derive(Debug, Clone)]
pub enum DrawCommand {
FillCircle { cx: f32, cy: f32, r: f32, color: u32 },
StrokeCircle { cx: f32, cy: f32, r: f32, color: u32, width: f32 },
StrokeArc { cx: f32, cy: f32, r: f32, start_deg: f32, end_deg: f32, color: u32, width: f32 },
Line { x1: f32, y1: f32, x2: f32, y2: f32, color: u32, width: f32 },
FillRect { x: f32, y: f32, w: f32, h: f32, color: u32 },
StrokeRect { x: f32, y: f32, w: f32, h: f32, color: u32, width: f32 },
}
/// Mouse state passed to the draw VM each frame
#[derive(Debug, Clone, Default)]
pub struct MouseState {
pub x: f32,
pub y: f32,
pub down: bool,
}
/// Lightweight VM for executing draw bytecode on the UI thread
#[derive(Clone)]
pub struct DrawVM {
pub bytecode: Vec<u8>,
pub constants_f32: Vec<f32>,
pub constants_i32: Vec<i32>,
stack: Vec<Value>,
sp: usize,
locals: Vec<Value>,
pub params: Vec<f32>,
pub state_scalars: Vec<Value>,
pub state_arrays: Vec<Vec<f32>>,
pub draw_commands: Vec<DrawCommand>,
pub mouse: MouseState,
instruction_limit: u64,
}
impl DrawVM {
pub fn new(
bytecode: Vec<u8>,
constants_f32: Vec<f32>,
constants_i32: Vec<i32>,
num_params: usize,
param_defaults: &[f32],
num_state_scalars: usize,
state_array_sizes: &[usize],
) -> 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(),
draw_commands: Vec::new(),
mouse: MouseState::default(),
instruction_limit: 1_000_000, // lower limit for draw (runs per frame)
}
}
/// Execute the draw bytecode. Call once per frame.
/// Draw commands accumulate in `self.draw_commands` (cleared at start).
pub fn execute(&mut self) -> Result<(), ScriptError> {
self.sp = 0;
self.draw_commands.clear();
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;
self.push_f(self.constants_f32[idx])?;
}
Some(OpCode::PushI32) => {
let idx = self.read_u16(&mut pc) as usize;
self.push_i(self.constants_i32[idx])?;
}
Some(OpCode::PushBool) => {
let v = self.bytecode[pc];
pc += 1;
self.push_b(v != 0)?;
}
Some(OpCode::Pop) => { self.pop()?; }
Some(OpCode::LoadLocal) => {
let idx = self.read_u16(&mut pc) as usize;
self.push(self.locals[idx])?;
}
Some(OpCode::StoreLocal) => {
let idx = self.read_u16(&mut pc) as usize;
self.locals[idx] = self.pop()?;
}
Some(OpCode::LoadParam) => {
let idx = self.read_u16(&mut pc) as usize;
self.push_f(self.params[idx])?;
}
Some(OpCode::LoadState) => {
let idx = self.read_u16(&mut pc) as usize;
self.push(self.state_scalars[idx])?;
}
Some(OpCode::StoreState) => {
let idx = self.read_u16(&mut pc) as usize;
self.state_scalars[idx] = self.pop()?;
}
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;
}
}
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)?;
}
// Audio I/O not available in draw context
Some(OpCode::LoadInput) | Some(OpCode::StoreOutput) => {
return Err(ScriptError::InvalidOpcode(op));
}
Some(OpCode::LoadSampleRate) | Some(OpCode::LoadBufferSize) => {
return Err(ScriptError::InvalidOpcode(op));
}
// Sample access not available in draw context
Some(OpCode::SampleLen) | Some(OpCode::SampleRead) | Some(OpCode::SampleRateOf) => {
pc += 1; // skip slot byte
self.push_i(0)?;
}
// 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())?; }
// Draw commands
Some(OpCode::DrawFillCircle) => {
let color = self.pop_i()? as u32;
let r = self.pop_f()?;
let cy = self.pop_f()?;
let cx = self.pop_f()?;
self.draw_commands.push(DrawCommand::FillCircle { cx, cy, r, color });
}
Some(OpCode::DrawStrokeCircle) => {
let width = self.pop_f()?;
let color = self.pop_i()? as u32;
let r = self.pop_f()?;
let cy = self.pop_f()?;
let cx = self.pop_f()?;
self.draw_commands.push(DrawCommand::StrokeCircle { cx, cy, r, color, width });
}
Some(OpCode::DrawStrokeArc) => {
let width = self.pop_f()?;
let color = self.pop_i()? as u32;
let end_deg = self.pop_f()?;
let start_deg = self.pop_f()?;
let r = self.pop_f()?;
let cy = self.pop_f()?;
let cx = self.pop_f()?;
self.draw_commands.push(DrawCommand::StrokeArc { cx, cy, r, start_deg, end_deg, color, width });
}
Some(OpCode::DrawLine) => {
let width = self.pop_f()?;
let color = self.pop_i()? as u32;
let y2 = self.pop_f()?;
let x2 = self.pop_f()?;
let y1 = self.pop_f()?;
let x1 = self.pop_f()?;
self.draw_commands.push(DrawCommand::Line { x1, y1, x2, y2, color, width });
}
Some(OpCode::DrawFillRect) => {
let color = self.pop_i()? as u32;
let h = self.pop_f()?;
let w = self.pop_f()?;
let y = self.pop_f()?;
let x = self.pop_f()?;
self.draw_commands.push(DrawCommand::FillRect { x, y, w, h, color });
}
Some(OpCode::DrawStrokeRect) => {
let width = self.pop_f()?;
let color = self.pop_i()? as u32;
let h = self.pop_f()?;
let w = self.pop_f()?;
let y = self.pop_f()?;
let x = self.pop_f()?;
self.draw_commands.push(DrawCommand::StrokeRect { x, y, w, h, color, width });
}
// Mouse input
Some(OpCode::MouseX) => { self.push_f(self.mouse.x)?; }
Some(OpCode::MouseY) => { self.push_f(self.mouse.y)?; }
Some(OpCode::MouseDown) => { self.push_f(if self.mouse.down { 1.0 } else { 0.0 })?; }
// Param write
Some(OpCode::StoreParam) => {
let idx = self.read_u16(&mut pc) as usize;
let val = self.pop_f()?;
if idx < self.params.len() {
self.params[idx] = val;
}
}
None => return Err(ScriptError::InvalidOpcode(op)),
}
}
Ok(())
}
// Stack helpers (identical to ScriptVM)
#[inline]
fn push(&mut self, v: Value) -> Result<(), ScriptError> {
if self.sp >= STACK_SIZE { return Err(ScriptError::StackOverflow); }
self.stack[self.sp] = v;
self.sp += 1;
Ok(())
}
#[inline]
fn push_f(&mut self, v: f32) -> Result<(), ScriptError> { self.push(Value { f: v }) }
#[inline]
fn push_i(&mut self, v: i32) -> Result<(), ScriptError> { self.push(Value { i: v }) }
#[inline]
fn push_b(&mut self, v: bool) -> Result<(), ScriptError> { self.push(Value { b: v }) }
#[inline]
fn pop(&mut self) -> Result<Value, ScriptError> {
if self.sp == 0 { return Err(ScriptError::StackUnderflow); }
self.sp -= 1;
Ok(self.stack[self.sp])
}
#[inline]
fn pop_f(&mut self) -> Result<f32, ScriptError> { Ok(unsafe { self.pop()?.f }) }
#[inline]
fn pop_i(&mut self) -> Result<i32, ScriptError> { Ok(unsafe { self.pop()?.i }) }
#[inline]
fn pop_b(&mut self) -> Result<bool, ScriptError> { Ok(unsafe { self.pop()?.b }) }
#[inline]
fn read_u16(&self, pc: &mut usize) -> u16 {
let v = u16::from_le_bytes([self.bytecode[*pc], self.bytecode[*pc + 1]]);
*pc += 2;
v
}
#[inline]
fn read_u32(&self, pc: &mut usize) -> u32 {
let v = u32::from_le_bytes([
self.bytecode[*pc], self.bytecode[*pc + 1],
self.bytecode[*pc + 2], self.bytecode[*pc + 3],
]);
*pc += 4;
v
}
}

View File

@ -241,6 +241,10 @@ pub struct GraphState {
pub pending_load_script_file: Option<NodeId>, pub pending_load_script_file: Option<NodeId>,
/// Pending script sample load request from bottom_ui sample picker /// Pending script sample load request from bottom_ui sample picker
pub pending_script_sample_load: Option<PendingScriptSampleLoad>, pub pending_script_sample_load: Option<PendingScriptSampleLoad>,
/// Draw VMs for canvas rendering, keyed by node ID
pub draw_vms: HashMap<NodeId, beamdsp::DrawVM>,
/// Pending param changes from draw block (node_id, param_index, new_value)
pub pending_draw_param_changes: Vec<(NodeId, u32, f32)>,
} }
impl Default for GraphState { impl Default for GraphState {
@ -261,6 +265,8 @@ impl Default for GraphState {
pending_new_script: None, pending_new_script: None,
pending_load_script_file: None, pending_load_script_file: None,
pending_script_sample_load: None, pending_script_sample_load: None,
draw_vms: HashMap::new(),
pending_draw_param_changes: Vec::new(),
} }
} }
} }
@ -1362,7 +1368,21 @@ impl NodeDataTrait for NodeData {
egui::Popup::close_id(ui.ctx(), popup_id); egui::Popup::close_id(ui.ctx(), popup_id);
} }
// Render declarative UI elements (sample pickers, groups) // Sync param values from node input ports to draw VM
if let Some(draw_vm) = user_state.draw_vms.get_mut(&node_id) {
if let Some(node) = _graph.nodes.get(node_id) {
for (_name, input_id) in &node.inputs {
if let ValueType::Float { value, backend_param_id: Some(pid), .. } = &_graph.get_input(*input_id).value {
let idx = *pid as usize;
if idx < draw_vm.params.len() {
draw_vm.params[idx] = *value;
}
}
}
}
}
// Render declarative UI elements (sample pickers, groups, canvas)
if let Some(ref ui_decl) = self.ui_declaration { 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); let backend_node_id = user_state.node_backend_ids.get(&node_id).copied().unwrap_or(0);
render_script_ui_elements( render_script_ui_elements(
@ -1373,6 +1393,8 @@ impl NodeDataTrait for NodeData {
&user_state.available_clips, &user_state.available_clips,
&mut user_state.sampler_search_text, &mut user_state.sampler_search_text,
&mut user_state.pending_script_sample_load, &mut user_state.pending_script_sample_load,
&mut user_state.draw_vms,
&mut user_state.pending_draw_param_changes,
); );
} }
} else { } else {
@ -1382,7 +1404,17 @@ impl NodeDataTrait for NodeData {
} }
} }
/// Render UiDeclaration elements for Script nodes (sample pickers, groups, spacers) /// Convert a u32 RGBA color to egui Color32
fn color_from_u32(c: u32) -> egui::Color32 {
egui::Color32::from_rgba_unmultiplied(
((c >> 24) & 0xFF) as u8,
((c >> 16) & 0xFF) as u8,
((c >> 8) & 0xFF) as u8,
(c & 0xFF) as u8,
)
}
/// Render UiDeclaration elements for Script nodes (sample pickers, groups, canvas, spacers)
fn render_script_ui_elements( fn render_script_ui_elements(
ui: &mut egui::Ui, ui: &mut egui::Ui,
node_id: NodeId, node_id: NodeId,
@ -1393,9 +1425,121 @@ fn render_script_ui_elements(
available_clips: &[SamplerClipInfo], available_clips: &[SamplerClipInfo],
search_text: &mut String, search_text: &mut String,
pending_load: &mut Option<PendingScriptSampleLoad>, pending_load: &mut Option<PendingScriptSampleLoad>,
draw_vms: &mut HashMap<NodeId, beamdsp::DrawVM>,
pending_param_changes: &mut Vec<(NodeId, u32, f32)>,
) { ) {
for element in elements { for element in elements {
match element { match element {
beamdsp::UiElement::Canvas { width, height } => {
let size = egui::vec2(*width, *height);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click_and_drag());
let painter = ui.painter_at(rect);
// Dark background
painter.rect_filled(rect, 2.0, egui::Color32::from_rgb(0x1a, 0x1a, 0x1a));
if let Some(draw_vm) = draw_vms.get_mut(&node_id) {
// Set mouse state
if let Some(pos) = response.hover_pos() {
draw_vm.mouse.x = pos.x - rect.left();
draw_vm.mouse.y = pos.y - rect.top();
}
draw_vm.mouse.down = response.dragged() || response.drag_started();
// Save params before execution to detect changes
let params_before: Vec<f32> = draw_vm.params.clone();
// Execute draw block
if let Err(e) = draw_vm.execute() {
painter.text(
rect.center(), egui::Align2::CENTER_CENTER,
&format!("draw error: {}", e),
egui::FontId::monospace(9.0), egui::Color32::RED,
);
} else {
// Render draw commands
for cmd in &draw_vm.draw_commands {
match cmd {
beamdsp::DrawCommand::FillCircle { cx, cy, r, color } => {
painter.circle_filled(
egui::pos2(rect.left() + cx, rect.top() + cy),
*r, color_from_u32(*color),
);
}
beamdsp::DrawCommand::StrokeCircle { cx, cy, r, color, width } => {
painter.circle_stroke(
egui::pos2(rect.left() + cx, rect.top() + cy),
*r, egui::Stroke::new(*width, color_from_u32(*color)),
);
}
beamdsp::DrawCommand::StrokeArc { cx, cy, r, start_deg, end_deg, color, width } => {
// Generate arc as polyline
let center = egui::pos2(rect.left() + cx, rect.top() + cy);
let start_rad = start_deg.to_radians();
let end_rad = end_deg.to_radians();
let arc_len = (end_rad - start_rad).abs();
let segments = ((arc_len * *r / 2.0).ceil() as usize).max(8).min(128);
let points: Vec<egui::Pos2> = (0..=segments)
.map(|i| {
let t = i as f32 / segments as f32;
let angle = start_rad + (end_rad - start_rad) * t;
egui::pos2(
center.x + angle.cos() * r,
center.y + angle.sin() * r,
)
})
.collect();
painter.add(egui::Shape::line(
points,
egui::Stroke::new(*width, color_from_u32(*color)),
));
}
beamdsp::DrawCommand::Line { x1, y1, x2, y2, color, width } => {
painter.line_segment(
[
egui::pos2(rect.left() + x1, rect.top() + y1),
egui::pos2(rect.left() + x2, rect.top() + y2),
],
egui::Stroke::new(*width, color_from_u32(*color)),
);
}
beamdsp::DrawCommand::FillRect { x, y, w, h, color } => {
painter.rect_filled(
egui::Rect::from_min_size(
egui::pos2(rect.left() + x, rect.top() + y),
egui::vec2(*w, *h),
),
0.0, color_from_u32(*color),
);
}
beamdsp::DrawCommand::StrokeRect { x, y, w, h, color, width } => {
painter.rect_stroke(
egui::Rect::from_min_size(
egui::pos2(rect.left() + x, rect.top() + y),
egui::vec2(*w, *h),
),
0.0,
egui::Stroke::new(*width, color_from_u32(*color)),
egui::StrokeKind::Outside,
);
}
}
}
}
// Detect param changes from draw block (e.g. knob drag)
for (i, (&before, &after)) in params_before.iter().zip(draw_vm.params.iter()).enumerate() {
if (after - before).abs() > 1e-10 {
pending_param_changes.push((node_id, i as u32, after));
}
}
// Request repaint while interacting
if draw_vm.mouse.down || response.hovered() {
ui.ctx().request_repaint();
}
}
}
beamdsp::UiElement::Sample(slot_name) => { beamdsp::UiElement::Sample(slot_name) => {
// Find the slot index by name // Find the slot index by name
let slot_index = sample_slot_names.iter().position(|n| n == slot_name); let slot_index = sample_slot_names.iter().position(|n| n == slot_name);
@ -1454,14 +1598,15 @@ fn render_script_ui_elements(
ui, node_id, backend_node_id, ui, node_id, backend_node_id,
children, sample_slot_names, script_sample_names, children, sample_slot_names, script_sample_names,
available_clips, search_text, pending_load, available_clips, search_text, pending_load,
draw_vms, pending_param_changes,
); );
}); });
} }
beamdsp::UiElement::Spacer(height) => { beamdsp::UiElement::Spacer(height) => {
ui.add_space(*height); ui.add_space(*height);
} }
beamdsp::UiElement::Param(_) | beamdsp::UiElement::Canvas { .. } => { beamdsp::UiElement::Param(_) => {
// Params are handled as inline input ports; Canvas is phase 6 // Params are handled as inline input ports
} }
} }
} }

View File

@ -391,6 +391,12 @@ impl NodeGraphPane {
node.user_data.ui_declaration = Some(compiled.ui_declaration.clone()); node.user_data.ui_declaration = Some(compiled.ui_declaration.clone());
node.user_data.sample_slot_names = compiled.sample_slots.clone(); node.user_data.sample_slot_names = compiled.sample_slots.clone();
} }
// Store draw VM in GraphState (needs &mut, can't live on immutable NodeData)
if let Some(draw_vm) = &compiled.draw_vm {
self.user_state.draw_vms.insert(node_id, draw_vm.clone());
} else {
self.user_state.draw_vms.remove(&node_id);
}
} }
fn handle_graph_response( fn handle_graph_response(
@ -2695,6 +2701,33 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
} }
} }
// Handle param changes from draw block (canvas knob drag etc.)
if !self.user_state.pending_draw_param_changes.is_empty() {
let changes: Vec<_> = self.user_state.pending_draw_param_changes.drain(..).collect();
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, param_id, value) in changes {
// Send to backend
if let Some(backend_id) = self.node_id_map.get(&node_id) {
let BackendNodeId::Audio(node_idx) = backend_id;
controller.graph_set_parameter(backend_track_id, node_idx.index() as u32, param_id, value);
}
// Update frontend graph input port value
if let Some(node) = self.state.graph.nodes.get(node_id) {
for (_name, input_id) in &node.inputs {
if let ValueType::Float { value: ref mut v, backend_param_id: Some(pid), .. } = self.state.graph.inputs[*input_id].value {
if pid == param_id {
*v = value;
}
}
}
}
}
}
}
}
// Resolve Script nodes loaded from preset: find or create ScriptDefinitions // Resolve Script nodes loaded from preset: find or create ScriptDefinitions
// (ports were already rebuilt during load_graph_from_json, this just sets script_id) // (ports were already rebuilt during load_graph_from_json, this just sets script_id)
if !self.pending_script_resolutions.is_empty() { if !self.pending_script_resolutions.is_empty() {

View File

@ -105,7 +105,7 @@ fn beamdsp_syntax() -> Syntax {
"if", "else", "for", "in", "let", "mut", "if", "else", "for", "in", "let", "mut",
"generator", "effect", "utility", "generator", "effect", "utility",
"audio", "cv", "midi", "audio", "cv", "midi",
"param", "sample", "group", "canvas", "spacer", "param", "sample", "group", "canvas", "spacer", "draw",
]), ]),
types: std::collections::BTreeSet::from([ types: std::collections::BTreeSet::from([
"f32", "int", "bool", "f32", "int", "bool",
@ -119,6 +119,9 @@ fn beamdsp_syntax() -> Syntax {
"len", "cv_or", "float", "len", "cv_or", "float",
"sample_len", "sample_read", "sample_rate_of", "sample_len", "sample_read", "sample_rate_of",
"sample_rate", "buffer_size", "sample_rate", "buffer_size",
"fill_circle", "stroke_circle", "stroke_arc",
"fill_rect", "stroke_rect", "line",
"mouse_x", "mouse_y", "mouse_down",
]), ]),
} }
} }