From c938ea44b0dd11b8e0eec116b841afccf746638d Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 19 Mar 2026 01:16:26 -0400 Subject: [PATCH] Add metronome --- daw-backend/src/audio/metronome.rs | 24 ++---- .../lightningbeam-editor/src/main.rs | 5 +- .../lightningbeam-editor/src/panes/mod.rs | 1 + .../src/panes/timeline.rs | 75 ++++++++++++++++++- 4 files changed, 84 insertions(+), 21 deletions(-) diff --git a/daw-backend/src/audio/metronome.rs b/daw-backend/src/audio/metronome.rs index 4612cf4..beacf0f 100644 --- a/daw-backend/src/audio/metronome.rs +++ b/daw-backend/src/audio/metronome.rs @@ -90,8 +90,9 @@ impl Metronome { self.last_beat = -1; // Reset beat tracking when disabled self.click_position = 0; // Stop any playing click } else { - // When enabling, don't trigger a click until the next beat - self.click_position = usize::MAX; // Set to max to prevent immediate click + // Reset beat tracking so the next beat boundary (including beat 0) fires a click + self.last_beat = -1; + self.click_position = self.high_click.len(); // Idle (past end, nothing playing) } } @@ -130,20 +131,11 @@ impl Metronome { if current_beat != self.last_beat && current_beat >= 0 { self.last_beat = current_beat; - // Only trigger a click if we're not in the "just enabled" state - if self.click_position != usize::MAX { - // Determine which click to play - // Beat 1 of each measure gets the accent (high click) - let beat_in_measure = (current_beat as u32 % self.time_signature_numerator) as usize; - let is_first_beat = beat_in_measure == 0; - - // Start playing the appropriate click - self.playing_high_click = is_first_beat; - self.click_position = 0; // Start from beginning of click - } else { - // We just got enabled - reset position but don't play yet - self.click_position = self.high_click.len(); // Set past end so no click plays - } + // 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; + self.playing_high_click = beat_in_measure == 0; + self.click_position = 0; // Start from beginning of click } // Continue playing click sample if we're currently in one diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 6fa9291..9681548 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -296,7 +296,7 @@ enum SplitPreviewMode { } /// Rasterize an embedded SVG and upload it as an egui texture -fn rasterize_svg(svg_data: &[u8], name: &str, render_size: u32, ctx: &egui::Context) -> Option { +pub(crate) fn rasterize_svg(svg_data: &[u8], name: &str, render_size: u32, ctx: &egui::Context) -> Option { let tree = resvg::usvg::Tree::from_data(svg_data, &resvg::usvg::Options::default()).ok()?; let pixmap_size = tree.size().to_int_size(); let scale_x = render_size as f32 / pixmap_size.width() as f32; @@ -855,6 +855,7 @@ struct EditorApp { #[allow(dead_code)] armed_layers: HashSet, is_recording: bool, // Whether recording is currently active + metronome_enabled: bool, // Whether metronome clicks during 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) @@ -1126,6 +1127,7 @@ impl EditorApp { recording_arm_mode: RecordingArmMode::default(), // Auto mode by default armed_layers: HashSet::new(), // No layers explicitly armed is_recording: false, // Not recording initially + metronome_enabled: false, // Metronome 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 @@ -5771,6 +5773,7 @@ impl eframe::App for EditorApp { playback_time: &mut self.playback_time, is_playing: &mut self.is_playing, is_recording: &mut self.is_recording, + metronome_enabled: &mut self.metronome_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/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index bcfff75..34947a4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -212,6 +212,7 @@ pub struct SharedPaneState<'a> { pub is_playing: &'a mut bool, // Whether playback is currently active /// 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 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 11df37b..3a6379d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -221,6 +221,8 @@ pub struct TimelinePane { automation_cache_generation: u64, /// Last seen graph_topology_generation; used to detect node additions/removals automation_topology_generation: u64, + /// Cached metronome icon texture (loaded on first use) + metronome_icon: Option, } /// Check if a clip type can be dropped on a layer type @@ -672,6 +674,7 @@ impl TimelinePane { pending_automation_actions: Vec::new(), automation_cache_generation: u64::MAX, automation_topology_generation: u64::MAX, + metronome_icon: None, } } @@ -983,10 +986,14 @@ impl TimelinePane { return; } - // Auto-start playback if needed - if !*shared.is_playing { - if let Some(controller_arc) = shared.audio_controller { - let mut controller = controller_arc.lock().unwrap(); + // Auto-start playback if needed, and enable metronome if requested. + // 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 { + controller.set_metronome_enabled(true); + } + if !*shared.is_playing { controller.play(); *shared.is_playing = true; println!("▶ Auto-started playback for recording"); @@ -1042,6 +1049,8 @@ impl TimelinePane { controller.stop_recording(); eprintln!("[STOP] Audio stop command sent at +{:.1}ms", stop_wall.elapsed().as_secs_f64() * 1000.0); } + // Always disable metronome on recording stop + controller.set_metronome_enabled(false); } // Note: Don't clear recording_layer_ids here! @@ -4738,6 +4747,64 @@ impl PaneRenderer for TimelinePane { if *shared.is_recording { ui.ctx().request_repaint(); } + + // Metronome toggle — only visible in Measures mode + if self.time_display_format == TimeDisplayFormat::Measures { + ui.add_space(4.0); + + let metro_tint = if *shared.metronome_enabled { + egui::Color32::from_rgb(100, 180, 255) + } else { + ui.visuals().text_color() + }; + + // Lazy-load the metronome SVG icon + if self.metronome_icon.is_none() { + const METRONOME_SVG: &[u8] = include_bytes!("../../../../src/assets/metronome.svg"); + self.metronome_icon = crate::rasterize_svg(METRONOME_SVG, "metronome_icon", 64, ui.ctx()); + } + + let metro_response = if let Some(icon) = &self.metronome_icon { + let (rect, response) = ui.allocate_exact_size(button_size, egui::Sense::click()); + let bg = if *shared.metronome_enabled { + egui::Color32::from_rgba_unmultiplied(100, 180, 255, 60) + } else if response.hovered() { + ui.visuals().widgets.hovered.bg_fill + } else { + egui::Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, 4.0, bg); + ui.painter().image( + icon.id(), + rect.shrink(4.0), + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + metro_tint, + ); + response + } else { + // Fallback if SVG failed to load + ui.add_sized(button_size, egui::Button::new( + egui::RichText::new("♩").color(metro_tint).size(16.0) + )) + }; + + let metro_response = metro_response.on_hover_text(if *shared.metronome_enabled { + "Disable metronome" + } else { + "Enable metronome" + }); + + if metro_response.clicked() { + *shared.metronome_enabled = !*shared.metronome_enabled; + // Sync live state if already recording + if *shared.is_recording { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.set_metronome_enabled(*shared.metronome_enabled); + } + } + } + } }); });