Allow setting node cv inputs via slider, add preferences window

This commit is contained in:
Skyler Lehmkuhl 2025-12-17 07:38:10 -05:00
parent 88dc60f036
commit caba4305d8
19 changed files with 738 additions and 96 deletions

View File

@ -386,10 +386,19 @@ impl AudioGraph {
let num_audio_cv_inputs = inputs.iter().filter(|p| p.signal_type != SignalType::Midi).count(); 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(); let num_midi_inputs = inputs.iter().filter(|p| p.signal_type == SignalType::Midi).count();
// Clear audio/CV input buffers // Clear input buffers
for i in 0..num_audio_cv_inputs { // - Audio inputs: fill with 0.0 (silence) when unconnected
if i < self.input_buffers.len() { // - CV inputs: fill with NaN to indicate "no connection" (allows nodes to use parameter values)
self.input_buffers[i].fill(0.0); 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]; let source_buffer = &source_node.output_buffers[conn.from_port];
if conn.to_port < self.input_buffers.len() { if conn.to_port < self.input_buffers.len() {
for (dst, src) in self.input_buffers[conn.to_port].iter_mut().zip(source_buffer.iter()) { 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;
}
} }
} }
} }

View File

@ -5,6 +5,6 @@ pub mod nodes;
pub mod preset; pub mod preset;
pub use graph::{Connection, GraphNode, AudioGraph}; 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 preset::{GraphPreset, PresetMetadata, SerializedConnection, SerializedNode};
pub use types::{ConnectionError, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; pub use types::{ConnectionError, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};

View File

@ -77,3 +77,34 @@ pub trait AudioNode: Send {
/// Downcast to `&dyn Any` for type-specific read-only operations /// Downcast to `&dyn Any` for type-specific read-only operations
fn as_any(&self) -> &dyn std::any::Any; 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
}
}

View File

@ -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::audio::midi::MidiEvent;
const PARAM_ATTACK: u32 = 0; const PARAM_ATTACK: u32 = 0;
@ -122,12 +122,9 @@ impl AudioNode for ADSRNode {
let frames = output.len(); let frames = output.len();
for frame in 0..frames { for frame in 0..frames {
// Read gate input (if available) // Gate input: when unconnected, defaults to 0.0 (off)
let gate_high = if !inputs.is_empty() && frame < inputs[0].len() { let gate_cv = cv_input_or_default(inputs, 0, frame, 0.0);
inputs[0][frame] > 0.5 // Gate is high if CV > 0.5 let gate_high = gate_cv > 0.5;
} else {
false
};
// Detect gate transitions // Detect gate transitions
if gate_high && !self.gate_was_high { if gate_high && !self.gate_was_high {

View File

@ -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::audio::midi::MidiEvent;
use crate::dsp::biquad::BiquadFilter; use crate::dsp::biquad::BiquadFilter;
@ -150,10 +150,19 @@ impl AudioNode for FilterNode {
output[..len].copy_from_slice(&input[..len]); output[..len].copy_from_slice(&input[..len]);
// Check for CV modulation (modulates cutoff) // Check for CV modulation (modulates cutoff)
if inputs.len() > 1 && !inputs[1].is_empty() { // Sample CV at the start of the buffer - per-sample would be too expensive
// CV input modulates cutoff frequency let cutoff_cv = cv_input_or_default(inputs, 1, 0, self.cutoff);
// For now, just use the base cutoff - per-sample modulation would be expensive if (cutoff_cv - self.cutoff).abs() > 0.01 {
// TODO: Sample CV at frame rate and update filter periodically // 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) // Apply filter (processes stereo interleaved)

View File

@ -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::audio::midi::MidiEvent;
use std::f32::consts::PI; use std::f32::consts::PI;
@ -256,18 +256,11 @@ impl AudioNode for FMSynthNode {
let frames = output.len() / 2; let frames = output.len() / 2;
for frame in 0..frames { for frame in 0..frames {
// Read CV inputs // Read CV inputs (both are mono signals)
let voct = if inputs.len() > 0 && !inputs[0].is_empty() { // V/Oct: when unconnected, defaults to 0.0 (A4 440 Hz)
inputs[0][frame.min(inputs[0].len() / 2 - 1) * 2] let voct = cv_input_or_default(inputs, 0, frame, 0.0);
} else { // Gate: when unconnected, defaults to 0.0 (off)
0.0 let gate = cv_input_or_default(inputs, 1, frame, 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
};
// Update state // Update state
self.current_frequency = Self::voct_to_freq(voct); self.current_frequency = Self::voct_to_freq(voct);

View File

@ -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::audio::midi::MidiEvent;
const PARAM_GAIN: u32 = 0; const PARAM_GAIN: u32 = 0;
@ -90,15 +90,11 @@ impl AudioNode for GainNode {
let frames = input.len().min(output.len()) / 2; let frames = input.len().min(output.len()) / 2;
for frame in 0..frames { for frame in 0..frames {
// Calculate final gain
let mut final_gain = self.gain;
// CV input acts as a VCA (voltage-controlled amplifier) // CV input acts as a VCA (voltage-controlled amplifier)
// CV ranges from 0.0 (silence) to 1.0 (full gain parameter value) // CV ranges from 0.0 (silence) to 1.0 (full gain parameter value)
if inputs.len() > 1 && frame < inputs[1].len() { // When unconnected (NaN), defaults to 1.0 (no modulation, use gain parameter as-is)
let cv = inputs[1][frame]; let cv = cv_input_or_default(inputs, 1, frame, 1.0);
final_gain *= cv; // Multiply gain by CV (0.0 = silence, 1.0 = full gain) let final_gain = self.gain * cv;
}
// Apply gain to both channels // Apply gain to both channels
output[frame * 2] = input[frame * 2] * final_gain; // Left output[frame * 2] = input[frame * 2] * final_gain; // Left

View File

@ -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::audio::midi::MidiEvent;
use std::f32::consts::PI; use std::f32::consts::PI;
@ -124,26 +124,28 @@ impl AudioNode for OscillatorNode {
let frames = output.len() / 2; let frames = output.len() / 2;
for frame in 0..frames { 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) // V/Oct input: Standard V/Oct (0V = A4 440Hz, ±1V per octave)
if !inputs.is_empty() && frame < inputs[0].len() { // Port 0: V/Oct CV input
let voct = inputs[0][frame]; // Read V/Oct CV (mono) // If connected, interprets the CV signal as V/Oct (440 * 2^voct)
// Convert V/Oct to frequency: f = 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 = 0.0 -> 440 Hz (A4)
// voct = 1.0 -> 880 Hz (A5) // voct = 1.0 -> 880 Hz (A5)
// voct = -0.75 -> 261.6 Hz (C4, middle C) // 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 // FM input: modulates the frequency
if inputs.len() > 1 && frame < inputs[1].len() { // Port 1: FM CV input
let fm = inputs[1][frame]; // Read FM CV (mono) // If connected, applies FM modulation (multiply by 1 + fm)
frequency *= 1.0 + 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);
let freq_mod = frequency;
// Generate waveform sample based on waveform type // Generate waveform sample based on waveform type
let sample = match self.waveform { let sample = match self.waveform {

View File

@ -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::audio::midi::MidiEvent;
use std::f32::consts::PI; use std::f32::consts::PI;
@ -113,18 +113,12 @@ impl AudioNode for PanNode {
let frames_to_process = frames.min(output_frames); let frames_to_process = frames.min(output_frames);
for frame in 0..frames_to_process { for frame in 0..frames_to_process {
// Get base pan position // Pan CV input: when connected, replaces parameter; when unconnected, uses parameter
let mut pan = self.pan; // 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 // Calculate gains using constant-power panning law
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
let angle = (pan + 1.0) * 0.5 * PI / 2.0; let angle = (pan + 1.0) * 0.5 * PI / 2.0;
let left_gain = angle.cos(); let left_gain = angle.cos();
let right_gain = angle.sin(); let right_gain = angle.sin();

View File

@ -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::audio::midi::MidiEvent;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -202,18 +202,11 @@ impl AudioNode for SimpleSamplerNode {
let frames = output.len() / 2; let frames = output.len() / 2;
for frame in 0..frames { for frame in 0..frames {
// Read CV inputs // Read CV inputs (both are mono signals)
let voct = if !inputs.is_empty() && !inputs[0].is_empty() { // V/Oct: when unconnected, defaults to 0.0 (original pitch)
inputs[0][frame.min(inputs[0].len() / 2 - 1) * 2] let voct = cv_input_or_default(inputs, 0, frame, 0.0);
} else { // Gate: when unconnected, defaults to 0.0 (off)
0.0 // Default to original pitch let gate = cv_input_or_default(inputs, 1, frame, 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
};
// Detect gate trigger (rising edge) // Detect gate trigger (rising edge)
let gate_active = gate > 0.5; let gate_active = gate > 0.5;

View File

@ -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::audio::midi::MidiEvent;
use std::f32::consts::PI; use std::f32::consts::PI;
@ -243,14 +243,11 @@ impl AudioNode for WavetableOscillatorNode {
let frames = output.len() / 2; let frames = output.len() / 2;
for frame in 0..frames { for frame in 0..frames {
// Read V/Oct input // V/Oct input: when unconnected, defaults to 0.0 (A4 440 Hz)
let voct = if !inputs.is_empty() && !inputs[0].is_empty() { // CV signals are mono, so read from frame index directly
inputs[0][frame.min(inputs[0].len() / 2 - 1) * 2] let voct = cv_input_or_default(inputs, 0, frame, 0.0);
} else {
0.0 // Default to A4 (440 Hz)
};
// Calculate frequency // Calculate frequency from V/Oct
let freq = self.voct_to_freq(voct); let freq = self.voct_to_freq(voct);
// Read from wavetable // Read from wavetable

View File

@ -7,12 +7,63 @@ pub struct AppConfig {
/// Recent files list (newest first, max 10 items) /// Recent files list (newest first, max 10 items)
#[serde(default)] #[serde(default)]
pub recent_files: Vec<PathBuf>, pub recent_files: Vec<PathBuf>,
// 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 { impl Default for AppConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
recent_files: Vec::new(), 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.recent_files.clear();
self.save(); 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() }
} }

View File

@ -28,6 +28,8 @@ mod default_instrument;
mod export; mod export;
mod preferences;
mod notifications; mod notifications;
mod effect_thumbnails; mod effect_thumbnails;
@ -55,13 +57,17 @@ fn main() -> eframe::Result {
// Parse command line arguments // Parse command line arguments
let args = Args::parse(); 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 { let theme_mode = if args.light {
ThemeMode::Light ThemeMode::Light
} else if args.dark { } else if args.dark {
ThemeMode::Dark ThemeMode::Dark
} else { } else {
ThemeMode::System // Use theme from config
ThemeMode::from_string(&config.theme_mode)
}; };
// Load theme // Load theme
@ -591,6 +597,8 @@ struct EditorApp {
export_dialog: export::dialog::ExportDialog, export_dialog: export::dialog::ExportDialog,
/// Export progress dialog /// Export progress dialog
export_progress_dialog: export::dialog::ExportProgressDialog, export_progress_dialog: export::dialog::ExportProgressDialog,
/// Preferences dialog
preferences_dialog: preferences::dialog::PreferencesDialog,
/// Export orchestrator for background exports /// Export orchestrator for background exports
export_orchestrator: Option<export::ExportOrchestrator>, export_orchestrator: Option<export::ExportOrchestrator>,
/// GPU-rendered effect thumbnail generator /// GPU-rendered effect thumbnail generator
@ -656,7 +664,7 @@ impl EditorApp {
// Initialize audio system and destructure it for sharing // Initialize audio system and destructure it for sharing
let (audio_stream, audio_controller, audio_event_rx, audio_sample_rate, audio_channels, file_command_tx) = 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) => { Ok(audio_system) => {
println!("✅ Audio engine initialized successfully"); println!("✅ Audio engine initialized successfully");
@ -751,6 +759,7 @@ impl EditorApp {
audio_extraction_rx, audio_extraction_rx,
export_dialog: export::dialog::ExportDialog::default(), export_dialog: export::dialog::ExportDialog::default(),
export_progress_dialog: export::dialog::ExportProgressDialog::default(), export_progress_dialog: export::dialog::ExportProgressDialog::default(),
preferences_dialog: preferences::dialog::PreferencesDialog::default(),
export_orchestrator: None, export_orchestrator: None,
effect_thumbnail_generator: None, // Initialized when GPU available effect_thumbnail_generator: None, // Initialized when GPU available
@ -1386,8 +1395,7 @@ impl EditorApp {
// TODO: Implement select none // TODO: Implement select none
} }
MenuAction::Preferences => { MenuAction::Preferences => {
println!("Menu: Preferences"); self.preferences_dialog.open(&self.config, &self.theme);
// TODO: Implement preferences dialog
} }
// Modify menu // Modify menu
@ -2900,6 +2908,13 @@ impl eframe::App for EditorApp {
ctx.request_repaint(); 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) // Render video frames incrementally (if video export in progress)
if let Some(orchestrator) = &mut self.export_orchestrator { if let Some(orchestrator) = &mut self.export_orchestrator {
if orchestrator.is_exporting() { if orchestrator.is_exporting() {

View File

@ -511,6 +511,9 @@ impl SetParameterAction {
let BackendNodeId::Audio(node_idx) = self.backend_node_id; 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( controller.graph_set_parameter(
*track_id, *track_id,
node_idx.index() as u32, node_idx.index() as u32,

View File

@ -223,7 +223,7 @@ impl NodeTemplateTrait for NodeTemplate {
"V/Oct".into(), "V/Oct".into(),
DataType::CV, DataType::CV,
ValueType::Float { value: 0.0 }, ValueType::Float { value: 0.0 },
InputParamKind::ConnectionOnly, InputParamKind::ConnectionOrConstant,
true, true,
); );
// FM input (frequency modulation) // FM input (frequency modulation)

View File

@ -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 // Execute pending action if any
if let Some(action) = self.pending_action.take() { if let Some(action) = self.pending_action.take() {
// Node graph actions need to update the backend, so use execute_with_backend // 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) { fn check_parameter_changes(&mut self) {
// Check all input parameters for value changes // 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 { for (input_id, input_param) in &self.state.graph.inputs {
// Only check parameters that can have constant values (not ConnectionOnly) // Only check parameters that can have constant values (not ConnectionOnly)
if matches!(input_param.kind, InputParamKind::ConnectionOnly) { if matches!(input_param.kind, InputParamKind::ConnectionOnly) {
connection_only_count += 1;
continue; continue;
} }
// Get current value // Get current value
let current_value = match &input_param.value { let current_value = match &input_param.value {
ValueType::Float { value } => *value, ValueType::Float { value } => {
_ => continue, // Skip non-float values for now 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 // Check if value has changed
let previous_value = self.parameter_values.get(&input_id).copied(); 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 // Value has changed, create SetParameterAction
if let Some(track_id) = self.track_id { if let Some(track_id) = self.track_id {
let node_id = input_param.node; let node_id = input_param.node;
@ -447,6 +472,8 @@ impl NodeGraphPane {
// Get parameter index (position in node's inputs array) // Get parameter index (position in node's inputs array)
if let Some(node) = self.state.graph.nodes.get(node_id) { if let Some(node) = self.state.graph.nodes.get(node_id) {
if let Some(param_index) = node.inputs.iter().position(|(_, id)| *id == input_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 // Create action to update backend
let action = Box::new(actions::NodeGraphAction::SetParameter( let action = Box::new(actions::NodeGraphAction::SetParameter(
actions::SetParameterAction::new( actions::SetParameterAction::new(
@ -466,6 +493,11 @@ impl NodeGraphPane {
self.parameter_values.insert(input_id, current_value); 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( 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 // Check for parameter value changes and send updates to backend
self.check_parameter_changes(); self.check_parameter_changes();
// Execute any parameter change actions
self.execute_pending_action(shared);
// Override library's default scroll behavior: // Override library's default scroll behavior:
// - Library uses scroll for zoom // - Library uses scroll for zoom
// - We want: scroll = pan, ctrl+scroll = zoom // - We want: scroll = pan, ctrl+scroll = zoom

View File

@ -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<String>,
}
/// 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<PreferencesSaveResult> {
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<PreferencesSaveResult> {
// 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,
})
}
}

View File

@ -0,0 +1,7 @@
//! Preferences module
//!
//! User preferences dialog and settings management
pub mod dialog;
pub use dialog::{PreferencesDialog, PreferencesSaveResult};

View File

@ -15,6 +15,26 @@ pub enum ThemeMode {
System, // Follow system preference 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 /// Style properties that can be applied to UI elements
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct Style { pub struct Style {