From caba4305d85951545a800f9273f24aa61023df08 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 17 Dec 2025 07:38:10 -0500 Subject: [PATCH] Allow setting node cv inputs via slider, add preferences window --- daw-backend/src/audio/node_graph/graph.rs | 24 +- daw-backend/src/audio/node_graph/mod.rs | 2 +- .../src/audio/node_graph/node_trait.rs | 31 ++ .../src/audio/node_graph/nodes/adsr.rs | 11 +- .../src/audio/node_graph/nodes/filter.rs | 19 +- .../src/audio/node_graph/nodes/fm_synth.rs | 19 +- .../src/audio/node_graph/nodes/gain.rs | 12 +- .../src/audio/node_graph/nodes/oscillator.rs | 32 +- daw-backend/src/audio/node_graph/nodes/pan.rs | 18 +- .../audio/node_graph/nodes/simple_sampler.rs | 19 +- .../node_graph/nodes/wavetable_oscillator.rs | 13 +- .../lightningbeam-editor/src/config.rs | 138 ++++++ .../lightningbeam-editor/src/main.rs | 25 +- .../src/panes/node_graph/actions.rs | 3 + .../src/panes/node_graph/graph_data.rs | 2 +- .../src/panes/node_graph/mod.rs | 41 +- .../src/preferences/dialog.rs | 398 ++++++++++++++++++ .../src/preferences/mod.rs | 7 + .../lightningbeam-editor/src/theme.rs | 20 + 19 files changed, 738 insertions(+), 96 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/preferences/mod.rs diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs index c0ab8f4..9f2f0f6 100644 --- a/daw-backend/src/audio/node_graph/graph.rs +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -386,10 +386,19 @@ impl AudioGraph { let num_audio_cv_inputs = inputs.iter().filter(|p| p.signal_type != SignalType::Midi).count(); let num_midi_inputs = inputs.iter().filter(|p| p.signal_type == SignalType::Midi).count(); - // Clear audio/CV input buffers - for i in 0..num_audio_cv_inputs { - if i < self.input_buffers.len() { - self.input_buffers[i].fill(0.0); + // Clear input buffers + // - Audio inputs: fill with 0.0 (silence) when unconnected + // - CV inputs: fill with NaN to indicate "no connection" (allows nodes to use parameter values) + let mut audio_cv_idx = 0; + for port in inputs.iter().filter(|p| p.signal_type != SignalType::Midi) { + if audio_cv_idx < self.input_buffers.len() { + let fill_value = match port.signal_type { + SignalType::Audio => 0.0, // Silence for audio + SignalType::CV => f32::NAN, // Sentinel for CV + SignalType::Midi => unreachable!(), // Already filtered out + }; + self.input_buffers[audio_cv_idx].fill(fill_value); + audio_cv_idx += 1; } } @@ -419,7 +428,12 @@ impl AudioGraph { let source_buffer = &source_node.output_buffers[conn.from_port]; if conn.to_port < self.input_buffers.len() { for (dst, src) in self.input_buffers[conn.to_port].iter_mut().zip(source_buffer.iter()) { - *dst += src; + // If dst is NaN (unconnected), replace it; otherwise add (for mixing) + if dst.is_nan() { + *dst = *src; + } else { + *dst += src; + } } } } diff --git a/daw-backend/src/audio/node_graph/mod.rs b/daw-backend/src/audio/node_graph/mod.rs index 0afe8b0..6d81906 100644 --- a/daw-backend/src/audio/node_graph/mod.rs +++ b/daw-backend/src/audio/node_graph/mod.rs @@ -5,6 +5,6 @@ pub mod nodes; pub mod preset; pub use graph::{Connection, GraphNode, AudioGraph}; -pub use node_trait::AudioNode; +pub use node_trait::{AudioNode, cv_input_or_default}; pub use preset::{GraphPreset, PresetMetadata, SerializedConnection, SerializedNode}; pub use types::{ConnectionError, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; diff --git a/daw-backend/src/audio/node_graph/node_trait.rs b/daw-backend/src/audio/node_graph/node_trait.rs index f11ec1a..38eca19 100644 --- a/daw-backend/src/audio/node_graph/node_trait.rs +++ b/daw-backend/src/audio/node_graph/node_trait.rs @@ -77,3 +77,34 @@ pub trait AudioNode: Send { /// Downcast to `&dyn Any` for type-specific read-only operations fn as_any(&self) -> &dyn std::any::Any; } + +/// Helper function for CV inputs with optional connections +/// +/// Returns the input value if connected (not NaN), otherwise returns the default value. +/// This implements "Blender-style" input behavior where parameters are replaced by +/// connected inputs. +/// +/// # Arguments +/// * `inputs` - Input buffer array from process() +/// * `port` - Input port index +/// * `frame` - Current frame index +/// * `default` - Default value to use when input is unconnected +/// +/// # Returns +/// The input value if connected, otherwise the default value +#[inline] +pub fn cv_input_or_default(inputs: &[&[f32]], port: usize, frame: usize, default: f32) -> f32 { + if port < inputs.len() && frame < inputs[port].len() { + let value = inputs[port][frame]; + if value.is_nan() { + // Unconnected: use default parameter value + default + } else { + // Connected: use input signal + value + } + } else { + // No input buffer: use default + default + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/adsr.rs b/daw-backend/src/audio/node_graph/nodes/adsr.rs index c65de34..b594c1a 100644 --- a/daw-backend/src/audio/node_graph/nodes/adsr.rs +++ b/daw-backend/src/audio/node_graph/nodes/adsr.rs @@ -1,4 +1,4 @@ -use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; use crate::audio::midi::MidiEvent; const PARAM_ATTACK: u32 = 0; @@ -122,12 +122,9 @@ impl AudioNode for ADSRNode { let frames = output.len(); for frame in 0..frames { - // Read gate input (if available) - let gate_high = if !inputs.is_empty() && frame < inputs[0].len() { - inputs[0][frame] > 0.5 // Gate is high if CV > 0.5 - } else { - false - }; + // Gate input: when unconnected, defaults to 0.0 (off) + let gate_cv = cv_input_or_default(inputs, 0, frame, 0.0); + let gate_high = gate_cv > 0.5; // Detect gate transitions if gate_high && !self.gate_was_high { diff --git a/daw-backend/src/audio/node_graph/nodes/filter.rs b/daw-backend/src/audio/node_graph/nodes/filter.rs index 16e4cc0..d9409a5 100644 --- a/daw-backend/src/audio/node_graph/nodes/filter.rs +++ b/daw-backend/src/audio/node_graph/nodes/filter.rs @@ -1,4 +1,4 @@ -use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; use crate::audio::midi::MidiEvent; use crate::dsp::biquad::BiquadFilter; @@ -150,10 +150,19 @@ impl AudioNode for FilterNode { output[..len].copy_from_slice(&input[..len]); // Check for CV modulation (modulates cutoff) - if inputs.len() > 1 && !inputs[1].is_empty() { - // CV input modulates cutoff frequency - // For now, just use the base cutoff - per-sample modulation would be expensive - // TODO: Sample CV at frame rate and update filter periodically + // Sample CV at the start of the buffer - per-sample would be too expensive + let cutoff_cv = cv_input_or_default(inputs, 1, 0, self.cutoff); + if (cutoff_cv - self.cutoff).abs() > 0.01 { + // CV changed significantly, update filter + let new_cutoff = cutoff_cv.clamp(20.0, 20000.0); + match self.filter_type { + FilterType::Lowpass => { + self.filter.set_lowpass(new_cutoff, self.resonance, self.sample_rate as f32); + } + FilterType::Highpass => { + self.filter.set_highpass(new_cutoff, self.resonance, self.sample_rate as f32); + } + } } // Apply filter (processes stereo interleaved) diff --git a/daw-backend/src/audio/node_graph/nodes/fm_synth.rs b/daw-backend/src/audio/node_graph/nodes/fm_synth.rs index 7efda85..fe2ce47 100644 --- a/daw-backend/src/audio/node_graph/nodes/fm_synth.rs +++ b/daw-backend/src/audio/node_graph/nodes/fm_synth.rs @@ -1,4 +1,4 @@ -use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; use crate::audio::midi::MidiEvent; use std::f32::consts::PI; @@ -256,18 +256,11 @@ impl AudioNode for FMSynthNode { let frames = output.len() / 2; for frame in 0..frames { - // Read CV inputs - let voct = if inputs.len() > 0 && !inputs[0].is_empty() { - inputs[0][frame.min(inputs[0].len() / 2 - 1) * 2] - } else { - 0.0 - }; - - let gate = if inputs.len() > 1 && !inputs[1].is_empty() { - inputs[1][frame.min(inputs[1].len() / 2 - 1) * 2] - } else { - 0.0 - }; + // Read CV inputs (both are mono signals) + // V/Oct: when unconnected, defaults to 0.0 (A4 440 Hz) + let voct = cv_input_or_default(inputs, 0, frame, 0.0); + // Gate: when unconnected, defaults to 0.0 (off) + let gate = cv_input_or_default(inputs, 1, frame, 0.0); // Update state self.current_frequency = Self::voct_to_freq(voct); diff --git a/daw-backend/src/audio/node_graph/nodes/gain.rs b/daw-backend/src/audio/node_graph/nodes/gain.rs index f4c10bc..fc4a769 100644 --- a/daw-backend/src/audio/node_graph/nodes/gain.rs +++ b/daw-backend/src/audio/node_graph/nodes/gain.rs @@ -1,4 +1,4 @@ -use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; use crate::audio::midi::MidiEvent; const PARAM_GAIN: u32 = 0; @@ -90,15 +90,11 @@ impl AudioNode for GainNode { let frames = input.len().min(output.len()) / 2; for frame in 0..frames { - // Calculate final gain - let mut final_gain = self.gain; - // CV input acts as a VCA (voltage-controlled amplifier) // CV ranges from 0.0 (silence) to 1.0 (full gain parameter value) - if inputs.len() > 1 && frame < inputs[1].len() { - let cv = inputs[1][frame]; - final_gain *= cv; // Multiply gain by CV (0.0 = silence, 1.0 = full gain) - } + // When unconnected (NaN), defaults to 1.0 (no modulation, use gain parameter as-is) + let cv = cv_input_or_default(inputs, 1, frame, 1.0); + let final_gain = self.gain * cv; // Apply gain to both channels output[frame * 2] = input[frame * 2] * final_gain; // Left diff --git a/daw-backend/src/audio/node_graph/nodes/oscillator.rs b/daw-backend/src/audio/node_graph/nodes/oscillator.rs index cfbb376..243a7bf 100644 --- a/daw-backend/src/audio/node_graph/nodes/oscillator.rs +++ b/daw-backend/src/audio/node_graph/nodes/oscillator.rs @@ -1,4 +1,4 @@ -use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; use crate::audio::midi::MidiEvent; use std::f32::consts::PI; @@ -124,26 +124,28 @@ impl AudioNode for OscillatorNode { let frames = output.len() / 2; for frame in 0..frames { - // Start with base frequency - let mut frequency = self.frequency; - // V/Oct input: Standard V/Oct (0V = A4 440Hz, ±1V per octave) - if !inputs.is_empty() && frame < inputs[0].len() { - let voct = inputs[0][frame]; // Read V/Oct CV (mono) - // Convert V/Oct to frequency: f = 440 * 2^(voct) + // Port 0: V/Oct CV input + // If connected, interprets the CV signal as V/Oct (440 * 2^voct) + // If unconnected, uses self.frequency directly as Hz + let voct = cv_input_or_default(inputs, 0, frame, f32::NAN); + let base_frequency = if voct.is_nan() { + // Unconnected: use frequency parameter directly + self.frequency + } else { + // Connected: convert V/Oct to frequency // voct = 0.0 -> 440 Hz (A4) // voct = 1.0 -> 880 Hz (A5) // voct = -0.75 -> 261.6 Hz (C4, middle C) - frequency = 440.0 * 2.0_f32.powf(voct); - } + 440.0 * 2.0_f32.powf(voct) + }; // FM input: modulates the frequency - if inputs.len() > 1 && frame < inputs[1].len() { - let fm = inputs[1][frame]; // Read FM CV (mono) - frequency *= 1.0 + fm; - } - - let freq_mod = frequency; + // Port 1: FM CV input + // If connected, applies FM modulation (multiply by 1 + fm) + // If unconnected, no modulation (fm = 0.0) + let fm = cv_input_or_default(inputs, 1, frame, 0.0); + let freq_mod = base_frequency * (1.0 + fm); // Generate waveform sample based on waveform type let sample = match self.waveform { diff --git a/daw-backend/src/audio/node_graph/nodes/pan.rs b/daw-backend/src/audio/node_graph/nodes/pan.rs index b91d2d7..1786288 100644 --- a/daw-backend/src/audio/node_graph/nodes/pan.rs +++ b/daw-backend/src/audio/node_graph/nodes/pan.rs @@ -1,4 +1,4 @@ -use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; use crate::audio::midi::MidiEvent; use std::f32::consts::PI; @@ -113,18 +113,12 @@ impl AudioNode for PanNode { let frames_to_process = frames.min(output_frames); for frame in 0..frames_to_process { - // Get base pan position - let mut pan = self.pan; + // Pan CV input: when connected, replaces parameter; when unconnected, uses parameter + // CV is in 0-1 range, mapped to -1 to +1 pan range + let cv_raw = cv_input_or_default(inputs, 1, frame, (self.pan + 1.0) * 0.5); + let pan = (cv_raw * 2.0 - 1.0).clamp(-1.0, 1.0); - // Add CV modulation if connected - if inputs.len() > 1 && frame < inputs[1].len() { - let cv = inputs[1][frame]; // CV is mono - // CV is 0-1, map to -1 to +1 range - pan += cv * 2.0 - 1.0; - pan = pan.clamp(-1.0, 1.0); - } - - // Update gains if pan changed from CV + // Calculate gains using constant-power panning law let angle = (pan + 1.0) * 0.5 * PI / 2.0; let left_gain = angle.cos(); let right_gain = angle.sin(); diff --git a/daw-backend/src/audio/node_graph/nodes/simple_sampler.rs b/daw-backend/src/audio/node_graph/nodes/simple_sampler.rs index 33d9f6c..d958c84 100644 --- a/daw-backend/src/audio/node_graph/nodes/simple_sampler.rs +++ b/daw-backend/src/audio/node_graph/nodes/simple_sampler.rs @@ -1,4 +1,4 @@ -use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; use crate::audio::midi::MidiEvent; use std::sync::{Arc, Mutex}; @@ -202,18 +202,11 @@ impl AudioNode for SimpleSamplerNode { let frames = output.len() / 2; for frame in 0..frames { - // Read CV inputs - let voct = if !inputs.is_empty() && !inputs[0].is_empty() { - inputs[0][frame.min(inputs[0].len() / 2 - 1) * 2] - } else { - 0.0 // Default to original pitch - }; - - let gate = if inputs.len() > 1 && !inputs[1].is_empty() { - inputs[1][frame.min(inputs[1].len() / 2 - 1) * 2] - } else { - 0.0 - }; + // Read CV inputs (both are mono signals) + // V/Oct: when unconnected, defaults to 0.0 (original pitch) + let voct = cv_input_or_default(inputs, 0, frame, 0.0); + // Gate: when unconnected, defaults to 0.0 (off) + let gate = cv_input_or_default(inputs, 1, frame, 0.0); // Detect gate trigger (rising edge) let gate_active = gate > 0.5; diff --git a/daw-backend/src/audio/node_graph/nodes/wavetable_oscillator.rs b/daw-backend/src/audio/node_graph/nodes/wavetable_oscillator.rs index c8a93ee..180ba62 100644 --- a/daw-backend/src/audio/node_graph/nodes/wavetable_oscillator.rs +++ b/daw-backend/src/audio/node_graph/nodes/wavetable_oscillator.rs @@ -1,4 +1,4 @@ -use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; use crate::audio::midi::MidiEvent; use std::f32::consts::PI; @@ -243,14 +243,11 @@ impl AudioNode for WavetableOscillatorNode { let frames = output.len() / 2; for frame in 0..frames { - // Read V/Oct input - let voct = if !inputs.is_empty() && !inputs[0].is_empty() { - inputs[0][frame.min(inputs[0].len() / 2 - 1) * 2] - } else { - 0.0 // Default to A4 (440 Hz) - }; + // V/Oct input: when unconnected, defaults to 0.0 (A4 440 Hz) + // CV signals are mono, so read from frame index directly + let voct = cv_input_or_default(inputs, 0, frame, 0.0); - // Calculate frequency + // Calculate frequency from V/Oct let freq = self.voct_to_freq(voct); // Read from wavetable diff --git a/lightningbeam-ui/lightningbeam-editor/src/config.rs b/lightningbeam-ui/lightningbeam-editor/src/config.rs index 0a5f0a8..72618bb 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/config.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/config.rs @@ -7,12 +7,63 @@ pub struct AppConfig { /// Recent files list (newest first, max 10 items) #[serde(default)] pub recent_files: Vec, + + // User Preferences + /// Default BPM for new projects + #[serde(default = "defaults::bpm")] + pub bpm: u32, + + /// Default framerate for new projects + #[serde(default = "defaults::framerate")] + pub framerate: u32, + + /// Default file width in pixels + #[serde(default = "defaults::file_width")] + pub file_width: u32, + + /// Default file height in pixels + #[serde(default = "defaults::file_height")] + pub file_height: u32, + + /// Scroll speed multiplier + #[serde(default = "defaults::scroll_speed")] + pub scroll_speed: f64, + + /// Audio buffer size in samples (128, 256, 512, 1024, 2048, 4096) + #[serde(default = "defaults::audio_buffer_size")] + pub audio_buffer_size: u32, + + /// Reopen last session on startup + #[serde(default = "defaults::reopen_last_session")] + pub reopen_last_session: bool, + + /// Restore layout when opening files + #[serde(default = "defaults::restore_layout_from_file")] + pub restore_layout_from_file: bool, + + /// Enable debug mode + #[serde(default = "defaults::debug")] + pub debug: bool, + + /// Theme mode ("light", "dark", or "system") + #[serde(default = "defaults::theme_mode")] + pub theme_mode: String, } impl Default for AppConfig { fn default() -> Self { Self { recent_files: Vec::new(), + bpm: defaults::bpm(), + framerate: defaults::framerate(), + file_width: defaults::file_width(), + file_height: defaults::file_height(), + scroll_speed: defaults::scroll_speed(), + audio_buffer_size: defaults::audio_buffer_size(), + reopen_last_session: defaults::reopen_last_session(), + restore_layout_from_file: defaults::restore_layout_from_file(), + debug: defaults::debug(), + theme_mode: defaults::theme_mode(), } } } @@ -126,4 +177,91 @@ impl AppConfig { self.recent_files.clear(); self.save(); } + + /// Validate BPM range (20-300) + pub fn validate_bpm(&self) -> Result<(), String> { + if self.bpm >= 20 && self.bpm <= 300 { + Ok(()) + } else { + Err(format!("BPM must be between 20 and 300 (got {})", self.bpm)) + } + } + + /// Validate framerate range (1-120) + pub fn validate_framerate(&self) -> Result<(), String> { + if self.framerate >= 1 && self.framerate <= 120 { + Ok(()) + } else { + Err(format!("Framerate must be between 1 and 120 (got {})", self.framerate)) + } + } + + /// Validate file width range (100-10000) + pub fn validate_file_width(&self) -> Result<(), String> { + if self.file_width >= 100 && self.file_width <= 10000 { + Ok(()) + } else { + Err(format!("File width must be between 100 and 10000 (got {})", self.file_width)) + } + } + + /// Validate file height range (100-10000) + pub fn validate_file_height(&self) -> Result<(), String> { + if self.file_height >= 100 && self.file_height <= 10000 { + Ok(()) + } else { + Err(format!("File height must be between 100 and 10000 (got {})", self.file_height)) + } + } + + /// Validate scroll speed range (0.1-10.0) + pub fn validate_scroll_speed(&self) -> Result<(), String> { + if self.scroll_speed >= 0.1 && self.scroll_speed <= 10.0 { + Ok(()) + } else { + Err(format!("Scroll speed must be between 0.1 and 10.0 (got {})", self.scroll_speed)) + } + } + + /// Validate audio buffer size (must be 128, 256, 512, 1024, 2048, or 4096) + pub fn validate_audio_buffer_size(&self) -> Result<(), String> { + match self.audio_buffer_size { + 128 | 256 | 512 | 1024 | 2048 | 4096 => Ok(()), + _ => Err(format!("Audio buffer size must be 128, 256, 512, 1024, 2048, or 4096 (got {})", self.audio_buffer_size)) + } + } + + /// Validate theme mode (must be "light", "dark", or "system") + pub fn validate_theme_mode(&self) -> Result<(), String> { + match self.theme_mode.to_lowercase().as_str() { + "light" | "dark" | "system" => Ok(()), + _ => Err(format!("Theme mode must be 'light', 'dark', or 'system' (got '{}')", self.theme_mode)) + } + } + + /// Validate all preferences + pub fn validate(&self) -> Result<(), String> { + self.validate_bpm()?; + self.validate_framerate()?; + self.validate_file_width()?; + self.validate_file_height()?; + self.validate_scroll_speed()?; + self.validate_audio_buffer_size()?; + self.validate_theme_mode()?; + Ok(()) + } +} + +/// Default values for preferences (matches JS implementation) +mod defaults { + pub fn bpm() -> u32 { 120 } + pub fn framerate() -> u32 { 24 } + pub fn file_width() -> u32 { 800 } + pub fn file_height() -> u32 { 600 } + pub fn scroll_speed() -> f64 { 1.0 } + pub fn audio_buffer_size() -> u32 { 256 } + pub fn reopen_last_session() -> bool { false } + pub fn restore_layout_from_file() -> bool { true } + pub fn debug() -> bool { false } + pub fn theme_mode() -> String { "system".to_string() } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 4d65c1d..3bd39ae 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -28,6 +28,8 @@ mod default_instrument; mod export; +mod preferences; + mod notifications; mod effect_thumbnails; @@ -55,13 +57,17 @@ fn main() -> eframe::Result { // Parse command line arguments let args = Args::parse(); - // Determine theme mode from arguments + // Load config to get theme preference + let config = AppConfig::load(); + + // Determine theme mode: command-line args override config let theme_mode = if args.light { ThemeMode::Light } else if args.dark { ThemeMode::Dark } else { - ThemeMode::System + // Use theme from config + ThemeMode::from_string(&config.theme_mode) }; // Load theme @@ -591,6 +597,8 @@ struct EditorApp { export_dialog: export::dialog::ExportDialog, /// Export progress dialog export_progress_dialog: export::dialog::ExportProgressDialog, + /// Preferences dialog + preferences_dialog: preferences::dialog::PreferencesDialog, /// Export orchestrator for background exports export_orchestrator: Option, /// GPU-rendered effect thumbnail generator @@ -656,7 +664,7 @@ impl EditorApp { // Initialize audio system and destructure it for sharing let (audio_stream, audio_controller, audio_event_rx, audio_sample_rate, audio_channels, file_command_tx) = - match daw_backend::AudioSystem::new(None, 256) { + match daw_backend::AudioSystem::new(None, config.audio_buffer_size) { Ok(audio_system) => { println!("✅ Audio engine initialized successfully"); @@ -751,6 +759,7 @@ impl EditorApp { audio_extraction_rx, export_dialog: export::dialog::ExportDialog::default(), export_progress_dialog: export::dialog::ExportProgressDialog::default(), + preferences_dialog: preferences::dialog::PreferencesDialog::default(), export_orchestrator: None, effect_thumbnail_generator: None, // Initialized when GPU available @@ -1386,8 +1395,7 @@ impl EditorApp { // TODO: Implement select none } MenuAction::Preferences => { - println!("Menu: Preferences"); - // TODO: Implement preferences dialog + self.preferences_dialog.open(&self.config, &self.theme); } // Modify menu @@ -2900,6 +2908,13 @@ impl eframe::App for EditorApp { ctx.request_repaint(); } + // Render preferences dialog + if let Some(result) = self.preferences_dialog.render(ctx, &mut self.config, &mut self.theme) { + if result.buffer_size_changed { + println!("⚠️ Audio buffer size will be applied on next app restart"); + } + } + // Render video frames incrementally (if video export in progress) if let Some(orchestrator) = &mut self.export_orchestrator { if orchestrator.is_exporting() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/actions.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/actions.rs index cc816bc..0963a12 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/actions.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/actions.rs @@ -511,6 +511,9 @@ impl SetParameterAction { let BackendNodeId::Audio(node_idx) = self.backend_node_id; + eprintln!("[DEBUG] Setting parameter: track {} node {} param {} = {}", + track_id, node_idx.index(), self.param_id, self.new_value); + controller.graph_set_parameter( *track_id, node_idx.index() as u32, 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 05da246..29e1549 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 @@ -223,7 +223,7 @@ impl NodeTemplateTrait for NodeTemplate { "V/Oct".into(), DataType::CV, ValueType::Float { value: 0.0 }, - InputParamKind::ConnectionOnly, + InputParamKind::ConnectionOrConstant, true, ); // FM input (frequency modulation) 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 469c986..c5c364d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -373,6 +373,11 @@ impl NodeGraphPane { } } + // Execute any pending action created during response handling + self.execute_pending_action(shared); + } + + fn execute_pending_action(&mut self, shared: &mut crate::panes::SharedPaneState) { // Execute pending action if any if let Some(action) = self.pending_action.take() { // Node graph actions need to update the backend, so use execute_with_backend @@ -423,21 +428,41 @@ impl NodeGraphPane { fn check_parameter_changes(&mut self) { // Check all input parameters for value changes + let mut checked_count = 0; + let mut connection_only_count = 0; + let mut non_float_count = 0; + for (input_id, input_param) in &self.state.graph.inputs { // Only check parameters that can have constant values (not ConnectionOnly) if matches!(input_param.kind, InputParamKind::ConnectionOnly) { + connection_only_count += 1; continue; } // Get current value let current_value = match &input_param.value { - ValueType::Float { value } => *value, - _ => continue, // Skip non-float values for now + ValueType::Float { value } => { + checked_count += 1; + *value + }, + other => { + non_float_count += 1; + eprintln!("[DEBUG] Non-float parameter type: {:?}", std::mem::discriminant(other)); + continue; + } }; // Check if value has changed let previous_value = self.parameter_values.get(&input_id).copied(); - if previous_value.is_none() || (previous_value.unwrap() - current_value).abs() > 0.0001 { + let has_changed = if let Some(prev) = previous_value { + (prev - current_value).abs() > 0.0001 + } else { + // First time seeing this parameter - don't send update, just store it + self.parameter_values.insert(input_id, current_value); + false + }; + + if has_changed { // Value has changed, create SetParameterAction if let Some(track_id) = self.track_id { let node_id = input_param.node; @@ -447,6 +472,8 @@ impl NodeGraphPane { // Get parameter index (position in node's inputs array) if let Some(node) = self.state.graph.nodes.get(node_id) { if let Some(param_index) = node.inputs.iter().position(|(_, id)| *id == input_id) { + eprintln!("[DEBUG] Parameter changed: node {:?} param {} from {:?} to {}", + backend_id, param_index, previous_value, current_value); // Create action to update backend let action = Box::new(actions::NodeGraphAction::SetParameter( actions::SetParameterAction::new( @@ -466,6 +493,11 @@ impl NodeGraphPane { self.parameter_values.insert(input_id, current_value); } } + + if checked_count > 0 || connection_only_count > 0 || non_float_count > 0 { + eprintln!("[DEBUG] Parameter check: {} float params checked, {} connection-only, {} non-float", + checked_count, connection_only_count, non_float_count); + } } fn draw_dot_grid_background( @@ -644,6 +676,9 @@ impl crate::panes::PaneRenderer for NodeGraphPane { // Check for parameter value changes and send updates to backend self.check_parameter_changes(); + // Execute any parameter change actions + self.execute_pending_action(shared); + // Override library's default scroll behavior: // - Library uses scroll for zoom // - We want: scroll = pan, ctrl+scroll = zoom diff --git a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs new file mode 100644 index 0000000..3769593 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs @@ -0,0 +1,398 @@ +//! Preferences dialog UI +//! +//! Provides a user interface for configuring application preferences + +use eframe::egui; +use crate::config::AppConfig; +use crate::theme::{Theme, ThemeMode}; + +/// Preferences dialog state +pub struct PreferencesDialog { + /// Is the dialog open? + pub open: bool, + + /// Working copy of preferences (allows cancel to discard changes) + working_prefs: PreferencesState, + + /// Original audio buffer size (to detect changes that need restart) + original_buffer_size: u32, + + /// Error message (if validation fails) + error_message: Option, +} + +/// Editable preferences state (working copy) +#[derive(Debug, Clone)] +struct PreferencesState { + bpm: u32, + framerate: u32, + file_width: u32, + file_height: u32, + scroll_speed: f64, + audio_buffer_size: u32, + reopen_last_session: bool, + restore_layout_from_file: bool, + debug: bool, + theme_mode: ThemeMode, +} + +impl From<(&AppConfig, &Theme)> for PreferencesState { + fn from((config, theme): (&AppConfig, &Theme)) -> Self { + Self { + bpm: config.bpm, + framerate: config.framerate, + file_width: config.file_width, + file_height: config.file_height, + scroll_speed: config.scroll_speed, + audio_buffer_size: config.audio_buffer_size, + reopen_last_session: config.reopen_last_session, + restore_layout_from_file: config.restore_layout_from_file, + debug: config.debug, + theme_mode: theme.mode(), + } + } +} + +impl Default for PreferencesState { + fn default() -> Self { + Self { + bpm: 120, + framerate: 24, + file_width: 800, + file_height: 600, + scroll_speed: 1.0, + audio_buffer_size: 256, + reopen_last_session: false, + restore_layout_from_file: true, + debug: false, + theme_mode: ThemeMode::System, + } + } +} + +/// Result returned when preferences are saved +#[derive(Debug, Clone)] +pub struct PreferencesSaveResult { + /// Whether audio buffer size changed (requires restart) + pub buffer_size_changed: bool, +} + +impl Default for PreferencesDialog { + fn default() -> Self { + Self { + open: false, + working_prefs: PreferencesState::default(), + original_buffer_size: 256, + error_message: None, + } + } +} + +impl PreferencesDialog { + /// Open the dialog with current config and theme + pub fn open(&mut self, config: &AppConfig, theme: &Theme) { + self.open = true; + self.working_prefs = PreferencesState::from((config, theme)); + self.original_buffer_size = config.audio_buffer_size; + self.error_message = None; + } + + /// Close the dialog + pub fn close(&mut self) { + self.open = false; + self.error_message = None; + } + + /// Render the preferences dialog + /// + /// Returns Some(PreferencesSaveResult) if user clicked Save, None otherwise. + pub fn render( + &mut self, + ctx: &egui::Context, + config: &mut AppConfig, + theme: &mut Theme, + ) -> Option { + if !self.open { + return None; + } + + let mut should_save = false; + let mut should_cancel = false; + let mut open = self.open; + + egui::Window::new("Preferences") + .open(&mut open) + .resizable(false) + .collapsible(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(ctx, |ui| { + ui.set_width(500.0); + + // Error message + if let Some(error) = &self.error_message { + ui.colored_label(egui::Color32::from_rgb(255, 100, 100), error); + ui.add_space(8.0); + } + + // Scrollable area for preferences sections + egui::ScrollArea::vertical() + .max_height(400.0) + .show(ui, |ui| { + self.render_general_section(ui); + ui.add_space(8.0); + self.render_audio_section(ui); + ui.add_space(8.0); + self.render_appearance_section(ui); + ui.add_space(8.0); + self.render_startup_section(ui); + ui.add_space(8.0); + self.render_advanced_section(ui); + }); + + ui.add_space(16.0); + + // Buttons + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + should_cancel = true; + } + + if ui.button("Reset to Defaults").clicked() { + self.reset_to_defaults(); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Save").clicked() { + should_save = true; + } + }); + }); + }); + + // Update open state + self.open = open; + + if should_cancel { + self.close(); + return None; + } + + if should_save { + return self.handle_save(config, theme); + } + + None + } + + fn render_general_section(&mut self, ui: &mut egui::Ui) { + egui::CollapsingHeader::new("General") + .default_open(true) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Default BPM:"); + ui.add( + egui::DragValue::new(&mut self.working_prefs.bpm) + .clamp_range(20..=300) + .speed(1.0), + ); + }); + + ui.horizontal(|ui| { + ui.label("Default Framerate:"); + ui.add( + egui::DragValue::new(&mut self.working_prefs.framerate) + .clamp_range(1..=120) + .speed(1.0) + .suffix(" fps"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Default File Width:"); + ui.add( + egui::DragValue::new(&mut self.working_prefs.file_width) + .clamp_range(100..=10000) + .speed(10.0) + .suffix(" px"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Default File Height:"); + ui.add( + egui::DragValue::new(&mut self.working_prefs.file_height) + .clamp_range(100..=10000) + .speed(10.0) + .suffix(" px"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Scroll Speed:"); + ui.add( + egui::DragValue::new(&mut self.working_prefs.scroll_speed) + .clamp_range(0.1..=10.0) + .speed(0.1), + ); + }); + }); + } + + fn render_audio_section(&mut self, ui: &mut egui::Ui) { + egui::CollapsingHeader::new("Audio") + .default_open(true) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Audio Buffer Size:"); + + egui::ComboBox::from_id_source("audio_buffer_size") + .selected_text(format!("{} samples", self.working_prefs.audio_buffer_size)) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.working_prefs.audio_buffer_size, + 128, + "128 samples (~3ms - Low latency)", + ); + ui.selectable_value( + &mut self.working_prefs.audio_buffer_size, + 256, + "256 samples (~6ms - Balanced)", + ); + ui.selectable_value( + &mut self.working_prefs.audio_buffer_size, + 512, + "512 samples (~12ms - Stable)", + ); + ui.selectable_value( + &mut self.working_prefs.audio_buffer_size, + 1024, + "1024 samples (~23ms - Very stable)", + ); + ui.selectable_value( + &mut self.working_prefs.audio_buffer_size, + 2048, + "2048 samples (~46ms - Low-end systems)", + ); + ui.selectable_value( + &mut self.working_prefs.audio_buffer_size, + 4096, + "4096 samples (~93ms - Very low-end systems)", + ); + }); + }); + + ui.label("⚠ Requires app restart to take effect"); + }); + } + + fn render_appearance_section(&mut self, ui: &mut egui::Ui) { + egui::CollapsingHeader::new("Appearance") + .default_open(true) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Theme:"); + + egui::ComboBox::from_id_source("theme_mode") + .selected_text(format!("{:?}", self.working_prefs.theme_mode)) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.working_prefs.theme_mode, + ThemeMode::Light, + "Light", + ); + ui.selectable_value( + &mut self.working_prefs.theme_mode, + ThemeMode::Dark, + "Dark", + ); + ui.selectable_value( + &mut self.working_prefs.theme_mode, + ThemeMode::System, + "System", + ); + }); + }); + }); + } + + fn render_startup_section(&mut self, ui: &mut egui::Ui) { + egui::CollapsingHeader::new("Startup") + .default_open(false) + .show(ui, |ui| { + ui.checkbox( + &mut self.working_prefs.reopen_last_session, + "Reopen last session on startup", + ); + ui.checkbox( + &mut self.working_prefs.restore_layout_from_file, + "Restore layout when opening files", + ); + }); + } + + fn render_advanced_section(&mut self, ui: &mut egui::Ui) { + egui::CollapsingHeader::new("Advanced") + .default_open(false) + .show(ui, |ui| { + ui.checkbox(&mut self.working_prefs.debug, "Enable debug mode"); + }); + } + + fn reset_to_defaults(&mut self) { + self.working_prefs = PreferencesState::default(); + self.error_message = None; + } + + fn handle_save( + &mut self, + config: &mut AppConfig, + theme: &mut Theme, + ) -> Option { + // Create temp config for validation + let mut temp_config = config.clone(); + temp_config.bpm = self.working_prefs.bpm; + temp_config.framerate = self.working_prefs.framerate; + temp_config.file_width = self.working_prefs.file_width; + temp_config.file_height = self.working_prefs.file_height; + temp_config.scroll_speed = self.working_prefs.scroll_speed; + temp_config.audio_buffer_size = self.working_prefs.audio_buffer_size; + temp_config.reopen_last_session = self.working_prefs.reopen_last_session; + temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; + temp_config.debug = self.working_prefs.debug; + temp_config.theme_mode = self.working_prefs.theme_mode.to_string_lower(); + + // Validate + if let Err(err) = temp_config.validate() { + self.error_message = Some(err); + return None; + } + + // Check if buffer size changed + let buffer_size_changed = self.working_prefs.audio_buffer_size != self.original_buffer_size; + + // Apply changes to config + config.bpm = self.working_prefs.bpm; + config.framerate = self.working_prefs.framerate; + config.file_width = self.working_prefs.file_width; + config.file_height = self.working_prefs.file_height; + config.scroll_speed = self.working_prefs.scroll_speed; + config.audio_buffer_size = self.working_prefs.audio_buffer_size; + config.reopen_last_session = self.working_prefs.reopen_last_session; + config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; + config.debug = self.working_prefs.debug; + config.theme_mode = self.working_prefs.theme_mode.to_string_lower(); + + // Apply theme immediately + theme.set_mode(self.working_prefs.theme_mode); + + // Save to disk + config.save(); + + // Close dialog + self.close(); + + Some(PreferencesSaveResult { + buffer_size_changed, + }) + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/preferences/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/preferences/mod.rs new file mode 100644 index 0000000..3f83e9b --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/preferences/mod.rs @@ -0,0 +1,7 @@ +//! Preferences module +//! +//! User preferences dialog and settings management + +pub mod dialog; + +pub use dialog::{PreferencesDialog, PreferencesSaveResult}; diff --git a/lightningbeam-ui/lightningbeam-editor/src/theme.rs b/lightningbeam-ui/lightningbeam-editor/src/theme.rs index 1aa8bd1..6e96b7a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/theme.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/theme.rs @@ -15,6 +15,26 @@ pub enum ThemeMode { System, // Follow system preference } +impl ThemeMode { + /// Convert from string ("light", "dark", or "system") + pub fn from_string(s: &str) -> Self { + match s.to_lowercase().as_str() { + "light" => Self::Light, + "dark" => Self::Dark, + _ => Self::System, + } + } + + /// Convert to lowercase string + pub fn to_string_lower(&self) -> String { + match self { + Self::Light => "light".to_string(), + Self::Dark => "dark".to_string(), + Self::System => "system".to_string(), + } + } +} + /// Style properties that can be applied to UI elements #[derive(Debug, Clone, Default)] pub struct Style {