Add metronome

This commit is contained in:
Skyler Lehmkuhl 2026-03-19 01:16:26 -04:00
parent 84a1a98452
commit c938ea44b0
4 changed files with 84 additions and 21 deletions

View File

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

View File

@ -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<egui::TextureHandle> {
pub(crate) fn rasterize_svg(svg_data: &[u8], name: &str, render_size: u32, ctx: &egui::Context) -> Option<egui::TextureHandle> {
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<Uuid>,
is_recording: bool, // Whether recording is currently active
metronome_enabled: bool, // Whether metronome clicks during recording
recording_clips: HashMap<Uuid, u32>, // layer_id -> backend clip_id during recording
recording_start_time: f64, // Playback time when recording started
recording_layer_ids: Vec<Uuid>, // 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,

View File

@ -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<uuid::Uuid, u32>, // layer_id -> clip_id
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

View File

@ -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<egui::TextureHandle>,
}
/// 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);
}
}
}
}
});
});