Compare commits
2 Commits
c938ea44b0
...
0d7f15853c
| Author | SHA1 | Date |
|---|---|---|
|
|
0d7f15853c | |
|
|
121fa3a50a |
|
|
@ -27,7 +27,7 @@ pub struct Engine {
|
||||||
project: Project,
|
project: Project,
|
||||||
audio_pool: AudioClipPool,
|
audio_pool: AudioClipPool,
|
||||||
buffer_pool: BufferPool,
|
buffer_pool: BufferPool,
|
||||||
playhead: u64, // Playhead position in samples
|
playhead: i64, // Playhead position in samples (may be negative during count-in pre-roll)
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
playing: bool,
|
playing: bool,
|
||||||
channels: u32,
|
channels: u32,
|
||||||
|
|
@ -74,6 +74,10 @@ pub struct Engine {
|
||||||
// MIDI recording state
|
// MIDI recording state
|
||||||
midi_recording_state: Option<MidiRecordingState>,
|
midi_recording_state: Option<MidiRecordingState>,
|
||||||
|
|
||||||
|
// Currently held MIDI notes per track (note -> velocity), updated on NoteOn/NoteOff
|
||||||
|
// Used to inject held notes when recording starts mid-press (e.g. after count-in)
|
||||||
|
midi_held_notes: HashMap<TrackId, HashMap<u8, u8>>,
|
||||||
|
|
||||||
// MIDI input manager for external MIDI devices
|
// MIDI input manager for external MIDI devices
|
||||||
midi_input_manager: Option<MidiInputManager>,
|
midi_input_manager: Option<MidiInputManager>,
|
||||||
|
|
||||||
|
|
@ -160,6 +164,7 @@ impl Engine {
|
||||||
recording_mirror_tx: None,
|
recording_mirror_tx: None,
|
||||||
recording_progress_counter: 0,
|
recording_progress_counter: 0,
|
||||||
midi_recording_state: None,
|
midi_recording_state: None,
|
||||||
|
midi_held_notes: HashMap::new(),
|
||||||
midi_input_manager: None,
|
midi_input_manager: None,
|
||||||
metronome: Metronome::new(sample_rate),
|
metronome: Metronome::new(sample_rate),
|
||||||
recording_sample_buffer: Vec::with_capacity(4096),
|
recording_sample_buffer: Vec::with_capacity(4096),
|
||||||
|
|
@ -396,17 +401,18 @@ impl Engine {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update playhead (convert total samples to frames)
|
// Update playhead (convert total samples to frames)
|
||||||
self.playhead += (output.len() / self.channels as usize) as u64;
|
self.playhead += (output.len() / self.channels as usize) as i64;
|
||||||
|
|
||||||
// Update atomic playhead for UI reads
|
// Update atomic playhead for UI reads (clamped to 0; negative = count-in pre-roll)
|
||||||
self.playhead_atomic
|
self.playhead_atomic
|
||||||
.store(self.playhead, Ordering::Relaxed);
|
.store(self.playhead.max(0) as u64, Ordering::Relaxed);
|
||||||
|
|
||||||
// Send periodic position updates
|
// Send periodic position updates
|
||||||
self.frames_since_last_event += output.len() / self.channels as usize;
|
self.frames_since_last_event += output.len() / self.channels as usize;
|
||||||
if self.frames_since_last_event >= self.event_interval_frames / self.channels as usize
|
if self.frames_since_last_event >= self.event_interval_frames / self.channels as usize
|
||||||
{
|
{
|
||||||
let position_seconds = self.playhead as f64 / self.sample_rate as f64;
|
// Clamp to 0 during count-in pre-roll (negative playhead = before project start)
|
||||||
|
let position_seconds = self.playhead.max(0) as f64 / self.sample_rate as f64;
|
||||||
let _ = self
|
let _ = self
|
||||||
.event_tx
|
.event_tx
|
||||||
.push(AudioEvent::PlaybackPosition(position_seconds));
|
.push(AudioEvent::PlaybackPosition(position_seconds));
|
||||||
|
|
@ -692,17 +698,17 @@ impl Engine {
|
||||||
self.project.stop_all_notes();
|
self.project.stop_all_notes();
|
||||||
}
|
}
|
||||||
Command::Seek(seconds) => {
|
Command::Seek(seconds) => {
|
||||||
let frames = (seconds * self.sample_rate as f64) as u64;
|
self.playhead = (seconds * self.sample_rate as f64) as i64;
|
||||||
self.playhead = frames;
|
// Clamp to 0 for atomic/disk-reader; negative = count-in pre-roll (no disk reads needed)
|
||||||
self.playhead_atomic
|
let clamped = self.playhead.max(0) as u64;
|
||||||
.store(self.playhead, Ordering::Relaxed);
|
self.playhead_atomic.store(clamped, Ordering::Relaxed);
|
||||||
// Stop all MIDI notes when seeking to prevent stuck notes
|
// Stop all MIDI notes when seeking to prevent stuck notes
|
||||||
self.project.stop_all_notes();
|
self.project.stop_all_notes();
|
||||||
// Reset all node graphs to clear effect buffers (echo, reverb, etc.)
|
// Reset all node graphs to clear effect buffers (echo, reverb, etc.)
|
||||||
self.project.reset_all_graphs();
|
self.project.reset_all_graphs();
|
||||||
// Notify disk reader to refill buffers from new position
|
// Notify disk reader to refill buffers from new position
|
||||||
if let Some(ref mut dr) = self.disk_reader {
|
if let Some(ref mut dr) = self.disk_reader {
|
||||||
dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek { frame: frames });
|
dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek { frame: clamped });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::SetTrackVolume(track_id, volume) => {
|
Command::SetTrackVolume(track_id, volume) => {
|
||||||
|
|
@ -1212,6 +1218,9 @@ impl Engine {
|
||||||
// Emit event to UI for visual feedback
|
// Emit event to UI for visual feedback
|
||||||
let _ = self.event_tx.push(AudioEvent::NoteOn(note, velocity));
|
let _ = self.event_tx.push(AudioEvent::NoteOn(note, velocity));
|
||||||
|
|
||||||
|
// Track held notes so count-in recording can inject them at start_time
|
||||||
|
self.midi_held_notes.entry(track_id).or_default().insert(note, velocity);
|
||||||
|
|
||||||
// If MIDI recording is active on this track, capture the event
|
// If MIDI recording is active on this track, capture the event
|
||||||
if let Some(recording) = &mut self.midi_recording_state {
|
if let Some(recording) = &mut self.midi_recording_state {
|
||||||
if recording.track_id == track_id {
|
if recording.track_id == track_id {
|
||||||
|
|
@ -1230,6 +1239,11 @@ impl Engine {
|
||||||
// Emit event to UI for visual feedback
|
// Emit event to UI for visual feedback
|
||||||
let _ = self.event_tx.push(AudioEvent::NoteOff(note));
|
let _ = self.event_tx.push(AudioEvent::NoteOff(note));
|
||||||
|
|
||||||
|
// Remove from held notes tracking
|
||||||
|
if let Some(track_notes) = self.midi_held_notes.get_mut(&track_id) {
|
||||||
|
track_notes.remove(¬e);
|
||||||
|
}
|
||||||
|
|
||||||
// If MIDI recording is active on this track, capture the event
|
// If MIDI recording is active on this track, capture the event
|
||||||
if let Some(recording) = &mut self.midi_recording_state {
|
if let Some(recording) = &mut self.midi_recording_state {
|
||||||
if recording.track_id == track_id {
|
if recording.track_id == track_id {
|
||||||
|
|
@ -3038,7 +3052,17 @@ impl Engine {
|
||||||
// Check if track exists and is a MIDI track
|
// Check if track exists and is a MIDI track
|
||||||
if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) {
|
||||||
// Create MIDI recording state
|
// Create MIDI recording state
|
||||||
let recording_state = MidiRecordingState::new(track_id, clip_id, start_time);
|
let mut recording_state = MidiRecordingState::new(track_id, clip_id, start_time);
|
||||||
|
|
||||||
|
// Inject any notes currently held on this track (pressed during count-in pre-roll)
|
||||||
|
// so they start at t=0 of the recording rather than being lost
|
||||||
|
if let Some(held) = self.midi_held_notes.get(&track_id) {
|
||||||
|
for (¬e, &velocity) in held {
|
||||||
|
eprintln!("[MIDI_RECORDING] Injecting held note {} vel {} at start_time {:.3}s", note, velocity, start_time);
|
||||||
|
recording_state.note_on(note, velocity, start_time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.midi_recording_state = Some(recording_state);
|
self.midi_recording_state = Some(recording_state);
|
||||||
|
|
||||||
eprintln!("[MIDI_RECORDING] Started MIDI recording on track {} for clip {}", track_id, clip_id);
|
eprintln!("[MIDI_RECORDING] Started MIDI recording on track {} for clip {}", track_id, clip_id);
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ impl Metronome {
|
||||||
pub fn process(
|
pub fn process(
|
||||||
&mut self,
|
&mut self,
|
||||||
output: &mut [f32],
|
output: &mut [f32],
|
||||||
playhead_samples: u64,
|
playhead_samples: i64,
|
||||||
playing: bool,
|
playing: bool,
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
channels: u32,
|
channels: u32,
|
||||||
|
|
@ -120,20 +120,21 @@ impl Metronome {
|
||||||
let frames = output.len() / channels as usize;
|
let frames = output.len() / channels as usize;
|
||||||
|
|
||||||
for frame in 0..frames {
|
for frame in 0..frames {
|
||||||
let current_sample = playhead_samples + frame as u64;
|
let current_sample = playhead_samples + frame as i64;
|
||||||
|
|
||||||
// Calculate current beat number
|
// Calculate current beat number
|
||||||
let current_time_seconds = current_sample as f64 / sample_rate as f64;
|
let current_time_seconds = current_sample as f64 / sample_rate as f64;
|
||||||
let beats_per_second = self.bpm as f64 / 60.0;
|
let beats_per_second = self.bpm as f64 / 60.0;
|
||||||
let current_beat = (current_time_seconds * beats_per_second).floor() as i64;
|
let current_beat = (current_time_seconds * beats_per_second).floor() as i64;
|
||||||
|
|
||||||
// Check if we crossed a beat boundary
|
// Check if we crossed a beat boundary (including negative beats during count-in pre-roll)
|
||||||
if current_beat != self.last_beat && current_beat >= 0 {
|
if current_beat != self.last_beat {
|
||||||
self.last_beat = current_beat;
|
self.last_beat = current_beat;
|
||||||
|
|
||||||
// Determine which click to play.
|
// Determine which click to play.
|
||||||
// Beat 0 of each measure gets the accent (high click).
|
// Beat 0 of each measure gets the accent (high click).
|
||||||
let beat_in_measure = (current_beat as u32 % self.time_signature_numerator) as usize;
|
// Use rem_euclid so negative beat numbers map correctly (e.g. -4 % 4 = 0).
|
||||||
|
let beat_in_measure = current_beat.rem_euclid(self.time_signature_numerator as i64) as usize;
|
||||||
self.playing_high_click = beat_in_measure == 0;
|
self.playing_high_click = beat_in_measure == 0;
|
||||||
self.click_position = 0; // Start from beginning of click
|
self.click_position = 0; // Start from beginning of click
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,6 @@ impl MidiRecordingState {
|
||||||
|
|
||||||
/// Handle a MIDI note on event
|
/// Handle a MIDI note on event
|
||||||
pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: f64) {
|
pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: f64) {
|
||||||
// Store this note as active
|
|
||||||
self.active_notes.insert(note, ActiveMidiNote {
|
self.active_notes.insert(note, ActiveMidiNote {
|
||||||
note,
|
note,
|
||||||
velocity,
|
velocity,
|
||||||
|
|
@ -225,14 +224,21 @@ impl MidiRecordingState {
|
||||||
pub fn note_off(&mut self, note: u8, absolute_time: f64) {
|
pub fn note_off(&mut self, note: u8, absolute_time: f64) {
|
||||||
// Find the matching noteOn
|
// Find the matching noteOn
|
||||||
if let Some(active_note) = self.active_notes.remove(¬e) {
|
if let Some(active_note) = self.active_notes.remove(¬e) {
|
||||||
// Calculate relative time offset and duration
|
// If the note was fully released before the recording start (e.g. during count-in
|
||||||
let time_offset = active_note.start_time - self.start_time;
|
// pre-roll), discard it — only notes still held at the clip start are kept.
|
||||||
let duration = absolute_time - active_note.start_time;
|
if absolute_time <= self.start_time {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp note start to clip start: notes held across the recording boundary
|
||||||
|
// are treated as starting at the clip position.
|
||||||
|
let note_start = active_note.start_time.max(self.start_time);
|
||||||
|
let time_offset = note_start - self.start_time;
|
||||||
|
let duration = absolute_time - note_start;
|
||||||
|
|
||||||
eprintln!("[MIDI_RECORDING_STATE] Completing note {}: note_start={:.3}s, note_end={:.3}s, recording_start={:.3}s, time_offset={:.3}s, duration={:.3}s",
|
eprintln!("[MIDI_RECORDING_STATE] Completing note {}: note_start={:.3}s, note_end={:.3}s, recording_start={:.3}s, time_offset={:.3}s, duration={:.3}s",
|
||||||
note, active_note.start_time, absolute_time, self.start_time, time_offset, duration);
|
note, note_start, absolute_time, self.start_time, time_offset, duration);
|
||||||
|
|
||||||
// Add to completed notes
|
|
||||||
self.completed_notes.push((
|
self.completed_notes.push((
|
||||||
time_offset,
|
time_offset,
|
||||||
active_note.note,
|
active_note.note,
|
||||||
|
|
@ -258,8 +264,9 @@ impl MidiRecordingState {
|
||||||
pub fn get_notes_with_active(&self, current_time: f64) -> Vec<(f64, u8, u8, f64)> {
|
pub fn get_notes_with_active(&self, current_time: f64) -> Vec<(f64, u8, u8, f64)> {
|
||||||
let mut notes = self.completed_notes.clone();
|
let mut notes = self.completed_notes.clone();
|
||||||
for active in self.active_notes.values() {
|
for active in self.active_notes.values() {
|
||||||
let time_offset = active.start_time - self.start_time;
|
let note_start = active.start_time.max(self.start_time);
|
||||||
let provisional_dur = (current_time - active.start_time).max(0.0);
|
let time_offset = note_start - self.start_time;
|
||||||
|
let provisional_dur = (current_time - note_start).max(0.0);
|
||||||
notes.push((time_offset, active.note, active.velocity, provisional_dur));
|
notes.push((time_offset, active.note, active.velocity, provisional_dur));
|
||||||
}
|
}
|
||||||
notes
|
notes
|
||||||
|
|
@ -273,15 +280,13 @@ impl MidiRecordingState {
|
||||||
/// Close out all active notes at the given time
|
/// Close out all active notes at the given time
|
||||||
/// This should be called when stopping recording to end any held notes
|
/// This should be called when stopping recording to end any held notes
|
||||||
pub fn close_active_notes(&mut self, end_time: f64) {
|
pub fn close_active_notes(&mut self, end_time: f64) {
|
||||||
// Collect all active notes and close them
|
|
||||||
let active_notes: Vec<_> = self.active_notes.drain().collect();
|
let active_notes: Vec<_> = self.active_notes.drain().collect();
|
||||||
|
|
||||||
for (_note_num, active_note) in active_notes {
|
for (_note_num, active_note) in active_notes {
|
||||||
// Calculate relative time offset and duration
|
let note_start = active_note.start_time.max(self.start_time);
|
||||||
let time_offset = active_note.start_time - self.start_time;
|
let time_offset = note_start - self.start_time;
|
||||||
let duration = end_time - active_note.start_time;
|
let duration = end_time - note_start;
|
||||||
|
|
||||||
// Add to completed notes
|
|
||||||
self.completed_notes.push((
|
self.completed_notes.push((
|
||||||
time_offset,
|
time_offset,
|
||||||
active_note.note,
|
active_note.note,
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,15 @@ impl Default for TimeSignature {
|
||||||
|
|
||||||
fn default_bpm() -> f64 { 120.0 }
|
fn default_bpm() -> f64 { 120.0 }
|
||||||
|
|
||||||
|
/// How time is displayed in the timeline
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub enum TimelineMode {
|
||||||
|
#[default]
|
||||||
|
Seconds,
|
||||||
|
Measures,
|
||||||
|
Frames,
|
||||||
|
}
|
||||||
|
|
||||||
/// Asset category for folder tree access
|
/// Asset category for folder tree access
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AssetCategory {
|
pub enum AssetCategory {
|
||||||
|
|
@ -226,6 +235,10 @@ pub struct Document {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub script_folders: AssetFolderTree,
|
pub script_folders: AssetFolderTree,
|
||||||
|
|
||||||
|
/// How time is displayed in the timeline (saved with document)
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeline_mode: TimelineMode,
|
||||||
|
|
||||||
/// Current UI layout state (serialized for save/load)
|
/// Current UI layout state (serialized for save/load)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub ui_layout: Option<LayoutNode>,
|
pub ui_layout: Option<LayoutNode>,
|
||||||
|
|
@ -270,6 +283,7 @@ impl Default for Document {
|
||||||
effect_folders: AssetFolderTree::new(),
|
effect_folders: AssetFolderTree::new(),
|
||||||
script_definitions: HashMap::new(),
|
script_definitions: HashMap::new(),
|
||||||
script_folders: AssetFolderTree::new(),
|
script_folders: AssetFolderTree::new(),
|
||||||
|
timeline_mode: TimelineMode::Seconds,
|
||||||
ui_layout: None,
|
ui_layout: None,
|
||||||
ui_layout_base: None,
|
ui_layout_base: None,
|
||||||
current_time: 0.0,
|
current_time: 0.0,
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,7 @@ impl From<MenuAction> for AppAction {
|
||||||
MenuAction::AddShapeTween => Self::AddShapeTween,
|
MenuAction::AddShapeTween => Self::AddShapeTween,
|
||||||
MenuAction::ReturnToStart => Self::ReturnToStart,
|
MenuAction::ReturnToStart => Self::ReturnToStart,
|
||||||
MenuAction::Play => Self::Play,
|
MenuAction::Play => Self::Play,
|
||||||
|
MenuAction::ToggleCountIn => Self::Play, // not directly mappable to AppAction
|
||||||
MenuAction::ZoomIn => Self::ZoomIn,
|
MenuAction::ZoomIn => Self::ZoomIn,
|
||||||
MenuAction::ZoomOut => Self::ZoomOut,
|
MenuAction::ZoomOut => Self::ZoomOut,
|
||||||
MenuAction::ActualSize => Self::ActualSize,
|
MenuAction::ActualSize => Self::ActualSize,
|
||||||
|
|
|
||||||
|
|
@ -856,6 +856,7 @@ struct EditorApp {
|
||||||
armed_layers: HashSet<Uuid>,
|
armed_layers: HashSet<Uuid>,
|
||||||
is_recording: bool, // Whether recording is currently active
|
is_recording: bool, // Whether recording is currently active
|
||||||
metronome_enabled: bool, // Whether metronome clicks during recording
|
metronome_enabled: bool, // Whether metronome clicks during recording
|
||||||
|
count_in_enabled: bool, // Whether count-in fires before recording
|
||||||
recording_clips: HashMap<Uuid, u32>, // layer_id -> backend clip_id during recording
|
recording_clips: HashMap<Uuid, u32>, // layer_id -> backend clip_id during recording
|
||||||
recording_start_time: f64, // Playback time when recording started
|
recording_start_time: f64, // Playback time when recording started
|
||||||
recording_layer_ids: Vec<Uuid>, // Layers being recorded to (for creating clips)
|
recording_layer_ids: Vec<Uuid>, // Layers being recorded to (for creating clips)
|
||||||
|
|
@ -1128,6 +1129,7 @@ impl EditorApp {
|
||||||
armed_layers: HashSet::new(), // No layers explicitly armed
|
armed_layers: HashSet::new(), // No layers explicitly armed
|
||||||
is_recording: false, // Not recording initially
|
is_recording: false, // Not recording initially
|
||||||
metronome_enabled: false, // Metronome off by default
|
metronome_enabled: false, // Metronome off by default
|
||||||
|
count_in_enabled: false, // Count-in off by default
|
||||||
recording_clips: HashMap::new(), // No active recording clips
|
recording_clips: HashMap::new(), // No active recording clips
|
||||||
recording_start_time: 0.0, // Will be set when recording starts
|
recording_start_time: 0.0, // Will be set when recording starts
|
||||||
recording_layer_ids: Vec::new(), // Will be populated when recording starts
|
recording_layer_ids: Vec::new(), // Will be populated when recording starts
|
||||||
|
|
@ -1488,6 +1490,13 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set default timeline mode based on activity
|
||||||
|
document.timeline_mode = match layout_index {
|
||||||
|
2 => lightningbeam_core::document::TimelineMode::Measures, // Music
|
||||||
|
1 => lightningbeam_core::document::TimelineMode::Seconds, // Video
|
||||||
|
_ => lightningbeam_core::document::TimelineMode::Frames, // Animation, Painting, etc.
|
||||||
|
};
|
||||||
|
|
||||||
// Reset action executor with new document
|
// Reset action executor with new document
|
||||||
self.action_executor = lightningbeam_core::action::ActionExecutor::new(document);
|
self.action_executor = lightningbeam_core::action::ActionExecutor::new(document);
|
||||||
|
|
||||||
|
|
@ -3577,6 +3586,12 @@ impl EditorApp {
|
||||||
println!("Menu: Play");
|
println!("Menu: Play");
|
||||||
// TODO: Implement play/pause
|
// TODO: Implement play/pause
|
||||||
}
|
}
|
||||||
|
MenuAction::ToggleCountIn => {
|
||||||
|
// Only effective when metronome is enabled (count-in requires a click track)
|
||||||
|
if self.metronome_enabled {
|
||||||
|
self.count_in_enabled = !self.count_in_enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// View menu
|
// View menu
|
||||||
MenuAction::ZoomIn => {
|
MenuAction::ZoomIn => {
|
||||||
|
|
@ -5632,9 +5647,28 @@ impl eframe::App for EditorApp {
|
||||||
if let Some(menu_system) = &self.menu_system {
|
if let Some(menu_system) = &self.menu_system {
|
||||||
let recent_files = self.config.get_recent_files();
|
let recent_files = self.config.get_recent_files();
|
||||||
let layout_names: Vec<String> = self.layouts.iter().map(|l| l.name.clone()).collect();
|
let layout_names: Vec<String> = self.layouts.iter().map(|l| l.name.clone()).collect();
|
||||||
|
|
||||||
|
// Determine timeline measures mode for conditional menu items
|
||||||
|
let timeline_is_measures = self.pane_instances.values().any(|p| {
|
||||||
|
if let panes::PaneInstance::Timeline(t) = p { t.is_measures_mode() } else { false }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Checked actions show "✔ Label"; hidden actions are not rendered at all
|
||||||
|
let checked: &[crate::menu::MenuAction] = if self.count_in_enabled && self.metronome_enabled {
|
||||||
|
&[crate::menu::MenuAction::ToggleCountIn]
|
||||||
|
} else {
|
||||||
|
&[]
|
||||||
|
};
|
||||||
|
let hidden: &[crate::menu::MenuAction] = if timeline_is_measures && self.metronome_enabled {
|
||||||
|
&[]
|
||||||
|
} else {
|
||||||
|
&[crate::menu::MenuAction::ToggleCountIn]
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(action) = menu_system.render_egui_menu_bar(
|
if let Some(action) = menu_system.render_egui_menu_bar(
|
||||||
ui, &recent_files, Some(&self.keymap),
|
ui, &recent_files, Some(&self.keymap),
|
||||||
&layout_names, self.current_layout_index,
|
&layout_names, self.current_layout_index,
|
||||||
|
checked, hidden,
|
||||||
) {
|
) {
|
||||||
self.handle_menu_action(action);
|
self.handle_menu_action(action);
|
||||||
}
|
}
|
||||||
|
|
@ -5774,6 +5808,7 @@ impl eframe::App for EditorApp {
|
||||||
is_playing: &mut self.is_playing,
|
is_playing: &mut self.is_playing,
|
||||||
is_recording: &mut self.is_recording,
|
is_recording: &mut self.is_recording,
|
||||||
metronome_enabled: &mut self.metronome_enabled,
|
metronome_enabled: &mut self.metronome_enabled,
|
||||||
|
count_in_enabled: &mut self.count_in_enabled,
|
||||||
recording_clips: &mut self.recording_clips,
|
recording_clips: &mut self.recording_clips,
|
||||||
recording_start_time: &mut self.recording_start_time,
|
recording_start_time: &mut self.recording_start_time,
|
||||||
recording_layer_ids: &mut self.recording_layer_ids,
|
recording_layer_ids: &mut self.recording_layer_ids,
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,7 @@ pub enum MenuAction {
|
||||||
AddShapeTween,
|
AddShapeTween,
|
||||||
ReturnToStart,
|
ReturnToStart,
|
||||||
Play,
|
Play,
|
||||||
|
ToggleCountIn,
|
||||||
|
|
||||||
// View menu
|
// View menu
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
|
|
@ -422,6 +423,7 @@ impl MenuItemDef {
|
||||||
const ADD_SHAPE_TWEEN: Self = Self { label: "Add Shape Tween", action: MenuAction::AddShapeTween, shortcut: None };
|
const ADD_SHAPE_TWEEN: Self = Self { label: "Add Shape Tween", action: MenuAction::AddShapeTween, shortcut: None };
|
||||||
const RETURN_TO_START: Self = Self { label: "Return to start", action: MenuAction::ReturnToStart, shortcut: None };
|
const RETURN_TO_START: Self = Self { label: "Return to start", action: MenuAction::ReturnToStart, shortcut: None };
|
||||||
const PLAY: Self = Self { label: "Play", action: MenuAction::Play, shortcut: None };
|
const PLAY: Self = Self { label: "Play", action: MenuAction::Play, shortcut: None };
|
||||||
|
const COUNT_IN: Self = Self { label: "Count In", action: MenuAction::ToggleCountIn, shortcut: None };
|
||||||
|
|
||||||
// View menu items
|
// View menu items
|
||||||
const ZOOM_IN: Self = Self { label: "Zoom In", action: MenuAction::ZoomIn, shortcut: Some(Shortcut::new(ShortcutKey::Equals, CTRL, NO_SHIFT, NO_ALT)) };
|
const ZOOM_IN: Self = Self { label: "Zoom In", action: MenuAction::ZoomIn, shortcut: Some(Shortcut::new(ShortcutKey::Equals, CTRL, NO_SHIFT, NO_ALT)) };
|
||||||
|
|
@ -548,6 +550,8 @@ impl MenuItemDef {
|
||||||
MenuDef::Separator,
|
MenuDef::Separator,
|
||||||
MenuDef::Item(&Self::RETURN_TO_START),
|
MenuDef::Item(&Self::RETURN_TO_START),
|
||||||
MenuDef::Item(&Self::PLAY),
|
MenuDef::Item(&Self::PLAY),
|
||||||
|
MenuDef::Separator,
|
||||||
|
MenuDef::Item(&Self::COUNT_IN),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// View menu
|
// View menu
|
||||||
|
|
@ -805,6 +809,8 @@ impl MenuSystem {
|
||||||
keymap: Option<&crate::keymap::KeymapManager>,
|
keymap: Option<&crate::keymap::KeymapManager>,
|
||||||
layout_names: &[String],
|
layout_names: &[String],
|
||||||
current_layout_index: usize,
|
current_layout_index: usize,
|
||||||
|
checked_actions: &[MenuAction],
|
||||||
|
hidden_actions: &[MenuAction],
|
||||||
) -> Option<MenuAction> {
|
) -> Option<MenuAction> {
|
||||||
let mut action = None;
|
let mut action = None;
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
|
|
@ -819,7 +825,7 @@ impl MenuSystem {
|
||||||
let response = ui.button(*label);
|
let response = ui.button(*label);
|
||||||
let popup_id = egui::Popup::default_response_id(&response);
|
let popup_id = egui::Popup::default_response_id(&response);
|
||||||
button_entries.push((response, popup_id, menu_def));
|
button_entries.push((response, popup_id, menu_def));
|
||||||
} else if let Some(a) = self.render_menu_def(ui, menu_def, recent_files, keymap, layout_names, current_layout_index) {
|
} else if let Some(a) = self.render_menu_def(ui, menu_def, recent_files, keymap, layout_names, current_layout_index, checked_actions, hidden_actions) {
|
||||||
action = Some(a);
|
action = Some(a);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -847,7 +853,7 @@ impl MenuSystem {
|
||||||
ui.set_width(min_width);
|
ui.set_width(min_width);
|
||||||
let mut a = None;
|
let mut a = None;
|
||||||
for child in *children {
|
for child in *children {
|
||||||
if let Some(result) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index) {
|
if let Some(result) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index, checked_actions, hidden_actions) {
|
||||||
a = Some(result);
|
a = Some(result);
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
|
|
@ -875,10 +881,15 @@ impl MenuSystem {
|
||||||
keymap: Option<&crate::keymap::KeymapManager>,
|
keymap: Option<&crate::keymap::KeymapManager>,
|
||||||
layout_names: &[String],
|
layout_names: &[String],
|
||||||
current_layout_index: usize,
|
current_layout_index: usize,
|
||||||
|
checked_actions: &[MenuAction],
|
||||||
|
hidden_actions: &[MenuAction],
|
||||||
) -> Option<MenuAction> {
|
) -> Option<MenuAction> {
|
||||||
match def {
|
match def {
|
||||||
MenuDef::Item(item_def) => {
|
MenuDef::Item(item_def) => {
|
||||||
if Self::render_menu_item(ui, item_def, keymap) {
|
if hidden_actions.contains(&item_def.action) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if Self::render_menu_item(ui, item_def, keymap, checked_actions) {
|
||||||
Some(item_def.action)
|
Some(item_def.action)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
@ -914,7 +925,7 @@ impl MenuSystem {
|
||||||
} else if *label == "Layout" {
|
} else if *label == "Layout" {
|
||||||
let mut action = None;
|
let mut action = None;
|
||||||
for child in *children {
|
for child in *children {
|
||||||
if let Some(a) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index) {
|
if let Some(a) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index, checked_actions, hidden_actions) {
|
||||||
action = Some(a);
|
action = Some(a);
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
|
|
@ -937,7 +948,7 @@ impl MenuSystem {
|
||||||
} else {
|
} else {
|
||||||
let mut action = None;
|
let mut action = None;
|
||||||
for child in *children {
|
for child in *children {
|
||||||
if let Some(a) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index) {
|
if let Some(a) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index, checked_actions, hidden_actions) {
|
||||||
action = Some(a);
|
action = Some(a);
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
|
|
@ -951,7 +962,7 @@ impl MenuSystem {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a single menu item with label and shortcut
|
/// Render a single menu item with label and shortcut
|
||||||
fn render_menu_item(ui: &mut egui::Ui, def: &MenuItemDef, keymap: Option<&crate::keymap::KeymapManager>) -> bool {
|
fn render_menu_item(ui: &mut egui::Ui, def: &MenuItemDef, keymap: Option<&crate::keymap::KeymapManager>, checked_actions: &[MenuAction]) -> bool {
|
||||||
// Look up shortcut from keymap if available, otherwise use static default
|
// Look up shortcut from keymap if available, otherwise use static default
|
||||||
let effective_shortcut = if let Some(km) = keymap {
|
let effective_shortcut = if let Some(km) = keymap {
|
||||||
if let Ok(app_action) = crate::keymap::AppAction::try_from(def.action) {
|
if let Ok(app_action) = crate::keymap::AppAction::try_from(def.action) {
|
||||||
|
|
@ -987,10 +998,15 @@ impl MenuSystem {
|
||||||
ui.visuals().widgets.inactive.text_color()
|
ui.visuals().widgets.inactive.text_color()
|
||||||
};
|
};
|
||||||
let label_pos = rect.min + egui::vec2(4.0, (rect.height() - 14.0) / 2.0);
|
let label_pos = rect.min + egui::vec2(4.0, (rect.height() - 14.0) / 2.0);
|
||||||
|
let label = if checked_actions.contains(&def.action) {
|
||||||
|
format!("✔ {}", def.label)
|
||||||
|
} else {
|
||||||
|
def.label.to_owned()
|
||||||
|
};
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
label_pos,
|
label_pos,
|
||||||
egui::Align2::LEFT_TOP,
|
egui::Align2::LEFT_TOP,
|
||||||
def.label,
|
label,
|
||||||
egui::FontId::proportional(14.0),
|
egui::FontId::proportional(14.0),
|
||||||
text_color,
|
text_color,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,7 @@ pub struct SharedPaneState<'a> {
|
||||||
/// Recording state
|
/// Recording state
|
||||||
pub is_recording: &'a mut bool, // Whether recording is currently active
|
pub is_recording: &'a mut bool, // Whether recording is currently active
|
||||||
pub metronome_enabled: &'a mut bool, // Whether metronome clicks during recording
|
pub metronome_enabled: &'a mut bool, // Whether metronome clicks during recording
|
||||||
|
pub count_in_enabled: &'a mut bool, // Whether count-in fires before recording
|
||||||
pub recording_clips: &'a mut std::collections::HashMap<uuid::Uuid, u32>, // layer_id -> clip_id
|
pub recording_clips: &'a mut std::collections::HashMap<uuid::Uuid, u32>, // layer_id -> clip_id
|
||||||
pub recording_start_time: &'a mut f64, // Playback time when recording started
|
pub recording_start_time: &'a mut f64, // Playback time when recording started
|
||||||
pub recording_layer_ids: &'a mut Vec<uuid::Uuid>, // Layers being recorded to
|
pub recording_layer_ids: &'a mut Vec<uuid::Uuid>, // Layers being recorded to
|
||||||
|
|
|
||||||
|
|
@ -136,13 +136,7 @@ enum ClipDragType {
|
||||||
LoopExtendLeft,
|
LoopExtendLeft,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How time is displayed in the ruler and header
|
use lightningbeam_core::document::TimelineMode;
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
enum TimeDisplayFormat {
|
|
||||||
Seconds,
|
|
||||||
Measures,
|
|
||||||
Frames,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// State for an in-progress layer header drag-to-reorder operation.
|
/// State for an in-progress layer header drag-to-reorder operation.
|
||||||
struct LayerDragState {
|
struct LayerDragState {
|
||||||
|
|
@ -194,7 +188,7 @@ pub struct TimelinePane {
|
||||||
context_menu_clip: Option<(Option<uuid::Uuid>, egui::Pos2)>,
|
context_menu_clip: Option<(Option<uuid::Uuid>, egui::Pos2)>,
|
||||||
|
|
||||||
/// Whether to display time as seconds or measures
|
/// Whether to display time as seconds or measures
|
||||||
time_display_format: TimeDisplayFormat,
|
time_display_format: TimelineMode,
|
||||||
|
|
||||||
/// Waveform upload progress: pool_index -> frames uploaded so far.
|
/// Waveform upload progress: pool_index -> frames uploaded so far.
|
||||||
/// Tracks chunked GPU uploads across frames to avoid hitches.
|
/// Tracks chunked GPU uploads across frames to avoid hitches.
|
||||||
|
|
@ -223,6 +217,16 @@ pub struct TimelinePane {
|
||||||
automation_topology_generation: u64,
|
automation_topology_generation: u64,
|
||||||
/// Cached metronome icon texture (loaded on first use)
|
/// Cached metronome icon texture (loaded on first use)
|
||||||
metronome_icon: Option<egui::TextureHandle>,
|
metronome_icon: Option<egui::TextureHandle>,
|
||||||
|
/// Count-in pre-roll state: set when count-in is active, cleared when recording fires
|
||||||
|
pending_recording_start: Option<PendingRecordingStart>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deferred recording start created during count-in pre-roll
|
||||||
|
struct PendingRecordingStart {
|
||||||
|
/// Original playhead position — where clips will be placed in the timeline
|
||||||
|
original_playhead: f64,
|
||||||
|
/// Transport time at which to fire the actual recording commands
|
||||||
|
trigger_time: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a clip type can be dropped on a layer type
|
/// Check if a clip type can be dropped on a layer type
|
||||||
|
|
@ -663,7 +667,7 @@ impl TimelinePane {
|
||||||
mousedown_pos: None,
|
mousedown_pos: None,
|
||||||
layer_control_clicked: false,
|
layer_control_clicked: false,
|
||||||
context_menu_clip: None,
|
context_menu_clip: None,
|
||||||
time_display_format: TimeDisplayFormat::Seconds,
|
time_display_format: TimelineMode::Seconds,
|
||||||
waveform_upload_progress: std::collections::HashMap::new(),
|
waveform_upload_progress: std::collections::HashMap::new(),
|
||||||
video_thumbnail_textures: std::collections::HashMap::new(),
|
video_thumbnail_textures: std::collections::HashMap::new(),
|
||||||
layer_drag: None,
|
layer_drag: None,
|
||||||
|
|
@ -675,9 +679,15 @@ impl TimelinePane {
|
||||||
automation_cache_generation: u64::MAX,
|
automation_cache_generation: u64::MAX,
|
||||||
automation_topology_generation: u64::MAX,
|
automation_topology_generation: u64::MAX,
|
||||||
metronome_icon: None,
|
metronome_icon: None,
|
||||||
|
pending_recording_start: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if the timeline is currently in Measures display mode.
|
||||||
|
pub fn is_measures_mode(&self) -> bool {
|
||||||
|
self.time_display_format == TimelineMode::Measures
|
||||||
|
}
|
||||||
|
|
||||||
/// Execute a view action with the given parameters
|
/// Execute a view action with the given parameters
|
||||||
/// Called from main.rs after determining this is the best handler
|
/// Called from main.rs after determining this is the best handler
|
||||||
#[allow(dead_code)] // Mirrors StagePane; wiring in main.rs pending (see TODO at view action dispatch)
|
#[allow(dead_code)] // Mirrors StagePane; wiring in main.rs pending (see TODO at view action dispatch)
|
||||||
|
|
@ -910,6 +920,37 @@ impl TimelinePane {
|
||||||
});
|
});
|
||||||
|
|
||||||
let start_time = *shared.playback_time;
|
let start_time = *shared.playback_time;
|
||||||
|
|
||||||
|
// Count-in: seek back N beats, start transport + metronome, defer ALL recording commands.
|
||||||
|
// Must happen before Step 4 so no clips or backend recordings are created yet.
|
||||||
|
if *shared.count_in_enabled
|
||||||
|
&& *shared.metronome_enabled
|
||||||
|
&& self.time_display_format == TimelineMode::Measures
|
||||||
|
{
|
||||||
|
let (bpm, beats_per_measure) = {
|
||||||
|
let doc = shared.action_executor.document();
|
||||||
|
(doc.bpm, doc.time_signature.numerator as f64)
|
||||||
|
};
|
||||||
|
let count_in_duration = beats_per_measure * (60.0 / bpm);
|
||||||
|
let seek_to = start_time - count_in_duration; // may be negative
|
||||||
|
|
||||||
|
if let Some(controller_arc) = shared.audio_controller {
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
controller.seek(seek_to);
|
||||||
|
controller.set_metronome_enabled(true);
|
||||||
|
if !*shared.is_playing {
|
||||||
|
controller.play();
|
||||||
|
*shared.is_playing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pending_recording_start = Some(PendingRecordingStart {
|
||||||
|
original_playhead: start_time,
|
||||||
|
trigger_time: start_time,
|
||||||
|
});
|
||||||
|
return; // Recording commands are deferred — check_pending_recording_start fires them
|
||||||
|
}
|
||||||
|
|
||||||
shared.recording_layer_ids.clear();
|
shared.recording_layer_ids.clear();
|
||||||
|
|
||||||
// Step 4: Dispatch recording for each candidate
|
// Step 4: Dispatch recording for each candidate
|
||||||
|
|
@ -986,8 +1027,8 @@ impl TimelinePane {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-start playback if needed, and enable metronome if requested.
|
// No count-in (or deferred re-call from check_pending_recording_start):
|
||||||
// Metronome must be enabled BEFORE play() so beat 0 is not missed.
|
// Start immediately. Metronome must be enabled BEFORE play() so beat 0 is not missed.
|
||||||
if let Some(controller_arc) = shared.audio_controller {
|
if let Some(controller_arc) = shared.audio_controller {
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
if *shared.metronome_enabled {
|
if *shared.metronome_enabled {
|
||||||
|
|
@ -1005,7 +1046,31 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop all active recordings
|
/// Stop all active recordings
|
||||||
|
/// Called every frame; fires deferred recording commands once the count-in pre-roll ends.
|
||||||
|
fn check_pending_recording_start(&mut self, shared: &mut SharedPaneState) {
|
||||||
|
let Some(ref pending) = self.pending_recording_start else { return };
|
||||||
|
if !*shared.is_playing || *shared.playback_time < pending.trigger_time {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let original_playhead = pending.original_playhead;
|
||||||
|
self.pending_recording_start = None;
|
||||||
|
|
||||||
|
// Re-run start_recording with playback_time temporarily set to the original playhead
|
||||||
|
// so it captures the correct start_time and places clips at the right position.
|
||||||
|
// Disable count_in_enabled so start_recording takes the immediate path (no re-seek).
|
||||||
|
let saved_time = *shared.playback_time;
|
||||||
|
let saved_count_in = *shared.count_in_enabled;
|
||||||
|
*shared.playback_time = original_playhead;
|
||||||
|
*shared.count_in_enabled = false;
|
||||||
|
self.start_recording(shared);
|
||||||
|
*shared.playback_time = saved_time;
|
||||||
|
*shared.count_in_enabled = saved_count_in;
|
||||||
|
}
|
||||||
|
|
||||||
fn stop_recording(&mut self, shared: &mut SharedPaneState) {
|
fn stop_recording(&mut self, shared: &mut SharedPaneState) {
|
||||||
|
// Cancel any in-progress count-in
|
||||||
|
self.pending_recording_start = None;
|
||||||
|
|
||||||
let stop_wall = std::time::Instant::now();
|
let stop_wall = std::time::Instant::now();
|
||||||
eprintln!("[STOP] stop_recording called at {:?}", stop_wall);
|
eprintln!("[STOP] stop_recording called at {:?}", stop_wall);
|
||||||
|
|
||||||
|
|
@ -1287,8 +1352,8 @@ impl TimelinePane {
|
||||||
framerate: f64,
|
framerate: f64,
|
||||||
) -> Option<f64> {
|
) -> Option<f64> {
|
||||||
match self.time_display_format {
|
match self.time_display_format {
|
||||||
TimeDisplayFormat::Frames => Some(1.0 / framerate),
|
TimelineMode::Frames => Some(1.0 / framerate),
|
||||||
TimeDisplayFormat::Measures => {
|
TimelineMode::Measures => {
|
||||||
use lightningbeam_core::beat_time::{beat_duration, measure_duration};
|
use lightningbeam_core::beat_time::{beat_duration, measure_duration};
|
||||||
let beat = beat_duration(bpm);
|
let beat = beat_duration(bpm);
|
||||||
let measure = measure_duration(bpm, time_sig);
|
let measure = measure_duration(bpm, time_sig);
|
||||||
|
|
@ -1308,7 +1373,7 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
Some(measure)
|
Some(measure)
|
||||||
}
|
}
|
||||||
TimeDisplayFormat::Seconds => None,
|
TimelineMode::Seconds => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1385,7 +1450,7 @@ impl TimelinePane {
|
||||||
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
|
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
|
||||||
|
|
||||||
match self.time_display_format {
|
match self.time_display_format {
|
||||||
TimeDisplayFormat::Seconds => {
|
TimelineMode::Seconds => {
|
||||||
let interval = self.calculate_ruler_interval();
|
let interval = self.calculate_ruler_interval();
|
||||||
let start_time = (self.viewport_start_time / interval).floor() * interval;
|
let start_time = (self.viewport_start_time / interval).floor() * interval;
|
||||||
let end_time = self.x_to_time(rect.width());
|
let end_time = self.x_to_time(rect.width());
|
||||||
|
|
@ -1418,7 +1483,7 @@ impl TimelinePane {
|
||||||
time += interval;
|
time += interval;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TimeDisplayFormat::Measures => {
|
TimelineMode::Measures => {
|
||||||
let beats_per_second = bpm / 60.0;
|
let beats_per_second = bpm / 60.0;
|
||||||
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
|
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
|
||||||
let bpm_count = time_sig.numerator;
|
let bpm_count = time_sig.numerator;
|
||||||
|
|
@ -1471,7 +1536,7 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TimeDisplayFormat::Frames => {
|
TimelineMode::Frames => {
|
||||||
let interval = self.calculate_ruler_interval_frames(framerate);
|
let interval = self.calculate_ruler_interval_frames(framerate);
|
||||||
let start_frame = (self.viewport_start_time.max(0.0) * framerate).floor() as i64;
|
let start_frame = (self.viewport_start_time.max(0.0) * framerate).floor() as i64;
|
||||||
let end_frame = (self.x_to_time(rect.width()) * framerate).ceil() as i64;
|
let end_frame = (self.x_to_time(rect.width()) * framerate).ceil() as i64;
|
||||||
|
|
@ -2509,7 +2574,7 @@ impl TimelinePane {
|
||||||
|
|
||||||
// Grid lines matching ruler
|
// Grid lines matching ruler
|
||||||
match self.time_display_format {
|
match self.time_display_format {
|
||||||
TimeDisplayFormat::Seconds => {
|
TimelineMode::Seconds => {
|
||||||
let interval = self.calculate_ruler_interval();
|
let interval = self.calculate_ruler_interval();
|
||||||
let start_time = (self.viewport_start_time / interval).floor() * interval;
|
let start_time = (self.viewport_start_time / interval).floor() * interval;
|
||||||
let end_time = self.x_to_time(rect.width());
|
let end_time = self.x_to_time(rect.width());
|
||||||
|
|
@ -2526,7 +2591,7 @@ impl TimelinePane {
|
||||||
time += interval;
|
time += interval;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TimeDisplayFormat::Measures => {
|
TimelineMode::Measures => {
|
||||||
let beats_per_second = document.bpm / 60.0;
|
let beats_per_second = document.bpm / 60.0;
|
||||||
let bpm_count = document.time_signature.numerator;
|
let bpm_count = document.time_signature.numerator;
|
||||||
let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64;
|
let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64;
|
||||||
|
|
@ -2544,7 +2609,7 @@ impl TimelinePane {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TimeDisplayFormat::Frames => {
|
TimelineMode::Frames => {
|
||||||
let framerate = document.framerate;
|
let framerate = document.framerate;
|
||||||
let px_per_frame = self.pixels_per_second / framerate as f32;
|
let px_per_frame = self.pixels_per_second / framerate as f32;
|
||||||
|
|
||||||
|
|
@ -4652,6 +4717,12 @@ impl TimelinePane {
|
||||||
|
|
||||||
impl PaneRenderer for TimelinePane {
|
impl PaneRenderer for TimelinePane {
|
||||||
fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool {
|
fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool {
|
||||||
|
// Sync timeline mode from document (document is source of truth)
|
||||||
|
self.time_display_format = shared.action_executor.document().timeline_mode;
|
||||||
|
|
||||||
|
// Fire deferred recording commands once count-in pre-roll has elapsed
|
||||||
|
self.check_pending_recording_start(shared);
|
||||||
|
|
||||||
ui.spacing_mut().item_spacing.x = 2.0; // Small spacing between button groups
|
ui.spacing_mut().item_spacing.x = 2.0; // Small spacing between button groups
|
||||||
|
|
||||||
// Main playback controls group
|
// Main playback controls group
|
||||||
|
|
@ -4749,7 +4820,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metronome toggle — only visible in Measures mode
|
// Metronome toggle — only visible in Measures mode
|
||||||
if self.time_display_format == TimeDisplayFormat::Measures {
|
if self.time_display_format == TimelineMode::Measures {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|
||||||
let metro_tint = if *shared.metronome_enabled {
|
let metro_tint = if *shared.metronome_enabled {
|
||||||
|
|
@ -4804,6 +4875,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -4822,10 +4894,10 @@ impl PaneRenderer for TimelinePane {
|
||||||
};
|
};
|
||||||
|
|
||||||
match self.time_display_format {
|
match self.time_display_format {
|
||||||
TimeDisplayFormat::Seconds => {
|
TimelineMode::Seconds => {
|
||||||
ui.colored_label(text_color, format!("Time: {:.2}s / {:.2}s", *shared.playback_time, self.duration));
|
ui.colored_label(text_color, format!("Time: {:.2}s / {:.2}s", *shared.playback_time, self.duration));
|
||||||
}
|
}
|
||||||
TimeDisplayFormat::Measures => {
|
TimelineMode::Measures => {
|
||||||
let time_sig = lightningbeam_core::document::TimeSignature { numerator: time_sig_num, denominator: time_sig_den };
|
let time_sig = lightningbeam_core::document::TimeSignature { numerator: time_sig_num, denominator: time_sig_den };
|
||||||
let pos = lightningbeam_core::beat_time::time_to_measure(
|
let pos = lightningbeam_core::beat_time::time_to_measure(
|
||||||
*shared.playback_time, bpm, &time_sig,
|
*shared.playback_time, bpm, &time_sig,
|
||||||
|
|
@ -4836,7 +4908,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
time_sig_num, time_sig_den,
|
time_sig_num, time_sig_den,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
TimeDisplayFormat::Frames => {
|
TimelineMode::Frames => {
|
||||||
let current_frame = (*shared.playback_time * framerate).floor() as i64 + 1;
|
let current_frame = (*shared.playback_time * framerate).floor() as i64 + 1;
|
||||||
let total_frames = (self.duration * framerate).ceil() as i64;
|
let total_frames = (self.duration * framerate).ceil() as i64;
|
||||||
ui.colored_label(text_color, format!(
|
ui.colored_label(text_color, format!(
|
||||||
|
|
@ -4855,16 +4927,18 @@ impl PaneRenderer for TimelinePane {
|
||||||
// Time display format toggle
|
// Time display format toggle
|
||||||
egui::ComboBox::from_id_salt("time_format")
|
egui::ComboBox::from_id_salt("time_format")
|
||||||
.selected_text(match self.time_display_format {
|
.selected_text(match self.time_display_format {
|
||||||
TimeDisplayFormat::Seconds => "Seconds",
|
TimelineMode::Seconds => "Seconds",
|
||||||
TimeDisplayFormat::Measures => "Measures",
|
TimelineMode::Measures => "Measures",
|
||||||
TimeDisplayFormat::Frames => "Frames",
|
TimelineMode::Frames => "Frames",
|
||||||
})
|
})
|
||||||
.width(80.0)
|
.width(80.0)
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Seconds, "Seconds");
|
ui.selectable_value(&mut self.time_display_format, TimelineMode::Seconds, "Seconds");
|
||||||
ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Measures, "Measures");
|
ui.selectable_value(&mut self.time_display_format, TimelineMode::Measures, "Measures");
|
||||||
ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Frames, "Frames");
|
ui.selectable_value(&mut self.time_display_format, TimelineMode::Frames, "Frames");
|
||||||
});
|
});
|
||||||
|
// Write change back to document so it persists and is the source of truth
|
||||||
|
shared.action_executor.document_mut().timeline_mode = self.time_display_format;
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue