Add virtual piano
This commit is contained in:
parent
c943f7bfe6
commit
8f1934ab59
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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[¬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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue