Add beat mode

This commit is contained in:
Skyler Lehmkuhl 2026-02-22 18:43:17 -05:00
parent 205dc9dd67
commit eab116c930
11 changed files with 402 additions and 105 deletions

View File

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

View File

@ -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<Vec<NodeIndex>>,
@ -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<dyn AudioNode>) -> 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::<BeatNode>() {
beat_node.set_playback_time(playback_time);
beat_node.set_tempo(self.bpm, self.beats_per_bar);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<uuid::Uuid>, 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(&current_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));