Lightningbeam/daw-backend/src/tui/mod.rs

923 lines
35 KiB
Rust

use crate::audio::EngineController;
use crate::command::AudioEvent;
use crate::io::load_midi_file;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame, Terminal,
};
use std::io;
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// TUI application mode
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AppMode {
/// Command mode - type vim-style commands
Command,
/// Play mode - use keyboard to play MIDI notes
Play,
}
/// TUI application state
pub struct TuiApp {
/// Current application mode
mode: AppMode,
/// Command input buffer (for Command mode)
command_input: String,
/// Current playback position (seconds)
playback_position: f64,
/// Whether playback is active
is_playing: bool,
/// Status message to display
status_message: String,
/// List of tracks (track_id, name)
tracks: Vec<(u32, String)>,
/// Currently selected track for MIDI input
selected_track: Option<u32>,
/// Active MIDI notes (currently held down)
active_notes: Vec<u8>,
/// Command history for up/down navigation
command_history: Vec<String>,
/// Current position in command history
history_index: Option<usize>,
/// Clips on timeline: (track_id, clip_id, start_time, duration, name, notes)
/// Notes: Vec<(pitch, time_offset, duration)>
clips: Vec<(u32, u32, f64, f64, String, Vec<(u8, f64, f64)>)>,
/// Next clip ID for locally created clips
next_clip_id: u32,
/// Timeline scroll offset in seconds (start of visible window)
timeline_scroll: f64,
/// Timeline visible duration in seconds (zoom level)
timeline_visible_duration: f64,
}
impl TuiApp {
pub fn new() -> Self {
Self {
mode: AppMode::Command,
command_input: String::new(),
playback_position: 0.0,
is_playing: false,
status_message: "SPACE=play/pause | ←/→ scroll | -/+ zoom | 'i'=Play mode | Type 'help'".to_string(),
tracks: Vec::new(),
selected_track: None,
active_notes: Vec::new(),
command_history: Vec::new(),
history_index: None,
clips: Vec::new(),
next_clip_id: 0,
timeline_scroll: 0.0,
timeline_visible_duration: 10.0, // Show 10 seconds at a time by default
}
}
/// Switch to command mode
pub fn enter_command_mode(&mut self) {
self.mode = AppMode::Command;
self.command_input.clear();
self.history_index = None;
self.status_message = "-- COMMAND -- SPACE=play/pause | ←/→ scroll | -/+ zoom | 'i' for Play mode | Type 'help'".to_string();
}
/// Switch to play mode
pub fn enter_play_mode(&mut self) {
self.mode = AppMode::Play;
self.command_input.clear();
self.status_message = "-- PLAY -- Press '?' for help, 'ESC' for Command mode".to_string();
}
/// Add a character to command input
pub fn push_command_char(&mut self, c: char) {
self.command_input.push(c);
}
/// Remove last character from command input
pub fn pop_command_char(&mut self) {
self.command_input.pop();
}
/// Get the current command input
pub fn command_input(&self) -> &str {
&self.command_input
}
/// Clear command input
pub fn clear_command(&mut self) {
self.command_input.clear();
self.history_index = None;
}
/// Add command to history
pub fn add_to_history(&mut self, command: String) {
if !command.is_empty() && self.command_history.last() != Some(&command) {
self.command_history.push(command);
}
}
/// Navigate command history up
pub fn history_up(&mut self) {
if self.command_history.is_empty() {
return;
}
let new_index = match self.history_index {
None => Some(self.command_history.len() - 1),
Some(0) => Some(0),
Some(i) => Some(i - 1),
};
if let Some(idx) = new_index {
self.history_index = Some(idx);
self.command_input = self.command_history[idx].clone();
}
}
/// Navigate command history down
pub fn history_down(&mut self) {
match self.history_index {
None => {}
Some(i) if i >= self.command_history.len() - 1 => {
self.history_index = None;
self.command_input.clear();
}
Some(i) => {
let new_idx = i + 1;
self.history_index = Some(new_idx);
self.command_input = self.command_history[new_idx].clone();
}
}
}
/// Update playback position and auto-scroll timeline if needed
pub fn update_playback_position(&mut self, position: f64) {
self.playback_position = position;
// Auto-scroll to keep playhead in view when playing
if self.is_playing {
// Keep playhead in the visible window, with some margin
let margin = self.timeline_visible_duration * 0.1; // 10% margin
// If playhead is ahead of visible window, scroll forward
if position > self.timeline_scroll + self.timeline_visible_duration - margin {
self.timeline_scroll = (position - self.timeline_visible_duration * 0.5).max(0.0);
}
// If playhead is behind visible window, scroll backward
else if position < self.timeline_scroll + margin {
self.timeline_scroll = (position - margin).max(0.0);
}
}
}
/// Set playing state
pub fn set_playing(&mut self, playing: bool) {
self.is_playing = playing;
}
/// Set status message
pub fn set_status(&mut self, message: String) {
self.status_message = message;
}
/// Add a track to the UI
pub fn add_track(&mut self, track_id: u32, name: String) {
self.tracks.push((track_id, name));
// Auto-select first MIDI track for playing
if self.selected_track.is_none() {
self.selected_track = Some(track_id);
}
}
/// Clear all tracks
pub fn clear_tracks(&mut self) {
self.tracks.clear();
self.clips.clear();
self.selected_track = None;
self.next_clip_id = 0;
self.timeline_scroll = 0.0;
}
/// Select a track by index
pub fn select_track(&mut self, index: usize) {
if let Some((track_id, _)) = self.tracks.get(index) {
self.selected_track = Some(*track_id);
}
}
/// Get selected track
pub fn selected_track(&self) -> Option<u32> {
self.selected_track
}
/// Add a clip to the timeline
pub fn add_clip(&mut self, track_id: u32, clip_id: u32, start_time: f64, duration: f64, name: String, notes: Vec<(u8, f64, f64)>) {
self.clips.push((track_id, clip_id, start_time, duration, name, notes));
}
/// Get max timeline duration based on clips
pub fn get_timeline_duration(&self) -> f64 {
self.clips
.iter()
.map(|(_, _, start, dur, _, _)| start + dur)
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or(10.0) // Default to 10 seconds if no clips
}
/// Add an active MIDI note
pub fn add_active_note(&mut self, note: u8) {
if !self.active_notes.contains(&note) {
self.active_notes.push(note);
}
}
/// Remove an active MIDI note
pub fn remove_active_note(&mut self, note: u8) {
self.active_notes.retain(|&n| n != note);
}
/// Get current mode
pub fn mode(&self) -> AppMode {
self.mode
}
/// Scroll timeline left
pub fn scroll_timeline_left(&mut self) {
let scroll_amount = self.timeline_visible_duration * 0.2; // Scroll by 20% of visible duration
self.timeline_scroll = (self.timeline_scroll - scroll_amount).max(0.0);
}
/// Scroll timeline right
pub fn scroll_timeline_right(&mut self) {
let scroll_amount = self.timeline_visible_duration * 0.2; // Scroll by 20% of visible duration
let max_duration = self.get_timeline_duration();
self.timeline_scroll = (self.timeline_scroll + scroll_amount).min(max_duration - self.timeline_visible_duration).max(0.0);
}
/// Zoom timeline in (show less time, more detail)
pub fn zoom_timeline_in(&mut self) {
self.timeline_visible_duration = (self.timeline_visible_duration * 0.8).max(1.0); // Min 1 second visible
}
/// Zoom timeline out (show more time, less detail)
pub fn zoom_timeline_out(&mut self) {
let max_duration = self.get_timeline_duration();
self.timeline_visible_duration = (self.timeline_visible_duration * 1.25).min(max_duration).max(1.0);
}
}
/// Map keyboard keys to MIDI notes
/// Uses chromatic layout: awsedftgyhujkolp;'
/// This provides 1.5 octaves starting from C4 (MIDI note 60)
pub fn key_to_midi_note(key: KeyCode) -> Option<u8> {
let base = 60; // C4
match key {
KeyCode::Char('a') => Some(base), // C4
KeyCode::Char('w') => Some(base + 1), // C#4
KeyCode::Char('s') => Some(base + 2), // D4
KeyCode::Char('e') => Some(base + 3), // D#4
KeyCode::Char('d') => Some(base + 4), // E4
KeyCode::Char('f') => Some(base + 5), // F4
KeyCode::Char('t') => Some(base + 6), // F#4
KeyCode::Char('g') => Some(base + 7), // G4
KeyCode::Char('y') => Some(base + 8), // G#4
KeyCode::Char('h') => Some(base + 9), // A4
KeyCode::Char('u') => Some(base + 10), // A#4
KeyCode::Char('j') => Some(base + 11), // B4
KeyCode::Char('k') => Some(base + 12), // C5
KeyCode::Char('o') => Some(base + 13), // C#5
KeyCode::Char('l') => Some(base + 14), // D5
KeyCode::Char('p') => Some(base + 15), // D#5
KeyCode::Char(';') => Some(base + 16), // E5
KeyCode::Char('\'') => Some(base + 17), // F5
_ => None,
}
}
/// Convert pitch % 8 to braille dot bit position
fn pitch_to_braille_bit(pitch_mod_8: u8) -> u8 {
match pitch_mod_8 {
0 => 0x01, // Dot 1
1 => 0x02, // Dot 2
2 => 0x04, // Dot 3
3 => 0x40, // Dot 7
4 => 0x08, // Dot 4
5 => 0x10, // Dot 5
6 => 0x20, // Dot 6
7 => 0x80, // Dot 8
_ => 0x00,
}
}
/// Draw the timeline view with clips
fn draw_timeline(f: &mut Frame, area: ratatui::layout::Rect, app: &TuiApp) {
let num_tracks = app.tracks.len();
// Use visible duration for the timeline window
let visible_start = app.timeline_scroll;
let visible_end = app.timeline_scroll + app.timeline_visible_duration;
// Create the timeline block with visible range
let block = Block::default()
.borders(Borders::ALL)
.title(format!("Timeline ({:.1}s - {:.1}s) | ←/→ scroll | -/+ zoom", visible_start, visible_end));
let inner_area = block.inner(area);
f.render_widget(block, area);
// Calculate dimensions
let width = inner_area.width as usize;
if width == 0 || num_tracks == 0 {
return;
}
// Fixed track height: 2 lines per track
let track_height = 2;
// Build timeline content with braille characters
let mut lines: Vec<Line> = Vec::new();
for track_idx in 0..num_tracks {
let track_id = if let Some((id, _)) = app.tracks.get(track_idx) {
*id
} else {
continue;
};
// Create exactly 2 lines for this track
for _ in 0..track_height {
let mut spans = Vec::new();
// Build the timeline character by character
for char_x in 0..width {
// Map character position to time, using scroll offset
let time_pos = visible_start + (char_x as f64 / width as f64) * app.timeline_visible_duration;
// Check if playhead is at this position
let is_playhead = (time_pos - app.playback_position).abs() < (app.timeline_visible_duration / width as f64);
// Find all notes active at this time position on this track
let mut braille_pattern: u8 = 0;
let mut has_notes = false;
for (clip_track_id, _clip_id, clip_start, _clip_duration, _name, notes) in &app.clips {
if *clip_track_id == track_id {
// Check each note in this clip
for (pitch, note_offset, note_duration) in notes {
let note_start = clip_start + note_offset;
let note_end = note_start + note_duration;
// Is this note active at current time position?
if time_pos >= note_start && time_pos < note_end {
let pitch_mod = pitch % 8;
braille_pattern |= pitch_to_braille_bit(pitch_mod);
has_notes = true;
}
}
}
}
// Determine color
let color = if Some(track_id) == app.selected_track {
Color::Yellow
} else {
Color::Cyan
};
// Create span
if is_playhead {
// Playhead: red background
if has_notes {
// Show white notes with red background
let braille_char = char::from_u32(0x2800 + braille_pattern as u32).unwrap_or(' ');
spans.push(Span::styled(braille_char.to_string(), Style::default().fg(Color::White).bg(Color::Red)));
} else {
spans.push(Span::styled(" ", Style::default().bg(Color::Red)));
}
} else if has_notes {
// Show white braille pattern on colored background
let braille_char = char::from_u32(0x2800 + braille_pattern as u32).unwrap_or(' ');
spans.push(Span::styled(braille_char.to_string(), Style::default().fg(Color::White).bg(color)));
} else {
// Empty space
spans.push(Span::raw(" "));
}
}
lines.push(Line::from(spans));
}
}
let paragraph = Paragraph::new(lines);
f.render_widget(paragraph, inner_area);
}
/// Draw the TUI
pub fn draw_ui(f: &mut Frame, app: &TuiApp) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Title bar
Constraint::Min(10), // Main content
Constraint::Length(3), // Status bar
Constraint::Length(1), // Command line
])
.split(f.size());
// Title bar
let title = Paragraph::new("Lightningbeam DAW")
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.block(Block::default().borders(Borders::ALL));
f.render_widget(title, chunks[0]);
// Main content area - split into tracks and timeline
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(20), Constraint::Percentage(80)])
.split(chunks[1]);
// Tracks list - each track gets 2 lines to match timeline
let track_items: Vec<ListItem> = app
.tracks
.iter()
.map(|(id, name)| {
let style = if app.selected_track == Some(*id) {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
// Create a 2-line item: track info on first line, empty second line
let lines = vec![
Line::from(format!("T{}: {}", id, name)),
Line::from(""),
];
ListItem::new(lines).style(style)
})
.collect();
let tracks_list = List::new(track_items)
.block(Block::default().borders(Borders::ALL).title("Tracks"));
f.render_widget(tracks_list, content_chunks[0]);
// Timeline area - split vertically into playback info and timeline view
let timeline_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(4), Constraint::Min(5)])
.split(content_chunks[1]);
// Playback info
let playback_info = vec![
Line::from(vec![
Span::raw("Position: "),
Span::styled(
format!("{:.2}s", app.playback_position),
Style::default().fg(Color::Green),
),
Span::raw(" | Status: "),
Span::styled(
if app.is_playing { "Playing" } else { "Stopped" },
if app.is_playing {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Red)
},
),
]),
Line::from(format!("Active Notes: {}",
app.active_notes
.iter()
.map(|n| format!("{} ", n))
.collect::<String>()
)),
];
let info = Paragraph::new(playback_info)
.block(Block::default().borders(Borders::ALL).title("Playback"));
f.render_widget(info, timeline_chunks[0]);
// Draw timeline
draw_timeline(f, timeline_chunks[1], app);
// Status bar
let mode_indicator = match app.mode {
AppMode::Command => "COMMAND",
AppMode::Play => "PLAY",
};
let status_text = format!("Mode: {} | {}", mode_indicator, app.status_message);
let status_bar = Paragraph::new(status_text)
.style(Style::default().fg(Color::White))
.block(Block::default().borders(Borders::ALL));
f.render_widget(status_bar, chunks[2]);
// Command line
let command_line = if app.mode == AppMode::Command {
format!(":{}", app.command_input)
} else {
String::from("ESC=cmd mode | awsedftgyhujkolp;'=notes | R=release notes | ?=help | SPACE=play/pause")
};
let cmd_widget = Paragraph::new(command_line).style(Style::default().fg(Color::Yellow));
f.render_widget(cmd_widget, chunks[3]);
}
/// Run the TUI application
pub fn run_tui(
mut controller: EngineController,
event_rx: Arc<Mutex<rtrb::Consumer<AudioEvent>>>,
) -> Result<(), Box<dyn std::error::Error>> {
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Create app state
let mut app = TuiApp::new();
// Main loop
loop {
// Draw UI
terminal.draw(|f| draw_ui(f, &app))?;
// Poll for audio events
if let Ok(mut rx) = event_rx.lock() {
while let Ok(event) = rx.pop() {
match event {
AudioEvent::PlaybackPosition(pos) => {
app.update_playback_position(pos);
}
AudioEvent::PlaybackStopped => {
app.set_playing(false);
}
AudioEvent::TrackCreated(track_id, _, name) => {
app.add_track(track_id, name);
}
AudioEvent::RecordingStopped(clip_id, _pool_index, _waveform) => {
// Update status
app.set_status(format!("Recording stopped - Clip {}", clip_id));
}
AudioEvent::ProjectReset => {
app.clear_tracks();
app.set_status("Project reset".to_string());
}
_ => {}
}
}
}
// Handle keyboard input with timeout
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
match app.mode() {
AppMode::Command => {
match key.code {
KeyCode::Left => {
// Scroll timeline left only if command buffer is empty
if app.command_input().is_empty() {
app.scroll_timeline_left();
}
}
KeyCode::Right => {
// Scroll timeline right only if command buffer is empty
if app.command_input().is_empty() {
app.scroll_timeline_right();
}
}
KeyCode::Char('-') | KeyCode::Char('_') => {
// Zoom out only if command buffer is empty
if app.command_input().is_empty() {
app.zoom_timeline_out();
}
}
KeyCode::Char('+') | KeyCode::Char('=') => {
// Zoom in only if command buffer is empty
if app.command_input().is_empty() {
app.zoom_timeline_in();
}
}
KeyCode::Char(' ') => {
// Spacebar toggles play/pause only if command buffer is empty
// Otherwise, add space to command
if app.command_input().is_empty() {
if app.is_playing {
controller.pause();
app.set_playing(false);
app.set_status("Paused".to_string());
} else {
controller.play();
app.set_playing(true);
app.set_status("Playing".to_string());
}
} else {
app.push_command_char(' ');
}
}
KeyCode::Esc => {
app.clear_command();
}
KeyCode::Enter => {
let command = app.command_input().to_string();
app.add_to_history(command.clone());
// Execute command
match execute_command(&command, &mut controller, &mut app) {
Err(e) if e == "Quit requested" => {
break; // Exit the application
}
Err(e) => {
app.set_status(format!("Error: {}", e));
}
Ok(_) => {}
}
app.clear_command();
}
KeyCode::Backspace => {
app.pop_command_char();
}
KeyCode::Up => {
app.history_up();
}
KeyCode::Down => {
app.history_down();
}
KeyCode::Char('i') => {
// Only switch to Play mode if command buffer is empty
if app.command_input().is_empty() {
app.enter_play_mode();
} else {
app.push_command_char('i');
}
}
KeyCode::Char(c) => {
app.push_command_char(c);
}
_ => {}
}
}
AppMode::Play => {
// Check for mode switch first
if key.code == KeyCode::Esc {
app.enter_command_mode();
continue;
}
// Check for quit
if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) {
break;
}
// Handle MIDI note playing
if let Some(note) = key_to_midi_note(key.code) {
if let Some(track_id) = app.selected_track() {
// Release all previous notes before playing new one
for prev_note in app.active_notes.clone() {
controller.send_midi_note_off(track_id, prev_note);
}
app.active_notes.clear();
// Play the new note
controller.send_midi_note_on(track_id, note, 100);
app.add_active_note(note);
}
} else {
// Handle other play mode shortcuts
match key.code {
KeyCode::Char(' ') => {
// Release all notes and toggle play/pause
if let Some(track_id) = app.selected_track() {
for note in app.active_notes.clone() {
controller.send_midi_note_off(track_id, note);
}
app.active_notes.clear();
}
if app.is_playing {
controller.pause();
app.set_playing(false);
} else {
controller.play();
app.set_playing(true);
}
}
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// Release all notes and stop
if let Some(track_id) = app.selected_track() {
for note in app.active_notes.clone() {
controller.send_midi_note_off(track_id, note);
}
app.active_notes.clear();
}
controller.stop();
app.set_playing(false);
}
KeyCode::Char('r') | KeyCode::Char('R') => {
// Release all notes manually (r for release)
if let Some(track_id) = app.selected_track() {
for note in app.active_notes.clone() {
controller.send_midi_note_off(track_id, note);
}
app.active_notes.clear();
}
app.set_status("All notes released".to_string());
}
KeyCode::Char('?') | KeyCode::Char('h') | KeyCode::Char('H') => {
app.set_status("Play Mode: awsedftgyhujkolp;'=notes | R=release | SPACE=play/pause | ESC=command | Ctrl+Q=quit".to_string());
}
_ => {}
}
}
}
}
}
}
}
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
/// Execute a command string
fn execute_command(
command: &str,
controller: &mut EngineController,
app: &mut TuiApp,
) -> Result<(), String> {
let parts: Vec<&str> = command.trim().split_whitespace().collect();
if parts.is_empty() {
return Ok(());
}
match parts[0] {
"play" => {
controller.play();
app.set_playing(true);
app.set_status("Playing".to_string());
}
"pause" => {
controller.pause();
app.set_playing(false);
app.set_status("Paused".to_string());
}
"stop" => {
controller.stop();
app.set_playing(false);
app.set_status("Stopped".to_string());
}
"seek" => {
if parts.len() < 2 {
return Err("Usage: seek <seconds>".to_string());
}
let pos: f64 = parts[1].parse().map_err(|_| "Invalid position")?;
controller.seek(pos);
app.set_status(format!("Seeked to {:.2}s", pos));
}
"track" => {
if parts.len() < 2 {
return Err("Usage: track <name>".to_string());
}
let name = parts[1..].join(" ");
controller.create_midi_track(name.clone());
app.set_status(format!("Created MIDI track: {}", name));
}
"audiotrack" => {
if parts.len() < 2 {
return Err("Usage: audiotrack <name>".to_string());
}
let name = parts[1..].join(" ");
controller.create_audio_track(name.clone());
app.set_status(format!("Created audio track: {}", name));
}
"select" => {
if parts.len() < 2 {
return Err("Usage: select <track_number>".to_string());
}
let idx: usize = parts[1].parse().map_err(|_| "Invalid track number")?;
app.select_track(idx);
app.set_status(format!("Selected track {}", idx));
}
"clip" => {
if parts.len() < 4 {
return Err("Usage: clip <track_id> <start_time> <duration>".to_string());
}
let track_id: u32 = parts[1].parse().map_err(|_| "Invalid track ID")?;
let start_time: f64 = parts[2].parse().map_err(|_| "Invalid start time")?;
let duration: f64 = parts[3].parse().map_err(|_| "Invalid duration")?;
// Add clip to local UI state (empty clip, no notes)
let clip_id = app.next_clip_id;
app.next_clip_id += 1;
app.add_clip(track_id, clip_id, start_time, duration, format!("Clip {}", clip_id), Vec::new());
controller.create_midi_clip(track_id, start_time, duration);
app.set_status(format!("Created MIDI clip on track {} at {:.2}s for {:.2}s", track_id, start_time, duration));
}
"loadmidi" => {
if parts.len() < 3 {
return Err("Usage: loadmidi <track_id> <file_path> [start_time]".to_string());
}
let track_id: u32 = parts[1].parse().map_err(|_| "Invalid track ID")?;
let file_path = parts[2];
let start_time: f64 = if parts.len() >= 4 {
parts[3].parse().unwrap_or(0.0)
} else {
0.0
};
// Load the MIDI file
match load_midi_file(file_path, app.next_clip_id, 48000) {
Ok(midi_clip) => {
let clip_id = midi_clip.id;
let duration = midi_clip.duration;
let event_count = midi_clip.events.len();
// Extract note data for visualization
let mut notes = Vec::new();
let mut active_notes: std::collections::HashMap<u8, f64> = std::collections::HashMap::new();
let sample_rate = 48000.0; // Sample rate used for loading MIDI
for event in &midi_clip.events {
let status = event.status & 0xF0;
let time_seconds = event.timestamp as f64 / sample_rate;
match status {
0x90 if event.data2 > 0 => {
// Note on
active_notes.insert(event.data1, time_seconds);
}
0x80 | 0x90 => {
// Note off (or note on with velocity 0)
if let Some(start) = active_notes.remove(&event.data1) {
let note_duration = time_seconds - start;
notes.push((event.data1, start, note_duration));
}
}
_ => {}
}
}
// Add to local UI state with note data
app.add_clip(track_id, clip_id, start_time, duration, file_path.to_string(), notes);
app.next_clip_id += 1;
// Send to audio engine with the start_time (clip content is separate from timeline position)
controller.add_loaded_midi_clip(track_id, midi_clip, start_time);
app.set_status(format!("Loaded {} ({} events, {:.2}s) to track {} at {:.2}s",
file_path, event_count, duration, track_id, start_time));
}
Err(e) => {
return Err(format!("Failed to load MIDI file: {}", e));
}
}
}
"reset" => {
controller.reset();
app.clear_tracks();
app.set_status("Project reset".to_string());
}
"q" | "quit" => {
return Err("Quit requested".to_string());
}
"help" | "h" | "?" => {
// Show comprehensive help
let help_msg = concat!(
"Commands: ",
"play | pause | stop | seek <s> | ",
"track <name> | audiotrack <name> | select <idx> | ",
"clip <track_id> <start> <dur> | ",
"loadmidi <track_id> <file> [start] | ",
"reset | quit | help | ",
"Keys: ←/→ scroll | -/+ zoom"
);
app.set_status(help_msg.to_string());
}
_ => {
return Err(format!("Unknown command: '{}'. Type 'help' for commands", parts[0]));
}
}
Ok(())
}