From eab116c930b9ba59c3744d88e4e0cb2531592fe1 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 22 Feb 2026 18:43:17 -0500 Subject: [PATCH] Add beat mode --- daw-backend/src/audio/engine.rs | 10 + daw-backend/src/audio/node_graph/graph.rs | 14 + .../src/audio/node_graph/nodes/beat.rs | 19 +- daw-backend/src/audio/project.rs | 11 + daw-backend/src/command/types.rs | 2 + .../lightningbeam-core/src/beat_time.rs | 44 +++ .../lightningbeam-core/src/document.rs | 25 ++ .../lightningbeam-core/src/lib.rs | 1 + .../lightningbeam-editor/src/main.rs | 7 + .../src/panes/piano_roll.rs | 77 +++-- .../src/panes/timeline.rs | 297 +++++++++++++----- 11 files changed, 402 insertions(+), 105 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/beat_time.rs diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 12e8dcc..b73adcb 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1136,6 +1136,11 @@ impl Engine { self.metronome.set_enabled(enabled); } + Command::SetTempo(bpm, time_sig) => { + self.metronome.update_timing(bpm, time_sig); + self.project.set_tempo(bpm, time_sig.0); + } + // Node graph commands Command::GraphAddNode(track_id, node_type, x, y) => { eprintln!("[DEBUG] GraphAddNode received: track_id={}, node_type={}, x={}, y={}", track_id, node_type, x, y); @@ -3197,6 +3202,11 @@ impl EngineController { let _ = self.command_tx.push(Command::SetMetronomeEnabled(enabled)); } + /// Set project tempo (BPM) and time signature + pub fn set_tempo(&mut self, bpm: f32, time_signature: (u32, u32)) { + let _ = self.command_tx.push(Command::SetTempo(bpm, time_signature)); + } + // Node graph operations /// Add a node to a track's instrument graph diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs index 0a77303..51df5d8 100644 --- a/daw-backend/src/audio/node_graph/graph.rs +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -96,6 +96,11 @@ pub struct AudioGraph { /// Current playback time (for automation nodes) playback_time: f64, + /// Project tempo (synced from Engine via SetTempo) + bpm: f32, + /// Beats per bar (time signature numerator) + beats_per_bar: u32, + /// Cached topological sort order (invalidated on graph mutation) topo_cache: Option>, @@ -119,11 +124,19 @@ impl AudioGraph { midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(), node_positions: std::collections::HashMap::new(), playback_time: 0.0, + bpm: 120.0, + beats_per_bar: 4, topo_cache: None, frontend_groups: Vec::new(), } } + /// Set the project tempo and time signature for BeatNodes + pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) { + self.bpm = bpm; + self.beats_per_bar = beats_per_bar; + } + /// Add a node to the graph pub fn add_node(&mut self, node: Box) -> NodeIndex { let graph_node = GraphNode::new(node, self.buffer_size); @@ -452,6 +465,7 @@ impl AudioGraph { auto_node.set_playback_time(playback_time); } else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::() { beat_node.set_playback_time(playback_time); + beat_node.set_tempo(self.bpm, self.beats_per_bar); } } diff --git a/daw-backend/src/audio/node_graph/nodes/beat.rs b/daw-backend/src/audio/node_graph/nodes/beat.rs index 0bd368b..6b0113d 100644 --- a/daw-backend/src/audio/node_graph/nodes/beat.rs +++ b/daw-backend/src/audio/node_graph/nodes/beat.rs @@ -3,8 +3,8 @@ use crate::audio::midi::MidiEvent; const PARAM_RESOLUTION: u32 = 0; -/// Hardcoded BPM until project tempo is implemented const DEFAULT_BPM: f32 = 120.0; +const DEFAULT_BEATS_PER_BAR: u32 = 4; #[derive(Debug, Clone, Copy, PartialEq)] pub enum BeatResolution { @@ -47,17 +47,19 @@ impl BeatResolution { /// Beat clock node — generates tempo-synced CV signals. /// +/// BPM and time signature are synced from the project document via SetTempo. /// When playing: synced to timeline position. -/// When stopped: free-runs continuously at the set BPM. +/// When stopped: free-runs continuously at the project BPM. /// /// Outputs: /// - BPM: constant CV proportional to tempo (bpm / 240) /// - Beat Phase: sawtooth 0→1 per beat subdivision -/// - Bar Phase: sawtooth 0→1 per bar (4 beats) +/// - Bar Phase: sawtooth 0→1 per bar (uses project time signature) /// - Gate: 1.0 for first half of each subdivision, 0.0 otherwise pub struct BeatNode { name: String, bpm: f32, + beats_per_bar: u32, resolution: BeatResolution, /// Playback time in seconds, set by the graph before process() playback_time: f64, @@ -88,6 +90,7 @@ impl BeatNode { Self { name: name.into(), bpm: DEFAULT_BPM, + beats_per_bar: DEFAULT_BEATS_PER_BAR, resolution: BeatResolution::Quarter, playback_time: 0.0, prev_playback_time: -1.0, @@ -101,6 +104,11 @@ impl BeatNode { pub fn set_playback_time(&mut self, time: f64) { self.playback_time = time; } + + pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) { + self.bpm = bpm; + self.beats_per_bar = beats_per_bar; + } } impl AudioNode for BeatNode { @@ -167,8 +175,8 @@ impl AudioNode for BeatNode { // Beat subdivision phase: 0→1 sawtooth let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32; - // Bar phase: 0→1 over 4 quarter-note beats - let bar_phase = ((beat_pos / 4.0) % 1.0) as f32; + // Bar phase: 0→1 over one bar (beats_per_bar beats) + let bar_phase = ((beat_pos / self.beats_per_bar as f64) % 1.0) as f32; // Gate: high for first half of each subdivision let gate = if sub_phase < 0.5 { 1.0f32 } else { 0.0 }; @@ -201,6 +209,7 @@ impl AudioNode for BeatNode { Box::new(Self { name: self.name.clone(), bpm: self.bpm, + beats_per_bar: self.beats_per_bar, resolution: self.resolution, playback_time: 0.0, prev_playback_time: -1.0, diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index ce497e1..f838c64 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -569,6 +569,17 @@ impl Project { } } + /// Propagate tempo to all audio graphs (for BeatNode sync) + pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) { + for track in self.tracks.values_mut() { + match track { + TrackNode::Audio(t) => t.effects_graph.set_tempo(bpm, beats_per_bar), + TrackNode::Midi(t) => t.instrument_graph.set_tempo(bpm, beats_per_bar), + TrackNode::Group(g) => g.audio_graph.set_tempo(bpm, beats_per_bar), + } + } + } + /// Process live MIDI input from all MIDI tracks (called even when not playing) pub fn process_live_midi(&mut self, output: &mut [f32], sample_rate: u32, channels: u32) { // Process all MIDI tracks to handle queued live input events diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 698bd65..855f0bb 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -138,6 +138,8 @@ pub enum Command { // Metronome command /// Enable or disable the metronome click track SetMetronomeEnabled(bool), + /// Set project tempo and time signature (bpm, (numerator, denominator)) + SetTempo(f32, (u32, u32)), // Node graph commands /// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y) diff --git a/lightningbeam-ui/lightningbeam-core/src/beat_time.rs b/lightningbeam-ui/lightningbeam-core/src/beat_time.rs new file mode 100644 index 0000000..52b7cdd --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/beat_time.rs @@ -0,0 +1,44 @@ +//! Beat/measure ↔ seconds conversion utilities + +use crate::document::TimeSignature; + +/// Position expressed as measure, beat, tick +#[derive(Debug, Clone, Copy)] +pub struct MeasurePosition { + pub measure: u32, // 1-indexed + pub beat: u32, // 1-indexed + pub tick: u32, // 0-999 (subdivision of beat) +} + +/// Convert a time in seconds to a measure position +pub fn time_to_measure(time: f64, bpm: f64, time_sig: &TimeSignature) -> MeasurePosition { + let beats_per_second = bpm / 60.0; + let total_beats = (time * beats_per_second).max(0.0); + let beats_per_measure = time_sig.numerator as f64; + + let measure = (total_beats / beats_per_measure).floor() as u32 + 1; + let beat = (total_beats.rem_euclid(beats_per_measure)).floor() as u32 + 1; + let tick = ((total_beats.rem_euclid(1.0)) * 1000.0).floor() as u32; + + MeasurePosition { measure, beat, tick } +} + +/// Convert a measure position to seconds +pub fn measure_to_time(pos: MeasurePosition, bpm: f64, time_sig: &TimeSignature) -> f64 { + let beats_per_measure = time_sig.numerator as f64; + let total_beats = (pos.measure as f64 - 1.0) * beats_per_measure + + (pos.beat as f64 - 1.0) + + (pos.tick as f64 / 1000.0); + let beats_per_second = bpm / 60.0; + total_beats / beats_per_second +} + +/// Get the duration of one beat in seconds +pub fn beat_duration(bpm: f64) -> f64 { + 60.0 / bpm +} + +/// Get the duration of one measure in seconds +pub fn measure_duration(bpm: f64, time_sig: &TimeSignature) -> f64 { + beat_duration(bpm) * time_sig.numerator as f64 +} diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 16c15f6..fe621c3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -70,6 +70,21 @@ impl Default for GraphicsObject { } } +/// Musical time signature +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TimeSignature { + pub numerator: u32, // beats per measure (e.g., 4) + pub denominator: u32, // beat unit (e.g., 4 = quarter note) +} + +impl Default for TimeSignature { + fn default() -> Self { + Self { numerator: 4, denominator: 4 } + } +} + +fn default_bpm() -> f64 { 120.0 } + /// Asset category for folder tree access #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssetCategory { @@ -101,6 +116,14 @@ pub struct Document { /// Framerate (frames per second) pub framerate: f64, + /// Tempo in beats per minute + #[serde(default = "default_bpm")] + pub bpm: f64, + + /// Time signature + #[serde(default)] + pub time_signature: TimeSignature, + /// Duration in seconds pub duration: f64, @@ -182,6 +205,8 @@ impl Default for Document { width: 1920.0, height: 1080.0, framerate: 60.0, + bpm: 120.0, + time_signature: TimeSignature::default(), duration: 10.0, root: GraphicsObject::default(), vector_clips: HashMap::new(), diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index c06c9d7..f4a4a43 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -1,6 +1,7 @@ // Lightningbeam Core Library // Shared data structures and types +pub mod beat_time; pub mod gpu; pub mod layout; pub mod pane; diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 8f35980..cfbd34c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -2940,6 +2940,13 @@ impl EditorApp { return; } eprintln!("📊 [APPLY] Step 4: Set audio project took {:.2}ms", step4_start.elapsed().as_secs_f64() * 1000.0); + + // Sync BPM/time signature to metronome + let doc = self.action_executor.document(); + controller.set_tempo( + doc.bpm as f32, + (doc.time_signature.numerator, doc.time_signature.denominator), + ); } // Reset state and restore track mappings diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 5012de9..5d82e31 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -205,16 +205,23 @@ impl PianoRollPane { // ── Ruler interval calculation ─────────────────────────────────────── - fn ruler_interval(&self) -> f64 { + fn ruler_interval(&self, bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) -> f64 { let min_pixel_gap = 80.0; - let min_seconds = min_pixel_gap / self.pixels_per_second; - let intervals = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0]; - for &interval in &intervals { - if interval >= min_seconds as f64 { + let min_seconds = (min_pixel_gap / self.pixels_per_second) as f64; + + // Use beat-aligned intervals + let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm); + let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig); + let beat_intervals = [ + beat_dur / 4.0, beat_dur / 2.0, beat_dur, beat_dur * 2.0, + measure_dur, measure_dur * 2.0, measure_dur * 4.0, + ]; + for &interval in &beat_intervals { + if interval >= min_seconds { return interval; } } - 60.0 + measure_dur * 4.0 } // ── MIDI mode rendering ────────────────────────────────────────────── @@ -287,7 +294,11 @@ impl PianoRollPane { // Render grid (clipped to grid area) let grid_painter = ui.painter_at(grid_rect); - self.render_grid(&grid_painter, grid_rect); + let (grid_bpm, grid_time_sig) = { + let doc = shared.action_executor.document(); + (doc.bpm, doc.time_signature.clone()) + }; + self.render_grid(&grid_painter, grid_rect, grid_bpm, &grid_time_sig); // Render clip boundaries and notes for &(midi_clip_id, timeline_start, trim_start, duration, _instance_id) in &clip_data { @@ -419,7 +430,8 @@ impl PianoRollPane { ); } - fn render_grid(&self, painter: &egui::Painter, grid_rect: Rect) { + fn render_grid(&self, painter: &egui::Painter, grid_rect: Rect, + bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) { // Horizontal lines (note separators) for note in MIN_NOTE..=MAX_NOTE { let y = self.note_to_y(note, grid_rect); @@ -445,8 +457,11 @@ impl PianoRollPane { ); } - // Vertical lines (time grid) - let interval = self.ruler_interval(); + // Vertical lines (beat-aligned time grid) + let interval = self.ruler_interval(bpm, time_sig); + let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm); + let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig); + let start = (self.viewport_start_time / interval).floor() as i64; let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64; let end = (end_time / interval).ceil() as i64; @@ -458,27 +473,36 @@ impl PianoRollPane { continue; } - let is_major = (i % 4 == 0) || interval >= 1.0; - let alpha = if is_major { 50 } else { 20 }; + // Determine tick importance: measure boundary > beat > subdivision + let is_measure = (time / measure_dur).fract().abs() < 1e-9 || (time / measure_dur).fract() > 1.0 - 1e-9; + let is_beat = (time / beat_dur).fract().abs() < 1e-9 || (time / beat_dur).fract() > 1.0 - 1e-9; + let alpha = if is_measure { 60 } else if is_beat { 35 } else { 20 }; + painter.line_segment( [pos2(x, grid_rect.min.y), pos2(x, grid_rect.max.y)], Stroke::new(1.0, Color32::from_white_alpha(alpha)), ); - // Time labels at major lines - if is_major && x > grid_rect.min.x + 20.0 { - let label = if time >= 60.0 { - format!("{}:{:05.2}", (time / 60.0) as u32, time % 60.0) - } else { - format!("{:.2}s", time) - }; + // Labels at measure boundaries + if is_measure && x > grid_rect.min.x + 20.0 { + let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig); painter.text( pos2(x + 2.0, grid_rect.min.y + 2.0), Align2::LEFT_TOP, - label, + format!("{}", pos.measure), FontId::proportional(9.0), Color32::from_white_alpha(80), ); + } else if is_beat && !is_measure && x > grid_rect.min.x + 20.0 + && beat_dur as f32 * self.pixels_per_second > 40.0 { + let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig); + painter.text( + pos2(x + 2.0, grid_rect.min.y + 2.0), + Align2::LEFT_TOP, + format!("{}.{}", pos.measure, pos.beat), + FontId::proportional(9.0), + Color32::from_white_alpha(50), + ); } } } @@ -578,9 +602,10 @@ impl PianoRollPane { ); } - fn render_dot_grid(&self, painter: &egui::Painter, grid_rect: Rect) { + fn render_dot_grid(&self, painter: &egui::Painter, grid_rect: Rect, + bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) { // Collect visible time grid positions - let interval = self.ruler_interval(); + let interval = self.ruler_interval(bpm, time_sig); let start = (self.viewport_start_time / interval).floor() as i64; let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64; let end = (end_time / interval).ceil() as i64; @@ -1414,7 +1439,13 @@ impl PianoRollPane { // Dot grid background (visible where the spectrogram doesn't draw) let grid_painter = ui.painter_at(view_rect); - self.render_dot_grid(&grid_painter, view_rect); + { + let (dot_bpm, dot_ts) = { + let doc = shared.action_executor.document(); + (doc.bpm, doc.time_signature.clone()) + }; + self.render_dot_grid(&grid_painter, view_rect, dot_bpm, &dot_ts); + } // Find audio pool index for the active layer's clips let layer_id = match *shared.active_layer_id { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index a4c9e68..4c420d8 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -130,6 +130,13 @@ enum ClipDragType { LoopExtendLeft, } +/// How time is displayed in the ruler and header +#[derive(Debug, Clone, Copy, PartialEq)] +enum TimeDisplayFormat { + Seconds, + Measures, +} + pub struct TimelinePane { /// Horizontal zoom level (pixels per second) pixels_per_second: f32, @@ -163,6 +170,9 @@ pub struct TimelinePane { /// Context menu state: Some((optional_clip_instance_id, position)) when a right-click menu is open /// clip_id is None when right-clicking on empty timeline space context_menu_clip: Option<(Option, egui::Pos2)>, + + /// Whether to display time as seconds or measures + time_display_format: TimeDisplayFormat, } /// Check if a clip type can be dropped on a layer type @@ -231,6 +241,7 @@ impl TimelinePane { mousedown_pos: None, layer_control_clicked: false, context_menu_clip: None, + time_display_format: TimeDisplayFormat::Seconds, } } @@ -548,72 +559,105 @@ impl TimelinePane { } /// Render the time ruler at the top - fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme) { + fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme, + bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) { let painter = ui.painter(); // Background let bg_style = theme.style(".timeline-background", ui.ctx()); let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_rgb(34, 34, 34)); - painter.rect_filled( - rect, - 0.0, - bg_color, - ); + painter.rect_filled(rect, 0.0, bg_color); - // Get text color from theme let text_style = theme.style(".text-primary", ui.ctx()); let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); - // Calculate interval for tick marks - let interval = self.calculate_ruler_interval(); + match self.time_display_format { + TimeDisplayFormat::Seconds => { + let interval = self.calculate_ruler_interval(); + let start_time = (self.viewport_start_time / interval).floor() * interval; + let end_time = self.x_to_time(rect.width()); - // Draw tick marks and labels - let start_time = (self.viewport_start_time / interval).floor() * interval; - let end_time = self.x_to_time(rect.width()); - - let mut time = start_time; - while time <= end_time { - let x = self.time_to_x(time); - - if x >= 0.0 && x <= rect.width() { - // Major tick mark - painter.line_segment( - [ - rect.min + egui::vec2(x, rect.height() - 10.0), - rect.min + egui::vec2(x, rect.height()), - ], - egui::Stroke::new(1.0, egui::Color32::from_gray(100)), - ); - - // Time label - let label = format!("{:.1}s", time); - painter.text( - rect.min + egui::vec2(x + 2.0, 5.0), - egui::Align2::LEFT_TOP, - label, - egui::FontId::proportional(12.0), - text_color, - ); - } - - // Minor tick marks (subdivisions) - let minor_interval = interval / 5.0; - for i in 1..5 { - let minor_time = time + minor_interval * i as f64; - let minor_x = self.time_to_x(minor_time); - - if minor_x >= 0.0 && minor_x <= rect.width() { - painter.line_segment( - [ - rect.min + egui::vec2(minor_x, rect.height() - 5.0), - rect.min + egui::vec2(minor_x, rect.height()), - ], - egui::Stroke::new(1.0, egui::Color32::from_gray(60)), - ); + let mut time = start_time; + while time <= end_time { + let x = self.time_to_x(time); + if x >= 0.0 && x <= rect.width() { + painter.line_segment( + [rect.min + egui::vec2(x, rect.height() - 10.0), + rect.min + egui::vec2(x, rect.height())], + egui::Stroke::new(1.0, egui::Color32::from_gray(100)), + ); + painter.text( + rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP, + format!("{:.1}s", time), egui::FontId::proportional(12.0), text_color, + ); + } + let minor_interval = interval / 5.0; + for i in 1..5 { + let minor_x = self.time_to_x(time + minor_interval * i as f64); + if minor_x >= 0.0 && minor_x <= rect.width() { + painter.line_segment( + [rect.min + egui::vec2(minor_x, rect.height() - 5.0), + rect.min + egui::vec2(minor_x, rect.height())], + egui::Stroke::new(1.0, egui::Color32::from_gray(60)), + ); + } + } + time += interval; } } + TimeDisplayFormat::Measures => { + let beats_per_second = bpm / 60.0; + let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm); + let bpm_count = time_sig.numerator; + let px_per_beat = beat_dur as f32 * self.pixels_per_second; - time += interval; + let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64; + let end_beat = (self.x_to_time(rect.width()) * beats_per_second).ceil() as i64; + + // Adaptive: how often to label measures + let measure_px = px_per_beat * bpm_count as f32; + let label_every = if measure_px > 60.0 { 1u32 } else if measure_px > 20.0 { 4 } else { 16 }; + + for beat_idx in start_beat..=end_beat { + if beat_idx < 0 { continue; } + let x = self.time_to_x(beat_idx as f64 / beats_per_second); + if x < 0.0 || x > rect.width() { continue; } + + let beat_in_measure = (beat_idx as u32) % bpm_count; + let measure = (beat_idx as u32) / bpm_count + 1; + let is_measure_boundary = beat_in_measure == 0; + + // Tick height, stroke width, and brightness based on beat importance + let (tick_h, stroke_w, gray) = if is_measure_boundary { + (12.0, 2.0, 140u8) + } else if beat_in_measure % 2 == 0 { + (8.0, 1.0, 80) + } else { + (5.0, 1.0, 50) + }; + + painter.line_segment( + [rect.min + egui::vec2(x, rect.height() - tick_h), + rect.min + egui::vec2(x, rect.height())], + egui::Stroke::new(stroke_w, egui::Color32::from_gray(gray)), + ); + + // Labels: measure numbers at boundaries, beat numbers when zoomed in + if is_measure_boundary && (label_every == 1 || measure % label_every == 1) { + painter.text( + rect.min + egui::vec2(x + 3.0, 3.0), egui::Align2::LEFT_TOP, + format!("{}", measure), egui::FontId::proportional(12.0), text_color, + ); + } else if !is_measure_boundary && px_per_beat > 40.0 { + let alpha = if beat_in_measure % 2 == 0 { 0.5 } else if px_per_beat > 80.0 { 0.25 } else { continue }; + painter.text( + rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP, + format!("{}.{}", measure, beat_in_measure + 1), + egui::FontId::proportional(10.0), text_color.gamma_multiply(alpha), + ); + } + } + } } } @@ -1104,25 +1148,42 @@ impl TimelinePane { painter.rect_filled(layer_rect, 0.0, bg_color); // Grid lines matching ruler - let interval = self.calculate_ruler_interval(); - let start_time = (self.viewport_start_time / interval).floor() * interval; - let end_time = self.x_to_time(rect.width()); - - let mut time = start_time; - while time <= end_time { - let x = self.time_to_x(time); - - if x >= 0.0 && x <= rect.width() { - painter.line_segment( - [ - egui::pos2(rect.min.x + x, y), - egui::pos2(rect.min.x + x, y + LAYER_HEIGHT), - ], - egui::Stroke::new(1.0, egui::Color32::from_gray(30)), - ); + match self.time_display_format { + TimeDisplayFormat::Seconds => { + let interval = self.calculate_ruler_interval(); + let start_time = (self.viewport_start_time / interval).floor() * interval; + let end_time = self.x_to_time(rect.width()); + let mut time = start_time; + while time <= end_time { + let x = self.time_to_x(time); + if x >= 0.0 && x <= rect.width() { + painter.line_segment( + [egui::pos2(rect.min.x + x, y), + egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)], + egui::Stroke::new(1.0, egui::Color32::from_gray(30)), + ); + } + time += interval; + } + } + TimeDisplayFormat::Measures => { + let beats_per_second = document.bpm / 60.0; + let bpm_count = document.time_signature.numerator; + let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64; + let end_beat = (self.x_to_time(rect.width()) * beats_per_second).ceil() as i64; + for beat_idx in start_beat..=end_beat { + if beat_idx < 0 { continue; } + let x = self.time_to_x(beat_idx as f64 / beats_per_second); + if x < 0.0 || x > rect.width() { continue; } + let is_measure_boundary = (beat_idx as u32) % bpm_count == 0; + let gray = if is_measure_boundary { 45 } else { 25 }; + painter.line_segment( + [egui::pos2(rect.min.x + x, y), + egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)], + egui::Stroke::new(if is_measure_boundary { 1.5 } else { 1.0 }, egui::Color32::from_gray(gray)), + ); + } } - - time += interval; } // Draw clip instances for this layer @@ -2647,13 +2708,95 @@ impl PaneRenderer for TimelinePane { let text_style = shared.theme.style(".text-primary", ui.ctx()); let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); - // Time display - ui.colored_label(text_color, format!("Time: {:.2}s / {:.2}s", *shared.playback_time, self.duration)); + // Time display (format-dependent) + { + let (bpm, time_sig_num, time_sig_den) = { + let doc = shared.action_executor.document(); + (doc.bpm, doc.time_signature.numerator, doc.time_signature.denominator) + }; - ui.separator(); + match self.time_display_format { + TimeDisplayFormat::Seconds => { + ui.colored_label(text_color, format!("Time: {:.2}s / {:.2}s", *shared.playback_time, self.duration)); + } + TimeDisplayFormat::Measures => { + let time_sig = lightningbeam_core::document::TimeSignature { numerator: time_sig_num, denominator: time_sig_den }; + let pos = lightningbeam_core::beat_time::time_to_measure( + *shared.playback_time, bpm, &time_sig, + ); + ui.colored_label(text_color, format!( + "BAR: {}.{} | BPM: {:.0} | {}/{}", + pos.measure, pos.beat, bpm, + time_sig_num, time_sig_den, + )); + } + } - // Zoom display - ui.colored_label(text_color, format!("Zoom: {:.0}px/s", self.pixels_per_second)); + ui.separator(); + + // Zoom display + ui.colored_label(text_color, format!("Zoom: {:.0}px/s", self.pixels_per_second)); + + ui.separator(); + + // Time display format toggle + egui::ComboBox::from_id_salt("time_format") + .selected_text(match self.time_display_format { + TimeDisplayFormat::Seconds => "Seconds", + TimeDisplayFormat::Measures => "Measures", + }) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Seconds, "Seconds"); + ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Measures, "Measures"); + }); + + ui.separator(); + + // BPM control + let mut bpm_val = bpm; + ui.label("BPM:"); + let bpm_response = ui.add(egui::DragValue::new(&mut bpm_val) + .range(20.0..=300.0) + .speed(0.5) + .fixed_decimals(1)); + if bpm_response.changed() { + shared.action_executor.document_mut().bpm = bpm_val; + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.set_tempo(bpm_val as f32, (time_sig_num, time_sig_den)); + } + } + + ui.separator(); + + // Time signature selector + let time_sig_presets: [(u32, u32); 8] = [ + (2, 4), (3, 4), (4, 4), (5, 4), + (6, 8), (7, 8), (9, 8), (12, 8), + ]; + let current_ts_label = format!("{}/{}", time_sig_num, time_sig_den); + egui::ComboBox::from_id_salt("time_sig") + .selected_text(¤t_ts_label) + .width(60.0) + .show_ui(ui, |ui| { + for (num, den) in &time_sig_presets { + let label = format!("{}/{}", num, den); + if ui.selectable_label( + time_sig_num == *num && time_sig_den == *den, + &label, + ).clicked() { + let doc = shared.action_executor.document_mut(); + doc.time_signature.numerator = *num; + doc.time_signature.denominator = *den; + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.set_tempo(doc.bpm as f32, (*num, *den)); + } + } + } + }); + } true } @@ -2750,7 +2893,7 @@ impl PaneRenderer for TimelinePane { // Render time ruler (clip to ruler rect) ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); - self.render_ruler(ui, ruler_rect, shared.theme); + self.render_ruler(ui, ruler_rect, shared.theme, document.bpm, &document.time_signature); // Render layer rows with clipping ui.set_clip_rect(content_rect.intersect(original_clip_rect));