Add beat mode
This commit is contained in:
parent
205dc9dd67
commit
eab116c930
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(¤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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue