From 4f3da810d0854ea5fcea66c1de05de079b358dd7 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 18 Mar 2026 11:25:48 -0400 Subject: [PATCH 1/7] Add automation inputs for audio graphs --- daw-backend/Cargo.toml | 1 + daw-backend/src/audio/engine.rs | 149 +++++- daw-backend/src/audio/node_graph/graph.rs | 90 +++- daw-backend/src/audio/node_graph/lbins.rs | 192 +++++++ daw-backend/src/audio/node_graph/mod.rs | 1 + .../src/audio/node_graph/nodes/amp_sim.rs | 15 + .../node_graph/nodes/automation_input.rs | 10 +- daw-backend/src/audio/sample_loader.rs | 60 +-- daw-backend/src/audio/track.rs | 6 +- daw-backend/src/command/types.rs | 8 + .../lightningbeam-core/src/renderer.rs | 61 ++- .../lightningbeam-editor/src/curve_editor.rs | 323 ++++++++++++ .../lightningbeam-editor/src/main.rs | 6 + .../lightningbeam-editor/src/panes/mod.rs | 3 + .../src/panes/node_graph/graph_data.rs | 24 + .../src/panes/node_graph/mod.rs | 41 ++ .../src/panes/preset_browser.rs | 42 +- .../src/panes/timeline.rs | 487 ++++++++++++++++-- 18 files changed, 1412 insertions(+), 107 deletions(-) create mode 100644 daw-backend/src/audio/node_graph/lbins.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/curve_editor.rs diff --git a/daw-backend/Cargo.toml b/daw-backend/Cargo.toml index 74b0f64..1878591 100644 --- a/daw-backend/Cargo.toml +++ b/daw-backend/Cargo.toml @@ -35,6 +35,7 @@ dasp_peak = "0.11" dasp_rms = "0.11" petgraph = "0.6" serde_json = "1.0" +zip = "0.6" # BeamDSP scripting engine beamdsp = { path = "../lightningbeam-ui/beamdsp" } diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index beb7543..66788a3 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1655,7 +1655,7 @@ impl Engine { // Extract the directory path from the preset path for resolving relative sample paths let preset_base_path = std::path::Path::new(&preset_path).parent(); - match AudioGraph::from_preset(&preset, self.sample_rate, 8192, preset_base_path) { + match AudioGraph::from_preset(&preset, self.sample_rate, 8192, preset_base_path, None) { Ok(graph) => { // Replace the track's graph match self.project.get_track_mut(track_id) { @@ -1705,6 +1705,80 @@ impl Engine { } } + Command::GraphLoadLbins(track_id, path) => { + match crate::audio::node_graph::lbins::load_lbins(&path) { + Ok((preset, assets)) => { + match AudioGraph::from_preset(&preset, self.sample_rate, 8192, None, Some(&assets)) { + Ok(graph) => { + match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => { + track.instrument_graph = graph; + track.graph_is_default = true; + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); + } + Some(TrackNode::Audio(track)) => { + track.effects_graph = graph; + track.graph_is_default = true; + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); + } + Some(TrackNode::Group(track)) => { + track.audio_graph = graph; + track.graph_is_default = true; + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); + } + _ => {} + } + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to load .lbins graph: {}", e), + )); + } + } + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to open .lbins file: {}", e), + )); + } + } + } + + Command::GraphSaveLbins(track_id, path, preset_name, description, tags) => { + let graph = match self.project.get_track(track_id) { + Some(TrackNode::Midi(track)) => Some(&track.instrument_graph), + Some(TrackNode::Audio(track)) => Some(&track.effects_graph), + Some(TrackNode::Group(track)) => Some(&track.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + let mut preset = graph.to_preset(&preset_name); + preset.metadata.description = description; + preset.metadata.tags = tags; + preset.metadata.author = String::from("User"); + + match crate::audio::node_graph::lbins::save_lbins(&path, &preset, None) { + Ok(()) => { + let _ = self.event_tx.push(AudioEvent::GraphPresetSaved( + track_id, + path.to_string_lossy().to_string(), + )); + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to save .lbins: {}", e), + )); + } + } + } + } + Command::GraphSaveTemplatePreset(track_id, voice_allocator_id, preset_path, preset_name) => { use crate::audio::node_graph::nodes::VoiceAllocatorNode; @@ -2456,6 +2530,27 @@ impl Engine { } } + Query::GetAutomationRange(track_id, node_id) => { + use crate::audio::node_graph::nodes::AutomationInputNode; + + if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) { + let graph = &track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + + if let Some(graph_node) = graph.get_graph_node(node_idx) { + if let Some(auto_node) = graph_node.node.as_any().downcast_ref::() { + QueryResponse::AutomationRange(Ok((auto_node.value_min, auto_node.value_max))) + } else { + QueryResponse::AutomationRange(Err(format!("Node {} is not an AutomationInputNode", node_id))) + } + } else { + QueryResponse::AutomationRange(Err(format!("Node {} not found", node_id))) + } + } else { + QueryResponse::AutomationRange(Err(format!("Track {} not found or is not a MIDI track", track_id))) + } + } + Query::SerializeAudioPool(project_path) => { QueryResponse::AudioPoolSerialized(self.audio_pool.serialize(&project_path)) } @@ -2509,12 +2604,12 @@ impl Engine { match track_node { TrackNode::Audio(track) => { // Load into effects graph with proper buffer size (8192 to handle any callback size) - track.effects_graph = AudioGraph::from_preset(&preset, self.sample_rate, 8192, preset_base_path)?; + track.effects_graph = AudioGraph::from_preset(&preset, self.sample_rate, 8192, preset_base_path, None)?; Ok(()) } TrackNode::Midi(track) => { // Load into instrument graph with proper buffer size (8192 to handle any callback size) - track.instrument_graph = AudioGraph::from_preset(&preset, self.sample_rate, 8192, preset_base_path)?; + track.instrument_graph = AudioGraph::from_preset(&preset, self.sample_rate, 8192, preset_base_path, None)?; Ok(()) } TrackNode::Group(_) => { @@ -3396,6 +3491,25 @@ impl EngineController { )); } + /// Add a keyframe to an AutomationInput node + pub fn automation_add_keyframe(&mut self, track_id: TrackId, node_id: u32, + time: f64, value: f32, interpolation: String, + ease_out: (f32, f32), ease_in: (f32, f32)) { + let _ = self.command_tx.push(Command::AutomationAddKeyframe( + track_id, node_id, time, value, interpolation, ease_out, ease_in)); + } + + /// Remove a keyframe from an AutomationInput node + pub fn automation_remove_keyframe(&mut self, track_id: TrackId, node_id: u32, time: f64) { + let _ = self.command_tx.push(Command::AutomationRemoveKeyframe( + track_id, node_id, time)); + } + + /// Set the display name of an AutomationInput node + pub fn automation_set_name(&mut self, track_id: TrackId, node_id: u32, name: String) { + let _ = self.command_tx.push(Command::AutomationSetName(track_id, node_id, name)); + } + /// Start recording on a track pub fn start_recording(&mut self, track_id: TrackId, start_time: f64) { let _ = self.command_tx.push(Command::StartRecording(track_id, start_time)); @@ -3542,6 +3656,16 @@ impl EngineController { let _ = self.command_tx.push(Command::GraphLoadPreset(track_id, preset_path)); } + /// Load a `.lbins` instrument bundle into a track's graph + pub fn graph_load_lbins(&mut self, track_id: TrackId, path: std::path::PathBuf) { + let _ = self.command_tx.push(Command::GraphLoadLbins(track_id, path)); + } + + /// Save a track's graph as a `.lbins` instrument bundle + pub fn graph_save_lbins(&mut self, track_id: TrackId, path: std::path::PathBuf, preset_name: String, description: String, tags: Vec) { + let _ = self.command_tx.push(Command::GraphSaveLbins(track_id, path, preset_name, description, tags)); + } + /// Save a VoiceAllocator's template graph as a preset pub fn graph_save_template_preset(&mut self, track_id: TrackId, voice_allocator_id: u32, preset_path: String, preset_name: String) { let _ = self.command_tx.push(Command::GraphSaveTemplatePreset(track_id, voice_allocator_id, preset_path, preset_name)); @@ -3809,6 +3933,25 @@ impl EngineController { Err("Query timeout".to_string()) } + /// Query automation node value range (min, max) + pub fn query_automation_range(&mut self, track_id: TrackId, node_id: u32) -> Result<(f32, f32), String> { + if let Err(_) = self.query_tx.push(Query::GetAutomationRange(track_id, node_id)) { + return Err("Failed to send query - queue full".to_string()); + } + + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(100); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::AutomationRange(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_micros(50)); + } + + Err("Query timeout".to_string()) + } + /// Serialize the audio pool for project saving pub fn serialize_audio_pool(&mut self, project_path: &std::path::Path) -> Result, String> { // Send query diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs index e0b8ba8..9d053a5 100644 --- a/daw-backend/src/audio/node_graph/graph.rs +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -1053,7 +1053,7 @@ impl AudioGraph { } /// Deserialize a preset into the graph - pub fn from_preset(preset: &crate::audio::node_graph::preset::GraphPreset, sample_rate: u32, buffer_size: usize, preset_base_path: Option<&std::path::Path>) -> Result { + pub fn from_preset(preset: &crate::audio::node_graph::preset::GraphPreset, sample_rate: u32, buffer_size: usize, preset_base_path: Option<&std::path::Path>, embedded_assets: Option<&std::collections::HashMap>>) -> Result { use crate::audio::node_graph::nodes::*; use petgraph::stable_graph::NodeIndex; use std::collections::HashMap; @@ -1124,7 +1124,7 @@ impl AudioGraph { if serialized_node.node_type == "VoiceAllocator" { if let Some(ref template_preset) = serialized_node.template_graph { if let Some(va) = node.as_any_mut().downcast_mut::() { - let template_graph = Self::from_preset(template_preset, sample_rate, buffer_size, preset_base_path)?; + let template_graph = Self::from_preset(template_preset, sample_rate, buffer_size, preset_base_path, embedded_assets)?; *va.template_graph_mut() = template_graph; va.rebuild_voices(); } @@ -1182,10 +1182,28 @@ impl AudioGraph { sampler_node.set_sample(samples, embedded.sample_rate as f32); } } else if let Some(ref path) = file_path { - // Fall back to loading from file (resolve path relative to preset) - let resolved_path = resolve_sample_path(path); - if let Err(e) = sampler_node.load_sample_from_file(&resolved_path) { - eprintln!("Failed to load sample from {}: {}", resolved_path, e); + // Check embedded assets map first (from .lbins bundle) + let loaded = if let Some(assets) = embedded_assets { + if let Some(bytes) = assets.get(path.as_str()) { + match crate::audio::sample_loader::load_audio_from_bytes(bytes, path) { + Ok(data) => { + sampler_node.set_sample(data.samples, data.sample_rate as f32); + true + } + Err(e) => { + eprintln!("Failed to decode bundled sample {}: {}", path, e); + false + } + } + } else { false } + } else { false }; + + if !loaded { + // Fall back to loading from filesystem + let resolved_path = resolve_sample_path(path); + if let Err(e) = sampler_node.load_sample_from_file(&resolved_path) { + eprintln!("Failed to load sample from {}: {}", resolved_path, e); + } } } } @@ -1225,20 +1243,49 @@ impl AudioGraph { ); } } else if let Some(ref path) = layer.file_path { - // Fall back to loading from file (resolve path relative to preset) - let resolved_path = resolve_sample_path(path); - if let Err(e) = multi_sampler_node.load_layer_from_file( - &resolved_path, - layer.key_min, - layer.key_max, - layer.root_key, - layer.velocity_min, - layer.velocity_max, - layer.loop_start, - layer.loop_end, - layer.loop_mode, - ) { - eprintln!("Failed to load sample layer from {}: {}", resolved_path, e); + // Check embedded assets map first (from .lbins bundle) + let loaded = if let Some(assets) = embedded_assets { + if let Some(bytes) = assets.get(path.as_str()) { + match crate::audio::sample_loader::load_audio_from_bytes(bytes, path) { + Ok(data) => { + multi_sampler_node.add_layer( + data.samples, + data.sample_rate as f32, + layer.key_min, + layer.key_max, + layer.root_key, + layer.velocity_min, + layer.velocity_max, + layer.loop_start, + layer.loop_end, + layer.loop_mode, + ); + true + } + Err(e) => { + eprintln!("Failed to decode bundled sample layer {}: {}", path, e); + false + } + } + } else { false } + } else { false }; + + if !loaded { + // Fall back to loading from filesystem + let resolved_path = resolve_sample_path(path); + if let Err(e) = multi_sampler_node.load_layer_from_file( + &resolved_path, + layer.key_min, + layer.key_max, + layer.root_key, + layer.velocity_min, + layer.velocity_max, + layer.loop_start, + layer.loop_end, + layer.loop_mode, + ) { + eprintln!("Failed to load sample layer from {}: {}", resolved_path, e); + } } } } @@ -1258,6 +1305,9 @@ impl AudioGraph { let result = if let Some(bundled_name) = model_path.strip_prefix("bundled:") { eprintln!("[AmpSim] Preset: loading bundled model {:?}", bundled_name); amp_sim.load_bundled_model(bundled_name) + } else if let Some(bytes) = embedded_assets.and_then(|a| a.get(model_path.as_str())) { + eprintln!("[AmpSim] Preset: loading from bundle {:?}", model_path); + amp_sim.load_model_from_bytes(model_path, bytes) } else { let resolved_path = resolve_sample_path(model_path); eprintln!("[AmpSim] Preset: loading from file {:?}", resolved_path); diff --git a/daw-backend/src/audio/node_graph/lbins.rs b/daw-backend/src/audio/node_graph/lbins.rs new file mode 100644 index 0000000..3516301 --- /dev/null +++ b/daw-backend/src/audio/node_graph/lbins.rs @@ -0,0 +1,192 @@ +/// Load and save `.lbins` instrument bundle files. +/// +/// A `.lbins` file is a ZIP archive with the following layout: +/// +/// ``` +/// instrument.lbins (ZIP) +/// ├── instrument.json ← GraphPreset JSON (existing schema) +/// ├── samples/ +/// │ ├── kick.wav +/// │ └── snare.flac +/// └── models/ +/// └── amp.nam +/// ``` +/// +/// All asset paths in `instrument.json` are ZIP-relative +/// (e.g. `"samples/kick.wav"`, `"models/amp.nam"`). + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::path::Path; + +use crate::audio::node_graph::preset::{GraphPreset, SampleData}; + +/// Load a `.lbins` file. +/// +/// Returns the deserialized `GraphPreset` together with a map of all +/// non-JSON entries keyed by their ZIP-relative path (e.g. `"samples/kick.wav"`). +pub fn load_lbins(path: &Path) -> Result<(GraphPreset, HashMap>), String> { + let file = std::fs::File::open(path) + .map_err(|e| format!("Failed to open .lbins file: {}", e))?; + + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| format!("Failed to read ZIP archive: {}", e))?; + + // Read instrument.json first + let preset_json = { + let mut entry = archive + .by_name("instrument.json") + .map_err(|_| "Missing instrument.json in .lbins archive".to_string())?; + let mut buf = String::new(); + entry + .read_to_string(&mut buf) + .map_err(|e| format!("Failed to read instrument.json: {}", e))?; + buf + }; + + let preset = GraphPreset::from_json(&preset_json) + .map_err(|e| format!("Failed to parse instrument.json: {}", e))?; + + // Read all other entries into memory + let mut assets: HashMap> = HashMap::new(); + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| format!("Failed to read ZIP entry {}: {}", i, e))?; + + let entry_name = entry.name().to_string(); + if entry_name == "instrument.json" || entry.is_dir() { + continue; + } + + let mut bytes = Vec::new(); + entry + .read_to_end(&mut bytes) + .map_err(|e| format!("Failed to read {}: {}", entry_name, e))?; + + assets.insert(entry_name, bytes); + } + + Ok((preset, assets)) +} + +/// Save a preset to a `.lbins` file. +/// +/// Asset paths in `preset` are rewritten to ZIP-relative form +/// (`samples/` or `models/`). +/// If the path is already ZIP-relative (starts with `samples/` or `models/`) +/// it is used as-is. Absolute / relative filesystem paths are resolved +/// relative to `asset_base` (typically the directory that contained the +/// original `.json` preset) and then read from disk. +pub fn save_lbins(path: &Path, preset: &GraphPreset, asset_base: Option<&Path>) -> Result<(), String> { + let file = std::fs::File::create(path) + .map_err(|e| format!("Failed to create .lbins file: {}", e))?; + + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::FileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + // We'll build a rewritten copy of the preset while collecting assets + let mut rewritten = preset.clone(); + // Map: original path → (zip_path, file_bytes) + let mut asset_map: HashMap)> = HashMap::new(); + + // Helper: given an original asset path string and a subdirectory ("samples" or "models"), + // resolve the bytes and return the canonical ZIP-relative path. + let mut resolve_asset = |orig_path: &str, subdir: &str| -> Result { + // Already a ZIP-relative path — no re-reading needed, caller stored bytes already + // or the asset will be provided by a prior pass. Just normalise the subdirectory. + if orig_path.starts_with(&format!("{}/", subdir)) { + return Ok(orig_path.to_string()); + } + + let basename = Path::new(orig_path) + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| format!("Cannot determine filename for asset: {}", orig_path))?; + + let zip_path = format!("{}/{}", subdir, basename); + + if !asset_map.contains_key(orig_path) { + // Resolve to an absolute filesystem path + let fs_path = if Path::new(orig_path).is_absolute() { + std::path::PathBuf::from(orig_path) + } else if let Some(base) = asset_base { + base.join(orig_path) + } else { + std::path::PathBuf::from(orig_path) + }; + + let bytes = std::fs::read(&fs_path) + .map_err(|e| format!("Failed to read asset {}: {}", fs_path.display(), e))?; + + asset_map.insert(orig_path.to_string(), (zip_path.clone(), bytes)); + } + + Ok(zip_path) + }; + + // Rewrite paths in all nodes + for node in &mut rewritten.nodes { + // Sample data paths + if let Some(ref mut sample_data) = node.sample_data { + match sample_data { + SampleData::SimpleSampler { ref mut file_path, .. } => { + if let Some(ref orig) = file_path.clone() { + if !orig.is_empty() { + match resolve_asset(orig, "samples") { + Ok(zip_path) => *file_path = Some(zip_path), + Err(e) => eprintln!("Warning: {}", e), + } + } + } + } + SampleData::MultiSampler { ref mut layers } => { + for layer in layers.iter_mut() { + if let Some(ref orig) = layer.file_path.clone() { + if !orig.is_empty() { + match resolve_asset(orig, "samples") { + Ok(zip_path) => layer.file_path = Some(zip_path), + Err(e) => eprintln!("Warning: {}", e), + } + } + } + } + } + } + } + + // NAM model path + if let Some(ref orig) = node.nam_model_path.clone() { + if !orig.starts_with("bundled:") && !orig.is_empty() { + match resolve_asset(orig, "models") { + Ok(zip_path) => node.nam_model_path = Some(zip_path), + Err(e) => eprintln!("Warning: {}", e), + } + } + } + } + + // Write all collected assets to the ZIP + for (_, (zip_path, bytes)) in &asset_map { + zip.start_file(zip_path, options) + .map_err(|e| format!("Failed to start ZIP entry {}: {}", zip_path, e))?; + zip.write_all(bytes) + .map_err(|e| format!("Failed to write {}: {}", zip_path, e))?; + } + + // Write instrument.json last (after assets so paths are already rewritten) + let json = rewritten + .to_json() + .map_err(|e| format!("Failed to serialize preset: {}", e))?; + + zip.start_file("instrument.json", options) + .map_err(|e| format!("Failed to start instrument.json entry: {}", e))?; + zip.write_all(json.as_bytes()) + .map_err(|e| format!("Failed to write instrument.json: {}", e))?; + + zip.finish() + .map_err(|e| format!("Failed to finalize ZIP: {}", e))?; + + Ok(()) +} diff --git a/daw-backend/src/audio/node_graph/mod.rs b/daw-backend/src/audio/node_graph/mod.rs index 08c313c..c42e8a1 100644 --- a/daw-backend/src/audio/node_graph/mod.rs +++ b/daw-backend/src/audio/node_graph/mod.rs @@ -1,6 +1,7 @@ mod graph; mod node_trait; mod types; +pub mod lbins; pub mod nodes; pub mod preset; diff --git a/daw-backend/src/audio/node_graph/nodes/amp_sim.rs b/daw-backend/src/audio/node_graph/nodes/amp_sim.rs index 142a556..f2a043c 100644 --- a/daw-backend/src/audio/node_graph/nodes/amp_sim.rs +++ b/daw-backend/src/audio/node_graph/nodes/amp_sim.rs @@ -75,6 +75,21 @@ impl AmpSimNode { Ok(()) } + /// Load a .nam model from in-memory bytes (used when loading from a .lbins bundle). + /// `zip_path` is the ZIP-relative path stored back in `model_path` for serialization. + pub fn load_model_from_bytes(&mut self, zip_path: &str, bytes: &[u8]) -> Result<(), String> { + let basename = std::path::Path::new(zip_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(zip_path); + let mut model = nam_ffi::NamModel::from_bytes(basename, bytes) + .map_err(|e| format!("{}", e))?; + model.set_max_buffer_size(1024); + self.model = Some(model); + self.model_path = Some(zip_path.to_string()); + Ok(()) + } + /// Get the loaded model path (for preset serialization). pub fn model_path(&self) -> Option<&str> { self.model_path.as_deref() diff --git a/daw-backend/src/audio/node_graph/nodes/automation_input.rs b/daw-backend/src/audio/node_graph/nodes/automation_input.rs index 0d1ca39..6da7bcf 100644 --- a/daw-backend/src/audio/node_graph/nodes/automation_input.rs +++ b/daw-backend/src/audio/node_graph/nodes/automation_input.rs @@ -49,6 +49,10 @@ pub struct AutomationInputNode { parameters: Vec, /// Shared playback time (set by the graph before processing) playback_time: Arc>, + /// Minimum output value (for UI display range) + pub value_min: f32, + /// Maximum output value (for UI display range) + pub value_max: f32, } impl AutomationInputNode { @@ -62,10 +66,12 @@ impl AutomationInputNode { Self { name: name.clone(), display_name: "Automation".to_string(), - keyframes: Vec::new(), + keyframes: vec![AutomationKeyframe::new(0.0, 0.0)], outputs, parameters: Vec::new(), playback_time: Arc::new(RwLock::new(0.0)), + value_min: -1.0, + value_max: 1.0, } } @@ -275,6 +281,8 @@ impl AudioNode for AutomationInputNode { outputs: self.outputs.clone(), parameters: self.parameters.clone(), playback_time: Arc::new(RwLock::new(0.0)), + value_min: self.value_min, + value_max: self.value_max, }) } diff --git a/daw-backend/src/audio/sample_loader.rs b/daw-backend/src/audio/sample_loader.rs index c378fe7..40e00ac 100644 --- a/daw-backend/src/audio/sample_loader.rs +++ b/daw-backend/src/audio/sample_loader.rs @@ -6,6 +6,7 @@ use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; use std::fs::File; +use std::io::Cursor; use std::path::Path; /// Loaded audio sample data @@ -20,33 +21,36 @@ pub struct SampleData { /// Load an audio file and decode it to mono f32 samples pub fn load_audio_file(path: impl AsRef) -> Result { let path = path.as_ref(); - - // Open the file - let file = File::open(path) - .map_err(|e| format!("Failed to open file: {}", e))?; - - // Create a media source stream + let file = File::open(path).map_err(|e| format!("Failed to open file: {}", e))?; let mss = MediaSourceStream::new(Box::new(file), Default::default()); - - // Create a hint to help the format registry guess the format let mut hint = Hint::new(); - if let Some(extension) = path.extension() { - if let Some(ext_str) = extension.to_str() { - hint.with_extension(ext_str); - } + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); } + decode_mss(mss, hint) +} - // Probe the media source for a format - let format_opts = FormatOptions::default(); - let metadata_opts = MetadataOptions::default(); +/// Load audio from an in-memory byte slice and decode it to mono f32 samples. +/// Supports WAV, FLAC, MP3, AAC, and any other format Symphonia recognises. +/// `filename_hint` is used to help Symphonia detect the format (e.g. "kick.wav"). +pub fn load_audio_from_bytes(bytes: &[u8], filename_hint: &str) -> Result { + let cursor = Cursor::new(bytes.to_vec()); + let mss = MediaSourceStream::new(Box::new(cursor), Default::default()); + let mut hint = Hint::new(); + if let Some(ext) = std::path::Path::new(filename_hint).extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + decode_mss(mss, hint) +} +/// Shared decode logic: probe `mss`, find the first audio track, decode to mono f32. +fn decode_mss(mss: MediaSourceStream, hint: Hint) -> Result { let probed = symphonia::default::get_probe() - .format(&hint, mss, &format_opts, &metadata_opts) + .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default()) .map_err(|e| format!("Failed to probe format: {}", e))?; let mut format = probed.format; - // Find the first audio track let track = format .tracks() .iter() @@ -56,47 +60,33 @@ pub fn load_audio_file(path: impl AsRef) -> Result { let track_id = track.id; let sample_rate = track.codec_params.sample_rate.unwrap_or(48000); - // Create a decoder for the track - let dec_opts = DecoderOptions::default(); let mut decoder = symphonia::default::get_codecs() - .make(&track.codec_params, &dec_opts) + .make(&track.codec_params, &DecoderOptions::default()) .map_err(|e| format!("Failed to create decoder: {}", e))?; - // Decode all packets let mut all_samples = Vec::new(); loop { - // Get the next packet let packet = match format.next_packet() { Ok(packet) => packet, Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { - // End of stream break; } - Err(e) => { - return Err(format!("Error reading packet: {}", e)); - } + Err(e) => return Err(format!("Error reading packet: {}", e)), }; - // Skip packets that don't belong to the selected track if packet.track_id() != track_id { continue; } - // Decode the packet let decoded = decoder .decode(&packet) .map_err(|e| format!("Failed to decode packet: {}", e))?; - // Convert to f32 samples and mix to mono - let samples = convert_to_mono_f32(&decoded); - all_samples.extend_from_slice(&samples); + all_samples.extend_from_slice(&convert_to_mono_f32(&decoded)); } - Ok(SampleData { - samples: all_samples, - sample_rate, - }) + Ok(SampleData { samples: all_samples, sample_rate }) } /// Convert an audio buffer to mono f32 samples diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs index 7b2adc3..3a3c476 100644 --- a/daw-backend/src/audio/track.rs +++ b/daw-backend/src/audio/track.rs @@ -438,7 +438,7 @@ impl Metatrack { pub fn rebuild_audio_graph(&mut self, sample_rate: u32, buffer_size: usize) -> Result<(), String> { if let Some(preset) = &self.audio_graph_preset { if !preset.nodes.is_empty() && preset.output_node.is_some() { - self.audio_graph = AudioGraph::from_preset(preset, sample_rate, buffer_size, None)?; + self.audio_graph = AudioGraph::from_preset(preset, sample_rate, buffer_size, None, None)?; // graph_is_default remains as serialized (false for user-modified graphs) } else { self.audio_graph = Self::create_empty_graph(sample_rate, buffer_size); @@ -703,7 +703,7 @@ impl MidiTrack { /// Rebuild the instrument graph from preset after deserialization pub fn rebuild_audio_graph(&mut self, sample_rate: u32, buffer_size: usize) -> Result<(), String> { if let Some(preset) = &self.instrument_graph_preset { - self.instrument_graph = AudioGraph::from_preset(preset, sample_rate, buffer_size, None)?; + self.instrument_graph = AudioGraph::from_preset(preset, sample_rate, buffer_size, None, None)?; } else { // No preset - create default graph self.instrument_graph = AudioGraph::new(sample_rate, buffer_size); @@ -985,7 +985,7 @@ impl AudioTrack { if has_nodes && has_output { // Valid preset - rebuild from it - self.effects_graph = AudioGraph::from_preset(preset, sample_rate, buffer_size, None)?; + self.effects_graph = AudioGraph::from_preset(preset, sample_rate, buffer_size, None, None)?; } else { // Empty or invalid preset - create default graph self.effects_graph = Self::create_default_graph(sample_rate, buffer_size); diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 5e3f660..9ff4db8 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -181,6 +181,10 @@ pub enum Command { GraphSavePreset(TrackId, String, String, String, Vec), /// Load a preset into a track's graph (track_id, preset_path) GraphLoadPreset(TrackId, String), + /// Load a .lbins instrument bundle into a track's graph (track_id, path) + GraphLoadLbins(TrackId, std::path::PathBuf), + /// Save a track's graph as a .lbins instrument bundle (track_id, path, preset_name, description, tags) + GraphSaveLbins(TrackId, std::path::PathBuf, String, String, Vec), // Metatrack subtrack graph commands /// Replace a metatrack's mixing graph with the default SubtrackInputs→Mixer→Output layout. @@ -392,6 +396,8 @@ pub enum Query { GetAutomationKeyframes(TrackId, u32), /// Get the display name of an AutomationInput node (track_id, node_id) GetAutomationName(TrackId, u32), + /// Get the value range (min, max) of an AutomationInput node (track_id, node_id) + GetAutomationRange(TrackId, u32), /// Serialize audio pool for project saving (project_path) SerializeAudioPool(std::path::PathBuf), /// Load audio pool from serialized entries (entries, project_path) @@ -480,6 +486,8 @@ pub enum QueryResponse { AutomationKeyframes(Result, String>), /// Automation node name AutomationName(Result), + /// Automation node value range (min, max) + AutomationRange(Result<(f32, f32), String>), /// Serialized audio pool entries AudioPoolSerialized(Result, String>), /// Audio pool loaded (returns list of missing pool indices) diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 94bd361..088da89 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -24,6 +24,8 @@ use vello::Scene; /// Cache for decoded image data to avoid re-decoding every frame pub struct ImageCache { cache: HashMap>, + /// CPU path: tiny-skia pixmaps decoded from the same assets (premultiplied RGBA8) + cpu_cache: HashMap>, } impl ImageCache { @@ -31,6 +33,7 @@ impl ImageCache { pub fn new() -> Self { Self { cache: HashMap::new(), + cpu_cache: HashMap::new(), } } @@ -47,14 +50,28 @@ impl ImageCache { Some(arc_image) } + /// Get or decode an image as a premultiplied tiny-skia Pixmap (CPU render path). + pub fn get_or_decode_cpu(&mut self, asset: &ImageAsset) -> Option> { + if let Some(cached) = self.cpu_cache.get(&asset.id) { + return Some(Arc::clone(cached)); + } + + let pixmap = decode_image_to_pixmap(asset)?; + let arc = Arc::new(pixmap); + self.cpu_cache.insert(asset.id, Arc::clone(&arc)); + Some(arc) + } + /// Clear cache entry when an image asset is deleted or modified pub fn invalidate(&mut self, id: &Uuid) { self.cache.remove(id); + self.cpu_cache.remove(id); } /// Clear all cached images pub fn clear(&mut self) { self.cache.clear(); + self.cpu_cache.clear(); } } @@ -64,6 +81,25 @@ impl Default for ImageCache { } } +/// Decode an image asset to a premultiplied tiny-skia Pixmap (CPU render path). +fn decode_image_to_pixmap(asset: &ImageAsset) -> Option { + let data = asset.data.as_ref()?; + let img = image::load_from_memory(data).ok()?; + let rgba = img.to_rgba8(); + let mut pixmap = tiny_skia::Pixmap::new(asset.width, asset.height)?; + for (dst, src) in pixmap.pixels_mut().iter_mut().zip(rgba.pixels()) { + let [r, g, b, a] = src.0; + // Convert straight alpha (image crate output) to premultiplied (tiny-skia internal format) + let af = a as f32 / 255.0; + let pr = (r as f32 * af).round() as u8; + let pg = (g as f32 * af).round() as u8; + let pb = (b as f32 * af).round() as u8; + // from_rgba only fails when channel > alpha; premultiplied values are always ≤ alpha + *dst = tiny_skia::PremultipliedColorU8::from_rgba(pr, pg, pb, a).unwrap(); + } + Some(pixmap) +} + /// Decode an image asset to peniko ImageBrush fn decode_image_asset(asset: &ImageAsset) -> Option { // Get the raw file data @@ -1368,8 +1404,8 @@ fn render_dcel_cpu( pixmap: &mut tiny_skia::PixmapMut<'_>, transform: tiny_skia::Transform, opacity: f32, - _document: &Document, - _image_cache: &mut ImageCache, + document: &Document, + image_cache: &mut ImageCache, ) { // 1. Faces (fills) for (i, face) in dcel.faces.iter().enumerate() { @@ -1412,8 +1448,25 @@ fn render_dcel_cpu( } } - // Image fill — not yet implemented for CPU renderer; fall through to solid or skip - // TODO: decode image to Pixmap and use as Pattern shader + // Image fill — decode to Pixmap and use as a Pattern shader + if let Some(image_asset_id) = face.image_fill { + if let Some(asset) = document.get_image_asset(&image_asset_id) { + if let Some(img_pixmap) = image_cache.get_or_decode_cpu(asset) { + let pattern = tiny_skia::Pattern::new( + tiny_skia::Pixmap::as_ref(&img_pixmap), + tiny_skia::SpreadMode::Pad, + tiny_skia::FilterQuality::Bilinear, + opacity, + tiny_skia::Transform::identity(), + ); + let mut paint = tiny_skia::Paint::default(); + paint.shader = pattern; + paint.anti_alias = true; + pixmap.fill_path(&ts_path, &paint, fill_type, transform, None); + filled = true; + } + } + } // Solid colour fill if !filled { diff --git a/lightningbeam-ui/lightningbeam-editor/src/curve_editor.rs b/lightningbeam-ui/lightningbeam-editor/src/curve_editor.rs new file mode 100644 index 0000000..6a4556f --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/curve_editor.rs @@ -0,0 +1,323 @@ +/// Generic curve lane widget — renders a keyframe curve and handles editing interactions. +/// +/// Used for audio automation lanes (AutomationInput nodes) and, in future, for visual +/// property animation lanes on vector/raster layers. + +use eframe::egui::{self, Color32, Pos2, Rect, Shape, Stroke, Vec2}; + +// ─── Data types ────────────────────────────────────────────────────────────── + +/// A single keyframe. Values are in the caller's raw unit space (not normalised). +/// Convert from `AutomationKeyframeData` or `lightningbeam_core::animation::Keyframe` +/// before passing in. +#[derive(Clone, Debug)] +pub struct CurvePoint { + pub time: f64, + pub value: f32, + pub interpolation: CurveInterpolation, + /// Outgoing Bezier tangent (x, y) relative to this keyframe, range 0–1 + pub ease_out: (f32, f32), + /// Incoming Bezier tangent (x, y) relative to next keyframe, range 0–1 + pub ease_in: (f32, f32), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CurveInterpolation { + Linear, + Bezier, + Step, + Hold, +} + +/// Edit action the user performed during one frame, returned from [`render_curve_lane`]. +#[derive(Debug)] +pub enum CurveEditAction { + None, + AddKeyframe { time: f64, value: f32 }, + MoveKeyframe { index: usize, new_time: f64, new_value: f32 }, + DeleteKeyframe { index: usize }, +} + +/// Drag state for an in-progress keyframe move. +/// Stored by the caller alongside the lane's cached keyframe list. +#[derive(Clone, Debug)] +pub struct CurveDragState { + pub keyframe_index: usize, + pub original_time: f64, + pub original_value: f32, + pub current_time: f64, + pub current_value: f32, +} + +// ─── Curve evaluation ──────────────────────────────────────────────────────── + +/// Evaluate the curve defined by `keyframes` at the given `time`. +/// +/// Matches the interpolation logic of `AutomationInputNode::evaluate_at_time()`. +pub fn evaluate_curve(keyframes: &[CurvePoint], time: f64) -> f32 { + if keyframes.is_empty() { + return 0.0; + } + if keyframes.len() == 1 || time <= keyframes[0].time { + return keyframes[0].value; + } + let last = &keyframes[keyframes.len() - 1]; + if time >= last.time { + return last.value; + } + + // Find the pair that brackets `time` + let right = keyframes.partition_point(|kf| kf.time <= time); + let kf1 = &keyframes[right - 1]; + let kf2 = &keyframes[right]; + + let t = if kf2.time == kf1.time { + 0.0f32 + } else { + ((time - kf1.time) / (kf2.time - kf1.time)) as f32 + }; + + match kf1.interpolation { + CurveInterpolation::Linear => kf1.value + (kf2.value - kf1.value) * t, + CurveInterpolation::Bezier => { + let eased = cubic_bezier_ease(t, kf1.ease_out, kf2.ease_in); + kf1.value + (kf2.value - kf1.value) * eased + } + CurveInterpolation::Step | CurveInterpolation::Hold => kf1.value, + } +} + +/// Simplified cubic Bezier easing (0,0 → ease_out → ease_in → 1,1). +/// Identical to `AutomationInputNode::cubic_bezier_ease`. +fn cubic_bezier_ease(t: f32, ease_out: (f32, f32), ease_in: (f32, f32)) -> f32 { + let u = 1.0 - t; + 3.0 * u * u * t * ease_out.1 + 3.0 * u * t * t * ease_in.1 + t * t * t +} + +// ─── Rendering ─────────────────────────────────────────────────────────────── + +const DIAMOND_RADIUS: f32 = 5.0; + +/// Render a curve lane within `rect` and return any edit action the user performed. +/// +/// `drag_state` is an in/out reference; the caller is responsible for storing it between +/// frames alongside the lane's keyframe list. +/// +/// `value_min` and `value_max` define the displayed value range (bottom to top of rect). +/// Keyframe values outside this range are clamped visually. +/// +/// `time_to_x` maps a project time (seconds) to an **absolute** screen X coordinate. +/// `x_to_time` maps an **absolute** screen X coordinate to project time. +pub fn render_curve_lane( + ui: &mut egui::Ui, + rect: Rect, + keyframes: &[CurvePoint], + drag_state: &mut Option, + playback_time: f64, + accent_color: Color32, + id: egui::Id, + value_min: f32, + value_max: f32, + time_to_x: impl Fn(f64) -> f32, + x_to_time: impl Fn(f32) -> f64, +) -> CurveEditAction { + let painter = ui.painter_at(rect); + + // Helper: raw value → normalised [0,1] for screen-Y mapping + let normalize = |v: f32| -> f32 { + if (value_max - value_min).abs() < f32::EPSILON { + 0.5 + } else { + (v - value_min) / (value_max - value_min) + } + }; + // Helper: normalised [0,1] → raw value + let denormalize = |n: f32| -> f32 { + value_min + n * (value_max - value_min) + }; + + // ── Background ────────────────────────────────────────────────────────── + painter.rect_filled(rect, 0.0, Color32::from_rgba_premultiplied(20, 20, 25, 230)); + + // Zero-line (value = 0, or mid-line if range doesn't include 0) + let zero_norm = normalize(0.0).clamp(0.0, 1.0); + let zero_y = value_to_y(zero_norm, rect); + painter.line_segment( + [Pos2::new(rect.min.x, zero_y), Pos2::new(rect.max.x, zero_y)], + Stroke::new(1.0, Color32::from_rgba_premultiplied(80, 80, 80, 120)), + ); + + // ── Curve polyline ─────────────────────────────────────────────────────── + // Build a working keyframe list with any in-progress drag preview applied + let display_keyframes: Vec = if let Some(ref ds) = drag_state { + let mut kfs = keyframes.to_vec(); + if ds.keyframe_index < kfs.len() { + kfs[ds.keyframe_index].time = ds.current_time; + kfs[ds.keyframe_index].value = ds.current_value; + kfs.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap_or(std::cmp::Ordering::Equal)); + } + kfs + } else { + keyframes.to_vec() + }; + + if !display_keyframes.is_empty() { + let step = 2.0f32; // sample every 2 screen pixels + let num_steps = ((rect.width() / step) as usize).max(1); + let mut points: Vec = Vec::with_capacity(num_steps + 1); + + for i in 0..=num_steps { + let x = rect.min.x + i as f32 * step; + let t = x_to_time(x.min(rect.max.x)); + let v = evaluate_curve(&display_keyframes, t); + let y = value_to_y(normalize(v), rect); + points.push(Pos2::new(x.min(rect.max.x), y)); + } + + let curve_color = accent_color.linear_multiply(0.8); + painter.add(Shape::line(points, Stroke::new(1.5, curve_color))); + } + + // ── Playhead ───────────────────────────────────────────────────────────── + let ph_x = time_to_x(playback_time); + if ph_x >= rect.min.x && ph_x <= rect.max.x { + painter.line_segment( + [Pos2::new(ph_x, rect.min.y), Pos2::new(ph_x, rect.max.y)], + Stroke::new(1.0, Color32::from_rgb(255, 80, 80)), + ); + } + + // ── Interaction ────────────────────────────────────────────────────────── + let sense = egui::Sense::click_and_drag(); + let response = ui.interact(rect, id, sense); + + // latest_pos() works whether the pointer button is up or down (unlike interact_pos). + let pointer_pos: Option = ui.input(|i| i.pointer.latest_pos()); + + // Find which keyframe (if any) the pointer is near + let hovered_kf: Option = pointer_pos.and_then(|pos| { + keyframes.iter().enumerate().find(|(_, kf)| { + let kx = time_to_x(kf.time); + let ky = value_to_y(normalize(kf.value), rect); + let d = Vec2::new(pos.x - kx, pos.y - ky).length(); + d <= DIAMOND_RADIUS * 1.5 + }).map(|(i, _)| i) + }); + + // Draw keyframe diamonds (after interaction setup so hover color works) + for (idx, kf) in keyframes.iter().enumerate() { + let kx = time_to_x(kf.time); + if kx < rect.min.x - DIAMOND_RADIUS || kx > rect.max.x + DIAMOND_RADIUS { + continue; + } + let ky = value_to_y(normalize(kf.value), rect); + + // During drag, show this diamond at its preview position + let (draw_x, draw_y) = if let Some(ref ds) = drag_state { + if ds.keyframe_index == idx { + (time_to_x(ds.current_time), value_to_y(normalize(ds.current_value), rect)) + } else { + (kx, ky) + } + } else { + (kx, ky) + }; + + let is_hovered = hovered_kf == Some(idx); + let is_dragging = drag_state.as_ref().map_or(false, |d| d.keyframe_index == idx); + + let fill = if is_dragging { + Color32::WHITE + } else if is_hovered { + accent_color + } else { + accent_color.linear_multiply(0.7) + }; + + draw_diamond(&painter, Pos2::new(draw_x, draw_y), DIAMOND_RADIUS, fill); + } + + // ── Interaction logic ──────────────────────────────────────────────────── + + // Right-click → delete keyframe + if response.secondary_clicked() { + if let Some(idx) = hovered_kf { + return CurveEditAction::DeleteKeyframe { index: idx }; + } + } + + // Left drag start → begin dragging a keyframe + if response.drag_started() { + if let Some(idx) = hovered_kf { + let kf = &keyframes[idx]; + *drag_state = Some(CurveDragState { + keyframe_index: idx, + original_time: kf.time, + original_value: kf.value, + current_time: kf.time, + current_value: kf.value, + }); + } + } + + // Drag in progress → update preview position + if let Some(ref mut ds) = drag_state { + if response.dragged() { + if let Some(pos) = pointer_pos { + let clamped_x = pos.x.clamp(rect.min.x, rect.max.x); + let clamped_y = pos.y.clamp(rect.min.y, rect.max.y); + ds.current_time = x_to_time(clamped_x); + ds.current_value = denormalize(y_to_value(clamped_y, rect)); + } + } + // Drag released → commit + if response.drag_stopped() { + let ds = drag_state.take().unwrap(); + return CurveEditAction::MoveKeyframe { + index: ds.keyframe_index, + new_time: ds.current_time, + new_value: ds.current_value, + }; + } + } + + // Left click on empty space → add keyframe + // Use interact_pointer_pos() here: it captures the click position even after button release. + if response.clicked() && hovered_kf.is_none() && drag_state.is_none() { + if let Some(pos) = response.interact_pointer_pos() { + let t = x_to_time(pos.x); + let v = denormalize(y_to_value(pos.y, rect)); + return CurveEditAction::AddKeyframe { time: t, value: v }; + } + } + + CurveEditAction::None +} + +// ─── Coordinate helpers ─────────────────────────────────────────────────────── + +/// Map a normalised value (0=bottom, 1=top) to a Y screen coordinate within `rect`. +pub fn value_to_y(value: f32, rect: Rect) -> f32 { + rect.max.y - value.clamp(0.0, 1.0) * rect.height() +} + +/// Map a screen Y coordinate within `rect` to a normalised value (0=bottom, 1=top). +pub fn y_to_value(y: f32, rect: Rect) -> f32 { + ((rect.max.y - y) / rect.height()).clamp(0.0, 1.0) +} + +// ─── Drawing utilities ──────────────────────────────────────────────────────── + +fn draw_diamond(painter: &egui::Painter, center: Pos2, radius: f32, fill: Color32) { + let points = vec![ + Pos2::new(center.x, center.y - radius), // top + Pos2::new(center.x + radius, center.y), // right + Pos2::new(center.x, center.y + radius), // bottom + Pos2::new(center.x - radius, center.y), // left + ]; + painter.add(Shape::convex_polygon( + points, + fill, + Stroke::new(1.0, Color32::from_rgba_premultiplied(0, 0, 0, 180)), + )); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 203d8dc..5862509 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -57,6 +57,8 @@ mod test_mode; mod sample_import; mod sample_import_dialog; +mod curve_editor; + /// Lightningbeam Editor - Animation and video editing software #[derive(Parser, Debug)] #[command(name = "Lightningbeam Editor")] @@ -840,6 +842,8 @@ struct EditorApp { track_to_layer_map: HashMap, /// Generation counter - incremented on project load to force UI components to reload project_generation: u64, + /// Incremented whenever node graph topology changes (add/remove node or connection) + graph_topology_generation: u64, // Clip instance ID mapping (Document clip instance UUIDs <-> backend clip instance IDs) clip_instance_to_backend_map: HashMap, // Playback state (global for all panes) @@ -1116,6 +1120,7 @@ impl EditorApp { layer_to_track_map: HashMap::new(), track_to_layer_map: HashMap::new(), project_generation: 0, + graph_topology_generation: 0, clip_instance_to_backend_map: HashMap::new(), playback_time: 0.0, // Start at beginning is_playing: false, // Start paused @@ -5823,6 +5828,7 @@ impl eframe::App for EditorApp { track_to_layer_map: &self.track_to_layer_map, waveform_stereo: self.config.waveform_stereo, project_generation: &mut self.project_generation, + graph_topology_generation: &mut self.graph_topology_generation, script_to_edit: &mut self.script_to_edit, script_saved: &mut self.script_saved, region_selection: &mut self.region_selection, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 2ba7076..e31240b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -268,6 +268,9 @@ pub struct SharedPaneState<'a> { pub waveform_stereo: bool, /// Generation counter - incremented on project load to force reloads pub project_generation: &'a mut u64, + /// Incremented whenever node graph topology changes (add/remove node or connection). + /// Used by the timeline to know when to refresh automation lane caches. + pub graph_topology_generation: &'a mut u64, /// Script ID to open in the script editor (set by node graph "Edit Script" action) pub script_to_edit: &'a mut Option, /// Script ID that was just saved (triggers auto-recompile of nodes using it) 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 3a8e34d..9bae541 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 @@ -301,6 +301,12 @@ pub struct GraphState { pub available_nam_models: Vec, /// Search text for the NAM model picker popup pub nam_search_text: String, + /// Edit buffers for AutomationInput display names, keyed by frontend NodeId + pub automation_name_edits: HashMap, + /// Pending automation name changes (node_id, backend_node_id, new_name) + pub pending_automation_name_changes: Vec<(NodeId, u32, String)>, + /// AutomationInput nodes whose display name still needs to be queried from backend + pub pending_automation_name_queries: Vec<(NodeId, u32)>, } impl Default for GraphState { @@ -327,6 +333,9 @@ impl Default for GraphState { pending_amp_sim_load: None, available_nam_models: Vec::new(), nam_search_text: String::new(), + automation_name_edits: HashMap::new(), + pending_automation_name_changes: Vec::new(), + pending_automation_name_queries: Vec::new(), } } } @@ -1511,6 +1520,21 @@ impl NodeDataTrait for NodeData { if close_popup { egui::Popup::close_id(ui.ctx(), popup_id); } + } else if self.template == NodeTemplate::AutomationInput { + let backend_node_id = user_state.node_backend_ids.get(&node_id).copied().unwrap_or(0); + let edit_buf = user_state.automation_name_edits + .entry(node_id) + .or_insert_with(String::new); + let resp = ui.add( + egui::TextEdit::singleline(edit_buf) + .hint_text("Lane name...") + .desired_width(f32::INFINITY), + ); + if resp.lost_focus() { + user_state.pending_automation_name_changes.push( + (node_id, backend_node_id, edit_buf.clone()), + ); + } } else { ui.label(""); } 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 bbad090..7b6bca1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -681,6 +681,9 @@ impl NodeGraphPane { if let Err(e) = shared.action_executor.execute_with_backend(action, &mut backend_context) { eprintln!("Failed to execute node graph action: {}", e); } else { + // Notify other panes (e.g. timeline automation cache) that graph topology changed + *shared.graph_topology_generation += 1; + // If this was a node addition, query backend to get the new node's ID if let Some((frontend_id, node_type, position)) = self.pending_node_addition.take() { if let Some(track_id) = self.track_id { @@ -1432,6 +1435,7 @@ impl NodeGraphPane { // Create nodes in frontend self.pending_script_resolutions.clear(); + self.user_state.pending_automation_name_queries.clear(); for node in &graph_state.nodes { let node_template = match NodeTemplate::from_backend_name(&node.node_type) { Some(t) => t, @@ -1456,6 +1460,13 @@ impl NodeGraphPane { } } } + + // For AutomationInput nodes: queue a name query to populate the edit buffer + if node.node_type == "AutomationInput" { + if let Some(fid) = frontend_id { + self.user_state.pending_automation_name_queries.push((fid, node.id)); + } + } } // Create connections in frontend @@ -2780,6 +2791,36 @@ impl crate::panes::PaneRenderer for NodeGraphPane { } } + // Populate automation name edit buffers (deferred after load) + if !self.user_state.pending_automation_name_queries.is_empty() { + let queries: Vec<_> = self.user_state.pending_automation_name_queries.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, backend_node_id) in queries { + if let Ok(name) = controller.query_automation_name(backend_track_id, backend_node_id) { + self.user_state.automation_name_edits.insert(node_id, name); + } + } + } + } + } + + // Handle pending automation name changes + if !self.user_state.pending_automation_name_changes.is_empty() { + let changes: Vec<_> = self.user_state.pending_automation_name_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, backend_node_id, name) in changes { + controller.automation_set_name(backend_track_id, backend_node_id, name); + } + // Invalidate timeline automation cache so renamed lanes appear immediately + *shared.graph_topology_generation += 1; + } + } + } + // 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(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/preset_browser.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/preset_browser.rs index 5f31e08..45f364f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/preset_browser.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/preset_browser.rs @@ -7,10 +7,18 @@ use eframe::egui; use std::path::PathBuf; use super::{NodePath, PaneRenderer, SharedPaneState}; +/// Format of a preset file +#[derive(Clone, Copy, PartialEq)] +enum PresetFormat { + Json, + Lbins, +} + /// Metadata extracted from a preset file struct PresetInfo { name: String, path: PathBuf, + format: PresetFormat, category: String, description: String, author: String, @@ -120,19 +128,29 @@ impl PresetBrowserPane { let path = entry.path(); if path.is_dir() { self.scan_directory(&path, base_dir, is_factory); - } else if path.extension().is_some_and(|e| e == "json") { - if let Some(info) = self.load_preset_info(&path, base_dir, is_factory) { - self.presets.push(info); + } else { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext == "json" || ext == "lbins" { + if let Some(info) = self.load_preset_info(&path, base_dir, is_factory) { + self.presets.push(info); + } } } } } - /// Load metadata from a preset JSON file + /// Load metadata from a preset file (.json or .lbins) fn load_preset_info(&self, path: &std::path::Path, base_dir: &std::path::Path, is_factory: bool) -> Option { - let contents = std::fs::read_to_string(path).ok()?; - let preset: daw_backend::audio::node_graph::GraphPreset = - serde_json::from_str(&contents).ok()?; + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + let (preset, format) = if ext == "lbins" { + let (p, _assets) = daw_backend::audio::node_graph::lbins::load_lbins(path).ok()?; + (p, PresetFormat::Lbins) + } else { + let contents = std::fs::read_to_string(path).ok()?; + let p: daw_backend::audio::node_graph::GraphPreset = + serde_json::from_str(&contents).ok()?; + (p, PresetFormat::Json) + }; // Category = first directory component relative to base_dir let relative = path.strip_prefix(base_dir).ok()?; @@ -144,6 +162,7 @@ impl PresetBrowserPane { Some(PresetInfo { name: preset.metadata.name, path: path.to_path_buf(), + format, category, description: preset.metadata.description, author: preset.metadata.author, @@ -189,7 +208,14 @@ impl PresetBrowserPane { if let Some(audio_controller) = &shared.audio_controller { let mut controller = audio_controller.lock().unwrap(); - controller.graph_load_preset(track_id, preset.path.to_string_lossy().to_string()); + match preset.format { + PresetFormat::Json => { + controller.graph_load_preset(track_id, preset.path.to_string_lossy().to_string()); + } + PresetFormat::Lbins => { + controller.graph_load_lbins(track_id, preset.path.clone()); + } + } } // Note: project_generation is incremented by the GraphPresetLoaded event handler // in main.rs, which fires after the audio thread has actually processed the load. diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index e67038e..4b859ef 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -22,6 +22,7 @@ const MAX_PIXELS_PER_SECOND: f32 = 500.0; const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles const LOOP_CORNER_SIZE: f32 = 12.0; // Size of loop corner hotzone at top-right of clip const MIN_CLIP_WIDTH_PX: f32 = 8.0; // Minimum visible width for very short clips (e.g. groups) +const AUTOMATION_LANE_HEIGHT: f32 = 40.0; /// Compute stacking row assignments for clip instances on a vector layer. /// Only clips that overlap in time are stacked; non-overlapping clips share row 0. @@ -207,6 +208,18 @@ pub struct TimelinePane { /// Cached mousedown position in header area (for drag threshold detection) header_mousedown_pos: Option, + /// Which audio/MIDI layers have automation lanes expanded + automation_expanded: std::collections::HashSet, + /// Cached automation lane info per layer_id + automation_cache: std::collections::HashMap>, + /// Drag state per (layer_id, node_id) for in-progress keyframe moves + automation_drag: std::collections::HashMap<(uuid::Uuid, u32), Option>, + /// Pending automation actions to process after render + pending_automation_actions: Vec, + /// Last seen project_generation; used to detect node graph changes and invalidate automation cache + automation_cache_generation: u64, + /// Last seen graph_topology_generation; used to detect node additions/removals + automation_topology_generation: u64, } /// Check if a clip type can be dropped on a layer type @@ -325,6 +338,35 @@ fn flatten_layer<'a>( } } +/// Cached automation lane data for timeline rendering +struct AutomationLaneInfo { + node_id: u32, + name: String, + keyframes: Vec, + value_min: f32, + value_max: f32, +} + +/// Data collected during render_layers for a single automation lane, used to call +/// render_curve_lane *after* handle_input so our widget registers last and wins priority. +struct AutomationLaneRender { + layer_id: uuid::Uuid, + node_id: u32, + lane_rect: egui::Rect, + keyframes: Vec, + value_min: f32, + value_max: f32, + accent_color: egui::Color32, + playback_time: f64, +} + +/// Pending automation keyframe edit action from curve lane interaction +enum AutomationLaneAction { + AddKeyframe { layer_id: uuid::Uuid, node_id: u32, time: f64, value: f32 }, + MoveKeyframe { layer_id: uuid::Uuid, node_id: u32, old_time: f64, new_time: f64, new_value: f32, interpolation: String, ease_out: (f32, f32), ease_in: (f32, f32) }, + DeleteKeyframe { layer_id: uuid::Uuid, node_id: u32, time: f64 }, +} + /// Paint a soft drop shadow around a rect using gradient meshes (bottom + right + corner). /// Three non-overlapping quads so alpha doesn't double up. fn paint_drop_shadow(painter: &egui::Painter, rect: egui::Rect, shadow_size: f32, alpha: u8) { @@ -622,6 +664,12 @@ impl TimelinePane { video_thumbnail_textures: std::collections::HashMap::new(), layer_drag: None, header_mousedown_pos: None, + automation_expanded: std::collections::HashSet::new(), + automation_cache: std::collections::HashMap::new(), + automation_drag: std::collections::HashMap::new(), + pending_automation_actions: Vec::new(), + automation_cache_generation: u64::MAX, + automation_topology_generation: u64::MAX, } } @@ -639,6 +687,95 @@ impl TimelinePane { } } + /// Extra height added to a layer row when automation lanes are expanded + fn automation_lanes_height(&self, layer_id: uuid::Uuid) -> f32 { + if !self.automation_expanded.contains(&layer_id) { + return 0.0; + } + let n = self.automation_cache.get(&layer_id).map_or(0, |v| v.len()); + n as f32 * AUTOMATION_LANE_HEIGHT + } + + /// Total height of a timeline row (LAYER_HEIGHT + automation lanes if expanded) + fn row_height(&self, row: &TimelineRow) -> f32 { + LAYER_HEIGHT + self.automation_lanes_height(row.layer_id()) + } + + /// Cumulative Y offset from top of rows area to the start of row at `idx` + fn cumulative_row_y(&self, rows: &[TimelineRow], idx: usize) -> f32 { + rows[..idx].iter().map(|r| self.row_height(r)).sum() + } + + /// Find which row contains `relative_y` (measured from top of rows area). + /// Returns (row_index, y_within_row). + fn row_at_y(&self, rows: &[TimelineRow], relative_y: f32) -> Option<(usize, f32)> { + let mut y = 0.0f32; + for (i, row) in rows.iter().enumerate() { + let h = self.row_height(row); + if relative_y >= y && relative_y < y + h { + return Some((i, relative_y - y)); + } + y += h; + } + None + } + + /// Refresh the automation lane cache for a layer by querying the backend. + fn refresh_automation_cache( + &mut self, + layer_id: uuid::Uuid, + controller: &mut daw_backend::EngineController, + layer_to_track_map: &std::collections::HashMap, + ) { + let track_id = match layer_to_track_map.get(&layer_id) { + Some(t) => *t, + None => return, + }; + + // Query the graph state JSON + let json = match controller.query_graph_state(track_id) { + Ok(j) => j, + Err(_) => return, + }; + + let preset: daw_backend::GraphPreset = match serde_json::from_str(&json) { + Ok(p) => p, + Err(_) => return, + }; + + let mut lanes = Vec::new(); + for node in &preset.nodes { + if node.node_type != "AutomationInput" { + continue; + } + let name = controller + .query_automation_name(track_id, node.id) + .unwrap_or_else(|_| "Automation".to_string()); + let keyframes = controller + .query_automation_keyframes(track_id, node.id) + .unwrap_or_default() + .iter() + .map(|k| crate::curve_editor::CurvePoint { + time: k.time, + value: k.value, + interpolation: match k.interpolation.as_str() { + "bezier" => crate::curve_editor::CurveInterpolation::Bezier, + "step" => crate::curve_editor::CurveInterpolation::Step, + "hold" => crate::curve_editor::CurveInterpolation::Hold, + _ => crate::curve_editor::CurveInterpolation::Linear, + }, + ease_out: k.ease_out, + ease_in: k.ease_in, + }) + .collect(); + let (value_min, value_max) = controller + .query_automation_range(track_id, node.id) + .unwrap_or((-1.0, 1.0)); + lanes.push(AutomationLaneInfo { node_id: node.id, name, keyframes, value_min, value_max }); + } + self.automation_cache.insert(layer_id, lanes); + } + /// Toggle recording on/off /// In Auto mode, records to the active layer (audio or video with camera) fn toggle_recording(&mut self, shared: &mut SharedPaneState) { @@ -937,11 +1074,11 @@ impl TimelinePane { } let relative_y = pointer_pos.y - header_rect.min.y + self.viewport_scroll_y; - let hovered_layer_index = (relative_y / LAYER_HEIGHT) as usize; - - if hovered_layer_index >= layer_count { - return None; - } + let (hovered_layer_index, _y_within_row) = match self.row_at_y(&rows, relative_y) { + Some(v) => v, + None => return None, + }; + let _ = layer_count; // suppress unused warning let row = &rows[hovered_layer_index]; // Collapsed groups have no directly clickable clips @@ -969,7 +1106,7 @@ impl TimelinePane { if mouse_x >= start_x && mouse_x <= end_x { // Check vertical bounds for stacked vector layer clips - let layer_top = header_rect.min.y + (hovered_layer_index as f32 * LAYER_HEIGHT) - self.viewport_scroll_y; + let layer_top = header_rect.min.y + self.cumulative_row_y(&rows, hovered_layer_index) - self.viewport_scroll_y; let (row, total_rows) = stacking[ci_idx]; let (cy_min, cy_max) = clip_instance_y_bounds(row, total_rows); let mouse_rel_y = pointer_pos.y - layer_top; @@ -1027,10 +1164,10 @@ impl TimelinePane { } let relative_y = pointer_pos.y - header_rect.min.y + self.viewport_scroll_y; - let hovered_index = (relative_y / LAYER_HEIGHT) as usize; - if hovered_index >= rows.len() { - return None; - } + let (hovered_index, _) = match self.row_at_y(&rows, relative_y) { + Some(v) => v, + None => return None, + }; let TimelineRow::CollapsedGroup { group, .. } = &rows[hovered_index] else { return None; @@ -1491,21 +1628,24 @@ impl TimelinePane { let gap_row_index = self.layer_drag.as_ref().map(|d| d.gap_row_index); // Build filtered row list (excluding dragged layers) - let rows: Vec<&TimelineRow> = all_rows.iter() + let rows: Vec = all_rows.iter() .filter(|r| !drag_layer_ids.contains(&r.layer_id())) + .copied() .collect(); // Draw layer headers from virtual row list for (filtered_i, row) in rows.iter().enumerate() { - // Compute Y with gap offset: rows at or after the gap shift down by drag_count * LAYER_HEIGHT - let visual_i = match gap_row_index { - Some(gap) if filtered_i >= gap => filtered_i + drag_count, - _ => filtered_i, + // Compute Y using cumulative heights (supports variable-height rows with automation lanes) + let base_y = self.cumulative_row_y(&rows, filtered_i); + let gap_shift = match gap_row_index { + Some(gap) if filtered_i >= gap => drag_count as f32 * LAYER_HEIGHT, + _ => 0.0, }; - let y = rect.min.y + visual_i as f32 * LAYER_HEIGHT - self.viewport_scroll_y; + let y = rect.min.y + base_y + gap_shift - self.viewport_scroll_y; + let row_total_height = self.row_height(row); - // Skip if layer is outside visible area - if y + LAYER_HEIGHT < rect.min.y || y > rect.max.y { + // Skip if row is outside visible area + if y + row_total_height < rect.min.y || y > rect.max.y { continue; } @@ -1959,6 +2099,103 @@ impl TimelinePane { ], egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))), ); + + // Automation expand/collapse button for Audio/MIDI layers + let is_audio_or_midi = matches!( + row, + TimelineRow::Normal(AnyLayer::Audio(_)) + | TimelineRow::GroupChild { child: AnyLayer::Audio(_), .. } + ); + if is_audio_or_midi { + let btn_rect = egui::Rect::from_min_size( + egui::pos2(rect.min.x + 4.0, y + LAYER_HEIGHT - 18.0), + egui::vec2(16.0, 14.0), + ); + let expanded = self.automation_expanded.contains(&layer_id); + let btn_label = if expanded { "▼" } else { "▶" }; + let btn_response = ui.scope_builder(egui::UiBuilder::new().max_rect(btn_rect), |ui| { + ui.allocate_rect(btn_rect, egui::Sense::click()) + }).inner; + ui.painter().text( + btn_rect.center(), + egui::Align2::CENTER_CENTER, + btn_label, + egui::FontId::proportional(9.0), + secondary_text_color, + ); + if btn_response.clicked() { + self.layer_control_clicked = true; + if expanded { + self.automation_expanded.remove(&layer_id); + } else { + self.automation_expanded.insert(layer_id); + // Trigger cache refresh + // We can't call refresh_automation_cache here (needs controller), + // so mark it as needing refresh via empty cache entry + self.automation_cache.remove(&layer_id); + } + } + } + + // Draw automation lane sub-headers below this row + if self.automation_expanded.contains(&layer_id) { + if let Some(lanes) = self.automation_cache.get(&layer_id) { + let lane_count = lanes.len(); + // Collect lane info to avoid borrow conflict + let lane_names: Vec = lanes.iter().map(|l| l.name.clone()).collect(); + for (lane_idx, lane_name) in lane_names.iter().enumerate() { + let lane_y = y + LAYER_HEIGHT + lane_idx as f32 * AUTOMATION_LANE_HEIGHT; + if lane_y + AUTOMATION_LANE_HEIGHT < rect.min.y || lane_y > rect.max.y { + continue; + } + let lane_rect = egui::Rect::from_min_size( + egui::pos2(rect.min.x, lane_y), + egui::vec2(LAYER_HEADER_WIDTH, AUTOMATION_LANE_HEIGHT), + ); + ui.painter().rect_filled( + lane_rect, + 0.0, + egui::Color32::from_rgb(25, 25, 30), + ); + // Indent line + let indent_rect = egui::Rect::from_min_size( + lane_rect.min, + egui::vec2(20.0, AUTOMATION_LANE_HEIGHT), + ); + ui.painter().rect_filled( + indent_rect, + 0.0, + egui::Color32::from_rgb(15, 15, 20), + ); + // Curve icon (small ≈ symbol) + ui.painter().text( + egui::pos2(lane_rect.min.x + 10.0, lane_y + AUTOMATION_LANE_HEIGHT * 0.5), + egui::Align2::CENTER_CENTER, + "~", + egui::FontId::proportional(11.0), + secondary_text_color, + ); + // Lane name + let display_name = if lane_name.is_empty() { "Automation" } else { lane_name.as_str() }; + ui.painter().text( + egui::pos2(lane_rect.min.x + 22.0, lane_y + AUTOMATION_LANE_HEIGHT * 0.5 - 6.0), + egui::Align2::LEFT_TOP, + display_name, + egui::FontId::proportional(11.0), + text_color, + ); + // Bottom separator + ui.painter().line_segment( + [ + egui::pos2(lane_rect.min.x, lane_y + AUTOMATION_LANE_HEIGHT - 1.0), + egui::pos2(lane_rect.max.x, lane_y + AUTOMATION_LANE_HEIGHT - 1.0), + ], + egui::Stroke::new(1.0, egui::Color32::from_gray(30)), + ); + let _ = lane_count; // suppress warning + } + } + } } // Draw floating dragged layer headers at mouse position with drop shadow @@ -2066,8 +2303,10 @@ impl TimelinePane { context_layers: &[&lightningbeam_core::layer::AnyLayer], video_manager: &std::sync::Arc>, audio_cache: &HashMap>, - ) -> Vec<(egui::Rect, uuid::Uuid, f64, f64)> { - let painter = ui.painter(); + playback_time: f64, + ) -> (Vec<(egui::Rect, uuid::Uuid, f64, f64)>, Vec) { + let painter = ui.painter().clone(); + let mut pending_lane_renders: Vec = Vec::new(); // Collect video clip rects for hover detection (to avoid borrow conflicts) let mut video_clip_hovers: Vec<(egui::Rect, uuid::Uuid, f64, f64)> = Vec::new(); @@ -2106,6 +2345,12 @@ impl TimelinePane { let drag_float_top_y: Option = self.layer_drag.as_ref() .map(|d| d.current_mouse_y - d.grab_offset_y); + // Pre-collect non-dragged rows for cumulative height calculation + let non_dragged_rows: Vec = all_rows.iter() + .filter(|r| !drag_layer_ids_content.contains(&r.layer_id())) + .copied() + .collect(); + let row_y_positions: Vec = { let mut positions = Vec::with_capacity(all_rows.len()); let mut filtered_i = 0usize; @@ -2117,12 +2362,16 @@ impl TimelinePane { positions.push(base_y + drag_offset as f32 * LAYER_HEIGHT); drag_offset += 1; } else { - // Non-dragged row: discrete position, shifted around gap - let visual = match gap_row_index_content { - Some(gap) if filtered_i >= gap => filtered_i + drag_count_content, - _ => filtered_i, + // Non-dragged row: discrete position using cumulative heights + let base_y: f32 = non_dragged_rows[..filtered_i] + .iter() + .map(|r| self.row_height(r)) + .sum(); + let gap_shift = match gap_row_index_content { + Some(gap) if filtered_i >= gap => drag_count_content as f32 * LAYER_HEIGHT, + _ => 0.0, }; - positions.push(rect.min.y + visual as f32 * LAYER_HEIGHT - self.viewport_scroll_y); + positions.push(rect.min.y + base_y + gap_shift - self.viewport_scroll_y); filtered_i += 1; } } @@ -2161,7 +2410,7 @@ impl TimelinePane { // Drop shadow for dragged rows if is_being_dragged { - paint_drop_shadow(painter, layer_rect, 8.0, 60); + paint_drop_shadow(&painter, layer_rect, 8.0, 60); } let row_layer_id = row.layer_id(); @@ -2938,7 +3187,7 @@ impl TimelinePane { if iter_duration <= 0.0 { continue; } Self::render_midi_piano_roll( - painter, + &painter, clip_rect, rect.min.x, events, @@ -2954,7 +3203,7 @@ impl TimelinePane { } } else { Self::render_midi_piano_roll( - painter, + &painter, clip_rect, rect.min.x, events, @@ -3332,13 +3581,41 @@ impl TimelinePane { ], egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))), ); + + // Collect automation lane render data — actual render_curve_lane calls happen after + // handle_input so our widgets register last and win egui's interaction priority. + if self.automation_expanded.contains(&row_layer_id) { + if let Some(lanes) = self.automation_cache.get(&row_layer_id) { + let (_, tc) = layer_type_info(layer); + for (lane_idx, lane) in lanes.iter().enumerate() { + let lane_top = y + LAYER_HEIGHT + lane_idx as f32 * AUTOMATION_LANE_HEIGHT; + if lane_top + AUTOMATION_LANE_HEIGHT < rect.min.y || lane_top > rect.max.y { + continue; + } + let lane_rect = egui::Rect::from_min_size( + egui::pos2(rect.min.x, lane_top), + egui::vec2(rect.width(), AUTOMATION_LANE_HEIGHT), + ); + pending_lane_renders.push(AutomationLaneRender { + layer_id: row_layer_id, + node_id: lane.node_id, + lane_rect, + keyframes: lane.keyframes.clone(), + value_min: lane.value_min, + value_max: lane.value_max, + accent_color: tc, + playback_time, + }); + } + } + } } // Clean up stale video thumbnail textures for clips no longer visible self.video_thumbnail_textures.retain(|&(clip_id, _), _| visible_video_clip_ids.contains(&clip_id)); - // Return video clip hover data for processing after input handling - video_clip_hovers + // Return video clip hover data and pending lane renders for processing after input handling + (video_clip_hovers, pending_lane_renders) } /// Handle mouse input for scrubbing, panning, zooming, layer selection, and clip instance selection @@ -4621,7 +4898,7 @@ impl PaneRenderer for TimelinePane { // Render layer rows with clipping ui.set_clip_rect(content_rect.intersect(original_clip_rect)); - let video_clip_hovers = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager, &audio_cache); + let (video_clip_hovers, pending_lane_renders) = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager, &audio_cache, *shared.playback_time); // Render playhead on top (clip to timeline area) ui.set_clip_rect(timeline_rect.intersect(original_clip_rect)); @@ -4630,6 +4907,88 @@ impl PaneRenderer for TimelinePane { // Restore original clip rect ui.set_clip_rect(original_clip_rect); + // Process pending automation lane edit actions + if !self.pending_automation_actions.is_empty() { + let actions = std::mem::take(&mut self.pending_automation_actions); + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + for action in actions { + match action { + AutomationLaneAction::AddKeyframe { layer_id, node_id, time, value } => { + if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) { + controller.automation_add_keyframe(track_id, node_id, time, value, "linear".to_string(), (0.0, 0.0), (0.0, 0.0)); + // Optimistic cache update + if let Some(lanes) = self.automation_cache.get_mut(&layer_id) { + if let Some(lane) = lanes.iter_mut().find(|l| l.node_id == node_id) { + let new_kf = crate::curve_editor::CurvePoint { + time, + value, + interpolation: crate::curve_editor::CurveInterpolation::Linear, + ease_out: (0.0, 0.0), + ease_in: (0.0, 0.0), + }; + lane.keyframes.push(new_kf); + lane.keyframes.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap_or(std::cmp::Ordering::Equal)); + } + } + } + } + AutomationLaneAction::MoveKeyframe { layer_id, node_id, old_time, new_time, new_value, interpolation, ease_out, ease_in } => { + if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) { + controller.automation_remove_keyframe(track_id, node_id, old_time); + controller.automation_add_keyframe(track_id, node_id, new_time, new_value, interpolation.clone(), ease_out, ease_in); + // Optimistic cache update + if let Some(lanes) = self.automation_cache.get_mut(&layer_id) { + if let Some(lane) = lanes.iter_mut().find(|l| l.node_id == node_id) { + if let Some(kf) = lane.keyframes.iter_mut().find(|k| (k.time - old_time).abs() < 0.001) { + kf.time = new_time; + kf.value = new_value; + } + lane.keyframes.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap_or(std::cmp::Ordering::Equal)); + } + } + } + } + AutomationLaneAction::DeleteKeyframe { layer_id, node_id, time } => { + if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) { + controller.automation_remove_keyframe(track_id, node_id, time); + // Optimistic cache update + if let Some(lanes) = self.automation_cache.get_mut(&layer_id) { + if let Some(lane) = lanes.iter_mut().find(|l| l.node_id == node_id) { + lane.keyframes.retain(|k| (k.time - time).abs() >= 0.001); + } + } + } + } + } + } + } + } + + // Invalidate automation cache when the project changes (new node added, etc.) + if *shared.project_generation != self.automation_cache_generation { + self.automation_cache_generation = *shared.project_generation; + self.automation_cache.clear(); + } + + // Refresh automation cache for expanded layers. + // Clears all caches when the project is reloaded (project_generation) or when the node + // graph topology changes (graph_topology_generation — bumped by the node graph pane on + // any successful add/remove/connect action). + let topology_changed = *shared.graph_topology_generation != self.automation_topology_generation; + if topology_changed { + self.automation_topology_generation = *shared.graph_topology_generation; + self.automation_cache.clear(); + } + for layer_id in self.automation_expanded.iter().copied().collect::>() { + if !self.automation_cache.contains_key(&layer_id) { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + self.refresh_automation_cache(layer_id, &mut controller, shared.layer_to_track_map); + } + } + } + // Handle input (use full rect including header column) self.handle_input( ui, @@ -4651,6 +5010,69 @@ impl PaneRenderer for TimelinePane { &audio_cache, ); + // Render automation lanes AFTER handle_input so our ui.interact registers last and wins + // egui's interaction priority over handle_input's full-content-area allocation. + ui.set_clip_rect(content_rect.intersect(original_clip_rect)); + for lane in &pending_lane_renders { + let drag_key = (lane.layer_id, lane.node_id); + let mut drag_state_local = self.automation_drag + .get(&drag_key) + .and_then(|v| v.clone()); + let lane_id = egui::Id::new("automation_lane") + .with(lane.layer_id) + .with(lane.node_id); + let lane_min_x = lane.lane_rect.min.x; + let action = crate::curve_editor::render_curve_lane( + ui, + lane.lane_rect, + &lane.keyframes, + &mut drag_state_local, + lane.playback_time, + lane.accent_color, + lane_id, + lane.value_min, + lane.value_max, + |t| lane_min_x + self.time_to_x(t), + |x| self.x_to_time(x - lane_min_x), + ); + self.automation_drag.insert(drag_key, drag_state_local); + let layer_id = lane.layer_id; + let node_id = lane.node_id; + let keyframes = &lane.keyframes; + match action { + crate::curve_editor::CurveEditAction::AddKeyframe { time, value } => { + self.pending_automation_actions.push(AutomationLaneAction::AddKeyframe { + layer_id, node_id, time, value, + }); + } + crate::curve_editor::CurveEditAction::MoveKeyframe { index, new_time, new_value } => { + if let Some(kf) = keyframes.get(index) { + self.pending_automation_actions.push(AutomationLaneAction::MoveKeyframe { + layer_id, node_id, + old_time: kf.time, new_time, new_value, + interpolation: match kf.interpolation { + crate::curve_editor::CurveInterpolation::Bezier => "bezier".to_string(), + crate::curve_editor::CurveInterpolation::Step => "step".to_string(), + crate::curve_editor::CurveInterpolation::Hold => "hold".to_string(), + _ => "linear".to_string(), + }, + ease_out: kf.ease_out, + ease_in: kf.ease_in, + }); + } + } + crate::curve_editor::CurveEditAction::DeleteKeyframe { index } => { + if let Some(kf) = keyframes.get(index) { + self.pending_automation_actions.push(AutomationLaneAction::DeleteKeyframe { + layer_id, node_id, time: kf.time, + }); + } + } + crate::curve_editor::CurveEditAction::None => {} + } + } + ui.set_clip_rect(original_clip_rect); + // Context menu: detect right-click on clips or empty timeline space let mut just_opened_menu = false; let secondary_clicked = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary)); @@ -4933,10 +5355,9 @@ impl PaneRenderer for TimelinePane { if content_rect.contains(pointer_pos) { // Calculate which layer the pointer is over let relative_y = pointer_pos.y - content_rect.min.y + self.viewport_scroll_y; - let hovered_layer_index = (relative_y / LAYER_HEIGHT) as usize; - // Get the layer at this index (using virtual rows for group support) let drop_rows = build_timeline_rows(&context_layers); + let hovered_layer_index = self.row_at_y(&drop_rows, relative_y).map(|(i, _)| i).unwrap_or(usize::MAX); let drop_layer = drop_rows.get(hovered_layer_index).and_then(|r| r.as_any_layer()); if let Some(layer) = drop_layer { From 6b6ae230a1a979e8c76573a9e0db1916d2bcbd5e Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 18 Mar 2026 23:11:24 -0400 Subject: [PATCH 2/7] Add pitch bend support --- daw-backend/src/audio/engine.rs | 66 +++ .../src/audio/node_graph/nodes/midi_to_cv.rs | 181 +++--- .../audio/node_graph/nodes/multi_sampler.rs | 69 ++- .../audio/node_graph/nodes/voice_allocator.rs | 11 +- daw-backend/src/command/types.rs | 8 + .../lightningbeam-core/src/action.rs | 21 + .../lightningbeam-core/src/actions/mod.rs | 2 + .../src/actions/update_midi_events.rs | 76 +++ .../lightningbeam-editor/src/main.rs | 103 ++-- .../src/panes/asset_library.rs | 7 +- .../src/panes/infopanel.rs | 16 +- .../lightningbeam-editor/src/panes/mod.rs | 2 +- .../src/panes/piano_roll.rs | 514 +++++++++++++++++- .../src/panes/timeline.rs | 14 +- 14 files changed, 891 insertions(+), 199 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/update_midi_events.rs diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 66788a3..2ae9454 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -992,6 +992,13 @@ impl Engine { clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); } } + Command::UpdateMidiClipEvents(_track_id, clip_id, events) => { + // Replace all events in a MIDI clip (used for CC/pitch bend editing) + if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { + clip.events = events; + clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); + } + } Command::RemoveMidiClip(track_id, instance_id) => { // Remove a MIDI clip instance from a track (for undo/redo support) let _ = self.project.remove_midi_clip(track_id, instance_id); @@ -2818,6 +2825,40 @@ impl Engine { }; QueryResponse::GraphIsDefault(is_default) } + + Query::GetPitchBendRange(track_id) => { + use crate::audio::node_graph::nodes::{MidiToCVNode, MultiSamplerNode, VoiceAllocatorNode}; + use crate::audio::node_graph::AudioNode; + let range = if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) { + let graph = &track.instrument_graph; + let mut found = None; + for idx in graph.node_indices() { + if let Some(gn) = graph.get_graph_node(idx) { + if let Some(ms) = gn.node.as_any().downcast_ref::() { + found = Some(ms.get_parameter(4)); // PARAM_PITCH_BEND_RANGE + break; + } + // Search inside VoiceAllocator template for MidiToCV + if let Some(va) = gn.node.as_any().downcast_ref::() { + let tg = va.template_graph(); + for tidx in tg.node_indices() { + if let Some(tgn) = tg.get_graph_node(tidx) { + if let Some(mc) = tgn.node.as_any().downcast_ref::() { + found = Some(mc.get_parameter(0)); // PARAM_PITCH_BEND_RANGE + break; + } + } + } + if found.is_some() { break; } + } + } + } + found.unwrap_or(2.0) + } else { + 2.0 + }; + QueryResponse::PitchBendRange(range) + } }; // Send response back @@ -3412,6 +3453,11 @@ impl EngineController { let _ = self.command_tx.push(Command::UpdateMidiClipNotes(track_id, clip_id, notes)); } + /// Replace all events in a MIDI clip (used for CC/pitch bend editing from the piano roll) + pub fn update_midi_clip_events(&mut self, track_id: TrackId, clip_id: MidiClipId, events: Vec) { + let _ = self.command_tx.push(Command::UpdateMidiClipEvents(track_id, clip_id, events)); + } + /// Remove a MIDI clip instance from a track (for undo/redo support) pub fn remove_midi_clip(&mut self, track_id: TrackId, instance_id: MidiClipInstanceId) { let _ = self.command_tx.push(Command::RemoveMidiClip(track_id, instance_id)); @@ -3952,6 +3998,26 @@ impl EngineController { Err("Query timeout".to_string()) } + /// Query the pitch bend range (semitones) for the instrument on a MIDI track. + /// Returns 2.0 (default) if the track or instrument cannot be found. + pub fn query_pitch_bend_range(&mut self, track_id: TrackId) -> f32 { + if let Err(_) = self.query_tx.push(Query::GetPitchBendRange(track_id)) { + return 2.0; + } + + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(100); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::PitchBendRange(range)) = self.query_response_rx.pop() { + return range; + } + std::thread::sleep(std::time::Duration::from_micros(50)); + } + + 2.0 // default on timeout + } + /// Serialize the audio pool for project saving pub fn serialize_audio_pool(&mut self, project_path: &std::path::Path) -> Result, String> { // Send query diff --git a/daw-backend/src/audio/node_graph/nodes/midi_to_cv.rs b/daw-backend/src/audio/node_graph/nodes/midi_to_cv.rs index 77f4d41..085333f 100644 --- a/daw-backend/src/audio/node_graph/nodes/midi_to_cv.rs +++ b/daw-backend/src/audio/node_graph/nodes/midi_to_cv.rs @@ -1,14 +1,19 @@ use crate::audio::midi::MidiEvent; -use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType}; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; + +const PARAM_PITCH_BEND_RANGE: u32 = 0; /// MIDI to CV converter /// Converts MIDI note events to control voltage signals pub struct MidiToCVNode { name: String, - note: u8, // Current MIDI note number - gate: f32, // Gate CV (1.0 when note on, 0.0 when off) - velocity: f32, // Velocity CV (0.0-1.0) - pitch_cv: f32, // Pitch CV (V/Oct: 0V = A4, ±1V per octave) + note: u8, // Current MIDI note number + gate: f32, // Gate CV (1.0 when note on, 0.0 when off) + velocity: f32, // Velocity CV (0.0-1.0) + pitch_cv: f32, // Pitch CV (V/Oct: 0V = A4, ±1V per octave), without bend + pitch_bend_range: f32, // Pitch bend range in semitones (default 2.0) + current_bend: f32, // Current pitch bend, normalised -1.0..=1.0 (0 = centre) + current_mod: f32, // Current modulation (CC1), 0.0..=1.0 inputs: Vec, outputs: Vec, parameters: Vec, @@ -18,26 +23,41 @@ impl MidiToCVNode { pub fn new(name: impl Into) -> Self { let name = name.into(); - // MIDI input port for receiving MIDI through graph connections let inputs = vec![ NodePort::new("MIDI In", SignalType::Midi, 0), + NodePort::new("Bend CV", SignalType::CV, 0), // External pitch bend in semitones + NodePort::new("Mod CV", SignalType::CV, 1), // External modulation 0.0..=1.0 ]; let outputs = vec![ - NodePort::new("V/Oct", SignalType::CV, 0), // V/Oct: 0V = A4, ±1V per octave + NodePort::new("V/Oct", SignalType::CV, 0), // V/Oct: 0V = A4, ±1V per octave (with bend applied) NodePort::new("Gate", SignalType::CV, 1), // 1.0 = on, 0.0 = off NodePort::new("Velocity", SignalType::CV, 2), // 0.0-1.0 + NodePort::new("Bend", SignalType::CV, 3), // Total pitch bend in semitones (MIDI + CV) + NodePort::new("Mod", SignalType::CV, 4), // Total modulation 0.0..=1.0 (MIDI CC1 + CV) + ]; + + let parameters = vec![ + Parameter::new( + PARAM_PITCH_BEND_RANGE, + "Pitch Bend Range", + 0.0, 48.0, 2.0, + ParameterUnit::Generic, + ), ]; Self { name, - note: 60, // Middle C + note: 60, gate: 0.0, velocity: 0.0, pitch_cv: Self::midi_note_to_voct(60), + pitch_bend_range: 2.0, + current_bend: 0.0, + current_mod: 0.0, inputs, outputs, - parameters: vec![], // No user parameters + parameters, } } @@ -48,6 +68,37 @@ impl MidiToCVNode { // Standard V/Oct: 0V at A4, 1V per octave (12 semitones) (note as f32 - 69.0) / 12.0 } + + fn apply_midi_event(&mut self, event: &MidiEvent) { + let status = event.status & 0xF0; + match status { + 0x90 if event.data2 > 0 => { + // Note on — reset per-note expression so previous note's bend doesn't bleed in + self.note = event.data1; + self.pitch_cv = Self::midi_note_to_voct(self.note); + self.velocity = event.data2 as f32 / 127.0; + self.gate = 1.0; + self.current_bend = 0.0; + self.current_mod = 0.0; + } + 0x80 | 0x90 => { + // Note off (or note on with velocity 0) + if event.data1 == self.note { + self.gate = 0.0; + } + } + 0xE0 => { + // Pitch bend: 14-bit value, center = 8192 + let bend_raw = ((event.data2 as i16) << 7) | (event.data1 as i16); + self.current_bend = (bend_raw - 8192) as f32 / 8192.0; + } + 0xB0 if event.data1 == 1 => { + // CC1 (modulation wheel) + self.current_mod = event.data2 as f32 / 127.0; + } + _ => {} + } + } } impl AudioNode for MidiToCVNode { @@ -67,46 +118,27 @@ impl AudioNode for MidiToCVNode { &self.parameters } - fn set_parameter(&mut self, _id: u32, _value: f32) { - // No parameters + fn set_parameter(&mut self, id: u32, value: f32) { + if id == PARAM_PITCH_BEND_RANGE { + self.pitch_bend_range = value.clamp(0.0, 48.0); + } } - fn get_parameter(&self, _id: u32) -> f32 { - 0.0 + fn get_parameter(&self, id: u32) -> f32 { + if id == PARAM_PITCH_BEND_RANGE { + self.pitch_bend_range + } else { + 0.0 + } } fn handle_midi(&mut self, event: &MidiEvent) { - let status = event.status & 0xF0; - - match status { - 0x90 => { - // Note on - if event.data2 > 0 { - // Velocity > 0 means note on - self.note = event.data1; - self.pitch_cv = Self::midi_note_to_voct(self.note); - self.velocity = event.data2 as f32 / 127.0; - self.gate = 1.0; - } else { - // Velocity = 0 means note off - if event.data1 == self.note { - self.gate = 0.0; - } - } - } - 0x80 => { - // Note off - if event.data1 == self.note { - self.gate = 0.0; - } - } - _ => {} - } + self.apply_midi_event(event); } fn process( &mut self, - _inputs: &[&[f32]], + inputs: &[&[f32]], outputs: &mut [&mut [f32]], midi_inputs: &[&[MidiEvent]], _midi_outputs: &mut [&mut Vec], @@ -115,52 +147,56 @@ impl AudioNode for MidiToCVNode { // Process MIDI events from input buffer if !midi_inputs.is_empty() { for event in midi_inputs[0] { - let status = event.status & 0xF0; - match status { - 0x90 if event.data2 > 0 => { - // Note on - self.note = event.data1; - self.pitch_cv = Self::midi_note_to_voct(self.note); - self.velocity = event.data2 as f32 / 127.0; - self.gate = 1.0; - } - 0x80 | 0x90 => { - // Note off (or note on with velocity 0) - if event.data1 == self.note { - self.gate = 0.0; - } - } - _ => {} - } + self.apply_midi_event(event); } } - if outputs.len() < 3 { + if outputs.len() < 5 { return; } - // CV signals are mono - // Use split_at_mut to get multiple mutable references - let (pitch_and_rest, rest) = outputs.split_at_mut(1); - let (gate_and_rest, velocity_slice) = rest.split_at_mut(1); + // Read CV inputs (use first sample of buffer). NaN = unconnected port → treat as 0. + let bend_cv = inputs.get(0).and_then(|b| b.first().copied()) + .filter(|v| v.is_finite()).unwrap_or(0.0); + let mod_cv = inputs.get(1).and_then(|b| b.first().copied()) + .filter(|v| v.is_finite()).unwrap_or(0.0); - let pitch_out = &mut pitch_and_rest[0]; - let gate_out = &mut gate_and_rest[0]; - let velocity_out = &mut velocity_slice[0]; + // Total bend in semitones: MIDI bend + CV bend + let bend_semitones = self.current_bend * self.pitch_bend_range + bend_cv; + // Total mod: MIDI CC1 + CV mod, clamped to 0..1 + let total_mod = (self.current_mod + mod_cv).clamp(0.0, 1.0); + // Pitch output includes bend + let pitch_out_val = self.pitch_cv + bend_semitones / 12.0; + + // Use split_at_mut to get multiple mutable references + let (v0, rest) = outputs.split_at_mut(1); + let (v1, rest) = rest.split_at_mut(1); + let (v2, rest) = rest.split_at_mut(1); + let (v3, v4_slice) = rest.split_at_mut(1); + + let pitch_out = &mut v0[0]; + let gate_out = &mut v1[0]; + let velocity_out = &mut v2[0]; + let bend_out = &mut v3[0]; + let mod_out = &mut v4_slice[0]; let frames = pitch_out.len(); // Output constant CV values for the entire buffer for frame in 0..frames { - pitch_out[frame] = self.pitch_cv; - gate_out[frame] = self.gate; + pitch_out[frame] = pitch_out_val; + gate_out[frame] = self.gate; velocity_out[frame] = self.velocity; + bend_out[frame] = bend_semitones; + mod_out[frame] = total_mod; } } fn reset(&mut self) { self.gate = 0.0; self.velocity = 0.0; + self.current_bend = 0.0; + self.current_mod = 0.0; } fn node_type(&self) -> &str { @@ -174,10 +210,13 @@ impl AudioNode for MidiToCVNode { fn clone_node(&self) -> Box { Box::new(Self { name: self.name.clone(), - note: 60, // Reset to middle C - gate: 0.0, // Reset gate - velocity: 0.0, // Reset velocity - pitch_cv: Self::midi_note_to_voct(60), // Reset pitch + note: 60, + gate: 0.0, + velocity: 0.0, + pitch_cv: Self::midi_note_to_voct(60), + pitch_bend_range: self.pitch_bend_range, + current_bend: 0.0, + current_mod: 0.0, inputs: self.inputs.clone(), outputs: self.outputs.clone(), parameters: self.parameters.clone(), diff --git a/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs b/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs index 344c3b1..8ba01b3 100644 --- a/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs +++ b/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs @@ -6,6 +6,7 @@ const PARAM_GAIN: u32 = 0; const PARAM_ATTACK: u32 = 1; const PARAM_RELEASE: u32 = 2; const PARAM_TRANSPOSE: u32 = 3; +const PARAM_PITCH_BEND_RANGE: u32 = 4; /// Loop playback mode #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] @@ -201,6 +202,7 @@ struct Voice { layer_index: usize, playhead: f32, note: u8, + channel: u8, // MIDI channel this voice was activated on velocity: u8, is_active: bool, @@ -221,11 +223,12 @@ enum EnvelopePhase { } impl Voice { - fn new(layer_index: usize, note: u8, velocity: u8) -> Self { + fn new(layer_index: usize, note: u8, channel: u8, velocity: u8) -> Self { Self { layer_index, playhead: 0.0, note, + channel, velocity, is_active: true, envelope_phase: EnvelopePhase::Attack, @@ -250,9 +253,14 @@ pub struct MultiSamplerNode { // Parameters gain: f32, - attack_time: f32, // seconds - release_time: f32, // seconds - transpose: i8, // semitones + attack_time: f32, // seconds + release_time: f32, // seconds + transpose: i8, // semitones + pitch_bend_range: f32, // semitones (default 2.0) + + // Live MIDI state + bend_per_channel: [f32; 16], // Pitch bend per MIDI channel; ch0 = global broadcast + current_mod: f32, // MIDI CC1 modulation 0.0..=1.0 inputs: Vec, outputs: Vec, @@ -265,6 +273,8 @@ impl MultiSamplerNode { let inputs = vec![ NodePort::new("MIDI In", SignalType::Midi, 0), + NodePort::new("Bend CV", SignalType::CV, 0), // External pitch bend in semitones + NodePort::new("Mod CV", SignalType::CV, 1), // External modulation 0.0..=1.0 ]; let outputs = vec![ @@ -276,6 +286,7 @@ impl MultiSamplerNode { Parameter::new(PARAM_ATTACK, "Attack", 0.001, 1.0, 0.01, ParameterUnit::Time), Parameter::new(PARAM_RELEASE, "Release", 0.01, 5.0, 0.1, ParameterUnit::Time), Parameter::new(PARAM_TRANSPOSE, "Transpose", -24.0, 24.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_PITCH_BEND_RANGE, "Pitch Bend Range", 0.0, 48.0, 2.0, ParameterUnit::Generic), ]; Self { @@ -288,6 +299,9 @@ impl MultiSamplerNode { attack_time: 0.01, release_time: 0.1, transpose: 0, + pitch_bend_range: 2.0, + bend_per_channel: [0.0; 16], + current_mod: 0.0, inputs, outputs, parameters, @@ -478,7 +492,9 @@ impl MultiSamplerNode { } /// Trigger a note - fn note_on(&mut self, note: u8, velocity: u8) { + fn note_on(&mut self, note: u8, channel: u8, velocity: u8) { + // Reset per-channel bend on note-on so a previous note's bend doesn't bleed in + self.bend_per_channel[channel as usize] = 0.0; let transposed_note = (note as i16 + self.transpose as i16).clamp(0, 127) as u8; if let Some(layer_index) = self.find_layer(transposed_note, velocity) { @@ -496,7 +512,7 @@ impl MultiSamplerNode { } }); - let voice = Voice::new(layer_index, note, velocity); + let voice = Voice::new(layer_index, note, channel, velocity); if voice_index < self.voices.len() { self.voices[voice_index] = voice; @@ -547,6 +563,9 @@ impl AudioNode for MultiSamplerNode { PARAM_TRANSPOSE => { self.transpose = value.clamp(-24.0, 24.0) as i8; } + PARAM_PITCH_BEND_RANGE => { + self.pitch_bend_range = value.clamp(0.0, 48.0); + } _ => {} } } @@ -557,13 +576,14 @@ impl AudioNode for MultiSamplerNode { PARAM_ATTACK => self.attack_time, PARAM_RELEASE => self.release_time, PARAM_TRANSPOSE => self.transpose as f32, + PARAM_PITCH_BEND_RANGE => self.pitch_bend_range, _ => 0.0, } } fn process( &mut self, - _inputs: &[&[f32]], + inputs: &[&[f32]], outputs: &mut [&mut [f32]], midi_inputs: &[&[MidiEvent]], _midi_outputs: &mut [&mut Vec], @@ -582,14 +602,32 @@ impl AudioNode for MultiSamplerNode { // Process MIDI events if !midi_inputs.is_empty() { for event in midi_inputs[0].iter() { - if event.is_note_on() { - self.note_on(event.data1, event.data2); - } else if event.is_note_off() { - self.note_off(event.data1); + let status = event.status & 0xF0; + match status { + _ if event.is_note_on() => self.note_on(event.data1, event.status & 0x0F, event.data2), + _ if event.is_note_off() => self.note_off(event.data1), + 0xE0 => { + // Pitch bend: 14-bit value, center = 8192; stored per-channel + let bend_raw = ((event.data2 as i16) << 7) | (event.data1 as i16); + let ch = (event.status & 0x0F) as usize; + self.bend_per_channel[ch] = (bend_raw - 8192) as f32 / 8192.0; + } + 0xB0 if event.data1 == 1 => { + // CC1 (modulation wheel) + self.current_mod = event.data2 as f32 / 127.0; + } + _ => {} } } } + // Read CV inputs. NaN = unconnected port → treat as 0. + let bend_cv = inputs.get(0).and_then(|b| b.first().copied()) + .filter(|v| v.is_finite()).unwrap_or(0.0); + // Global bend (channel 0) applies to all voices; per-channel bend is added per-voice below. + let global_bend_norm = self.bend_per_channel[0]; + let bend_per_channel = self.bend_per_channel; + // Extract parameters needed for processing let gain = self.gain; let attack_time = self.attack_time; @@ -607,9 +645,12 @@ impl AudioNode for MultiSamplerNode { let layer = &self.layers[voice.layer_index]; - // Calculate playback speed + // Calculate playback speed (includes pitch bend) + // Channel-0 = global; voice's own channel bend is added on top. + let voice_bend_norm = global_bend_norm + bend_per_channel[voice.channel as usize]; + let total_bend_semitones = voice_bend_norm * self.pitch_bend_range + bend_cv; let semitone_diff = voice.note as i16 - layer.root_key as i16; - let speed = 2.0_f32.powf(semitone_diff as f32 / 12.0); + let speed = 2.0_f32.powf((semitone_diff as f32 + total_bend_semitones) / 12.0); let speed_adjusted = speed * (layer.sample_rate / sample_rate as f32); for frame in 0..frames { @@ -765,6 +806,8 @@ impl AudioNode for MultiSamplerNode { fn reset(&mut self) { self.voices.clear(); + self.bend_per_channel = [0.0; 16]; + self.current_mod = 0.0; } fn node_type(&self) -> &str { diff --git a/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs b/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs index 2f2b9dc..e43c9e3 100644 --- a/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs +++ b/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs @@ -11,6 +11,7 @@ struct VoiceState { active: bool, releasing: bool, // Note-off received, still processing (e.g. ADSR release) note: u8, + note_channel: u8, // MIDI channel this voice was allocated on (0 = global/unset) age: u32, // For voice stealing pending_events: Vec, // MIDI events to send to this voice } @@ -21,6 +22,7 @@ impl VoiceState { active: false, releasing: false, note: 0, + note_channel: 0, age: 0, pending_events: Vec::new(), } @@ -273,6 +275,7 @@ impl AudioNode for VoiceAllocatorNode { self.voices[voice_idx].active = true; self.voices[voice_idx].releasing = false; self.voices[voice_idx].note = event.data1; + self.voices[voice_idx].note_channel = event.status & 0x0F; self.voices[voice_idx].age = 0; // Store MIDI event for this voice to process @@ -295,10 +298,12 @@ impl AudioNode for VoiceAllocatorNode { } } _ => { - // Other MIDI events (CC, pitch bend, etc.) - send to all active voices + // Route to matching-channel voices; channel 0 = global broadcast + let event_channel = event.status & 0x0F; for voice_idx in 0..self.voice_count { - if self.voices[voice_idx].active { - self.voices[voice_idx].pending_events.push(*event); + let voice = &mut self.voices[voice_idx]; + if voice.active && (event_channel == 0 || voice.note_channel == event_channel) { + voice.pending_events.push(*event); } } } diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 9ff4db8..2baf944 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -2,6 +2,7 @@ use crate::audio::{ AudioClipInstanceId, AutomationLaneId, ClipId, CurveType, MidiClip, MidiClipId, MidiClipInstanceId, ParameterId, TrackId, }; +use crate::audio::midi::MidiEvent; use crate::audio::buffer_pool::BufferPoolStats; use crate::audio::node_graph::nodes::LoopMode; use crate::io::WaveformPeak; @@ -85,6 +86,8 @@ pub enum Command { /// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>) /// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(f64, u8, u8, f64)>), + /// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing. + UpdateMidiClipEvents(TrackId, MidiClipId, Vec), /// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support RemoveMidiClip(TrackId, MidiClipInstanceId), /// Remove an audio clip instance from a track (track_id, instance_id) - for undo/redo support @@ -445,6 +448,9 @@ pub enum Query { DuplicateMidiClipSync(MidiClipId), /// Get whether a track's graph is still the auto-generated default GetGraphIsDefault(TrackId), + /// Get the pitch bend range (in semitones) for the instrument on a MIDI track. + /// Searches for MidiToCVNode (in VA templates) or MultiSamplerNode (direct). + GetPitchBendRange(TrackId), } /// Oscilloscope data from a node @@ -522,4 +528,6 @@ pub enum QueryResponse { MidiClipDuplicated(Result), /// Whether a track's graph is the auto-generated default GraphIsDefault(bool), + /// Pitch bend range in semitones for the track's instrument + PitchBendRange(f32), } diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index d09613f..ac8628f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -109,6 +109,17 @@ pub trait Action: Send { fn midi_notes_after_rollback(&self) -> Option<(u32, &[(f64, u8, u8, f64)])> { None } + + /// Return full MIDI event data (CC, pitch bend, etc.) reflecting the state after execute/redo. + /// Used to keep the frontend MIDI event cache in sync after undo/redo. + fn midi_events_after_execute(&self) -> Option<(u32, &[daw_backend::audio::midi::MidiEvent])> { + None + } + + /// Return full MIDI event data reflecting the state after rollback/undo. + fn midi_events_after_rollback(&self) -> Option<(u32, &[daw_backend::audio::midi::MidiEvent])> { + None + } } /// Action executor that wraps the document and manages undo/redo @@ -280,6 +291,16 @@ impl ActionExecutor { self.redo_stack.last().and_then(|a| a.midi_notes_after_rollback()) } + /// Get full MIDI event data from the last action on the undo stack (after redo). + pub fn last_undo_midi_events(&self) -> Option<(u32, &[daw_backend::audio::midi::MidiEvent])> { + self.undo_stack.last().and_then(|a| a.midi_events_after_execute()) + } + + /// Get full MIDI event data from the last action on the redo stack (after undo). + pub fn last_redo_midi_events(&self) -> Option<(u32, &[daw_backend::audio::midi::MidiEvent])> { + self.redo_stack.last().and_then(|a| a.midi_events_after_rollback()) + } + /// Get the description of the next action to redo pub fn redo_description(&self) -> Option { self.redo_stack.last().map(|a| a.description()) diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index bd0d375..12d88dc 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -23,6 +23,7 @@ pub mod rename_folder; pub mod delete_folder; pub mod move_asset_to_folder; pub mod update_midi_notes; +pub mod update_midi_events; pub mod loop_clip_instances; pub mod remove_clip_instances; pub mod set_keyframe; @@ -56,6 +57,7 @@ pub use rename_folder::RenameFolderAction; pub use delete_folder::{DeleteFolderAction, DeleteStrategy}; pub use move_asset_to_folder::MoveAssetToFolderAction; pub use update_midi_notes::UpdateMidiNotesAction; +pub use update_midi_events::UpdateMidiEventsAction; pub use loop_clip_instances::LoopClipInstancesAction; pub use remove_clip_instances::RemoveClipInstancesAction; pub use set_keyframe::SetKeyframeAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/update_midi_events.rs b/lightningbeam-ui/lightningbeam-core/src/actions/update_midi_events.rs new file mode 100644 index 0000000..75655ad --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/update_midi_events.rs @@ -0,0 +1,76 @@ +use crate::action::Action; +use crate::document::Document; +use uuid::Uuid; + +/// Action to replace all MIDI events in a clip (CC, pitch bend, notes, etc.) with undo/redo. +/// +/// Used when editing per-note CC or pitch bend from the piano roll. Stores full +/// `MidiEvent` lists rather than the simplified note-tuple format of `UpdateMidiNotesAction`. +pub struct UpdateMidiEventsAction { + /// Layer containing the MIDI clip + pub layer_id: Uuid, + /// Backend MIDI clip ID + pub midi_clip_id: u32, + /// Full event list before the edit + pub old_events: Vec, + /// Full event list after the edit + pub new_events: Vec, + /// Human-readable description + pub description_text: String, +} + +impl Action for UpdateMidiEventsAction { + fn execute(&mut self, _document: &mut Document) -> Result<(), String> { + Ok(()) + } + + fn rollback(&mut self, _document: &mut Document) -> Result<(), String> { + Ok(()) + } + + fn description(&self) -> String { + self.description_text.clone() + } + + fn execute_backend( + &mut self, + backend: &mut crate::action::BackendContext, + _document: &Document, + ) -> Result<(), String> { + let controller = match backend.audio_controller.as_mut() { + Some(c) => c, + None => return Ok(()), + }; + let track_id = backend + .layer_to_track_map + .get(&self.layer_id) + .ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; + controller.update_midi_clip_events(*track_id, self.midi_clip_id, self.new_events.clone()); + Ok(()) + } + + fn rollback_backend( + &mut self, + backend: &mut crate::action::BackendContext, + _document: &Document, + ) -> Result<(), String> { + let controller = match backend.audio_controller.as_mut() { + Some(c) => c, + None => return Ok(()), + }; + let track_id = backend + .layer_to_track_map + .get(&self.layer_id) + .ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; + controller.update_midi_clip_events(*track_id, self.midi_clip_id, self.old_events.clone()); + Ok(()) + } + + fn midi_events_after_execute(&self) -> Option<(u32, &[daw_backend::audio::midi::MidiEvent])> { + Some((self.midi_clip_id, &self.new_events)) + } + + fn midi_events_after_rollback(&self) -> Option<(u32, &[daw_backend::audio::midi::MidiEvent])> { + Some((self.midi_clip_id, &self.old_events)) + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 5862509..c90f0e1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -886,10 +886,9 @@ struct EditorApp { output_level: (f32, f32), track_levels: HashMap, - /// Cache for MIDI event data (keyed by backend midi_clip_id) - /// Prevents repeated backend queries for the same MIDI clip - /// Format: (timestamp, note_number, velocity, is_note_on) - midi_event_cache: HashMap>, + /// Cache for MIDI event data (keyed by backend midi_clip_id). + /// Stores full raw MidiEvents (note on/off, CC, pitch bend, etc.) + midi_event_cache: HashMap>, /// Cache for audio file durations to avoid repeated queries /// Format: pool_index -> duration in seconds audio_duration_cache: HashMap, @@ -3158,10 +3157,16 @@ impl EditorApp { }; // Rebuild MIDI cache after undo (backend_context dropped, borrows released) if undo_succeeded { - let midi_update = self.action_executor.last_redo_midi_notes() - .map(|(id, notes)| (id, notes.to_vec())); - if let Some((clip_id, notes)) = midi_update { - self.rebuild_midi_cache_entry(clip_id, ¬es); + if let Some((clip_id, events)) = self.action_executor.last_redo_midi_events() + .map(|(id, ev)| (id, ev.to_vec())) + { + self.midi_event_cache.insert(clip_id, events); + } else { + let midi_update = self.action_executor.last_redo_midi_notes() + .map(|(id, notes)| (id, notes.to_vec())); + if let Some((clip_id, notes)) = midi_update { + self.rebuild_midi_cache_entry(clip_id, ¬es); + } } // Stale vertex/edge/face IDs from before the undo would // crash selection rendering on the restored (smaller) DCEL. @@ -3196,10 +3201,16 @@ impl EditorApp { }; // Rebuild MIDI cache after redo (backend_context dropped, borrows released) if redo_succeeded { - let midi_update = self.action_executor.last_undo_midi_notes() - .map(|(id, notes)| (id, notes.to_vec())); - if let Some((clip_id, notes)) = midi_update { - self.rebuild_midi_cache_entry(clip_id, ¬es); + if let Some((clip_id, events)) = self.action_executor.last_undo_midi_events() + .map(|(id, ev)| (id, ev.to_vec())) + { + self.midi_event_cache.insert(clip_id, events); + } else { + let midi_update = self.action_executor.last_undo_midi_notes() + .map(|(id, notes)| (id, notes.to_vec())); + if let Some((clip_id, notes)) = midi_update { + self.rebuild_midi_cache_entry(clip_id, ¬es); + } } self.selection.clear_dcel_selection(); } @@ -3863,18 +3874,7 @@ impl EditorApp { // track_id is unused by the query, pass 0 match controller.query_midi_clip(0, clip_id) { Ok(clip_data) => { - let processed_events: Vec<(f64, u8, u8, bool)> = clip_data.events.iter() - .filter_map(|event| { - let status_type = event.status & 0xF0; - if status_type == 0x90 || status_type == 0x80 { - let is_note_on = status_type == 0x90 && event.data2 > 0; - Some((event.timestamp, event.data1, event.data2, is_note_on)) - } else { - None - } - }) - .collect(); - self.midi_event_cache.insert(clip_id, processed_events); + self.midi_event_cache.insert(clip_id, clip_data.events); midi_fetched += 1; } Err(e) => eprintln!("Failed to fetch MIDI clip {}: {}", clip_id, e), @@ -4013,12 +4013,12 @@ impl EditorApp { /// Rebuild a MIDI event cache entry from backend note format. /// Called after undo/redo to keep the cache consistent with the backend. fn rebuild_midi_cache_entry(&mut self, clip_id: u32, notes: &[(f64, u8, u8, f64)]) { - let mut events: Vec<(f64, u8, u8, bool)> = Vec::with_capacity(notes.len() * 2); + let mut events: Vec = Vec::with_capacity(notes.len() * 2); for &(start_time, note, velocity, duration) in notes { - events.push((start_time, note, velocity, true)); - events.push((start_time + duration, note, velocity, false)); + events.push(daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity)); + events.push(daw_backend::audio::midi::MidiEvent::note_off(start_time + duration, 0, note, 0)); } - events.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); self.midi_event_cache.insert(clip_id, events); } @@ -4037,22 +4037,7 @@ impl EditorApp { let duration = midi_clip.duration; let event_count = midi_clip.events.len(); - // Process MIDI events to cache format: (timestamp, note_number, velocity, is_note_on) - // Filter to note events only (status 0x90 = note-on, 0x80 = note-off) - let processed_events: Vec<(f64, u8, u8, bool)> = midi_clip.events.iter() - .filter_map(|event| { - let status_type = event.status & 0xF0; - if status_type == 0x90 || status_type == 0x80 { - // Note-on is 0x90 with velocity > 0, Note-off is 0x80 or velocity = 0 - let is_note_on = status_type == 0x90 && event.data2 > 0; - Some((event.timestamp, event.data1, event.data2, is_note_on)) - } else { - None // Ignore non-note events (CC, pitch bend, etc.) - } - }) - .collect(); - - let note_event_count = processed_events.len(); + let processed_events = midi_clip.events.clone(); // Add to backend MIDI clip pool FIRST and get the backend clip ID if let Some(ref controller_arc) = self.audio_controller { @@ -4067,9 +4052,8 @@ impl EditorApp { let clip = AudioClip::new_midi(&name, backend_clip_id, duration); let frontend_clip_id = self.action_executor.document_mut().add_audio_clip(clip); - println!("Imported MIDI '{}' ({:.1}s, {} total events, {} note events) - Frontend ID: {}, Backend ID: {}", - name, duration, event_count, note_event_count, frontend_clip_id, backend_clip_id); - println!("✅ Added MIDI clip to backend pool and cached {} note events", note_event_count); + println!("Imported MIDI '{}' ({:.1}s, {} total events) - Frontend ID: {}, Backend ID: {}", + name, duration, event_count, frontend_clip_id, backend_clip_id); Some(ImportedAssetInfo { clip_id: frontend_clip_id, @@ -5212,15 +5196,14 @@ impl eframe::App for EditorApp { } // Update midi_event_cache with notes captured so far - // (inlined instead of calling rebuild_midi_cache_entry to avoid - // conflicting &mut self borrow with event_rx loop) + // (inlined to avoid conflicting &mut self borrow) { - let mut events: Vec<(f64, u8, u8, bool)> = Vec::with_capacity(notes.len() * 2); + let mut events: Vec = Vec::with_capacity(notes.len() * 2); for &(start_time, note, velocity, dur) in ¬es { - events.push((start_time, note, velocity, true)); - events.push((start_time + dur, note, velocity, false)); + events.push(daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity)); + events.push(daw_backend::audio::midi::MidiEvent::note_off(start_time + dur, 0, note, 0)); } - events.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); self.midi_event_cache.insert(clip_id, events); } ctx.request_repaint(); @@ -5234,20 +5217,8 @@ impl eframe::App for EditorApp { let mut controller = controller_arc.lock().unwrap(); match controller.query_midi_clip(track_id, clip_id) { Ok(midi_clip_data) => { - // Convert backend MidiEvent format to cache format - let cache_events: Vec<(f64, u8, u8, bool)> = midi_clip_data.events.iter() - .filter_map(|event| { - let status_type = event.status & 0xF0; - if status_type == 0x90 || status_type == 0x80 { - let is_note_on = status_type == 0x90 && event.data2 > 0; - Some((event.timestamp, event.data1, event.data2, is_note_on)) - } else { - None - } - }) - .collect(); drop(controller); - self.midi_event_cache.insert(clip_id, cache_events); + self.midi_event_cache.insert(clip_id, midi_clip_data.events.clone()); // Update document clip with final duration and name let midi_layer_id = self.track_to_layer_map.get(&track_id) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index befbdb5..d3d22e7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -372,7 +372,7 @@ fn generate_video_thumbnail( /// Generate a piano roll thumbnail for MIDI clips /// Shows notes as horizontal bars with Y position = note % 12 (one octave) fn generate_midi_thumbnail( - events: &[(f64, u8, u8, bool)], // (timestamp, note_number, velocity, is_note_on) + events: &[daw_backend::audio::midi::MidiEvent], duration: f64, bg_color: egui::Color32, note_color: egui::Color32, @@ -390,10 +390,11 @@ fn generate_midi_thumbnail( } // Draw note events - for &(timestamp, note_number, _velocity, is_note_on) in events { - if !is_note_on || timestamp > preview_duration { + for event in events { + if !event.is_note_on() || event.timestamp > preview_duration { continue; } + let (timestamp, note_number) = (event.timestamp, event.data1); let x = ((timestamp / preview_duration) * size as f64) as usize; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 924db25..7b16a03 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -1176,14 +1176,16 @@ impl InfopanelPane { if indices.len() == 1 { // Single note — show details if we can resolve from the event cache if let Some(events) = shared.midi_event_cache.get(&midi_clip_id) { - // Events are (time, note, velocity, is_on) — resolve to notes - let mut notes: Vec<(f64, u8, u8, f64)> = Vec::new(); // (time, note, vel, dur) + // Resolve note-on/off pairs to (time, note, vel, dur) tuples + let mut notes: Vec<(f64, u8, u8, f64)> = Vec::new(); let mut pending: std::collections::HashMap = std::collections::HashMap::new(); - for &(time, note, vel, is_on) in events { - if is_on { - pending.insert(note, (time, vel)); - } else if let Some((start, v)) = pending.remove(¬e) { - notes.push((start, note, v, time - start)); + for event in events { + if event.is_note_on() { + pending.insert(event.data1, (event.timestamp, event.data2)); + } else if event.is_note_off() { + if let Some((start, v)) = pending.remove(&event.data1) { + notes.push((start, event.data1, v, event.timestamp - start)); + } } } notes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index e31240b..bcfff75 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -233,7 +233,7 @@ pub struct SharedPaneState<'a> { /// NOTE: If an action later fails during execution, the cache may be out of sync with the /// backend. This is acceptable because MIDI note edits are simple and unlikely to fail. /// Undo/redo rebuilds affected entries from the backend to restore consistency. - pub midi_event_cache: &'a mut std::collections::HashMap>, + pub midi_event_cache: &'a mut std::collections::HashMap>, /// Audio pool indices that got new raw audio data this frame (for thumbnail invalidation) pub audio_pools_with_new_waveforms: &'a std::collections::HashSet, /// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels)) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 21c3db5..b1dfcd7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -27,12 +27,30 @@ const DEFAULT_VELOCITY: u8 = 100; // ── Types ──────────────────────────────────────────────────────────────────── +#[derive(Debug, Clone, Copy, PartialEq)] +enum PitchBendZone { + Start, // First 30% of note: ramp from bend → 0 + Middle, // Middle 40%: bell curve 0 → bend → 0 + End, // Last 30%: ramp from 0 → bend +} + #[derive(Debug, Clone, Copy, PartialEq)] enum DragMode { MoveNotes { start_time_offset: f64, start_note_offset: i32 }, ResizeNote { note_index: usize, original_duration: f64 }, CreateNote, SelectRect, + /// Alt-drag pitch bend editing on a note + PitchBend { + note_index: usize, + zone: PitchBendZone, + note_pitch: u8, + note_channel: u8, + note_start: f64, + note_duration: f64, + origin_y: f32, + current_semitones: f32, + }, } #[derive(Debug, Clone)] @@ -47,6 +65,7 @@ struct TempNote { #[derive(Debug, Clone)] struct ResolvedNote { note: u8, + channel: u8, start_time: f64, duration: f64, velocity: u8, @@ -94,6 +113,11 @@ pub struct PianoRollPane { // Spectrogram gamma (power curve for colormap) spectrogram_gamma: f32, + + // Instrument pitch bend range in semitones (queried from backend when layer changes) + pitch_bend_range: f32, + // Layer ID for which pitch_bend_range was last queried + pitch_bend_range_layer: Option, } impl PianoRollPane { @@ -123,6 +147,8 @@ impl PianoRollPane { user_scrolled_since_play: false, cached_clip_id: None, spectrogram_gamma: 0.8, + pitch_bend_range: 2.0, + pitch_bend_range_layer: None, } } @@ -166,28 +192,33 @@ impl PianoRollPane { // ── Note resolution ────────────────────────────────────────────────── - fn resolve_notes(events: &[(f64, u8, u8, bool)]) -> Vec { - let mut active: HashMap = HashMap::new(); // note -> (start_time, velocity) + fn resolve_notes(events: &[daw_backend::audio::midi::MidiEvent]) -> Vec { + let mut active: HashMap = HashMap::new(); // note -> (start_time, velocity, channel) let mut notes = Vec::new(); - for &(timestamp, note_number, velocity, is_note_on) in events { - if is_note_on { - active.insert(note_number, (timestamp, velocity)); - } else if let Some((start, vel)) = active.remove(¬e_number) { - let duration = (timestamp - start).max(MIN_NOTE_DURATION); - notes.push(ResolvedNote { - note: note_number, - start_time: start, - duration, - velocity: vel, - }); + for event in events { + let channel = event.status & 0x0F; + if event.is_note_on() { + active.insert(event.data1, (event.timestamp, event.data2, channel)); + } else if event.is_note_off() { + if let Some((start, vel, ch)) = active.remove(&event.data1) { + let duration = (event.timestamp - start).max(MIN_NOTE_DURATION); + notes.push(ResolvedNote { + note: event.data1, + channel: ch, + start_time: start, + duration, + velocity: vel, + }); + } } } // Handle unterminated notes - for (¬e_number, &(start, vel)) in &active { + for (¬e_number, &(start, vel, ch)) in &active { notes.push(ResolvedNote { note: note_number, + channel: ch, start_time: start, duration: 0.5, // default duration for unterminated velocity: vel, @@ -251,16 +282,51 @@ impl PianoRollPane { None => return, }; + // Query pitch bend range from backend when the layer changes + if self.pitch_bend_range_layer != Some(layer_id) { + if let Some(track_id) = shared.layer_to_track_map.get(&layer_id) { + if let Some(ctrl) = shared.audio_controller.as_ref() { + if let Ok(mut c) = ctrl.lock() { + self.pitch_bend_range = c.query_pitch_bend_range(*track_id); + } + } + } + self.pitch_bend_range_layer = Some(layer_id); + } + let document = shared.action_executor.document(); - // Collect clip data we need before borrowing shared mutably + // Collect clip data using the engine snapshot (source of truth), which reflects + // recorded clips immediately. Falls back to document if snapshot is empty/absent. let mut clip_data: Vec<(u32, f64, f64, f64, Uuid)> = Vec::new(); // (midi_clip_id, timeline_start, trim_start, duration, instance_id) - if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer(&layer_id) { - for instance in &audio_layer.clip_instances { - if let Some(clip) = document.audio_clips.get(&instance.clip_id) { - if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { - let duration = instance.effective_duration(clip.duration); - clip_data.push((midi_clip_id, instance.timeline_start, instance.trim_start, duration, instance.id)); + + let snapshot_clips: Option> = + shared.clip_snapshot.as_ref().and_then(|arc| { + let snap = arc.read().ok()?; + let track_id = shared.layer_to_track_map.get(&layer_id)?; + snap.midi.get(track_id).cloned() + }); + + if let Some(midi_instances) = snapshot_clips.filter(|v| !v.is_empty()) { + // Use snapshot data (engine is source of truth) + for mc in &midi_instances { + if let Some((clip_doc_id, _)) = document.audio_clip_by_midi_clip_id(mc.clip_id) { + let clip_doc_id = clip_doc_id; // doc-side AudioClip uuid + let duration = mc.external_duration; + let instance_uuid = Uuid::nil(); // no doc-side instance uuid yet + clip_data.push((mc.clip_id, mc.external_start, mc.internal_start, duration, instance_uuid)); + let _ = clip_doc_id; // used above for the if-let pattern + } + } + } else { + // Fall back to document (handles recording-in-progress and pre-snapshot clips) + if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer(&layer_id) { + for instance in &audio_layer.clip_instances { + if let Some(clip) = document.audio_clips.get(&instance.clip_id) { + if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { + let duration = instance.effective_duration(clip.duration); + clip_data.push((midi_clip_id, instance.timeline_start, instance.trim_start, duration, instance.id)); + } } } } @@ -337,7 +403,7 @@ impl PianoRollPane { // Render notes if let Some(events) = shared.midi_event_cache.get(&midi_clip_id) { let resolved = Self::resolve_notes(events); - self.render_notes(&grid_painter, grid_rect, &resolved, timeline_start, trim_start, duration, opacity, is_selected); + self.render_notes(&grid_painter, grid_rect, &resolved, events, timeline_start, trim_start, duration, opacity, is_selected, midi_clip_id); } } @@ -508,16 +574,159 @@ impl PianoRollPane { } } + /// Find the peak pitch bend value (in semitones) for a note in the event list. + /// Returns 0.0 if no pitch bend events are present in the note's time range. + fn find_peak_pitch_bend_semitones( + events: &[daw_backend::audio::midi::MidiEvent], + note_start: f64, + note_end: f64, + channel: u8, + pitch_bend_range: f32, + ) -> f32 { + let mut peak = 0.0f32; + for ev in events { + if ev.timestamp > note_end + 0.01 { break; } + if ev.timestamp >= note_start - 0.01 + && (ev.status & 0xF0) == 0xE0 + && (ev.status & 0x0F) == channel + { + let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16); + let normalized = (raw - 8192) as f32 / 8192.0; + let semitones = normalized * pitch_bend_range; + if semitones.abs() > peak.abs() { + peak = semitones; + } + } + } + peak + } + + /// Determine which zone of a note was clicked based on the X position within the note rect. + fn pitch_bend_zone_from_x(click_x: f32, note_left: f32, note_right: f32) -> PitchBendZone { + let t = (click_x - note_left) / (note_right - note_left).max(1.0); + if t < 0.3 { + PitchBendZone::Start + } else if t < 0.7 { + PitchBendZone::Middle + } else { + PitchBendZone::End + } + } + + /// Generate pitch bend MIDI events for a note based on the zone and target semitones. + fn generate_pitch_bend_events( + note_start: f64, + note_duration: f64, + zone: PitchBendZone, + semitones: f32, + channel: u8, + pitch_bend_range: f32, + ) -> Vec { + use daw_backend::audio::midi::MidiEvent; + let num_steps: usize = 128; + let mut events = Vec::new(); + let encode_bend = |normalized: f32| -> (u8, u8) { + let value_14 = (normalized * 8191.0 + 8192.0).clamp(0.0, 16383.0) as i16; + ((value_14 & 0x7F) as u8, ((value_14 >> 7) & 0x7F) as u8) + }; + // Use t directly (0..=1 across the full note) — same formula as the visual ghost. + // Start: peak → 0 (ramps down over full note) + // Middle: 0 → peak → 0 (sine arch, peaks at center) + // End: 0 → peak (ramps up over full note) + for i in 0..=num_steps { + let t = i as f64 / num_steps as f64; + let t_f32 = t as f32; + // Cosine ease curves: Start+End at equal value = perfectly flat (partition of unity). + // Start: (1+cos(πt))/2 — peaks at t=0, smooth decay to 0 at t=1 + // End: (1-cos(πt))/2 — 0 at t=0, smooth rise to peak at t=1 + // Middle: sin(πt) — arch peaking at t=0.5 + let normalized = match zone { + PitchBendZone::Start => semitones / pitch_bend_range * (1.0 + (std::f32::consts::PI * t_f32).cos()) * 0.5, + PitchBendZone::Middle => semitones / pitch_bend_range * (std::f32::consts::PI * t_f32).sin(), + PitchBendZone::End => semitones / pitch_bend_range * (1.0 - (std::f32::consts::PI * t_f32).cos()) * 0.5, + }; + let timestamp = note_start + t * note_duration; + let (lsb, msb) = encode_bend(normalized); + events.push(MidiEvent { timestamp, status: 0xE0 | channel, data1: lsb, data2: msb }); + } + events + } + + /// Find the lowest available MIDI channel (1–15) not already used by any note + /// overlapping [note_start, note_end], excluding the note being assigned itself. + /// Returns the note's current channel unchanged if it is already uniquely assigned (non-zero). + fn find_or_assign_channel( + events: &[daw_backend::audio::midi::MidiEvent], + note_start: f64, + note_end: f64, + note_pitch: u8, + current_channel: u8, + ) -> u8 { + use std::collections::HashMap; + let mut used = [false; 16]; + // Walk events to find which channels have notes overlapping the target range. + // key = (pitch, channel), value = note_start_time + let mut active: HashMap<(u8, u8), f64> = HashMap::new(); + for ev in events { + let ch = ev.status & 0x0F; + let msg = ev.status & 0xF0; + if msg == 0x90 && ev.data2 > 0 { + active.insert((ev.data1, ch), ev.timestamp); + } else if msg == 0x80 || (msg == 0x90 && ev.data2 == 0) { + if let Some(start) = active.remove(&(ev.data1, ch)) { + // Overlaps target range and is NOT the note we're assigning + if start < note_end && ev.timestamp > note_start + && !(ev.data1 == note_pitch && ch == current_channel) + { + used[ch as usize] = true; + } + } + } + } + // Mark still-active (no note-off seen) notes + for ((pitch, ch), start) in &active { + if *start < note_end && !(*pitch == note_pitch && *ch == current_channel) { + used[*ch as usize] = true; + } + } + // Keep current channel if already uniquely assigned and non-zero + if current_channel != 0 && !used[current_channel as usize] { + return current_channel; + } + // Find lowest free channel in 1..15 + for ch in 1u8..16 { + if !used[ch as usize] { return ch; } + } + current_channel // fallback (>15 simultaneous notes) + } + + /// Find the CC1 (modulation) value for a note in the event list. + /// Searches for a CC1 event at or just before the note's start time on the same channel. + fn find_cc1_for_note(events: &[daw_backend::audio::midi::MidiEvent], note_start: f64, note_end: f64, channel: u8) -> u8 { + let mut cc1 = 0u8; + for ev in events { + if ev.timestamp > note_end { break; } + if (ev.status & 0xF0) == 0xB0 && (ev.status & 0x0F) == channel && ev.data1 == 1 { + if ev.timestamp <= note_start { + cc1 = ev.data2; + } + } + } + cc1 + } + fn render_notes( &self, painter: &egui::Painter, grid_rect: Rect, notes: &[ResolvedNote], + events: &[daw_backend::audio::midi::MidiEvent], clip_timeline_start: f64, trim_start: f64, clip_duration: f64, opacity: f32, is_selected_clip: bool, + clip_id: u32, ) { for (i, note) in notes.iter().enumerate() { // Skip notes entirely outside the visible trim window @@ -588,6 +797,107 @@ impl PianoRollPane { if clipped.is_positive() { painter.rect_filled(clipped, 1.0, color); painter.rect_stroke(clipped, 1.0, Stroke::new(1.0, Color32::from_rgba_unmultiplied(0, 0, 0, (76.0 * opacity) as u8)), StrokeKind::Middle); + + // Modulation (CC1) bar: 3px column on left edge of note, fills from bottom + let cc1 = Self::find_cc1_for_note(events, note.start_time, note.start_time + note.duration, note.channel); + if cc1 > 0 { + let bar_width = 3.0_f32.min(clipped.width()); + let bar_height = (cc1 as f32 / 127.0) * clipped.height(); + let bar_rect = Rect::from_min_size( + pos2(clipped.min.x, clipped.max.y - bar_height), + vec2(bar_width, bar_height), + ); + let bar_alpha = (128.0 * opacity) as u8; + painter.rect_filled(bar_rect, 0.0, Color32::from_rgba_unmultiplied(255, 255, 255, bar_alpha)); + } + + // Pitch bend ghost overlay — contour-following filled band + // Build a curve of semitone values sampled across the note width. + // For live drag: existing bend + new zone contribution (additive). + // For persisted: sample actual events. + const N_SAMPLES: usize = 24; + let bend_curve: Option<[f32; N_SAMPLES + 1]> = + if let Some(DragMode::PitchBend { note_index: drag_idx, current_semitones, zone, note_channel: drag_ch, .. }) = self.drag_mode { + if drag_idx == i && is_selected_clip && Some(clip_id) == self.selected_clip_id { + let mut curve = [0.0f32; N_SAMPLES + 1]; + let pi = std::f32::consts::PI; + for s in 0..=N_SAMPLES { + let t = s as f32 / N_SAMPLES as f32; + // Sample existing bend at this time position + let ts = note.start_time + t as f64 * note.duration; + let mut existing_norm = 0.0f32; + for ev in events { + if ev.timestamp > ts { break; } + if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == drag_ch { + let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16); + existing_norm = (raw - 8192) as f32 / 8192.0; + } + } + let existing_semi = existing_norm * self.pitch_bend_range; + // New zone contribution + let zone_semi = match zone { + PitchBendZone::Start => current_semitones * (1.0 + (pi * t).cos()) * 0.5, + PitchBendZone::Middle => current_semitones * (pi * t).sin(), + PitchBendZone::End => current_semitones * (1.0 - (pi * t).cos()) * 0.5, + }; + curve[s] = existing_semi + zone_semi; + } + // Only show ghost if there's any meaningful bend at all + if curve.iter().any(|v| v.abs() >= 0.05) { + Some(curve) + } else { + None + } + } else { + None + } + } else { + None + }; + + // For persisted notes (no live drag), sample actual pitch bend events + let bend_curve = bend_curve.or_else(|| { + let peak = Self::find_peak_pitch_bend_semitones( + events, note.start_time, note.start_time + note.duration, + note.channel, self.pitch_bend_range); + if peak.abs() < 0.05 { return None; } + let mut curve = [0.0f32; N_SAMPLES + 1]; + for s in 0..=N_SAMPLES { + let t = s as f64 / N_SAMPLES as f64; + let ts = note.start_time + t * note.duration; + // Find last pitch bend event at or before ts + let mut bend_norm = 0.0f32; + for ev in events { + if ev.timestamp > ts { break; } + if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == note.channel { + let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16); + bend_norm = (raw - 8192) as f32 / 8192.0; + } + } + curve[s] = bend_norm * self.pitch_bend_range; + } + Some(curve) + }); + + if let Some(curve) = bend_curve { + // Draw a stroked curve relative to the note's centerline. + let note_center_y = y + h * 0.5; + // Brighten toward white for visibility + let brighten = |c: u8| -> u8 { (c as u16 + (255 - c as u16) * 3 / 4) as u8 }; + let stroke_color = Color32::from_rgba_unmultiplied( + brighten(r), brighten(g), brighten(b), (220.0 * opacity) as u8, + ); + + let points: Vec = (0..=N_SAMPLES).map(|s| { + let t = s as f32 / N_SAMPLES as f32; + let px = (x + t * w).clamp(grid_rect.min.x, grid_rect.max.x); + let bend_px = (curve[s] * self.note_height) + .clamp(-(grid_rect.height()), grid_rect.height()); + let py = (note_center_y - bend_px).clamp(grid_rect.min.y, grid_rect.max.y); + pos2(px, py) + }).collect(); + painter.add(egui::Shape::line(points, egui::Stroke::new(3.0, stroke_color))); + } } } } @@ -654,6 +964,7 @@ impl PianoRollPane { let response = ui.allocate_rect(full_rect, egui::Sense::click_and_drag()); let shift_held = ui.input(|i| i.modifiers.shift); let ctrl_held = ui.input(|i| i.modifiers.ctrl); + let alt_held = ui.input(|i| i.modifiers.alt); let now = ui.input(|i| i.time); // Auto-release preview note after its duration expires. @@ -784,7 +1095,7 @@ impl PianoRollPane { if full_rect.contains(pos) { let in_grid = pos.x >= grid_rect.min.x; if in_grid { - self.on_grid_press(pos, grid_rect, shift_held, ctrl_held, now, shared, clip_data); + self.on_grid_press(pos, grid_rect, shift_held, ctrl_held, alt_held, now, shared, clip_data); } else { // Keyboard click - preview note (hold until mouse-up) let note = self.y_to_note(pos.y, keyboard_rect); @@ -807,10 +1118,14 @@ impl PianoRollPane { } // Update cursor - if let Some(hover_pos) = response.hover_pos() { + if matches!(self.drag_mode, Some(DragMode::PitchBend { .. })) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } else if let Some(hover_pos) = response.hover_pos() { if hover_pos.x >= grid_rect.min.x { if shift_held { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } else if alt_held && self.hit_test_note(hover_pos, grid_rect, shared, clip_data).is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } else if self.hit_test_note_edge(hover_pos, grid_rect, shared, clip_data).is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } else if self.hit_test_note(hover_pos, grid_rect, shared, clip_data).is_some() { @@ -831,6 +1146,7 @@ impl PianoRollPane { grid_rect: Rect, shift_held: bool, ctrl_held: bool, + alt_held: bool, now: f64, shared: &mut SharedPaneState, clip_data: &[(u32, f64, f64, f64, Uuid)], @@ -841,6 +1157,35 @@ impl PianoRollPane { self.drag_start_time = time; self.drag_start_note = note; + // Alt+click on a note: start pitch bend drag + if alt_held { + if let Some(note_idx) = self.hit_test_note(pos, grid_rect, shared, clip_data) { + if let Some(clip_id) = self.selected_clip_id { + if let Some(events) = shared.midi_event_cache.get(&clip_id) { + let resolved = Self::resolve_notes(events); + if note_idx < resolved.len() { + let n = &resolved[note_idx]; + // Determine zone from X position within note rect + let note_x = self.time_to_x(n.start_time, grid_rect); + let note_w = (n.duration as f32 * self.pixels_per_second).max(2.0); + let zone = Self::pitch_bend_zone_from_x(pos.x, note_x, note_x + note_w); + self.drag_mode = Some(DragMode::PitchBend { + note_index: note_idx, + zone, + note_pitch: n.note, + note_channel: n.channel, + note_start: n.start_time, + note_duration: n.duration, + origin_y: pos.y, + current_semitones: 0.0, // additive delta; existing bend shown separately + }); + return; + } + } + } + } + } + // Check if clicking on a note edge (resize) if let Some(note_idx) = self.hit_test_note_edge(pos, grid_rect, shared, clip_data) { if let Some(clip_id) = self.selected_clip_id { @@ -971,8 +1316,19 @@ impl PianoRollPane { self.update_selection_from_rect(grid_rect, shared, clip_data); } } + Some(DragMode::PitchBend { .. }) => { + // Handled below (needs mutable access to self.drag_mode and self.pitch_bend_range) + } None => {} } + + // Pitch bend drag: update current_semitones based on Y movement + if let Some(DragMode::PitchBend { ref mut current_semitones, ref mut origin_y, .. }) = self.drag_mode { + let range = self.pitch_bend_range; + let delta_semitones = (*origin_y - pos.y) / self.note_height; + *current_semitones = (*current_semitones + delta_semitones).clamp(-range, range); + *origin_y = pos.y; + } } fn on_grid_release( @@ -1012,6 +1368,84 @@ impl PianoRollPane { self.selection_rect = None; self.update_focus(shared); } + Some(DragMode::PitchBend { note_pitch, note_channel, note_start, note_duration, zone, current_semitones, .. }) => { + // Only commit if the drag added a meaningful new contribution + if current_semitones.abs() >= 0.05 { + if let Some(clip_id) = self.selected_clip_id { + let range = self.pitch_bend_range; + let old_events = shared.midi_event_cache.get(&clip_id).cloned().unwrap_or_default(); + let mut new_events = old_events.clone(); + + // Assign a unique channel to this note so bend only affects it + let target_channel = Self::find_or_assign_channel( + &new_events, note_start, note_start + note_duration, + note_pitch, note_channel, + ); + + // Re-stamp note-on/off for this specific note if channel changed + if target_channel != note_channel { + for ev in &mut new_events { + let msg = ev.status & 0xF0; + let ch = ev.status & 0x0F; + if (msg == 0x90 || msg == 0x80) && ev.data1 == note_pitch && ch == note_channel { + ev.status = (ev.status & 0xF0) | target_channel; + } + } + } + + // Sample existing bend (normalised -1..1) at each step, then add the + // new zone contribution additively and write back as combined events. + let num_steps: usize = 128; + let pi = std::f32::consts::PI; + let existing_norm: Vec = (0..=num_steps).map(|i| { + let t = i as f64 / num_steps as f64; + let ts = note_start + t * note_duration; + let mut bend = 0.0f32; + for ev in &new_events { + if ev.timestamp > ts { break; } + if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel { + let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16); + bend = (raw - 8192) as f32 / 8192.0; + } + } + bend + }).collect(); + + // Remove old bend events in range before writing combined + new_events.retain(|ev| { + let is_bend = (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel; + let in_range = ev.timestamp >= note_start - 0.001 && ev.timestamp <= note_start + note_duration + 0.01; + !(is_bend && in_range) + }); + + let encode_bend = |normalized: f32| -> (u8, u8) { + let v = (normalized * 8191.0 + 8192.0).clamp(0.0, 16383.0) as i16; + ((v & 0x7F) as u8, ((v >> 7) & 0x7F) as u8) + }; + for i in 0..=num_steps { + let t = i as f32 / num_steps as f32; + let zone_norm = match zone { + PitchBendZone::Start => current_semitones / range * (1.0 + (pi * t).cos()) * 0.5, + PitchBendZone::Middle => current_semitones / range * (pi * t).sin(), + PitchBendZone::End => current_semitones / range * (1.0 - (pi * t).cos()) * 0.5, + }; + let combined = (existing_norm[i] + zone_norm).clamp(-1.0, 1.0); + let (lsb, msb) = encode_bend(combined); + let ts = note_start + i as f64 / num_steps as f64 * note_duration; + new_events.push(daw_backend::audio::midi::MidiEvent { timestamp: ts, status: 0xE0 | target_channel, data1: lsb, data2: msb }); + } + // For End zone: reset just after note ends so it doesn't bleed into next note + if zone == PitchBendZone::End { + let (lsb, msb) = encode_bend(0.0); + new_events.push(daw_backend::audio::midi::MidiEvent { timestamp: note_start + note_duration + 0.005, status: 0xE0 | target_channel, data1: lsb, data2: msb }); + } + + new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal)); + self.push_events_action("Set pitch bend", clip_id, old_events, new_events.clone(), shared); + shared.midi_event_cache.insert(clip_id, new_events); + } + } + } None => {} } @@ -1160,12 +1594,12 @@ impl PianoRollPane { /// simple operations unlikely to fail, and undo/redo rebuilds cache from the action's /// stored note data to restore consistency. fn update_cache_from_resolved(clip_id: u32, resolved: &[ResolvedNote], shared: &mut SharedPaneState) { - let mut events: Vec<(f64, u8, u8, bool)> = Vec::with_capacity(resolved.len() * 2); + let mut events: Vec = Vec::with_capacity(resolved.len() * 2); for n in resolved { - events.push((n.start_time, n.note, n.velocity, true)); - events.push((n.start_time + n.duration, n.note, n.velocity, false)); + events.push(daw_backend::audio::midi::MidiEvent::note_on(n.start_time, 0, n.note, n.velocity)); + events.push(daw_backend::audio::midi::MidiEvent::note_off(n.start_time + n.duration, 0, n.note, 0)); } - events.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); shared.midi_event_cache.insert(clip_id, events); } @@ -1185,6 +1619,7 @@ impl PianoRollPane { resolved.push(ResolvedNote { note: temp.note, + channel: 0, start_time: temp.start_time, duration: temp.duration, velocity: temp.velocity, @@ -1346,6 +1781,7 @@ impl PianoRollPane { for &(rel_time, note, velocity, duration) in ¬es_to_paste { resolved.push(ResolvedNote { note, + channel: 0, start_time: paste_time + rel_time, duration, velocity, @@ -1389,6 +1825,28 @@ impl PianoRollPane { shared.pending_actions.push(Box::new(action)); } + fn push_events_action( + &self, + description: &str, + clip_id: u32, + old_events: Vec, + new_events: Vec, + shared: &mut SharedPaneState, + ) { + let layer_id = match *shared.active_layer_id { + Some(id) => id, + None => return, + }; + let action = lightningbeam_core::actions::UpdateMidiEventsAction { + layer_id, + midi_clip_id: clip_id, + old_events, + new_events, + description_text: description.to_string(), + }; + shared.pending_actions.push(Box::new(action)); + } + // ── Note preview ───────────────────────────────────────────────────── fn preview_note_on(&mut self, note: u8, velocity: u8, duration: Option, time: f64, shared: &mut SharedPaneState) { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 4b859ef..3051af9 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -1474,7 +1474,7 @@ impl TimelinePane { painter: &egui::Painter, clip_rect: egui::Rect, rect_min_x: f32, // Timeline panel left edge (for proper viewport-relative positioning) - events: &[(f64, u8, u8, bool)], // (timestamp, note_number, velocity, is_note_on) + events: &[daw_backend::audio::midi::MidiEvent], trim_start: f64, visible_duration: f64, timeline_start: f64, @@ -1497,12 +1497,12 @@ impl TimelinePane { let mut note_rectangles: Vec<(egui::Rect, u8)> = Vec::new(); // First pass: pair note-ons with note-offs to calculate durations - for &(timestamp, note_number, _velocity, is_note_on) in events { - if is_note_on { - // Store note-on timestamp + for event in events { + if event.is_note_on() { + let (note_number, timestamp) = (event.data1, event.timestamp); active_notes.insert(note_number, timestamp); - } else { - // Note-off: find matching note-on and calculate duration + } else if event.is_note_off() { + let (note_number, timestamp) = (event.data1, event.timestamp); if let Some(¬e_on_time) = active_notes.get(¬e_number) { let duration = timestamp - note_on_time; @@ -2295,7 +2295,7 @@ impl TimelinePane { active_layer_id: &Option, focus: &lightningbeam_core::selection::FocusSelection, selection: &lightningbeam_core::selection::Selection, - midi_event_cache: &std::collections::HashMap>, + midi_event_cache: &std::collections::HashMap>, raw_audio_cache: &std::collections::HashMap>, u32, u32)>, waveform_gpu_dirty: &mut std::collections::HashSet, target_format: wgpu::TextureFormat, From 164ed2ba732864d07804216c7bc8b298c685e90b Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 18 Mar 2026 23:35:18 -0400 Subject: [PATCH 3/7] Add velocity and modulation editing --- .../src/panes/piano_roll.rs | 111 ++++++++++++++++-- 1 file changed, 101 insertions(+), 10 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index b1dfcd7..d3c038d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -114,6 +114,10 @@ pub struct PianoRollPane { // Spectrogram gamma (power curve for colormap) spectrogram_gamma: f32, + // Header slider values — persist across frames during drag + header_vel: f32, + header_mod: f32, + // Instrument pitch bend range in semitones (queried from backend when layer changes) pitch_bend_range: f32, // Layer ID for which pitch_bend_range was last queried @@ -147,6 +151,8 @@ impl PianoRollPane { user_scrolled_since_play: false, cached_clip_id: None, spectrogram_gamma: 0.8, + header_vel: 100.0, + header_mod: 0.0, pitch_bend_range: 2.0, pitch_bend_range_layer: None, } @@ -2122,20 +2128,105 @@ impl PaneRenderer for PianoRollPane { ); } - // Velocity display for selected notes - if self.selected_note_indices.len() == 1 { + // Velocity + modulation sliders for selected note(s) + if !self.selected_note_indices.is_empty() { if let Some(clip_id) = self.selected_clip_id { - if let Some(events) = shared.midi_event_cache.get(&clip_id) { - let resolved = Self::resolve_notes(events); - if let Some(&idx) = self.selected_note_indices.iter().next() { + if let Some(events) = shared.midi_event_cache.get(&clip_id).cloned() { + let resolved = Self::resolve_notes(&events); + // Pick the first selected note as the representative value + let first_idx = self.selected_note_indices.iter().copied().next(); + if let Some(idx) = first_idx { if idx < resolved.len() { - ui.separator(); let n = &resolved[idx]; - ui.label( - egui::RichText::new(format!("{} vel:{}", Self::note_name(n.note), n.velocity)) - .color(header_secondary) - .size(10.0), + + // ── Velocity ────────────────────────────── + ui.separator(); + ui.label(egui::RichText::new("Vel").color(header_secondary).size(10.0)); + let vel_resp = ui.add( + egui::DragValue::new(&mut self.header_vel) + .range(1.0..=127.0) + .max_decimals(0) + .speed(1.0), ); + // Commit before syncing so header_vel isn't overwritten first + if vel_resp.drag_stopped() || vel_resp.lost_focus() { + let new_vel = self.header_vel.round().clamp(1.0, 127.0) as u8; + if new_vel != n.velocity { + let old_notes = Self::notes_to_backend_format(&resolved); + let mut new_resolved = resolved.clone(); + for &i in &self.selected_note_indices { + if i < new_resolved.len() { + new_resolved[i].velocity = new_vel; + } + } + let new_notes = Self::notes_to_backend_format(&new_resolved); + self.push_update_action("Set velocity", clip_id, old_notes, new_notes, shared, &[]); + // Patch the event cache immediately so next frame sees the new velocity + if let Some(cached) = shared.midi_event_cache.get_mut(&clip_id) { + for &i in &self.selected_note_indices { + if i >= resolved.len() { continue; } + let sn = &resolved[i]; + for ev in cached.iter_mut() { + if ev.is_note_on() && ev.data1 == sn.note + && (ev.status & 0x0F) == sn.channel + && (ev.timestamp - sn.start_time).abs() < 1e-6 + { + ev.data2 = new_vel; + } + } + } + } + } + } + // Sync from note only when idle (not on commit frames) + if !vel_resp.dragged() && !vel_resp.has_focus() && !vel_resp.drag_stopped() && !vel_resp.lost_focus() { + self.header_vel = n.velocity as f32; + } + + // ── Modulation (CC1) ────────────────────── + ui.separator(); + ui.label(egui::RichText::new("Mod").color(header_secondary).size(10.0)); + let current_cc1 = Self::find_cc1_for_note(&events, n.start_time, n.start_time + n.duration, n.channel); + let mod_resp = ui.add( + egui::DragValue::new(&mut self.header_mod) + .range(0.0..=127.0) + .max_decimals(0) + .speed(1.0), + ); + // Commit before syncing + if mod_resp.drag_stopped() || mod_resp.lost_focus() { + let new_cc1 = self.header_mod.round().clamp(0.0, 127.0) as u8; + if new_cc1 != current_cc1 { + let old_events = events.clone(); + let mut new_events = events.clone(); + for &i in &self.selected_note_indices { + if i >= resolved.len() { continue; } + let sn = &resolved[i]; + new_events.retain(|ev| { + let is_cc1 = (ev.status & 0xF0) == 0xB0 + && (ev.status & 0x0F) == sn.channel + && ev.data1 == 1; + let at_start = (ev.timestamp - sn.start_time).abs() < 0.001; + !(is_cc1 && at_start) + }); + if new_cc1 > 0 { + new_events.push(daw_backend::audio::midi::MidiEvent { + timestamp: sn.start_time, + status: 0xB0 | sn.channel, + data1: 1, + data2: new_cc1, + }); + } + } + new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal)); + self.push_events_action("Set modulation", clip_id, old_events, new_events.clone(), shared); + shared.midi_event_cache.insert(clip_id, new_events); + } + } + // Sync from note only when idle + if !mod_resp.dragged() && !mod_resp.has_focus() && !mod_resp.drag_stopped() && !mod_resp.lost_focus() { + self.header_mod = current_cc1 as f32; + } } } } From 84a1a98452c182464244fe5dad829edfd024f20b Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 19 Mar 2026 00:47:15 -0400 Subject: [PATCH 4/7] Snap to beats in measures mode --- .../lightningbeam-editor/src/main.rs | 106 +++++++------ .../src/panes/timeline.rs | 146 +++++++++++++++--- 2 files changed, 178 insertions(+), 74 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index c90f0e1..6fa9291 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -5155,40 +5155,57 @@ impl eframe::App for EditorApp { if let Some(layer_id) = midi_layer_id { // Lazily create the doc clip + instance on the first progress event // (there is no MidiRecordingStarted event from the backend). - let already_exists = self.clip_instance_to_backend_map.values().any(|v| { - matches!(v, lightningbeam_core::action::BackendClipInstanceId::Midi(id) if *id == clip_id) - }); - if !already_exists { - use lightningbeam_core::clip::{AudioClip, ClipInstance}; - let clip = AudioClip::new_recording("Recording..."); - let doc_clip_id = self.action_executor.document_mut().add_audio_clip(clip); - let clip_instance = ClipInstance::new(doc_clip_id) - .with_timeline_start(self.recording_start_time); - let clip_instance_id = clip_instance.id; - if let Some(layer) = self.action_executor.document_mut().get_layer_mut(&layer_id) { - if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { - audio_layer.clip_instances.push(clip_instance); - } - } - self.clip_instance_to_backend_map.insert( - clip_instance_id, - lightningbeam_core::action::BackendClipInstanceId::Midi(clip_id), - ); - } - - let doc_clip_id = { - let document = self.action_executor.document(); - document.get_layer(&layer_id) - .and_then(|layer| { - if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { - audio_layer.clip_instances.last().map(|i| i.clip_id) - } else { - None - } - }) + // + // MidiClipId (clip_id) is the content ID; MidiClipInstanceId is + // the placement ID used in the snapshot and backend operations. + // We need to store the instance ID, not the content ID, so that + // build_audio_clip_cache can correlate mc.id → doc UUID. + // Command::CreateMidiClip has already been processed and the + // snapshot refreshed by the time this event arrives. + let backend_instance_id: u32 = if let Some(ref controller_arc) = self.audio_controller { + let controller = controller_arc.lock().unwrap(); + let snap = controller.clip_snapshot(); + let snap = snap.read().unwrap(); + snap.midi.get(&_track_id) + .and_then(|instances| instances.iter().find(|mc| mc.clip_id == clip_id)) + .map(|mc| mc.id) + .unwrap_or(clip_id) + } else { + clip_id }; + // Find the Midi-typed clip instance the timeline already created. + // Register it in the map (using the correct instance ID, not the + // content ID) so trim/move actions can find it via the snapshot. + let already_mapped = self.clip_instance_to_backend_map.values().any(|v| { + matches!(v, lightningbeam_core::action::BackendClipInstanceId::Midi(id) if *id == backend_instance_id) + }); + let doc_clip_id = { + let doc = self.action_executor.document(); + doc.audio_clip_by_midi_clip_id(clip_id).map(|(id, _)| id) + }; if let Some(doc_clip_id) = doc_clip_id { + if !already_mapped { + // Find the clip instance for this clip on the layer + let instance_id = { + let doc = self.action_executor.document(); + doc.get_layer(&layer_id) + .and_then(|l| { + if let lightningbeam_core::layer::AnyLayer::Audio(al) = l { + al.clip_instances.iter() + .find(|ci| ci.clip_id == doc_clip_id) + .map(|ci| ci.id) + } else { None } + }) + }; + if let Some(instance_id) = instance_id { + self.clip_instance_to_backend_map.insert( + instance_id, + lightningbeam_core::action::BackendClipInstanceId::Midi(backend_instance_id), + ); + } + } + // Update the clip's duration so the timeline bar grows if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { clip.duration = duration; } @@ -5221,26 +5238,13 @@ impl eframe::App for EditorApp { self.midi_event_cache.insert(clip_id, midi_clip_data.events.clone()); // Update document clip with final duration and name - let midi_layer_id = self.track_to_layer_map.get(&track_id) - .filter(|lid| self.recording_layer_ids.contains(lid)) - .copied(); - if let Some(layer_id) = midi_layer_id { - let doc_clip_id = { - let document = self.action_executor.document(); - document.get_layer(&layer_id) - .and_then(|layer| { - if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { - audio_layer.clip_instances.last().map(|i| i.clip_id) - } else { - None - } - }) - }; - if let Some(doc_clip_id) = doc_clip_id { - if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { - clip.duration = midi_clip_data.duration; - clip.name = format!("MIDI Recording {}", clip_id); - } + let doc_clip_id = self.action_executor.document() + .audio_clip_by_midi_clip_id(clip_id) + .map(|(id, _)| id); + if let Some(doc_clip_id) = doc_clip_id { + if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { + clip.duration = midi_clip_data.duration; + clip.name = format!("MIDI Recording {}", clip_id); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 3051af9..11df37b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -181,6 +181,7 @@ pub struct TimelinePane { /// Clip drag state (None if not dragging) clip_drag_state: Option, drag_offset: f64, // Time offset being applied during drag (for preview) + drag_anchor_start: f64, // Original timeline_start of earliest selected clip; used for snapped move offset /// Cached mouse position from mousedown (used for edge detection when drag starts) mousedown_pos: Option, @@ -656,6 +657,7 @@ impl TimelinePane { last_pan_pos: None, clip_drag_state: None, drag_offset: 0.0, + drag_anchor_start: 0.0, mousedown_pos: None, layer_control_clicked: false, context_menu_clip: None, @@ -1265,6 +1267,73 @@ impl TimelinePane { self.viewport_start_time + (x / self.pixels_per_second) as f64 } + /// Returns the quantization grid size in seconds, or None to disable snapping. + /// - Measures mode: zoom-adaptive (coarser when zoomed out, None when very zoomed in) + /// - Frames mode: always 1/framerate regardless of zoom + /// - Seconds mode: no snapping + fn quantize_grid_size( + &self, + bpm: f64, + time_sig: &lightningbeam_core::document::TimeSignature, + framerate: f64, + ) -> Option { + match self.time_display_format { + TimeDisplayFormat::Frames => Some(1.0 / framerate), + TimeDisplayFormat::Measures => { + use lightningbeam_core::beat_time::{beat_duration, measure_duration}; + let beat = beat_duration(bpm); + let measure = measure_duration(bpm, time_sig); + let pps = self.pixels_per_second as f64; + // Very zoomed in: 16th note > 40px → no snap + if pps * beat / 4.0 > 40.0 { return None; } + // Find finest subdivision with >= 15px spacing (finest → coarsest) + const MIN_PX: f64 = 15.0; + for &sub in &[beat / 4.0, beat / 2.0, beat, beat * 2.0, measure] { + if pps * sub >= MIN_PX { return Some(sub); } + } + // Very zoomed out: try 2x, 4x, ... multiples of a measure + let mut m = measure * 2.0; + for _ in 0..10 { + if pps * m >= MIN_PX { return Some(m); } + m *= 2.0; + } + Some(measure) + } + TimeDisplayFormat::Seconds => None, + } + } + + /// Snap a time value to the nearest quantization grid point (or return unchanged). + fn snap_to_grid( + &self, + t: f64, + bpm: f64, + time_sig: &lightningbeam_core::document::TimeSignature, + framerate: f64, + ) -> f64 { + match self.quantize_grid_size(bpm, time_sig, framerate) { + Some(grid) => (t / grid).round() * grid, + None => t, + } + } + + /// Effective drag offset for Move operations. + /// Snaps the anchor clip's resulting position to the grid; all selected clips use the same offset. + fn snapped_move_offset( + &self, + bpm: f64, + time_sig: &lightningbeam_core::document::TimeSignature, + framerate: f64, + ) -> f64 { + match self.quantize_grid_size(bpm, time_sig, framerate) { + Some(grid) => { + let snapped = ((self.drag_anchor_start + self.drag_offset) / grid).round() * grid; + snapped - self.drag_anchor_start + } + None => self.drag_offset, + } + } + /// Calculate appropriate interval for time ruler based on zoom level fn calculate_ruler_interval(&self) -> f64 { // Target: 50-100px between major ticks @@ -2530,7 +2599,7 @@ impl TimelinePane { let dur = ci.total_duration(clip_dur); // Apply drag offset for selected clips during move if is_move_drag && selection.contains_clip_instance(&ci.id) { - start = (start + self.drag_offset).max(0.0); + start = (start + self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate)).max(0.0); } ranges.push((start, start + dur)); } @@ -2600,7 +2669,7 @@ impl TimelinePane { .unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start); let mut ci_start = ci.effective_start(); if is_move_drag && selection.contains_clip_instance(&ci.id) { - ci_start = (ci_start + self.drag_offset).max(0.0); + ci_start = (ci_start + self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate)).max(0.0); } let ci_duration = ci.total_duration(clip_dur); let ci_end = ci_start + ci_duration; @@ -2719,7 +2788,7 @@ impl TimelinePane { let clip_dur = audio_clip.duration; let mut ci_start = ci.effective_start(); if is_move_drag && selection.contains_clip_instance(&ci.id) { - ci_start = (ci_start + self.drag_offset).max(0.0); + ci_start = (ci_start + self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate)).max(0.0); } let ci_duration = ci.total_duration(clip_dur); @@ -2821,7 +2890,7 @@ impl TimelinePane { }) .collect(); if !group.is_empty() { - Some(document.clamp_group_move_offset(&layer.id(), &group, self.drag_offset)) + Some(document.clamp_group_move_offset(&layer.id(), &group, self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate))) } else { None } @@ -2854,7 +2923,7 @@ impl TimelinePane { } } ClipDragType::TrimLeft => { - let new_trim = (ci.trim_start + self.drag_offset).max(0.0).min(clip_dur); + let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, document.bpm, &document.time_signature, document.framerate).max(0.0).min(clip_dur); let offset = new_trim - ci.trim_start; start = (ci.timeline_start + offset).max(0.0); duration = (clip_dur - new_trim).max(0.0); @@ -2864,14 +2933,16 @@ impl TimelinePane { } ClipDragType::TrimRight => { let old_trim_end = ci.trim_end.unwrap_or(clip_dur); - let new_trim_end = (old_trim_end + self.drag_offset).max(ci.trim_start).min(clip_dur); + let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.bpm, &document.time_signature, document.framerate).max(ci.trim_start).min(clip_dur); duration = (new_trim_end - ci.trim_start).max(0.0); } ClipDragType::LoopExtendRight => { let trim_end = ci.trim_end.unwrap_or(clip_dur); let content_window = (trim_end - ci.trim_start).max(0.0); let current_right = ci.timeline_duration.unwrap_or(content_window); - let new_right = (current_right + self.drag_offset).max(content_window); + let right_edge = ci.timeline_start + current_right + self.drag_offset; + let snapped_edge = self.snap_to_grid(right_edge, document.bpm, &document.time_signature, document.framerate); + let new_right = (snapped_edge - ci.timeline_start).max(content_window); let loop_before = ci.loop_before.unwrap_or(0.0); duration = loop_before + new_right; } @@ -2945,7 +3016,7 @@ impl TimelinePane { } ClipDragType::TrimLeft => { // Trim left: calculate new trim_start with snap to adjacent clips - let desired_trim_start = (clip_instance.trim_start + self.drag_offset) + let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.bpm, &document.time_signature, document.framerate) .max(0.0) .min(clip_duration); @@ -2985,8 +3056,7 @@ impl TimelinePane { ClipDragType::TrimRight => { // Trim right: extend or reduce duration with snap to adjacent clips let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration); - let desired_change = self.drag_offset; - let desired_trim_end = (old_trim_end + desired_change) + let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.bpm, &document.time_signature, document.framerate) .max(clip_instance.trim_start) .min(clip_duration); @@ -3019,7 +3089,9 @@ impl TimelinePane { let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let content_window = (trim_end - clip_instance.trim_start).max(0.0); let current_right = clip_instance.timeline_duration.unwrap_or(content_window); - let desired_right = (current_right + self.drag_offset).max(content_window); + let right_edge = clip_instance.timeline_start + current_right + self.drag_offset; + let snapped_edge = self.snap_to_grid(right_edge, document.bpm, &document.time_signature, document.framerate); + let desired_right = (snapped_edge - clip_instance.timeline_start).max(content_window); let new_right = if desired_right > current_right { let max_extend = document.find_max_trim_extend_right( @@ -4008,6 +4080,18 @@ impl TimelinePane { // Start dragging with the detected drag type self.clip_drag_state = Some(drag_type); self.drag_offset = 0.0; + if drag_type == ClipDragType::Move { + // Find earliest selected clip as snap anchor for quantized moves + let mut earliest = f64::MAX; + for (_, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) { + for ci in clip_instances { + if selection.contains_clip_instance(&ci.id) && ci.timeline_start < earliest { + earliest = ci.timeline_start; + } + } + } + self.drag_anchor_start = if earliest == f64::MAX { 0.0 } else { earliest }; + } } else if let Some(child_ids) = self.detect_collapsed_group_at_pointer( mousedown_pos, document, @@ -4026,6 +4110,16 @@ impl TimelinePane { *focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec()); self.clip_drag_state = Some(ClipDragType::Move); self.drag_offset = 0.0; + // Find earliest selected clip as snap anchor + let mut earliest = f64::MAX; + for (_, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) { + for ci in clip_instances { + if selection.contains_clip_instance(&ci.id) && ci.timeline_start < earliest { + earliest = ci.timeline_start; + } + } + } + self.drag_anchor_start = if earliest == f64::MAX { 0.0 } else { earliest }; } } } @@ -4046,6 +4140,9 @@ impl TimelinePane { let mut layer_moves: HashMap> = HashMap::new(); + // Compute snapped offset once for all selected clips (preserves relative spacing) + let move_offset = self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate); + // Iterate through all layers (including group children) to find selected clip instances for (layer, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) { let layer_id = layer.id(); @@ -4053,7 +4150,7 @@ impl TimelinePane { for clip_instance in clip_instances { if selection.contains_clip_instance(&clip_instance.id) { let old_timeline_start = clip_instance.timeline_start; - let new_timeline_start = old_timeline_start + self.drag_offset; + let new_timeline_start = old_timeline_start + move_offset; // Add to layer_moves layer_moves @@ -4104,11 +4201,11 @@ impl TimelinePane { let old_timeline_start = clip_instance.timeline_start; - // New trim_start is clamped to valid range - let desired_trim_start = (old_trim_start - + self.drag_offset) - .max(0.0) - .min(clip_duration); + // New trim_start is snapped then clamped to valid range + let desired_trim_start = self.snap_to_grid( + old_trim_start + self.drag_offset, + document.bpm, &document.time_signature, document.framerate, + ).max(0.0).min(clip_duration); // Apply overlap prevention when extending left let new_trim_start = if desired_trim_start < old_trim_start { @@ -4152,9 +4249,10 @@ impl TimelinePane { let current_duration = clip_instance.effective_duration(clip_duration); let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration); - let desired_trim_end = (old_trim_end_val + self.drag_offset) - .max(clip_instance.trim_start) - .min(clip_duration); + let desired_trim_end = self.snap_to_grid( + old_trim_end_val + self.drag_offset, + document.bpm, &document.time_signature, document.framerate, + ).max(clip_instance.trim_start).min(clip_duration); // Apply overlap prevention when extending right let new_trim_end_val = if desired_trim_end > old_trim_end_val { @@ -4230,7 +4328,9 @@ impl TimelinePane { let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let content_window = (trim_end - clip_instance.trim_start).max(0.0); let current_right = clip_instance.timeline_duration.unwrap_or(content_window); - let desired_right = current_right + self.drag_offset; + let right_edge = clip_instance.timeline_start + current_right + self.drag_offset; + let snapped_edge = self.snap_to_grid(right_edge, document.bpm, &document.time_signature, document.framerate); + let desired_right = snapped_edge - clip_instance.timeline_start; let new_right = if desired_right > current_right { let max_extend = document.find_max_trim_extend_right( @@ -4407,7 +4507,7 @@ impl TimelinePane { if cursor_over_ruler && !alt_held && (response.clicked() || (response.dragged() && !self.is_panning)) { if let Some(pos) = response.interact_pointer_pos() { let x = (pos.x - content_rect.min.x).max(0.0); - let new_time = self.x_to_time(x).max(0.0); + let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.bpm, &document.time_signature, document.framerate); *playback_time = new_time; self.is_scrubbing = true; // Seek immediately so it works while playing @@ -4421,7 +4521,7 @@ impl TimelinePane { else if self.is_scrubbing && response.dragged() && !self.is_panning { if let Some(pos) = response.interact_pointer_pos() { let x = (pos.x - content_rect.min.x).max(0.0); - let new_time = self.x_to_time(x).max(0.0); + let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.bpm, &document.time_signature, document.framerate); *playback_time = new_time; if let Some(controller_arc) = audio_controller { let mut controller = controller_arc.lock().unwrap(); From c938ea44b0dd11b8e0eec116b841afccf746638d Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 19 Mar 2026 01:16:26 -0400 Subject: [PATCH 5/7] Add metronome --- daw-backend/src/audio/metronome.rs | 24 ++---- .../lightningbeam-editor/src/main.rs | 5 +- .../lightningbeam-editor/src/panes/mod.rs | 1 + .../src/panes/timeline.rs | 75 ++++++++++++++++++- 4 files changed, 84 insertions(+), 21 deletions(-) diff --git a/daw-backend/src/audio/metronome.rs b/daw-backend/src/audio/metronome.rs index 4612cf4..beacf0f 100644 --- a/daw-backend/src/audio/metronome.rs +++ b/daw-backend/src/audio/metronome.rs @@ -90,8 +90,9 @@ impl Metronome { self.last_beat = -1; // Reset beat tracking when disabled self.click_position = 0; // Stop any playing click } else { - // When enabling, don't trigger a click until the next beat - self.click_position = usize::MAX; // Set to max to prevent immediate click + // Reset beat tracking so the next beat boundary (including beat 0) fires a click + self.last_beat = -1; + self.click_position = self.high_click.len(); // Idle (past end, nothing playing) } } @@ -130,20 +131,11 @@ impl Metronome { if current_beat != self.last_beat && current_beat >= 0 { self.last_beat = current_beat; - // Only trigger a click if we're not in the "just enabled" state - if self.click_position != usize::MAX { - // Determine which click to play - // Beat 1 of each measure gets the accent (high click) - let beat_in_measure = (current_beat as u32 % self.time_signature_numerator) as usize; - let is_first_beat = beat_in_measure == 0; - - // Start playing the appropriate click - self.playing_high_click = is_first_beat; - self.click_position = 0; // Start from beginning of click - } else { - // We just got enabled - reset position but don't play yet - self.click_position = self.high_click.len(); // Set past end so no click plays - } + // Determine which click to play. + // Beat 0 of each measure gets the accent (high click). + let beat_in_measure = (current_beat as u32 % self.time_signature_numerator) as usize; + self.playing_high_click = beat_in_measure == 0; + self.click_position = 0; // Start from beginning of click } // Continue playing click sample if we're currently in one diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 6fa9291..9681548 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -296,7 +296,7 @@ enum SplitPreviewMode { } /// Rasterize an embedded SVG and upload it as an egui texture -fn rasterize_svg(svg_data: &[u8], name: &str, render_size: u32, ctx: &egui::Context) -> Option { +pub(crate) fn rasterize_svg(svg_data: &[u8], name: &str, render_size: u32, ctx: &egui::Context) -> Option { let tree = resvg::usvg::Tree::from_data(svg_data, &resvg::usvg::Options::default()).ok()?; let pixmap_size = tree.size().to_int_size(); let scale_x = render_size as f32 / pixmap_size.width() as f32; @@ -855,6 +855,7 @@ struct EditorApp { #[allow(dead_code)] armed_layers: HashSet, is_recording: bool, // Whether recording is currently active + metronome_enabled: bool, // Whether metronome clicks during recording recording_clips: HashMap, // layer_id -> backend clip_id during recording recording_start_time: f64, // Playback time when recording started recording_layer_ids: Vec, // Layers being recorded to (for creating clips) @@ -1126,6 +1127,7 @@ impl EditorApp { recording_arm_mode: RecordingArmMode::default(), // Auto mode by default armed_layers: HashSet::new(), // No layers explicitly armed is_recording: false, // Not recording initially + metronome_enabled: false, // Metronome off by default recording_clips: HashMap::new(), // No active recording clips recording_start_time: 0.0, // Will be set when recording starts recording_layer_ids: Vec::new(), // Will be populated when recording starts @@ -5771,6 +5773,7 @@ impl eframe::App for EditorApp { playback_time: &mut self.playback_time, is_playing: &mut self.is_playing, is_recording: &mut self.is_recording, + metronome_enabled: &mut self.metronome_enabled, recording_clips: &mut self.recording_clips, recording_start_time: &mut self.recording_start_time, recording_layer_ids: &mut self.recording_layer_ids, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index bcfff75..34947a4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -212,6 +212,7 @@ pub struct SharedPaneState<'a> { pub is_playing: &'a mut bool, // Whether playback is currently active /// Recording state pub is_recording: &'a mut bool, // Whether recording is currently active + pub metronome_enabled: &'a mut bool, // Whether metronome clicks during recording pub recording_clips: &'a mut std::collections::HashMap, // layer_id -> clip_id pub recording_start_time: &'a mut f64, // Playback time when recording started pub recording_layer_ids: &'a mut Vec, // Layers being recorded to diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 11df37b..3a6379d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -221,6 +221,8 @@ pub struct TimelinePane { automation_cache_generation: u64, /// Last seen graph_topology_generation; used to detect node additions/removals automation_topology_generation: u64, + /// Cached metronome icon texture (loaded on first use) + metronome_icon: Option, } /// Check if a clip type can be dropped on a layer type @@ -672,6 +674,7 @@ impl TimelinePane { pending_automation_actions: Vec::new(), automation_cache_generation: u64::MAX, automation_topology_generation: u64::MAX, + metronome_icon: None, } } @@ -983,10 +986,14 @@ impl TimelinePane { return; } - // Auto-start playback if needed - if !*shared.is_playing { - if let Some(controller_arc) = shared.audio_controller { - let mut controller = controller_arc.lock().unwrap(); + // Auto-start playback if needed, and enable metronome if requested. + // Metronome must be enabled BEFORE play() so beat 0 is not missed. + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + if *shared.metronome_enabled { + controller.set_metronome_enabled(true); + } + if !*shared.is_playing { controller.play(); *shared.is_playing = true; println!("▶ Auto-started playback for recording"); @@ -1042,6 +1049,8 @@ impl TimelinePane { controller.stop_recording(); eprintln!("[STOP] Audio stop command sent at +{:.1}ms", stop_wall.elapsed().as_secs_f64() * 1000.0); } + // Always disable metronome on recording stop + controller.set_metronome_enabled(false); } // Note: Don't clear recording_layer_ids here! @@ -4738,6 +4747,64 @@ impl PaneRenderer for TimelinePane { if *shared.is_recording { ui.ctx().request_repaint(); } + + // Metronome toggle — only visible in Measures mode + if self.time_display_format == TimeDisplayFormat::Measures { + ui.add_space(4.0); + + let metro_tint = if *shared.metronome_enabled { + egui::Color32::from_rgb(100, 180, 255) + } else { + ui.visuals().text_color() + }; + + // Lazy-load the metronome SVG icon + if self.metronome_icon.is_none() { + const METRONOME_SVG: &[u8] = include_bytes!("../../../../src/assets/metronome.svg"); + self.metronome_icon = crate::rasterize_svg(METRONOME_SVG, "metronome_icon", 64, ui.ctx()); + } + + let metro_response = if let Some(icon) = &self.metronome_icon { + let (rect, response) = ui.allocate_exact_size(button_size, egui::Sense::click()); + let bg = if *shared.metronome_enabled { + egui::Color32::from_rgba_unmultiplied(100, 180, 255, 60) + } else if response.hovered() { + ui.visuals().widgets.hovered.bg_fill + } else { + egui::Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, 4.0, bg); + ui.painter().image( + icon.id(), + rect.shrink(4.0), + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + metro_tint, + ); + response + } else { + // Fallback if SVG failed to load + ui.add_sized(button_size, egui::Button::new( + egui::RichText::new("♩").color(metro_tint).size(16.0) + )) + }; + + let metro_response = metro_response.on_hover_text(if *shared.metronome_enabled { + "Disable metronome" + } else { + "Enable metronome" + }); + + if metro_response.clicked() { + *shared.metronome_enabled = !*shared.metronome_enabled; + // Sync live state if already recording + if *shared.is_recording { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.set_metronome_enabled(*shared.metronome_enabled); + } + } + } + } }); }); From 121fa3a50a3d8fd1ea50fc9b58a57985100fecf1 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 20 Mar 2026 20:51:50 -0400 Subject: [PATCH 6/7] Add count-in --- daw-backend/src/audio/engine.rs | 46 ++++++++--- daw-backend/src/audio/metronome.rs | 11 +-- daw-backend/src/audio/recording.rs | 31 +++++--- .../lightningbeam-editor/src/keymap.rs | 1 + .../lightningbeam-editor/src/main.rs | 28 +++++++ .../lightningbeam-editor/src/menu.rs | 30 +++++-- .../lightningbeam-editor/src/panes/mod.rs | 1 + .../src/panes/timeline.rs | 79 ++++++++++++++++++- 8 files changed, 189 insertions(+), 38 deletions(-) diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 2ae9454..810c103 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -27,7 +27,7 @@ pub struct Engine { project: Project, audio_pool: AudioClipPool, buffer_pool: BufferPool, - playhead: u64, // Playhead position in samples + playhead: i64, // Playhead position in samples (may be negative during count-in pre-roll) sample_rate: u32, playing: bool, channels: u32, @@ -74,6 +74,10 @@ pub struct Engine { // MIDI recording state midi_recording_state: Option, + // Currently held MIDI notes per track (note -> velocity), updated on NoteOn/NoteOff + // Used to inject held notes when recording starts mid-press (e.g. after count-in) + midi_held_notes: HashMap>, + // MIDI input manager for external MIDI devices midi_input_manager: Option, @@ -160,6 +164,7 @@ impl Engine { recording_mirror_tx: None, recording_progress_counter: 0, midi_recording_state: None, + midi_held_notes: HashMap::new(), midi_input_manager: None, metronome: Metronome::new(sample_rate), recording_sample_buffer: Vec::with_capacity(4096), @@ -396,17 +401,18 @@ impl Engine { ); // Update playhead (convert total samples to frames) - self.playhead += (output.len() / self.channels as usize) as u64; + self.playhead += (output.len() / self.channels as usize) as i64; - // Update atomic playhead for UI reads + // Update atomic playhead for UI reads (clamped to 0; negative = count-in pre-roll) self.playhead_atomic - .store(self.playhead, Ordering::Relaxed); + .store(self.playhead.max(0) as u64, Ordering::Relaxed); // Send periodic position updates self.frames_since_last_event += output.len() / self.channels as usize; if self.frames_since_last_event >= self.event_interval_frames / self.channels as usize { - let position_seconds = self.playhead as f64 / self.sample_rate as f64; + // Clamp to 0 during count-in pre-roll (negative playhead = before project start) + let position_seconds = self.playhead.max(0) as f64 / self.sample_rate as f64; let _ = self .event_tx .push(AudioEvent::PlaybackPosition(position_seconds)); @@ -692,17 +698,17 @@ impl Engine { self.project.stop_all_notes(); } Command::Seek(seconds) => { - let frames = (seconds * self.sample_rate as f64) as u64; - self.playhead = frames; - self.playhead_atomic - .store(self.playhead, Ordering::Relaxed); + self.playhead = (seconds * self.sample_rate as f64) as i64; + // Clamp to 0 for atomic/disk-reader; negative = count-in pre-roll (no disk reads needed) + let clamped = self.playhead.max(0) as u64; + self.playhead_atomic.store(clamped, Ordering::Relaxed); // Stop all MIDI notes when seeking to prevent stuck notes self.project.stop_all_notes(); // Reset all node graphs to clear effect buffers (echo, reverb, etc.) self.project.reset_all_graphs(); // Notify disk reader to refill buffers from new position if let Some(ref mut dr) = self.disk_reader { - dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek { frame: frames }); + dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek { frame: clamped }); } } Command::SetTrackVolume(track_id, volume) => { @@ -1212,6 +1218,9 @@ impl Engine { // Emit event to UI for visual feedback let _ = self.event_tx.push(AudioEvent::NoteOn(note, velocity)); + // Track held notes so count-in recording can inject them at start_time + self.midi_held_notes.entry(track_id).or_default().insert(note, velocity); + // If MIDI recording is active on this track, capture the event if let Some(recording) = &mut self.midi_recording_state { if recording.track_id == track_id { @@ -1230,6 +1239,11 @@ impl Engine { // Emit event to UI for visual feedback let _ = self.event_tx.push(AudioEvent::NoteOff(note)); + // Remove from held notes tracking + if let Some(track_notes) = self.midi_held_notes.get_mut(&track_id) { + track_notes.remove(¬e); + } + // If MIDI recording is active on this track, capture the event if let Some(recording) = &mut self.midi_recording_state { if recording.track_id == track_id { @@ -3038,7 +3052,17 @@ impl Engine { // Check if track exists and is a MIDI track if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) { // Create MIDI recording state - let recording_state = MidiRecordingState::new(track_id, clip_id, start_time); + let mut recording_state = MidiRecordingState::new(track_id, clip_id, start_time); + + // Inject any notes currently held on this track (pressed during count-in pre-roll) + // so they start at t=0 of the recording rather than being lost + if let Some(held) = self.midi_held_notes.get(&track_id) { + for (¬e, &velocity) in held { + eprintln!("[MIDI_RECORDING] Injecting held note {} vel {} at start_time {:.3}s", note, velocity, start_time); + recording_state.note_on(note, velocity, start_time); + } + } + self.midi_recording_state = Some(recording_state); eprintln!("[MIDI_RECORDING] Started MIDI recording on track {} for clip {}", track_id, clip_id); diff --git a/daw-backend/src/audio/metronome.rs b/daw-backend/src/audio/metronome.rs index beacf0f..f3b8b7c 100644 --- a/daw-backend/src/audio/metronome.rs +++ b/daw-backend/src/audio/metronome.rs @@ -107,7 +107,7 @@ impl Metronome { pub fn process( &mut self, output: &mut [f32], - playhead_samples: u64, + playhead_samples: i64, playing: bool, sample_rate: u32, channels: u32, @@ -120,20 +120,21 @@ impl Metronome { let frames = output.len() / channels as usize; for frame in 0..frames { - let current_sample = playhead_samples + frame as u64; + let current_sample = playhead_samples + frame as i64; // Calculate current beat number let current_time_seconds = current_sample as f64 / sample_rate as f64; let beats_per_second = self.bpm as f64 / 60.0; let current_beat = (current_time_seconds * beats_per_second).floor() as i64; - // Check if we crossed a beat boundary - if current_beat != self.last_beat && current_beat >= 0 { + // Check if we crossed a beat boundary (including negative beats during count-in pre-roll) + if current_beat != self.last_beat { self.last_beat = current_beat; // Determine which click to play. // Beat 0 of each measure gets the accent (high click). - let beat_in_measure = (current_beat as u32 % self.time_signature_numerator) as usize; + // Use rem_euclid so negative beat numbers map correctly (e.g. -4 % 4 = 0). + let beat_in_measure = current_beat.rem_euclid(self.time_signature_numerator as i64) as usize; self.playing_high_click = beat_in_measure == 0; self.click_position = 0; // Start from beginning of click } diff --git a/daw-backend/src/audio/recording.rs b/daw-backend/src/audio/recording.rs index 8dd1f3f..392da27 100644 --- a/daw-backend/src/audio/recording.rs +++ b/daw-backend/src/audio/recording.rs @@ -213,7 +213,6 @@ impl MidiRecordingState { /// Handle a MIDI note on event pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: f64) { - // Store this note as active self.active_notes.insert(note, ActiveMidiNote { note, velocity, @@ -225,14 +224,21 @@ impl MidiRecordingState { pub fn note_off(&mut self, note: u8, absolute_time: f64) { // Find the matching noteOn if let Some(active_note) = self.active_notes.remove(¬e) { - // Calculate relative time offset and duration - let time_offset = active_note.start_time - self.start_time; - let duration = absolute_time - active_note.start_time; + // If the note was fully released before the recording start (e.g. during count-in + // pre-roll), discard it — only notes still held at the clip start are kept. + if absolute_time <= self.start_time { + return; + } + + // Clamp note start to clip start: notes held across the recording boundary + // are treated as starting at the clip position. + let note_start = active_note.start_time.max(self.start_time); + let time_offset = note_start - self.start_time; + let duration = absolute_time - note_start; eprintln!("[MIDI_RECORDING_STATE] Completing note {}: note_start={:.3}s, note_end={:.3}s, recording_start={:.3}s, time_offset={:.3}s, duration={:.3}s", - note, active_note.start_time, absolute_time, self.start_time, time_offset, duration); + note, note_start, absolute_time, self.start_time, time_offset, duration); - // Add to completed notes self.completed_notes.push(( time_offset, active_note.note, @@ -258,8 +264,9 @@ impl MidiRecordingState { pub fn get_notes_with_active(&self, current_time: f64) -> Vec<(f64, u8, u8, f64)> { let mut notes = self.completed_notes.clone(); for active in self.active_notes.values() { - let time_offset = active.start_time - self.start_time; - let provisional_dur = (current_time - active.start_time).max(0.0); + let note_start = active.start_time.max(self.start_time); + let time_offset = note_start - self.start_time; + let provisional_dur = (current_time - note_start).max(0.0); notes.push((time_offset, active.note, active.velocity, provisional_dur)); } notes @@ -273,15 +280,13 @@ impl MidiRecordingState { /// Close out all active notes at the given time /// This should be called when stopping recording to end any held notes pub fn close_active_notes(&mut self, end_time: f64) { - // Collect all active notes and close them let active_notes: Vec<_> = self.active_notes.drain().collect(); for (_note_num, active_note) in active_notes { - // Calculate relative time offset and duration - let time_offset = active_note.start_time - self.start_time; - let duration = end_time - active_note.start_time; + let note_start = active_note.start_time.max(self.start_time); + let time_offset = note_start - self.start_time; + let duration = end_time - note_start; - // Add to completed notes self.completed_notes.push(( time_offset, active_note.note, diff --git a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs index 3f7f147..220e8ef 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs @@ -339,6 +339,7 @@ impl From for AppAction { MenuAction::AddShapeTween => Self::AddShapeTween, MenuAction::ReturnToStart => Self::ReturnToStart, MenuAction::Play => Self::Play, + MenuAction::ToggleCountIn => Self::Play, // not directly mappable to AppAction MenuAction::ZoomIn => Self::ZoomIn, MenuAction::ZoomOut => Self::ZoomOut, MenuAction::ActualSize => Self::ActualSize, diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 9681548..044b6d4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -856,6 +856,7 @@ struct EditorApp { armed_layers: HashSet, is_recording: bool, // Whether recording is currently active metronome_enabled: bool, // Whether metronome clicks during recording + count_in_enabled: bool, // Whether count-in fires before recording recording_clips: HashMap, // layer_id -> backend clip_id during recording recording_start_time: f64, // Playback time when recording started recording_layer_ids: Vec, // Layers being recorded to (for creating clips) @@ -1128,6 +1129,7 @@ impl EditorApp { armed_layers: HashSet::new(), // No layers explicitly armed is_recording: false, // Not recording initially metronome_enabled: false, // Metronome off by default + count_in_enabled: false, // Count-in off by default recording_clips: HashMap::new(), // No active recording clips recording_start_time: 0.0, // Will be set when recording starts recording_layer_ids: Vec::new(), // Will be populated when recording starts @@ -3577,6 +3579,12 @@ impl EditorApp { println!("Menu: Play"); // TODO: Implement play/pause } + MenuAction::ToggleCountIn => { + // Only effective when metronome is enabled (count-in requires a click track) + if self.metronome_enabled { + self.count_in_enabled = !self.count_in_enabled; + } + } // View menu MenuAction::ZoomIn => { @@ -5632,9 +5640,28 @@ impl eframe::App for EditorApp { if let Some(menu_system) = &self.menu_system { let recent_files = self.config.get_recent_files(); let layout_names: Vec = self.layouts.iter().map(|l| l.name.clone()).collect(); + + // Determine timeline measures mode for conditional menu items + let timeline_is_measures = self.pane_instances.values().any(|p| { + if let panes::PaneInstance::Timeline(t) = p { t.is_measures_mode() } else { false } + }); + + // Checked actions show "✔ Label"; hidden actions are not rendered at all + let checked: &[crate::menu::MenuAction] = if self.count_in_enabled && self.metronome_enabled { + &[crate::menu::MenuAction::ToggleCountIn] + } else { + &[] + }; + let hidden: &[crate::menu::MenuAction] = if timeline_is_measures && self.metronome_enabled { + &[] + } else { + &[crate::menu::MenuAction::ToggleCountIn] + }; + if let Some(action) = menu_system.render_egui_menu_bar( ui, &recent_files, Some(&self.keymap), &layout_names, self.current_layout_index, + checked, hidden, ) { self.handle_menu_action(action); } @@ -5774,6 +5801,7 @@ impl eframe::App for EditorApp { is_playing: &mut self.is_playing, is_recording: &mut self.is_recording, metronome_enabled: &mut self.metronome_enabled, + count_in_enabled: &mut self.count_in_enabled, recording_clips: &mut self.recording_clips, recording_start_time: &mut self.recording_start_time, recording_layer_ids: &mut self.recording_layer_ids, diff --git a/lightningbeam-ui/lightningbeam-editor/src/menu.rs b/lightningbeam-ui/lightningbeam-editor/src/menu.rs index 0b27eb5..840ce4e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/menu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/menu.rs @@ -324,6 +324,7 @@ pub enum MenuAction { AddShapeTween, ReturnToStart, Play, + ToggleCountIn, // View menu ZoomIn, @@ -422,6 +423,7 @@ impl MenuItemDef { const ADD_SHAPE_TWEEN: Self = Self { label: "Add Shape Tween", action: MenuAction::AddShapeTween, shortcut: None }; const RETURN_TO_START: Self = Self { label: "Return to start", action: MenuAction::ReturnToStart, shortcut: None }; const PLAY: Self = Self { label: "Play", action: MenuAction::Play, shortcut: None }; + const COUNT_IN: Self = Self { label: "Count In", action: MenuAction::ToggleCountIn, shortcut: None }; // View menu items const ZOOM_IN: Self = Self { label: "Zoom In", action: MenuAction::ZoomIn, shortcut: Some(Shortcut::new(ShortcutKey::Equals, CTRL, NO_SHIFT, NO_ALT)) }; @@ -548,6 +550,8 @@ impl MenuItemDef { MenuDef::Separator, MenuDef::Item(&Self::RETURN_TO_START), MenuDef::Item(&Self::PLAY), + MenuDef::Separator, + MenuDef::Item(&Self::COUNT_IN), ], }, // View menu @@ -805,6 +809,8 @@ impl MenuSystem { keymap: Option<&crate::keymap::KeymapManager>, layout_names: &[String], current_layout_index: usize, + checked_actions: &[MenuAction], + hidden_actions: &[MenuAction], ) -> Option { let mut action = None; let ctx = ui.ctx().clone(); @@ -819,7 +825,7 @@ impl MenuSystem { let response = ui.button(*label); let popup_id = egui::Popup::default_response_id(&response); button_entries.push((response, popup_id, menu_def)); - } else if let Some(a) = self.render_menu_def(ui, menu_def, recent_files, keymap, layout_names, current_layout_index) { + } else if let Some(a) = self.render_menu_def(ui, menu_def, recent_files, keymap, layout_names, current_layout_index, checked_actions, hidden_actions) { action = Some(a); } } @@ -847,7 +853,7 @@ impl MenuSystem { ui.set_width(min_width); let mut a = None; for child in *children { - if let Some(result) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index) { + if let Some(result) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index, checked_actions, hidden_actions) { a = Some(result); ui.close(); } @@ -875,10 +881,15 @@ impl MenuSystem { keymap: Option<&crate::keymap::KeymapManager>, layout_names: &[String], current_layout_index: usize, + checked_actions: &[MenuAction], + hidden_actions: &[MenuAction], ) -> Option { match def { MenuDef::Item(item_def) => { - if Self::render_menu_item(ui, item_def, keymap) { + if hidden_actions.contains(&item_def.action) { + return None; + } + if Self::render_menu_item(ui, item_def, keymap, checked_actions) { Some(item_def.action) } else { None @@ -914,7 +925,7 @@ impl MenuSystem { } else if *label == "Layout" { let mut action = None; for child in *children { - if let Some(a) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index) { + if let Some(a) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index, checked_actions, hidden_actions) { action = Some(a); ui.close(); } @@ -937,7 +948,7 @@ impl MenuSystem { } else { let mut action = None; for child in *children { - if let Some(a) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index) { + if let Some(a) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index, checked_actions, hidden_actions) { action = Some(a); ui.close(); } @@ -951,7 +962,7 @@ impl MenuSystem { } /// Render a single menu item with label and shortcut - fn render_menu_item(ui: &mut egui::Ui, def: &MenuItemDef, keymap: Option<&crate::keymap::KeymapManager>) -> bool { + fn render_menu_item(ui: &mut egui::Ui, def: &MenuItemDef, keymap: Option<&crate::keymap::KeymapManager>, checked_actions: &[MenuAction]) -> bool { // Look up shortcut from keymap if available, otherwise use static default let effective_shortcut = if let Some(km) = keymap { if let Ok(app_action) = crate::keymap::AppAction::try_from(def.action) { @@ -987,10 +998,15 @@ impl MenuSystem { ui.visuals().widgets.inactive.text_color() }; let label_pos = rect.min + egui::vec2(4.0, (rect.height() - 14.0) / 2.0); + let label = if checked_actions.contains(&def.action) { + format!("✔ {}", def.label) + } else { + def.label.to_owned() + }; ui.painter().text( label_pos, egui::Align2::LEFT_TOP, - def.label, + label, egui::FontId::proportional(14.0), text_color, ); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 34947a4..027a839 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -213,6 +213,7 @@ pub struct SharedPaneState<'a> { /// Recording state pub is_recording: &'a mut bool, // Whether recording is currently active pub metronome_enabled: &'a mut bool, // Whether metronome clicks during recording + pub count_in_enabled: &'a mut bool, // Whether count-in fires before recording pub recording_clips: &'a mut std::collections::HashMap, // layer_id -> clip_id pub recording_start_time: &'a mut f64, // Playback time when recording started pub recording_layer_ids: &'a mut Vec, // Layers being recorded to diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 3a6379d..2b73e76 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -223,6 +223,16 @@ pub struct TimelinePane { automation_topology_generation: u64, /// Cached metronome icon texture (loaded on first use) metronome_icon: Option, + /// Count-in pre-roll state: set when count-in is active, cleared when recording fires + pending_recording_start: Option, +} + +/// Deferred recording start created during count-in pre-roll +struct PendingRecordingStart { + /// Original playhead position — where clips will be placed in the timeline + original_playhead: f64, + /// Transport time at which to fire the actual recording commands + trigger_time: f64, } /// Check if a clip type can be dropped on a layer type @@ -675,9 +685,15 @@ impl TimelinePane { automation_cache_generation: u64::MAX, automation_topology_generation: u64::MAX, metronome_icon: None, + pending_recording_start: None, } } + /// Returns true if the timeline is currently in Measures display mode. + pub fn is_measures_mode(&self) -> bool { + self.time_display_format == TimeDisplayFormat::Measures + } + /// Execute a view action with the given parameters /// Called from main.rs after determining this is the best handler #[allow(dead_code)] // Mirrors StagePane; wiring in main.rs pending (see TODO at view action dispatch) @@ -910,6 +926,37 @@ impl TimelinePane { }); let start_time = *shared.playback_time; + + // Count-in: seek back N beats, start transport + metronome, defer ALL recording commands. + // Must happen before Step 4 so no clips or backend recordings are created yet. + if *shared.count_in_enabled + && *shared.metronome_enabled + && self.time_display_format == TimeDisplayFormat::Measures + { + let (bpm, beats_per_measure) = { + let doc = shared.action_executor.document(); + (doc.bpm, doc.time_signature.numerator as f64) + }; + let count_in_duration = beats_per_measure * (60.0 / bpm); + let seek_to = start_time - count_in_duration; // may be negative + + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.seek(seek_to); + controller.set_metronome_enabled(true); + if !*shared.is_playing { + controller.play(); + *shared.is_playing = true; + } + } + + self.pending_recording_start = Some(PendingRecordingStart { + original_playhead: start_time, + trigger_time: start_time, + }); + return; // Recording commands are deferred — check_pending_recording_start fires them + } + shared.recording_layer_ids.clear(); // Step 4: Dispatch recording for each candidate @@ -986,8 +1033,8 @@ impl TimelinePane { return; } - // Auto-start playback if needed, and enable metronome if requested. - // Metronome must be enabled BEFORE play() so beat 0 is not missed. + // No count-in (or deferred re-call from check_pending_recording_start): + // Start immediately. Metronome must be enabled BEFORE play() so beat 0 is not missed. if let Some(controller_arc) = shared.audio_controller { let mut controller = controller_arc.lock().unwrap(); if *shared.metronome_enabled { @@ -1005,7 +1052,31 @@ impl TimelinePane { } /// Stop all active recordings + /// Called every frame; fires deferred recording commands once the count-in pre-roll ends. + fn check_pending_recording_start(&mut self, shared: &mut SharedPaneState) { + let Some(ref pending) = self.pending_recording_start else { return }; + if !*shared.is_playing || *shared.playback_time < pending.trigger_time { + return; + } + let original_playhead = pending.original_playhead; + self.pending_recording_start = None; + + // Re-run start_recording with playback_time temporarily set to the original playhead + // so it captures the correct start_time and places clips at the right position. + // Disable count_in_enabled so start_recording takes the immediate path (no re-seek). + let saved_time = *shared.playback_time; + let saved_count_in = *shared.count_in_enabled; + *shared.playback_time = original_playhead; + *shared.count_in_enabled = false; + self.start_recording(shared); + *shared.playback_time = saved_time; + *shared.count_in_enabled = saved_count_in; + } + fn stop_recording(&mut self, shared: &mut SharedPaneState) { + // Cancel any in-progress count-in + self.pending_recording_start = None; + let stop_wall = std::time::Instant::now(); eprintln!("[STOP] stop_recording called at {:?}", stop_wall); @@ -4652,6 +4723,9 @@ impl TimelinePane { impl PaneRenderer for TimelinePane { fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { + // Fire deferred recording commands once count-in pre-roll has elapsed + self.check_pending_recording_start(shared); + ui.spacing_mut().item_spacing.x = 2.0; // Small spacing between button groups // Main playback controls group @@ -4804,6 +4878,7 @@ impl PaneRenderer for TimelinePane { } } } + } }); }); From 0d7f15853cd706a66b194f9f2dc8e6432dc9d797 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 20 Mar 2026 21:05:00 -0400 Subject: [PATCH 7/7] Set default timeline mode based on activity --- .../lightningbeam-core/src/document.rs | 14 +++++ .../lightningbeam-editor/src/main.rs | 7 +++ .../src/panes/timeline.rs | 59 +++++++++---------- 3 files changed, 50 insertions(+), 30 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 9f66806..261bd74 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -133,6 +133,15 @@ impl Default for TimeSignature { fn default_bpm() -> f64 { 120.0 } +/// How time is displayed in the timeline +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum TimelineMode { + #[default] + Seconds, + Measures, + Frames, +} + /// Asset category for folder tree access #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssetCategory { @@ -226,6 +235,10 @@ pub struct Document { #[serde(default)] pub script_folders: AssetFolderTree, + /// How time is displayed in the timeline (saved with document) + #[serde(default)] + pub timeline_mode: TimelineMode, + /// Current UI layout state (serialized for save/load) #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_layout: Option, @@ -270,6 +283,7 @@ impl Default for Document { effect_folders: AssetFolderTree::new(), script_definitions: HashMap::new(), script_folders: AssetFolderTree::new(), + timeline_mode: TimelineMode::Seconds, ui_layout: None, ui_layout_base: None, current_time: 0.0, diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 044b6d4..bc3f91a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1490,6 +1490,13 @@ impl EditorApp { } }; + // Set default timeline mode based on activity + document.timeline_mode = match layout_index { + 2 => lightningbeam_core::document::TimelineMode::Measures, // Music + 1 => lightningbeam_core::document::TimelineMode::Seconds, // Video + _ => lightningbeam_core::document::TimelineMode::Frames, // Animation, Painting, etc. + }; + // Reset action executor with new document self.action_executor = lightningbeam_core::action::ActionExecutor::new(document); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 2b73e76..f53d9a9 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -136,13 +136,7 @@ enum ClipDragType { LoopExtendLeft, } -/// How time is displayed in the ruler and header -#[derive(Debug, Clone, Copy, PartialEq)] -enum TimeDisplayFormat { - Seconds, - Measures, - Frames, -} +use lightningbeam_core::document::TimelineMode; /// State for an in-progress layer header drag-to-reorder operation. struct LayerDragState { @@ -194,7 +188,7 @@ pub struct TimelinePane { context_menu_clip: Option<(Option, egui::Pos2)>, /// Whether to display time as seconds or measures - time_display_format: TimeDisplayFormat, + time_display_format: TimelineMode, /// Waveform upload progress: pool_index -> frames uploaded so far. /// Tracks chunked GPU uploads across frames to avoid hitches. @@ -673,7 +667,7 @@ impl TimelinePane { mousedown_pos: None, layer_control_clicked: false, context_menu_clip: None, - time_display_format: TimeDisplayFormat::Seconds, + time_display_format: TimelineMode::Seconds, waveform_upload_progress: std::collections::HashMap::new(), video_thumbnail_textures: std::collections::HashMap::new(), layer_drag: None, @@ -691,7 +685,7 @@ impl TimelinePane { /// Returns true if the timeline is currently in Measures display mode. pub fn is_measures_mode(&self) -> bool { - self.time_display_format == TimeDisplayFormat::Measures + self.time_display_format == TimelineMode::Measures } /// Execute a view action with the given parameters @@ -931,7 +925,7 @@ impl TimelinePane { // Must happen before Step 4 so no clips or backend recordings are created yet. if *shared.count_in_enabled && *shared.metronome_enabled - && self.time_display_format == TimeDisplayFormat::Measures + && self.time_display_format == TimelineMode::Measures { let (bpm, beats_per_measure) = { let doc = shared.action_executor.document(); @@ -1358,8 +1352,8 @@ impl TimelinePane { framerate: f64, ) -> Option { match self.time_display_format { - TimeDisplayFormat::Frames => Some(1.0 / framerate), - TimeDisplayFormat::Measures => { + TimelineMode::Frames => Some(1.0 / framerate), + TimelineMode::Measures => { use lightningbeam_core::beat_time::{beat_duration, measure_duration}; let beat = beat_duration(bpm); let measure = measure_duration(bpm, time_sig); @@ -1379,7 +1373,7 @@ impl TimelinePane { } Some(measure) } - TimeDisplayFormat::Seconds => None, + TimelineMode::Seconds => None, } } @@ -1456,7 +1450,7 @@ impl TimelinePane { let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); match self.time_display_format { - TimeDisplayFormat::Seconds => { + TimelineMode::Seconds => { let interval = self.calculate_ruler_interval(); let start_time = (self.viewport_start_time / interval).floor() * interval; let end_time = self.x_to_time(rect.width()); @@ -1489,7 +1483,7 @@ impl TimelinePane { time += interval; } } - TimeDisplayFormat::Measures => { + TimelineMode::Measures => { let beats_per_second = bpm / 60.0; let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm); let bpm_count = time_sig.numerator; @@ -1542,7 +1536,7 @@ impl TimelinePane { } } } - TimeDisplayFormat::Frames => { + TimelineMode::Frames => { let interval = self.calculate_ruler_interval_frames(framerate); let start_frame = (self.viewport_start_time.max(0.0) * framerate).floor() as i64; let end_frame = (self.x_to_time(rect.width()) * framerate).ceil() as i64; @@ -2580,7 +2574,7 @@ impl TimelinePane { // Grid lines matching ruler match self.time_display_format { - TimeDisplayFormat::Seconds => { + TimelineMode::Seconds => { let interval = self.calculate_ruler_interval(); let start_time = (self.viewport_start_time / interval).floor() * interval; let end_time = self.x_to_time(rect.width()); @@ -2597,7 +2591,7 @@ impl TimelinePane { time += interval; } } - TimeDisplayFormat::Measures => { + TimelineMode::Measures => { let beats_per_second = document.bpm / 60.0; let bpm_count = document.time_signature.numerator; let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64; @@ -2615,7 +2609,7 @@ impl TimelinePane { ); } } - TimeDisplayFormat::Frames => { + TimelineMode::Frames => { let framerate = document.framerate; let px_per_frame = self.pixels_per_second / framerate as f32; @@ -4723,6 +4717,9 @@ impl TimelinePane { impl PaneRenderer for TimelinePane { fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { + // Sync timeline mode from document (document is source of truth) + self.time_display_format = shared.action_executor.document().timeline_mode; + // Fire deferred recording commands once count-in pre-roll has elapsed self.check_pending_recording_start(shared); @@ -4823,7 +4820,7 @@ impl PaneRenderer for TimelinePane { } // Metronome toggle — only visible in Measures mode - if self.time_display_format == TimeDisplayFormat::Measures { + if self.time_display_format == TimelineMode::Measures { ui.add_space(4.0); let metro_tint = if *shared.metronome_enabled { @@ -4897,10 +4894,10 @@ impl PaneRenderer for TimelinePane { }; match self.time_display_format { - TimeDisplayFormat::Seconds => { + TimelineMode::Seconds => { ui.colored_label(text_color, format!("Time: {:.2}s / {:.2}s", *shared.playback_time, self.duration)); } - TimeDisplayFormat::Measures => { + TimelineMode::Measures => { let time_sig = lightningbeam_core::document::TimeSignature { numerator: time_sig_num, denominator: time_sig_den }; let pos = lightningbeam_core::beat_time::time_to_measure( *shared.playback_time, bpm, &time_sig, @@ -4911,7 +4908,7 @@ impl PaneRenderer for TimelinePane { time_sig_num, time_sig_den, )); } - TimeDisplayFormat::Frames => { + TimelineMode::Frames => { let current_frame = (*shared.playback_time * framerate).floor() as i64 + 1; let total_frames = (self.duration * framerate).ceil() as i64; ui.colored_label(text_color, format!( @@ -4930,16 +4927,18 @@ impl PaneRenderer for TimelinePane { // Time display format toggle egui::ComboBox::from_id_salt("time_format") .selected_text(match self.time_display_format { - TimeDisplayFormat::Seconds => "Seconds", - TimeDisplayFormat::Measures => "Measures", - TimeDisplayFormat::Frames => "Frames", + TimelineMode::Seconds => "Seconds", + TimelineMode::Measures => "Measures", + TimelineMode::Frames => "Frames", }) .width(80.0) .show_ui(ui, |ui| { - ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Seconds, "Seconds"); - ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Measures, "Measures"); - ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Frames, "Frames"); + ui.selectable_value(&mut self.time_display_format, TimelineMode::Seconds, "Seconds"); + ui.selectable_value(&mut self.time_display_format, TimelineMode::Measures, "Measures"); + ui.selectable_value(&mut self.time_display_format, TimelineMode::Frames, "Frames"); }); + // Write change back to document so it persists and is the source of truth + shared.action_executor.document_mut().timeline_mode = self.time_display_format; ui.separator();