diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index fa5639d..fcd37f0 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -534,6 +534,10 @@ impl Engine { // Notify UI about the new MIDI track let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, false, name)); } + Command::AddMidiClipToPool(clip) => { + // Add the clip to the pool without placing it on any track + self.project.midi_clip_pool.add_existing_clip(clip); + } Command::CreateMidiClip(track_id, start_time, duration) => { // Get the next MIDI clip ID from the atomic counter let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed); @@ -2083,6 +2087,12 @@ impl EngineController { let _ = self.command_tx.push(Command::CreateMidiTrack(name)); } + /// Add a MIDI clip to the pool without placing it on any track + /// This is useful for importing MIDI files into a clip library + pub fn add_midi_clip_to_pool(&mut self, clip: MidiClip) { + let _ = self.command_tx.push(Command::AddMidiClipToPool(clip)); + } + /// Create a new audio track synchronously (waits for creation to complete) pub fn create_audio_track_sync(&mut self, name: String) -> Result { if let Err(_) = self.query_tx.push(Query::CreateAudioTrackSync(name)) { diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index b116ee2..3f61f63 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -67,6 +67,8 @@ pub enum Command { // MIDI commands /// Create a new MIDI track with a name CreateMidiTrack(String), + /// Add a MIDI clip to the pool without placing it on a track + AddMidiClipToPool(MidiClip), /// Create a new MIDI clip on a track (track_id, start_time, duration) CreateMidiClip(TrackId, f64, f64), /// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration) diff --git a/lightningbeam-ui/lightningbeam-core/src/pane.rs b/lightningbeam-ui/lightningbeam-core/src/pane.rs index c7274b1..8ecf74c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/pane.rs +++ b/lightningbeam-ui/lightningbeam-core/src/pane.rs @@ -25,6 +25,8 @@ pub enum PaneType { Outliner, /// MIDI piano roll editor PianoRoll, + /// Virtual piano keyboard for live MIDI input + VirtualPiano, /// Node-based editor NodeEditor, /// Preset/asset browser @@ -43,6 +45,7 @@ impl PaneType { PaneType::Infopanel => "Info Panel", PaneType::Outliner => "Outliner", PaneType::PianoRoll => "Piano Roll", + PaneType::VirtualPiano => "Virtual Piano", PaneType::NodeEditor => "Node Editor", PaneType::PresetBrowser => "Preset Browser", PaneType::AssetLibrary => "Asset Library", @@ -60,6 +63,7 @@ impl PaneType { PaneType::Infopanel => "infopanel.svg", PaneType::Outliner => "stage.svg", // TODO: needs own icon PaneType::PianoRoll => "piano-roll.svg", + PaneType::VirtualPiano => "piano.svg", PaneType::NodeEditor => "node-editor.svg", PaneType::PresetBrowser => "stage.svg", // TODO: needs own icon PaneType::AssetLibrary => "stage.svg", // TODO: needs own icon @@ -76,6 +80,7 @@ impl PaneType { "infopanel" => Some(PaneType::Infopanel), "outlineer" | "outliner" => Some(PaneType::Outliner), "pianoroll" => Some(PaneType::PianoRoll), + "virtualpiano" => Some(PaneType::VirtualPiano), "nodeeditor" => Some(PaneType::NodeEditor), "presetbrowser" => Some(PaneType::PresetBrowser), "assetlibrary" => Some(PaneType::AssetLibrary), @@ -93,6 +98,7 @@ impl PaneType { PaneType::Outliner, PaneType::NodeEditor, PaneType::PianoRoll, + PaneType::VirtualPiano, PaneType::PresetBrowser, PaneType::AssetLibrary, ] @@ -107,6 +113,7 @@ impl PaneType { PaneType::Infopanel => "infopanel", PaneType::Outliner => "outlineer", // JSON uses outlineer PaneType::PianoRoll => "pianoRoll", + PaneType::VirtualPiano => "virtualPiano", PaneType::NodeEditor => "nodeEditor", PaneType::PresetBrowser => "presetBrowser", PaneType::AssetLibrary => "assetLibrary", diff --git a/lightningbeam-ui/lightningbeam-editor/src/default_instrument.rs b/lightningbeam-ui/lightningbeam-editor/src/default_instrument.rs new file mode 100644 index 0000000..48e9caf --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/default_instrument.rs @@ -0,0 +1,59 @@ +/// Default MIDI instrument loader +/// +/// This module provides a default instrument (bass synthesizer) for MIDI tracks +/// until the user implements the node editor to load custom instruments. + +use std::path::PathBuf; + +/// Embedded default MIDI instrument preset (bass synthesizer) +const DEFAULT_MIDI_INSTRUMENT: &str = include_str!("../../../src/assets/instruments/synthesizers/bass.json"); + +/// Load the default MIDI instrument into a daw-backend MIDI track +/// +/// This function: +/// 1. Parses the embedded bass.json preset +/// 2. Writes it to a temporary file (required by daw-backend API) +/// 3. Loads the preset into the track's instrument graph +/// 4. Asynchronously cleans up the temp file after a delay +/// +/// # Arguments +/// * `audio_controller` - Mutable reference to the daw-backend EngineController +/// * `track_id` - The MIDI track ID to load the instrument into +/// +/// # Returns +/// * `Ok(())` if successful +/// * `Err(String)` with error message if parsing or file I/O fails +pub fn load_default_instrument( + audio_controller: &mut daw_backend::EngineController, + track_id: daw_backend::TrackId, +) -> Result<(), String> { + // Verify the embedded JSON is valid by attempting to parse it + let _preset: serde_json::Value = serde_json::from_str(DEFAULT_MIDI_INSTRUMENT) + .map_err(|e| format!("Failed to parse embedded default instrument: {}", e))?; + + // Create temp directory path + let temp_dir = std::env::temp_dir(); + let temp_filename = format!("lightningbeam_default_instrument_{}.json", track_id); + let temp_path = temp_dir.join(&temp_filename); + + // Write preset to temporary file + std::fs::write(&temp_path, DEFAULT_MIDI_INSTRUMENT) + .map_err(|e| format!("Failed to write temp preset file: {}", e))?; + + // Load preset into track's instrument graph via daw-backend API + let temp_path_str = temp_path.to_string_lossy().to_string(); + audio_controller.graph_load_preset(track_id, temp_path_str); + + // Schedule async cleanup of temp file (give backend time to load it first) + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(500)); + let _ = std::fs::remove_file(temp_path); + }); + + Ok(()) +} + +/// Get the name of the default instrument for display purposes +pub fn default_instrument_name() -> &'static str { + "Deep Bass (Default)" +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 76d452c..912c8a6 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1,4 +1,5 @@ use eframe::egui; +use lightningbeam_core::layer::{AnyLayer, AudioLayer}; use lightningbeam_core::layout::{LayoutDefinition, LayoutNode}; use lightningbeam_core::pane::PaneType; use lightningbeam_core::tool::Tool; @@ -17,6 +18,8 @@ use menu::{MenuAction, MenuSystem}; mod theme; use theme::{Theme, ThemeMode}; +mod default_instrument; + /// Lightningbeam Editor - Animation and video editing software #[derive(Parser, Debug)] #[command(name = "Lightningbeam Editor")] @@ -270,6 +273,9 @@ struct EditorApp { schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0) // Audio engine integration audio_system: Option, // Audio system (must be kept alive for stream) + // Track ID mapping (Document layer UUIDs <-> daw-backend TrackIds) + layer_to_track_map: HashMap, + track_to_layer_map: HashMap, // Playback state (global for all panes) playback_time: f64, // Current playback position in seconds (persistent - save with document) is_playing: bool, // Whether playback is currently active (transient - don't save) @@ -366,6 +372,8 @@ impl EditorApp { rdp_tolerance: 10.0, // Default RDP tolerance schneider_max_error: 30.0, // Default Schneider max error audio_system, + layer_to_track_map: HashMap::new(), + track_to_layer_map: HashMap::new(), playback_time: 0.0, // Start at beginning is_playing: false, // Start paused dragging_asset: None, // No asset being dragged initially @@ -377,6 +385,61 @@ impl EditorApp { } } + /// Synchronize all existing MIDI layers in the document with daw-backend tracks + /// + /// This function should be called: + /// - After loading a document from file + /// - After creating a new document with pre-existing MIDI layers + /// + /// For each MIDI audio layer: + /// 1. Creates a daw-backend MIDI track + /// 2. Loads the default instrument + /// 3. Stores the bidirectional mapping + /// 4. Syncs any existing clips on the layer + fn sync_midi_layers_to_backend(&mut self) { + use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; + + // Iterate through all layers in the document + for layer in &self.action_executor.document().root.children { + // Only process Audio layers with MIDI type + if let AnyLayer::Audio(audio_layer) = layer { + if audio_layer.audio_layer_type == AudioLayerType::Midi { + let layer_id = audio_layer.layer.id; + let layer_name = &audio_layer.layer.name; + + // Skip if already mapped (shouldn't happen, but be defensive) + if self.layer_to_track_map.contains_key(&layer_id) { + continue; + } + + // Create daw-backend MIDI track + if let Some(ref mut audio_system) = self.audio_system { + match audio_system.controller.create_midi_track_sync(layer_name.clone()) { + Ok(track_id) => { + // Store bidirectional mapping + self.layer_to_track_map.insert(layer_id, track_id); + self.track_to_layer_map.insert(track_id, layer_id); + + // Load default instrument + if let Err(e) = default_instrument::load_default_instrument(&mut audio_system.controller, track_id) { + eprintln!("⚠️ Failed to load default instrument for {}: {}", layer_name, e); + } else { + println!("✅ Synced MIDI layer '{}' to backend (TrackId: {})", layer_name, track_id); + } + + // TODO: Sync any existing clips on this layer to the backend + // This will be implemented when we add clip synchronization + } + Err(e) => { + eprintln!("⚠️ Failed to create daw-backend track for MIDI layer '{}': {}", layer_name, e); + } + } + } + } + } + } + } + fn switch_layout(&mut self, index: usize) { self.current_layout_index = index; self.current_layout = self.layouts[index].layout.clone(); @@ -617,8 +680,45 @@ impl EditorApp { // TODO: Implement add audio track } MenuAction::AddMidiTrack => { - println!("Menu: Add MIDI Track"); - // TODO: Implement add MIDI track + // Create a new MIDI audio layer with a default name + let layer_count = self.action_executor.document().root.children.len(); + let layer_name = format!("MIDI Track {}", layer_count + 1); + + // Create MIDI layer in document + let midi_layer = AudioLayer::new_midi(layer_name.clone()); + let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Audio(midi_layer)); + self.action_executor.execute(Box::new(action)); + + // Get the newly created layer ID + if let Some(last_layer) = self.action_executor.document().root.children.last() { + let layer_id = last_layer.id(); + self.active_layer_id = Some(layer_id); + + // Create corresponding daw-backend MIDI track + if let Some(ref mut audio_system) = self.audio_system { + match audio_system.controller.create_midi_track_sync(layer_name.clone()) { + Ok(track_id) => { + // Store bidirectional mapping + self.layer_to_track_map.insert(layer_id, track_id); + self.track_to_layer_map.insert(track_id, layer_id); + + // Load default instrument into the track + if let Err(e) = default_instrument::load_default_instrument(&mut audio_system.controller, track_id) { + eprintln!("⚠️ Failed to load default instrument for {}: {}", layer_name, e); + } else { + println!("✅ Created {} (backend TrackId: {}, instrument: {})", + layer_name, track_id, default_instrument::default_instrument_name()); + } + } + Err(e) => { + eprintln!("⚠️ Failed to create daw-backend MIDI track for {}: {}", layer_name, e); + eprintln!(" Layer created but will be silent until backend track is available"); + } + } + } else { + println!("⚠️ Audio engine not initialized - {} created but will be silent", layer_name); + } + } } MenuAction::AddTestClip => { // Create a test vector clip and add it to the library (not to timeline) @@ -866,11 +966,17 @@ impl EditorApp { let duration = midi_clip.duration; - // Create MIDI audio clip in document + // Create MIDI audio clip in document library let clip = AudioClip::new_midi(&name, duration, events, false); let clip_id = self.action_executor.document_mut().add_audio_clip(clip); - println!("Imported MIDI '{}' ({:.1}s, {} events) - ID: {}", + println!("Imported MIDI '{}' ({:.1}s, {} events) to library - ID: {}", name, duration, midi_clip.events.len(), clip_id); + + // Add to daw-backend MIDI clip pool (for playback when placed on timeline) + if let Some(ref mut audio_system) = self.audio_system { + audio_system.controller.add_midi_clip_to_pool(midi_clip); + println!("✅ Added MIDI clip to backend pool"); + } } Err(e) => { eprintln!("Failed to load MIDI '{}': {}", path.display(), e); @@ -995,6 +1101,7 @@ impl eframe::App for EditorApp { fill_enabled: &mut self.fill_enabled, paint_bucket_gap_tolerance: &mut self.paint_bucket_gap_tolerance, polygon_sides: &mut self.polygon_sides, + layer_to_track_map: &self.layer_to_track_map, }; render_layout_node( @@ -1140,6 +1247,8 @@ struct RenderContext<'a> { fill_enabled: &'a mut bool, paint_bucket_gap_tolerance: &'a mut f64, polygon_sides: &'a mut u32, + /// Mapping from Document layer UUIDs to daw-backend TrackIds + layer_to_track_map: &'a std::collections::HashMap, } /// Recursively render a layout node with drag support @@ -1602,6 +1711,7 @@ fn render_pane( rdp_tolerance: ctx.rdp_tolerance, schneider_max_error: ctx.schneider_max_error, audio_controller: ctx.audio_controller.as_mut().map(|c| &mut **c), + layer_to_track_map: ctx.layer_to_track_map, playback_time: ctx.playback_time, is_playing: ctx.is_playing, dragging_asset: ctx.dragging_asset, @@ -1654,6 +1764,7 @@ fn render_pane( rdp_tolerance: ctx.rdp_tolerance, schneider_max_error: ctx.schneider_max_error, audio_controller: ctx.audio_controller.as_mut().map(|c| &mut **c), + layer_to_track_map: ctx.layer_to_track_map, playback_time: ctx.playback_time, is_playing: ctx.is_playing, dragging_asset: ctx.dragging_asset, @@ -1874,6 +1985,7 @@ fn pane_color(pane_type: PaneType) -> egui::Color32 { PaneType::Infopanel => egui::Color32::from_rgb(30, 50, 40), PaneType::Outliner => egui::Color32::from_rgb(40, 50, 30), PaneType::PianoRoll => egui::Color32::from_rgb(55, 35, 45), + PaneType::VirtualPiano => egui::Color32::from_rgb(45, 35, 55), PaneType::NodeEditor => egui::Color32::from_rgb(30, 45, 50), PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30), PaneType::AssetLibrary => egui::Color32::from_rgb(45, 50, 35), diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 804d59f..f9c8bc1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -56,6 +56,7 @@ pub mod timeline; pub mod infopanel; pub mod outliner; pub mod piano_roll; +pub mod virtual_piano; pub mod node_editor; pub mod preset_browser; pub mod asset_library; @@ -108,6 +109,8 @@ pub struct SharedPaneState<'a> { pub schneider_max_error: &'a mut f64, /// Audio engine controller for playback control pub audio_controller: Option<&'a mut daw_backend::EngineController>, + /// Mapping from Document layer UUIDs to daw-backend TrackIds + pub layer_to_track_map: &'a std::collections::HashMap, /// Global playback state pub playback_time: &'a mut f64, // Current playback position in seconds pub is_playing: &'a mut bool, // Whether playback is currently active @@ -158,6 +161,7 @@ pub enum PaneInstance { Infopanel(infopanel::InfopanelPane), Outliner(outliner::OutlinerPane), PianoRoll(piano_roll::PianoRollPane), + VirtualPiano(virtual_piano::VirtualPianoPane), NodeEditor(node_editor::NodeEditorPane), PresetBrowser(preset_browser::PresetBrowserPane), AssetLibrary(asset_library::AssetLibraryPane), @@ -173,6 +177,7 @@ impl PaneInstance { PaneType::Infopanel => PaneInstance::Infopanel(infopanel::InfopanelPane::new()), PaneType::Outliner => PaneInstance::Outliner(outliner::OutlinerPane::new()), PaneType::PianoRoll => PaneInstance::PianoRoll(piano_roll::PianoRollPane::new()), + PaneType::VirtualPiano => PaneInstance::VirtualPiano(virtual_piano::VirtualPianoPane::new()), PaneType::NodeEditor => PaneInstance::NodeEditor(node_editor::NodeEditorPane::new()), PaneType::PresetBrowser => { PaneInstance::PresetBrowser(preset_browser::PresetBrowserPane::new()) @@ -192,6 +197,7 @@ impl PaneInstance { PaneInstance::Infopanel(_) => PaneType::Infopanel, PaneInstance::Outliner(_) => PaneType::Outliner, PaneInstance::PianoRoll(_) => PaneType::PianoRoll, + PaneInstance::VirtualPiano(_) => PaneType::VirtualPiano, PaneInstance::NodeEditor(_) => PaneType::NodeEditor, PaneInstance::PresetBrowser(_) => PaneType::PresetBrowser, PaneInstance::AssetLibrary(_) => PaneType::AssetLibrary, @@ -208,6 +214,7 @@ impl PaneRenderer for PaneInstance { PaneInstance::Infopanel(p) => p.render_header(ui, shared), PaneInstance::Outliner(p) => p.render_header(ui, shared), PaneInstance::PianoRoll(p) => p.render_header(ui, shared), + PaneInstance::VirtualPiano(p) => p.render_header(ui, shared), PaneInstance::NodeEditor(p) => p.render_header(ui, shared), PaneInstance::PresetBrowser(p) => p.render_header(ui, shared), PaneInstance::AssetLibrary(p) => p.render_header(ui, shared), @@ -228,6 +235,7 @@ impl PaneRenderer for PaneInstance { PaneInstance::Infopanel(p) => p.render_content(ui, rect, path, shared), PaneInstance::Outliner(p) => p.render_content(ui, rect, path, shared), PaneInstance::PianoRoll(p) => p.render_content(ui, rect, path, shared), + PaneInstance::VirtualPiano(p) => p.render_content(ui, rect, path, shared), PaneInstance::NodeEditor(p) => p.render_content(ui, rect, path, shared), PaneInstance::PresetBrowser(p) => p.render_content(ui, rect, path, shared), PaneInstance::AssetLibrary(p) => p.render_content(ui, rect, path, shared), @@ -242,6 +250,7 @@ impl PaneRenderer for PaneInstance { PaneInstance::Infopanel(p) => p.name(), PaneInstance::Outliner(p) => p.name(), PaneInstance::PianoRoll(p) => p.name(), + PaneInstance::VirtualPiano(p) => p.name(), PaneInstance::NodeEditor(p) => p.name(), PaneInstance::PresetBrowser(p) => p.name(), PaneInstance::AssetLibrary(p) => p.name(), diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs new file mode 100644 index 0000000..69b7599 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs @@ -0,0 +1,334 @@ +/// Virtual Piano Pane - On-screen piano keyboard for live MIDI input +/// +/// Provides a clickable/draggable piano keyboard that sends MIDI note events +/// to the currently active MIDI track via daw-backend. + +use super::{NodePath, PaneRenderer, SharedPaneState}; +use eframe::egui; +use std::collections::HashSet; + +/// Virtual piano pane state +pub struct VirtualPianoPane { + /// White key aspect ratio (height:width) - matches JS version + white_key_aspect_ratio: f32, + /// Black key width ratio relative to white keys + black_key_width_ratio: f32, + /// Black key height ratio relative to white keys + black_key_height_ratio: f32, + /// Currently pressed notes (for visual feedback) + pressed_notes: HashSet, + /// Note being held by mouse drag (to prevent retriggering) + dragging_note: Option, + /// Octave offset for keyboard mapping (default: 0 = C4) + octave_offset: i8, +} + +impl Default for VirtualPianoPane { + fn default() -> Self { + Self::new() + } +} + +impl VirtualPianoPane { + pub fn new() -> Self { + Self { + white_key_aspect_ratio: 6.0, + black_key_width_ratio: 0.6, + black_key_height_ratio: 0.62, + pressed_notes: HashSet::new(), + dragging_note: None, + octave_offset: 0, // Center on C4 (MIDI note 60) + } + } + + /// Check if a MIDI note is a black key + fn is_black_key(note: u8) -> bool { + matches!(note % 12, 1 | 3 | 6 | 8 | 10) // C#, D#, F#, G#, A# + } + + /// Check if a MIDI note is a white key + fn is_white_key(note: u8) -> bool { + !Self::is_black_key(note) + } + + /// Calculate visible note range and white key width based on pane dimensions + /// Returns (visible_start_note, visible_end_note, white_key_width, offset_x) + fn calculate_visible_range(&self, width: f32, height: f32) -> (u8, u8, f32, f32) { + // Calculate white key width based on height to maintain aspect ratio + let white_key_width = height / self.white_key_aspect_ratio; + + // Calculate how many white keys can fit in the pane + let white_keys_fit = (width / white_key_width).ceil() as i32; + + // Keyboard-mapped range is C4 (60) to C5 (72), shifted by octave offset + // This contains 8 white keys: C, D, E, F, G, A, B, C + let keyboard_center = 60 + (self.octave_offset as i32 * 12); // C4 + octave shift + let keyboard_white_keys = 8; + + if white_keys_fit <= keyboard_white_keys { + // Not enough space to show all keyboard keys, just center what we have + let visible_start_note = keyboard_center as u8; + let visible_end_note = (keyboard_center + 12) as u8; // One octave up + let total_white_key_width = keyboard_white_keys as f32 * white_key_width; + let offset_x = (width - total_white_key_width) / 2.0; + return (visible_start_note, visible_end_note, white_key_width, offset_x); + } + + // Calculate how many extra white keys we have space for + let extra_white_keys = white_keys_fit - keyboard_white_keys; + let left_extra = extra_white_keys / 2; + let right_extra = extra_white_keys - left_extra; + + // Extend left from keyboard center + let mut start_note = keyboard_center; + let mut white_count = 0; + while white_count < left_extra && start_note > 0 { + start_note -= 1; + if Self::is_white_key(start_note as u8) { + white_count += 1; + } + } + + // Extend right from keyboard end (C5 = 72) + let mut end_note = keyboard_center + 12; // C5 + white_count = 0; + while white_count < right_extra && end_note < 127 { + end_note += 1; + if Self::is_white_key(end_note as u8) { + white_count += 1; + } + } + + // No offset - keys start from left edge and fill to the right + (start_note as u8, end_note as u8, white_key_width, 0.0) + } + + /// Render the piano keyboard + fn render_keyboard(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) { + // Calculate visible range and key dimensions based on pane size + let (visible_start, visible_end, white_key_width, offset_x) = + self.calculate_visible_range(rect.width(), rect.height()); + + let white_key_height = rect.height(); + let black_key_width = white_key_width * self.black_key_width_ratio; + let black_key_height = white_key_height * self.black_key_height_ratio; + + // Count white keys before each note for positioning + let mut white_key_positions: std::collections::HashMap = std::collections::HashMap::new(); + let mut white_count = 0; + for note in visible_start..=visible_end { + if Self::is_white_key(note) { + white_key_positions.insert(note, white_count as f32); + white_count += 1; + } + } + + // Draw white keys first (so black keys render on top) + for note in visible_start..=visible_end { + if !Self::is_white_key(note) { + continue; + } + + let white_pos = white_key_positions[¬e]; + let x = rect.min.x + offset_x + (white_pos * white_key_width); + let key_rect = egui::Rect::from_min_size( + egui::pos2(x, rect.min.y), + egui::vec2(white_key_width - 1.0, white_key_height), + ); + + // Visual feedback for pressed keys + let is_pressed = self.pressed_notes.contains(¬e); + let color = if is_pressed { + egui::Color32::from_rgb(100, 150, 255) // Blue when pressed + } else { + egui::Color32::WHITE + }; + + ui.painter().rect_filled(key_rect, 2.0, color); + ui.painter().rect_stroke( + key_rect, + 2.0, + egui::Stroke::new(1.0, egui::Color32::BLACK), + egui::StrokeKind::Middle, + ); + + // Handle interaction + let key_id = ui.id().with(("white_key", note)); + let response = ui.interact(key_rect, key_id, egui::Sense::click_and_drag()); + + // Check if pointer is currently over this key (works during drag too) + let pointer_over_key = ui.input(|i| { + i.pointer.hover_pos().map_or(false, |pos| key_rect.contains(pos)) + }); + + // Mouse down starts note (detect primary button pressed on this key) + if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { + self.send_note_on(note, 100, shared); + self.dragging_note = Some(note); + } + + // Mouse up stops note (detect primary button released) + if ui.input(|i| i.pointer.primary_released()) { + if self.dragging_note == Some(note) { + self.send_note_off(note, shared); + self.dragging_note = None; + } + } + + // Dragging over a new key (pointer is down and over a different key) + if pointer_over_key && ui.input(|i| i.pointer.primary_down()) { + if self.dragging_note != Some(note) { + // Stop previous note + if let Some(prev_note) = self.dragging_note { + self.send_note_off(prev_note, shared); + } + // Start new note + self.send_note_on(note, 100, shared); + self.dragging_note = Some(note); + } + } + } + + // Draw black keys on top + for note in visible_start..=visible_end { + if !Self::is_black_key(note) { + continue; + } + + // Find the white key immediately before this black key + let mut white_keys_before = 0; + for n in visible_start..note { + if Self::is_white_key(n) { + white_keys_before += 1; + } + } + + // Position black key at the right edge of the preceding white key + let x = rect.min.x + offset_x + (white_keys_before as f32 * white_key_width) - (black_key_width / 2.0); + let key_rect = egui::Rect::from_min_size( + egui::pos2(x, rect.min.y), + egui::vec2(black_key_width, black_key_height), + ); + + let is_pressed = self.pressed_notes.contains(¬e); + let color = if is_pressed { + egui::Color32::from_rgb(50, 100, 200) // Darker blue when pressed + } else { + egui::Color32::BLACK + }; + + ui.painter().rect_filled(key_rect, 2.0, color); + + // Handle interaction (same as white keys) + let key_id = ui.id().with(("black_key", note)); + let response = ui.interact(key_rect, key_id, egui::Sense::click_and_drag()); + + // Check if pointer is currently over this key (works during drag too) + let pointer_over_key = ui.input(|i| { + i.pointer.hover_pos().map_or(false, |pos| key_rect.contains(pos)) + }); + + // Mouse down starts note + if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { + self.send_note_on(note, 100, shared); + self.dragging_note = Some(note); + } + + // Mouse up stops note + if ui.input(|i| i.pointer.primary_released()) { + if self.dragging_note == Some(note) { + self.send_note_off(note, shared); + self.dragging_note = None; + } + } + + // Dragging over a new key + if pointer_over_key && ui.input(|i| i.pointer.primary_down()) { + if self.dragging_note != Some(note) { + if let Some(prev_note) = self.dragging_note { + self.send_note_off(prev_note, shared); + } + self.send_note_on(note, 100, shared); + self.dragging_note = Some(note); + } + } + } + } + + /// Send note-on event to daw-backend + fn send_note_on(&mut self, note: u8, velocity: u8, shared: &mut SharedPaneState) { + self.pressed_notes.insert(note); + + // Get active MIDI layer from shared state + if let Some(active_layer_id) = *shared.active_layer_id { + // Look up daw-backend track ID from layer ID + if let Some(&track_id) = shared.layer_to_track_map.get(&active_layer_id) { + if let Some(ref mut controller) = shared.audio_controller { + controller.send_midi_note_on(track_id, note, velocity); + } + } + } + } + + /// Send note-off event to daw-backend + fn send_note_off(&mut self, note: u8, shared: &mut SharedPaneState) { + self.pressed_notes.remove(¬e); + + if let Some(active_layer_id) = *shared.active_layer_id { + if let Some(&track_id) = shared.layer_to_track_map.get(&active_layer_id) { + if let Some(ref mut controller) = shared.audio_controller { + controller.send_midi_note_off(track_id, note); + } + } + } + } +} + +impl PaneRenderer for VirtualPianoPane { + fn render_header(&mut self, ui: &mut egui::Ui, _shared: &mut SharedPaneState) -> bool { + ui.horizontal(|ui| { + ui.label("Octave Shift:"); + if ui.button("-").clicked() && self.octave_offset > -3 { + self.octave_offset -= 1; + } + let center_note = 60 + (self.octave_offset as i32 * 12); + let octave_name = format!("C{}", center_note / 12); + ui.label(octave_name); + if ui.button("+").clicked() && self.octave_offset < 3 { + self.octave_offset += 1; + } + }); + + true // We rendered a header + } + + fn render_content( + &mut self, + ui: &mut egui::Ui, + rect: egui::Rect, + _path: &NodePath, + shared: &mut SharedPaneState, + ) { + // Check if there's an active MIDI layer + let has_active_midi_layer = if let Some(active_layer_id) = *shared.active_layer_id { + shared.layer_to_track_map.contains_key(&active_layer_id) + } else { + false + }; + + if !has_active_midi_layer { + // Show message if no active MIDI track + ui.centered_and_justified(|ui| { + ui.label("No MIDI track selected. Create a MIDI track to use the virtual piano."); + }); + return; + } + + // Render the keyboard + self.render_keyboard(ui, rect, shared); + } + + fn name(&self) -> &str { + "Virtual Piano" + } +}