Add virtual piano

This commit is contained in:
Skyler Lehmkuhl 2025-11-30 11:01:07 -05:00
parent c943f7bfe6
commit 8f1934ab59
7 changed files with 537 additions and 4 deletions

View File

@ -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<TrackId, String> {
if let Err(_) = self.query_tx.push(Query::CreateAudioTrackSync(name)) {

View File

@ -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)

View File

@ -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",

View File

@ -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)"
}

View File

@ -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<daw_backend::AudioSystem>, // Audio system (must be kept alive for stream)
// Track ID mapping (Document layer UUIDs <-> daw-backend TrackIds)
layer_to_track_map: HashMap<Uuid, daw_backend::TrackId>,
track_to_layer_map: HashMap<daw_backend::TrackId, Uuid>,
// 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<Uuid, daw_backend::TrackId>,
}
/// 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),

View File

@ -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<Uuid, daw_backend::TrackId>,
/// 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(),

View File

@ -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<u8>,
/// Note being held by mouse drag (to prevent retriggering)
dragging_note: Option<u8>,
/// 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<u8, f32> = 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[&note];
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(&note);
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(&note);
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(&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);
}
}
}
}
}
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"
}
}