From 121fa3a50a3d8fd1ea50fc9b58a57985100fecf1 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 20 Mar 2026 20:51:50 -0400 Subject: [PATCH] Add count-in --- daw-backend/src/audio/engine.rs | 46 ++++++++--- daw-backend/src/audio/metronome.rs | 11 +-- daw-backend/src/audio/recording.rs | 31 +++++--- .../lightningbeam-editor/src/keymap.rs | 1 + .../lightningbeam-editor/src/main.rs | 28 +++++++ .../lightningbeam-editor/src/menu.rs | 30 +++++-- .../lightningbeam-editor/src/panes/mod.rs | 1 + .../src/panes/timeline.rs | 79 ++++++++++++++++++- 8 files changed, 189 insertions(+), 38 deletions(-) diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 2ae9454..810c103 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -27,7 +27,7 @@ pub struct Engine { project: Project, audio_pool: AudioClipPool, 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, playing: bool, channels: u32, @@ -74,6 +74,10 @@ pub struct Engine { // MIDI recording state midi_recording_state: Option, + // 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>, + // MIDI input manager for external MIDI devices midi_input_manager: Option, @@ -160,6 +164,7 @@ impl Engine { recording_mirror_tx: None, recording_progress_counter: 0, midi_recording_state: None, + midi_held_notes: HashMap::new(), midi_input_manager: None, metronome: Metronome::new(sample_rate), recording_sample_buffer: Vec::with_capacity(4096), @@ -396,17 +401,18 @@ impl Engine { ); // 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 - .store(self.playhead, Ordering::Relaxed); + .store(self.playhead.max(0) as u64, Ordering::Relaxed); // Send periodic position updates 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 { - 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 .event_tx .push(AudioEvent::PlaybackPosition(position_seconds)); @@ -692,17 +698,17 @@ impl Engine { self.project.stop_all_notes(); } Command::Seek(seconds) => { - let frames = (seconds * self.sample_rate as f64) as u64; - self.playhead = frames; - self.playhead_atomic - .store(self.playhead, Ordering::Relaxed); + self.playhead = (seconds * self.sample_rate as f64) as i64; + // Clamp to 0 for atomic/disk-reader; negative = count-in pre-roll (no disk reads needed) + let clamped = self.playhead.max(0) as u64; + self.playhead_atomic.store(clamped, Ordering::Relaxed); // Stop all MIDI notes when seeking to prevent stuck notes self.project.stop_all_notes(); // Reset all node graphs to clear effect buffers (echo, reverb, etc.) self.project.reset_all_graphs(); // Notify disk reader to refill buffers from new position 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) => { @@ -1212,6 +1218,9 @@ impl Engine { // Emit event to UI for visual feedback 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 let Some(recording) = &mut self.midi_recording_state { if recording.track_id == track_id { @@ -1230,6 +1239,11 @@ impl Engine { // Emit event to UI for visual feedback 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 let Some(recording) = &mut self.midi_recording_state { if recording.track_id == track_id { @@ -3038,7 +3052,17 @@ impl Engine { // Check if track exists and is a MIDI track if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) { // 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); eprintln!("[MIDI_RECORDING] Started MIDI recording on track {} for clip {}", track_id, clip_id); diff --git a/daw-backend/src/audio/metronome.rs b/daw-backend/src/audio/metronome.rs index beacf0f..f3b8b7c 100644 --- a/daw-backend/src/audio/metronome.rs +++ b/daw-backend/src/audio/metronome.rs @@ -107,7 +107,7 @@ impl Metronome { pub fn process( &mut self, output: &mut [f32], - playhead_samples: u64, + playhead_samples: i64, playing: bool, sample_rate: u32, channels: u32, @@ -120,20 +120,21 @@ impl Metronome { let frames = output.len() / channels as usize; 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 let current_time_seconds = current_sample as f64 / sample_rate as f64; let beats_per_second = self.bpm as f64 / 60.0; let current_beat = (current_time_seconds * beats_per_second).floor() as i64; - // Check if we crossed a beat boundary - if current_beat != self.last_beat && current_beat >= 0 { + // Check if we crossed a beat boundary (including negative beats during count-in pre-roll) + if current_beat != self.last_beat { self.last_beat = current_beat; // Determine which click to play. // 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.click_position = 0; // Start from beginning of click } diff --git a/daw-backend/src/audio/recording.rs b/daw-backend/src/audio/recording.rs index 8dd1f3f..392da27 100644 --- a/daw-backend/src/audio/recording.rs +++ b/daw-backend/src/audio/recording.rs @@ -213,7 +213,6 @@ impl MidiRecordingState { /// Handle a MIDI note on event pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: f64) { - // Store this note as active self.active_notes.insert(note, ActiveMidiNote { note, velocity, @@ -225,14 +224,21 @@ impl MidiRecordingState { pub fn note_off(&mut self, note: u8, absolute_time: f64) { // Find the matching noteOn if let Some(active_note) = self.active_notes.remove(¬e) { - // Calculate relative time offset and duration - let time_offset = active_note.start_time - self.start_time; - let duration = absolute_time - active_note.start_time; + // If the note was fully released before the recording start (e.g. during count-in + // pre-roll), discard it — only notes still held at the clip start are kept. + 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", - 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(( time_offset, active_note.note, @@ -258,8 +264,9 @@ impl MidiRecordingState { pub fn get_notes_with_active(&self, current_time: f64) -> Vec<(f64, u8, u8, f64)> { let mut notes = self.completed_notes.clone(); for active in self.active_notes.values() { - let time_offset = active.start_time - self.start_time; - let provisional_dur = (current_time - active.start_time).max(0.0); + let note_start = active.start_time.max(self.start_time); + 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 @@ -273,15 +280,13 @@ impl MidiRecordingState { /// Close out all active notes at the given time /// This should be called when stopping recording to end any held notes 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(); for (_note_num, active_note) in active_notes { - // Calculate relative time offset and duration - let time_offset = active_note.start_time - self.start_time; - let duration = end_time - active_note.start_time; + let note_start = active_note.start_time.max(self.start_time); + let time_offset = note_start - self.start_time; + let duration = end_time - note_start; - // Add to completed notes self.completed_notes.push(( time_offset, active_note.note, diff --git a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs index 3f7f147..220e8ef 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs @@ -339,6 +339,7 @@ impl From for AppAction { MenuAction::AddShapeTween => Self::AddShapeTween, MenuAction::ReturnToStart => Self::ReturnToStart, MenuAction::Play => Self::Play, + MenuAction::ToggleCountIn => Self::Play, // not directly mappable to AppAction MenuAction::ZoomIn => Self::ZoomIn, MenuAction::ZoomOut => Self::ZoomOut, MenuAction::ActualSize => Self::ActualSize, diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 9681548..044b6d4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -856,6 +856,7 @@ struct EditorApp { armed_layers: HashSet, is_recording: bool, // Whether recording is currently active metronome_enabled: bool, // Whether metronome clicks during recording + count_in_enabled: bool, // Whether count-in fires before recording recording_clips: HashMap, // layer_id -> backend clip_id during recording recording_start_time: f64, // Playback time when recording started recording_layer_ids: Vec, // Layers being recorded to (for creating clips) @@ -1128,6 +1129,7 @@ impl EditorApp { armed_layers: HashSet::new(), // No layers explicitly armed is_recording: false, // Not recording initially 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_start_time: 0.0, // Will be set when recording starts recording_layer_ids: Vec::new(), // Will be populated when recording starts @@ -3577,6 +3579,12 @@ impl EditorApp { println!("Menu: Play"); // 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 MenuAction::ZoomIn => { @@ -5632,9 +5640,28 @@ impl eframe::App for EditorApp { if let Some(menu_system) = &self.menu_system { let recent_files = self.config.get_recent_files(); let layout_names: Vec = 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( ui, &recent_files, Some(&self.keymap), &layout_names, self.current_layout_index, + checked, hidden, ) { self.handle_menu_action(action); } @@ -5774,6 +5801,7 @@ impl eframe::App for EditorApp { is_playing: &mut self.is_playing, is_recording: &mut self.is_recording, metronome_enabled: &mut self.metronome_enabled, + count_in_enabled: &mut self.count_in_enabled, recording_clips: &mut self.recording_clips, recording_start_time: &mut self.recording_start_time, recording_layer_ids: &mut self.recording_layer_ids, diff --git a/lightningbeam-ui/lightningbeam-editor/src/menu.rs b/lightningbeam-ui/lightningbeam-editor/src/menu.rs index 0b27eb5..840ce4e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/menu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/menu.rs @@ -324,6 +324,7 @@ pub enum MenuAction { AddShapeTween, ReturnToStart, Play, + ToggleCountIn, // View menu ZoomIn, @@ -422,6 +423,7 @@ impl MenuItemDef { 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 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 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::Item(&Self::RETURN_TO_START), MenuDef::Item(&Self::PLAY), + MenuDef::Separator, + MenuDef::Item(&Self::COUNT_IN), ], }, // View menu @@ -805,6 +809,8 @@ impl MenuSystem { keymap: Option<&crate::keymap::KeymapManager>, layout_names: &[String], current_layout_index: usize, + checked_actions: &[MenuAction], + hidden_actions: &[MenuAction], ) -> Option { let mut action = None; let ctx = ui.ctx().clone(); @@ -819,7 +825,7 @@ impl MenuSystem { let response = ui.button(*label); let popup_id = egui::Popup::default_response_id(&response); 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); } } @@ -847,7 +853,7 @@ impl MenuSystem { ui.set_width(min_width); let mut a = None; 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); ui.close(); } @@ -875,10 +881,15 @@ impl MenuSystem { keymap: Option<&crate::keymap::KeymapManager>, layout_names: &[String], current_layout_index: usize, + checked_actions: &[MenuAction], + hidden_actions: &[MenuAction], ) -> Option { match 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) } else { None @@ -914,7 +925,7 @@ impl MenuSystem { } else if *label == "Layout" { let mut action = None; 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); ui.close(); } @@ -937,7 +948,7 @@ impl MenuSystem { } else { let mut action = None; 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); ui.close(); } @@ -951,7 +962,7 @@ impl MenuSystem { } /// 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 let effective_shortcut = if let Some(km) = keymap { if let Ok(app_action) = crate::keymap::AppAction::try_from(def.action) { @@ -987,10 +998,15 @@ impl MenuSystem { ui.visuals().widgets.inactive.text_color() }; 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( label_pos, egui::Align2::LEFT_TOP, - def.label, + label, egui::FontId::proportional(14.0), text_color, ); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 34947a4..027a839 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -213,6 +213,7 @@ pub struct SharedPaneState<'a> { /// Recording state pub is_recording: &'a mut bool, // Whether recording is currently active 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, // layer_id -> clip_id pub recording_start_time: &'a mut f64, // Playback time when recording started pub recording_layer_ids: &'a mut Vec, // Layers being recorded to diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 3a6379d..2b73e76 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -223,6 +223,16 @@ pub struct TimelinePane { automation_topology_generation: u64, /// Cached metronome icon texture (loaded on first use) metronome_icon: Option, + /// Count-in pre-roll state: set when count-in is active, cleared when recording fires + pending_recording_start: Option, +} + +/// 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 @@ -675,9 +685,15 @@ impl TimelinePane { automation_cache_generation: u64::MAX, automation_topology_generation: u64::MAX, 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 /// 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) @@ -910,6 +926,37 @@ impl TimelinePane { }); 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(); // Step 4: Dispatch recording for each candidate @@ -986,8 +1033,8 @@ impl TimelinePane { return; } - // Auto-start playback if needed, and enable metronome if requested. - // Metronome must be enabled BEFORE play() so beat 0 is not missed. + // No count-in (or deferred re-call from check_pending_recording_start): + // Start immediately. Metronome must be enabled BEFORE play() so beat 0 is not missed. if let Some(controller_arc) = shared.audio_controller { let mut controller = controller_arc.lock().unwrap(); if *shared.metronome_enabled { @@ -1005,7 +1052,31 @@ impl TimelinePane { } /// 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) { + // Cancel any in-progress count-in + self.pending_recording_start = None; + let stop_wall = std::time::Instant::now(); eprintln!("[STOP] stop_recording called at {:?}", stop_wall); @@ -4652,6 +4723,9 @@ impl TimelinePane { impl PaneRenderer for TimelinePane { 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 // Main playback controls group @@ -4804,6 +4878,7 @@ impl PaneRenderer for TimelinePane { } } } + } }); });