Add count-in

This commit is contained in:
Skyler Lehmkuhl 2026-03-20 20:51:50 -04:00
parent c938ea44b0
commit 121fa3a50a
8 changed files with 189 additions and 38 deletions

View File

@ -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(&note);
}
// 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 (&note, &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);

View File

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

View File

@ -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(&note) { if let Some(active_note) = self.active_notes.remove(&note) {
// 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,

View File

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

View File

@ -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
@ -3577,6 +3579,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 +5640,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 +5801,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,

View File

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

View File

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

View File

@ -223,6 +223,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
@ -675,9 +685,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 == TimeDisplayFormat::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 +926,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 == TimeDisplayFormat::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 +1033,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 +1052,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);
@ -4652,6 +4723,9 @@ 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 {
// 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
@ -4804,6 +4878,7 @@ impl PaneRenderer for TimelinePane {
} }
} }
} }
} }
}); });
}); });