From c09cd276a009708d913ab54ee43186befed7bfa9 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 30 Nov 2025 21:20:42 -0500 Subject: [PATCH] Add velocity support to virtual piano --- .../lightningbeam-editor/src/main.rs | 8 +- .../src/panes/virtual_piano.rs | 150 +++++++++++++++++- src/assets/instruments/synthesizers/bass.json | 17 +- 3 files changed, 163 insertions(+), 12 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 912c8a6..69a3ff9 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1178,9 +1178,13 @@ impl eframe::App for EditorApp { let wants_keyboard = ctx.wants_keyboard_input(); ctx.input(|i| { - // Check menu shortcuts (these use modifiers, so allow even when typing) + // Check menu shortcuts that use modifiers (Cmd+S, etc.) - allow even when typing + // But skip shortcuts without modifiers when keyboard input is claimed (e.g., virtual piano) if let Some(action) = MenuSystem::check_shortcuts(i) { - self.handle_menu_action(action); + // Only trigger if keyboard isn't claimed OR the shortcut uses modifiers + if !wants_keyboard || i.modifiers.ctrl || i.modifiers.command || i.modifiers.alt || i.modifiers.shift { + self.handle_menu_action(action); + } } // Check tool shortcuts (only if no modifiers are held AND no text input is focused) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs index 6038ae8..f6e8af4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs @@ -29,6 +29,10 @@ pub struct VirtualPianoPane { keyboard_map: HashMap, /// Reverse mapping for displaying labels (MIDI note -> key label) note_to_key_map: HashMap, + /// Sustain pedal state (Tab key toggles) + sustain_active: bool, + /// Notes being held by sustain pedal (not by active key/mouse press) + sustained_notes: HashSet, } impl Default for VirtualPianoPane { @@ -77,6 +81,8 @@ impl VirtualPianoPane { keyboard_velocity: 100, // Default MIDI velocity keyboard_map, note_to_key_map, + sustain_active: false, + sustained_notes: HashSet::new(), } } @@ -235,7 +241,11 @@ impl VirtualPianoPane { if !black_key_interacted { // 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); + // Calculate velocity based on mouse Y position + let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y; + let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect); + + self.send_note_on(note, velocity, shared); self.dragging_note = Some(note); } @@ -254,8 +264,11 @@ impl VirtualPianoPane { 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); + // Start new note with velocity from mouse position + let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y; + let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect); + + self.send_note_on(note, velocity, shared); self.dragging_note = Some(note); } } @@ -304,7 +317,11 @@ impl VirtualPianoPane { // Mouse down starts note if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { - self.send_note_on(note, 100, shared); + // Calculate velocity based on mouse Y position + let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y; + let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect); + + self.send_note_on(note, velocity, shared); self.dragging_note = Some(note); } @@ -322,7 +339,11 @@ impl VirtualPianoPane { if let Some(prev_note) = self.dragging_note { self.send_note_off(prev_note, shared); } - self.send_note_on(note, 100, shared); + // Start new note with velocity from mouse position + let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y; + let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect); + + self.send_note_on(note, velocity, shared); self.dragging_note = Some(note); } } @@ -344,9 +365,18 @@ impl VirtualPianoPane { } } - /// Send note-off event to daw-backend + /// Send note-off event to daw-backend (or add to sustain if active) fn send_note_off(&mut self, note: u8, shared: &mut SharedPaneState) { + // If sustain is active, move note to sustained set instead of releasing + if self.sustain_active { + self.sustained_notes.insert(note); + // Keep note in pressed_notes for visual feedback + return; + } + + // Normal release: remove from all note sets self.pressed_notes.remove(¬e); + self.sustained_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) { @@ -357,6 +387,65 @@ impl VirtualPianoPane { } } + /// Release sustain pedal - stop all sustained notes that aren't currently being held + fn release_sustain(&mut self, shared: &mut SharedPaneState) { + self.sustain_active = false; + + // Collect currently active notes (keyboard + mouse) + let mut currently_playing = HashSet::new(); + + // Add notes from keyboard + for ¬e in self.active_key_presses.values() { + currently_playing.insert(note); + } + + // Add note from mouse drag + if let Some(note) = self.dragging_note { + currently_playing.insert(note); + } + + // Release sustained notes that aren't currently being played + let notes_to_release: Vec = self.sustained_notes + .iter() + .filter(|&¬e| !currently_playing.contains(¬e)) + .copied() + .collect(); + + for note in notes_to_release { + self.send_note_off(note, shared); + } + + self.sustained_notes.clear(); + } + + /// Calculate MIDI velocity based on mouse Y position within key + /// + /// - Top of key: velocity 1 + /// - Top 75% of key: Linear scaling from velocity 1 to 127 + /// - Bottom 25% of key: Full velocity (127) + /// + /// # Arguments + /// * `mouse_y` - Y coordinate of mouse cursor + /// * `key_rect` - Rectangle bounds of the key + /// + /// # Returns + /// MIDI velocity value clamped to range [1, 127] + fn calculate_velocity_from_mouse_y(&self, mouse_y: f32, key_rect: egui::Rect) -> u8 { + // Calculate relative position (0.0 at top, 1.0 at bottom) + let key_height = key_rect.height(); + let relative_y = (mouse_y - key_rect.min.y) / key_height; + let relative_y = relative_y.clamp(0.0, 1.0); + + // Bottom 25% of key = full velocity + if relative_y >= 0.75 { + return 127; + } + + // Top 75% = linear scale from 1 to 127 + let velocity = 1.0 + (relative_y / 0.75) * 126.0; + velocity.round().clamp(1.0, 127.0) as u8 + } + /// Process keyboard input for virtual piano /// Returns true if the event was consumed fn handle_keyboard_input(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { @@ -398,6 +487,16 @@ impl VirtualPianoPane { consumed = true; } + // Handle sustain pedal (Tab key) + if i.key_pressed(egui::Key::Tab) { + self.sustain_active = true; + consumed = true; + } + if i.key_released(egui::Key::Tab) { + self.release_sustain(shared); + consumed = true; + } + // Process raw events for piano keys (need to track press/release separately) for event in &i.events { if let egui::Event::Key { key, pressed, repeat, .. } = event { @@ -457,9 +556,31 @@ impl VirtualPianoPane { fn release_all_keyboard_notes(&mut self, shared: &mut SharedPaneState) { let notes_to_release: Vec = self.active_key_presses.values().copied().collect(); for note in notes_to_release { - self.send_note_off(note, shared); + // Force release, bypassing sustain + 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); + } + } + } } self.active_key_presses.clear(); + + // Also release all sustained notes + for note in &self.sustained_notes { + self.pressed_notes.remove(note); + 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); + } + } + } + } + self.sustained_notes.clear(); + self.sustain_active = false; } /// Render keyboard letter labels on piano keys @@ -586,6 +707,21 @@ impl PaneRenderer for VirtualPianoPane { if ui.button("+").clicked() { self.keyboard_velocity = self.keyboard_velocity.saturating_add(10).min(127); } + + ui.separator(); + + // Sustain pedal indicator + ui.label("Sustain:"); + let sustain_text = if self.sustain_active { + egui::RichText::new("ON").color(egui::Color32::from_rgb(100, 200, 100)) + } else { + egui::RichText::new("OFF").color(egui::Color32::GRAY) + }; + ui.label(sustain_text); + + if !self.sustained_notes.is_empty() { + ui.label(format!("({} notes)", self.sustained_notes.len())); + } }); true // We rendered a header diff --git a/src/assets/instruments/synthesizers/bass.json b/src/assets/instruments/synthesizers/bass.json index 27e1084..1e148cb 100644 --- a/src/assets/instruments/synthesizers/bass.json +++ b/src/assets/instruments/synthesizers/bass.json @@ -7,7 +7,7 @@ "tags": ["bass", "sub", "synth"] }, "midi_targets": [0], - "output_node": 6, + "output_node": 7, "nodes": [ { "id": 0, @@ -57,6 +57,15 @@ }, { "id": 5, + "node_type": "Gain", + "name": "Velocity", + "parameters": { + "0": 1.0 + }, + "position": [1150.0, 100.0] + }, + { + "id": 6, "node_type": "Filter", "name": "LP Filter", "parameters": { @@ -67,7 +76,7 @@ "position": [1300.0, 100.0] }, { - "id": 6, + "id": 7, "node_type": "AudioOutput", "name": "Out", "parameters": {}, @@ -78,9 +87,11 @@ { "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 }, { "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 }, { "from_node": 1, "from_port": 1, "to_node": 3, "to_port": 0 }, + { "from_node": 1, "from_port": 2, "to_node": 5, "to_port": 1 }, { "from_node": 2, "from_port": 0, "to_node": 4, "to_port": 0 }, { "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 1 }, { "from_node": 4, "from_port": 0, "to_node": 5, "to_port": 0 }, - { "from_node": 5, "from_port": 0, "to_node": 6, "to_port": 0 } + { "from_node": 5, "from_port": 0, "to_node": 6, "to_port": 0 }, + { "from_node": 6, "from_port": 0, "to_node": 7, "to_port": 0 } ] }