diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index bc3c224..b782cd2 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -33,6 +33,15 @@ pub struct Engine { playing: bool, channels: u32, + /// Transport cycle (loop) region, authored in BEATS so it survives tempo changes. + /// Sample bounds are derived from the tempo map at the wrap check. + loop_region: Option<(Beats, Beats)>, + /// Whether the transport wraps at the end of `loop_region`. + loop_enabled: bool, + /// Cycle-region sample bounds frozen for the duration of an **audio** recording. + /// See `loop_bounds_samples` for why. `None` = derive live from the tempo map. + loop_bounds_frozen: Option<(i64, i64)>, + // Lock-free communication command_rx: rtrb::Consumer, midi_command_rx: Option>, @@ -155,6 +164,9 @@ impl Engine { sample_rate, playing: false, channels, + loop_region: None, + loop_enabled: false, + loop_bounds_frozen: None, command_rx, midi_command_rx: None, event_tx, @@ -508,6 +520,38 @@ impl Engine { // Update playhead (convert total samples to frames) self.playhead += (output.len() / self.channels as usize) as i64; + // Cycle/loop wrap. Gated on playhead >= 0 so a count-in pre-roll never wraps. + // Sounding voices are deliberately left alone — no `stop_all_notes()` / + // `reset_all_graphs()` like Command::Seek does, since that would chop sustain and + // reverb tails at every wrap. + if self.loop_enabled && self.playhead >= 0 { + if let Some((ls_beats, le_beats)) = self.loop_region { + // Sample bounds are frozen while audio is recording (see loop_bounds_samples). + let (ls, le) = self.loop_bounds_samples(ls_beats, le_beats); + if le > ls && self.playhead >= le { + // Phase-preserving wrap. Modulo (not a single subtraction) so an overshoot + // larger than the loop — e.g. after a tempo change shrank it — can't strand + // the playhead outside the region. + self.playhead = ls + (self.playhead - ls) % (le - ls); + + // A MIDI recording in progress stores note times as offsets from its + // start, and the playhead just jumped backwards — so any note still held + // across the boundary needs its note-off written at the region end (and + // re-opened at the region start if the key is still down). Otherwise it + // would get a negative duration, or never close at all. + if let Some(ref mut rec) = self.midi_recording_state { + rec.wrap_at_cycle(le_beats, ls_beats); + } + + if let Some(ref mut dr) = self.disk_reader { + dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek { + frame: self.playhead.max(0) as u64, + }); + } + } + } + } + // Update atomic playhead for UI reads (clamped to 0; negative = count-in pre-roll) self.playhead_atomic .store(self.playhead.max(0) as u64, Ordering::Relaxed); @@ -679,6 +723,8 @@ impl Engine { format!("Recording write error: {}", e) )); self.recording_state = None; + // Audio recording is over — let the cycle region track tempo again. + self.loop_bounds_frozen = None; } } } @@ -783,12 +829,71 @@ impl Engine { None } + /// Convert a beats position to a sample position using the current tempo map. + /// + /// The cycle region is authored in beats (so it survives tempo changes); its sample bounds are + /// derived here at the wrap check rather than cached, which keeps it correct across tempo edits + /// with no invalidation bookkeeping. + fn beats_to_samples(&self, beats: Beats) -> i64 { + (self.tempo_map.beats_to_seconds(beats).seconds_to_f64() * self.sample_rate as f64) as i64 + } + + /// Sample bounds of the cycle region. + /// + /// These are FROZEN while an audio recording is in flight (see `loop_bounds_frozen`, captured + /// in `handle_start_recording`), so a tempo change mid-take cannot resize the loop. + /// + /// Why freeze only for audio: take segmentation for audio is geometric in samples — every pass + /// is exactly `loop_len` frames, which is what lets us pad all takes to a uniform length and + /// comp between them. A tempo change would resize `loop_len` mid-session and break that. And + /// since we have no time-stretching, audio takes captured at two different tempos could never + /// be comped together anyway, so honoring the change would buy nothing and cost the invariant. + /// + /// MIDI is deliberately unaffected: it is segmented in *beats*, which are tempo-invariant, so + /// changing tempo during a MIDI-only recording is fully supported (slow down a hard passage and + /// keep stacking takes). The freeze is keyed on *audio* being recorded, not on "not MIDI", so + /// when multi-track recording lands it will correctly freeze if ANY recorded track is audio. + fn loop_bounds_samples(&self, ls_beats: Beats, le_beats: Beats) -> (i64, i64) { + if let Some(frozen) = self.loop_bounds_frozen { + return frozen; + } + (self.beats_to_samples(ls_beats), self.beats_to_samples(le_beats)) + } + /// Handle a command from the UI thread fn handle_command(&mut self, cmd: Command) { match cmd { Command::Play => { + // Starting playback from outside the cycle region jumps to its start — otherwise + // you'd play forward from wherever the playhead happened to be and only fall into + // the loop if you happened to cross its end. Inside the region we start where we + // are, so you can still audition from the middle of a loop. + // + // A negative playhead is a count-in pre-roll that was deliberately placed *before* + // the region, so leave it alone. + if self.loop_enabled && self.playhead >= 0 { + if let Some((ls_beats, le_beats)) = self.loop_region { + let (ls, le) = self.loop_bounds_samples(ls_beats, le_beats); + if le > ls && (self.playhead < ls || self.playhead >= le) { + self.playhead = ls; + self.playhead_atomic.store(ls.max(0) as u64, Ordering::Relaxed); + if let Some(ref mut dr) = self.disk_reader { + dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek { + frame: ls.max(0) as u64, + }); + } + } + } + } self.playing = true; } + Command::SetLoopRegion(region) => { + // Stored in beats; sample bounds are derived at the wrap check. + self.loop_region = region; + } + Command::SetLoopEnabled(enabled) => { + self.loop_enabled = enabled; + } Command::Stop => { self.playing = false; self.playhead = 0; @@ -1309,6 +1414,12 @@ impl Engine { // Stop any active recording self.recording_state = None; + // Clear the cycle region — it's a document property, and the new document will + // push its own (or none) once loaded. + self.loop_region = None; + self.loop_enabled = false; + self.loop_bounds_frozen = None; + // Clear all project data self.project = Project::new(self.sample_rate); @@ -3034,6 +3145,19 @@ impl Engine { use crate::io::WavWriter; use std::env; + // Freeze the cycle region's sample bounds for the duration of this AUDIO recording, so a + // tempo change mid-take can't resize the loop and break the uniform-take invariant that + // take segmentation and comping depend on. See `loop_bounds_samples`. (MIDI-only + // recordings never take this path, so they stay free to change tempo.) + if self.loop_enabled { + if let Some((ls_beats, le_beats)) = self.loop_region { + self.loop_bounds_frozen = Some(( + self.beats_to_samples(ls_beats), + self.beats_to_samples(le_beats), + )); + } + } + // Check if track exists and is an audio track if let Some(crate::audio::track::TrackNode::Audio(_)) = self.project.get_track_mut(track_id) { // Generate a unique temp file path @@ -3118,6 +3242,9 @@ impl Engine { fn handle_stop_recording(&mut self) { eprintln!("[STOP_RECORDING] handle_stop_recording called"); + // Audio is no longer recording, so the cycle region can track the tempo map again. + self.loop_bounds_frozen = None; + // Check if we have an active MIDI recording first if self.midi_recording_state.is_some() { eprintln!("[STOP_RECORDING] Detected active MIDI recording, delegating to handle_stop_midi_recording"); @@ -3342,6 +3469,20 @@ impl EngineController { let _ = self.command_tx.push(Command::Pause); } + /// Set the cycle region the transport loops over (None clears it). + /// + /// Authored in beats so it survives tempo changes. Note the region's sample bounds are frozen + /// while an audio recording is in flight, so a call made mid-take won't resize the loop until + /// recording stops (see `Engine::loop_bounds_samples`). + pub fn set_loop_region(&mut self, region: Option<(Beats, Beats)>) { + let _ = self.command_tx.push(Command::SetLoopRegion(region)); + } + + /// Enable/disable wrapping at the end of the cycle region. + pub fn set_loop_enabled(&mut self, enabled: bool) { + let _ = self.command_tx.push(Command::SetLoopEnabled(enabled)); + } + /// Stop playback and reset to beginning pub fn stop(&mut self) { let _ = self.command_tx.push(Command::Stop); diff --git a/daw-backend/src/audio/recording.rs b/daw-backend/src/audio/recording.rs index 8203081..f1b3cbb 100644 --- a/daw-backend/src/audio/recording.rs +++ b/daw-backend/src/audio/recording.rs @@ -261,4 +261,27 @@ impl MidiRecordingState { )); } } + + /// Handle a transport cycle wrap during MIDI recording. + /// + /// Note times are stored as offsets from `start_time`, and the playhead jumps *backwards* at a + /// wrap — so a note still held across the boundary would otherwise get a nonsensical (negative) + /// duration, or never be closed at all. Write its note-off at `region_end` (exactly as + /// `close_active_notes` does when recording stops), then re-open it at `region_start` so a key + /// the player is still physically holding keeps being captured in the next pass. Mirrors the + /// way `handle_start_midi_recording` re-injects already-held notes at the recording start. + pub fn wrap_at_cycle(&mut self, region_end: Beats, region_start: Beats) { + // Snapshot the held notes (close_active_notes drains them and loses the velocities). + let held: Vec<(u8, u8)> = self + .active_notes + .values() + .map(|n| (n.note, n.velocity)) + .collect(); + + self.close_active_notes(region_end); + + for (note, velocity) in held { + self.note_on(note, velocity, region_start); + } + } } diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 98cce70..4a57152 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -113,6 +113,13 @@ pub enum Command { /// Enable/disable an automation lane (track_id, lane_id, enabled) SetAutomationLaneEnabled(TrackId, AutomationLaneId, bool), + // Transport cycle (loop) region + /// Set the cycle region the transport loops over, in beats (None clears it). + /// Authored in beats so it survives tempo changes. + SetLoopRegion(Option<(Beats, Beats)>), + /// Enable/disable wrapping at the cycle region's end. + SetLoopEnabled(bool), + // Recording commands /// Start recording on a track (track_id, start_time) StartRecording(TrackId, Beats), diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index 97aed88..185ba15 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -3628,7 +3628,7 @@ dependencies = [ [[package]] name = "lightningbeam-editor" -version = "1.0.8-alpha" +version = "1.0.9-alpha" dependencies = [ "beamdsp", "bytemuck", diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 7bed42f..83d426f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -14,6 +14,7 @@ pub mod move_clip_instances; pub mod reorder_clip_instances; pub mod paint_bucket; pub mod remove_effect; +pub mod set_cycle_region; pub mod set_document_properties; pub mod set_instance_properties; pub mod set_layer_properties; @@ -50,6 +51,7 @@ pub mod set_text_content; pub mod resize_text_box; pub use add_clip_instance::AddClipInstanceAction; +pub use set_cycle_region::SetCycleRegionAction; pub use add_effect::AddEffectAction; pub use add_layer::AddLayerAction; pub use add_shape::AddShapeAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_cycle_region.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_cycle_region.rs new file mode 100644 index 0000000..28d1597 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_cycle_region.rs @@ -0,0 +1,93 @@ +//! Set the transport cycle (loop) region. +//! +//! The cycle region is document state (it's saved in the `.beam`), so changing it goes through the +//! action system like any other edit: it's undoable and it marks the document modified. +//! +//! The region is stored in **beats** so it stays put musically across tempo changes. Callers commit +//! one action per gesture (e.g. on drag release, or a toggle click) rather than one per frame — +//! the timeline previews the drag from its own local state, exactly like a clip drag does. + +use crate::action::{Action, BackendContext}; +use crate::document::Document; +use daw_backend::Beats; + +/// Action that sets the cycle region and/or whether the transport loops over it. +#[derive(Clone)] +pub struct SetCycleRegionAction { + old_region: Option<(Beats, Beats)>, + old_enabled: bool, + new_region: Option<(Beats, Beats)>, + new_enabled: bool, +} + +impl SetCycleRegionAction { + /// Build from the document's current state and the desired new region/enabled flag. + pub fn new( + document: &Document, + new_region: Option<(Beats, Beats)>, + new_enabled: bool, + ) -> Self { + Self { + old_region: document.cycle_region, + old_enabled: document.cycle_enabled, + new_region, + new_enabled, + } + } + + /// Toggle looping on/off, leaving the region itself alone. + pub fn toggle_enabled(document: &Document) -> Self { + Self::new(document, document.cycle_region, !document.cycle_enabled) + } + + /// True if this action would not actually change anything (lets callers skip a no-op undo entry). + pub fn is_noop(&self) -> bool { + self.old_region == self.new_region && self.old_enabled == self.new_enabled + } +} + +impl Action for SetCycleRegionAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + document.cycle_region = self.new_region; + document.cycle_enabled = self.new_enabled; + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + document.cycle_region = self.old_region; + document.cycle_enabled = self.old_enabled; + Ok(()) + } + + fn description(&self) -> String { + "Set cycle region".to_string() + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + let controller = match backend.audio_controller.as_mut() { + Some(c) => c, + None => return Ok(()), + }; + controller.set_loop_region(self.new_region); + controller.set_loop_enabled(self.new_enabled); + Ok(()) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + let controller = match backend.audio_controller.as_mut() { + Some(c) => c, + None => return Ok(()), + }; + controller.set_loop_region(self.old_region); + controller.set_loop_enabled(self.old_enabled); + Ok(()) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index a26b86b..19cb018 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -201,6 +201,17 @@ pub struct Document { #[serde(default)] pub time_signature: TimeSignature, + /// Transport cycle (loop) region, as `(start, end)` in **beats**. + /// + /// Authored in beats so it stays put musically when the tempo changes. `None` = no region set. + /// Saved with the project; `#[serde(default)]` keeps older `.beam` files loading. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cycle_region: Option<(Beats, Beats)>, + + /// Whether the transport loops over `cycle_region`. + #[serde(default)] + pub cycle_enabled: bool, + /// Master track (master bus + tempo automation lane). /// Stored separately from the root layer tree; shown in timeline when /// `show_master_track` is enabled in the editor state. @@ -297,6 +308,8 @@ impl Default for Document { height: 1080.0, framerate: 60.0, time_signature: TimeSignature::default(), + cycle_region: None, + cycle_enabled: false, master_layer: { let mut ml = GroupLayer::new_master(120.0); ml.layer.id = uuid::Uuid::new_v4(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index e97c10e..a2f1e88 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -2039,6 +2039,22 @@ impl EditorApp { fn sync_audio_layers_to_backend(&mut self) { use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; + // Push the document's cycle region to the engine. Needed on load/new: the region is + // document state, but the engine starts blank (and is cleared on Reset), so without this a + // loaded project would show its cycle strip while the transport never actually looped. + // Changes made later go through SetCycleRegionAction's execute_backend. + { + let (region, enabled) = { + let doc = self.action_executor.document(); + (doc.cycle_region, doc.cycle_enabled) + }; + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.set_loop_region(region); + controller.set_loop_enabled(enabled); + } + } + // Ensure the master layer has a backend group track. let master_layer_id = self.action_executor.document().master_layer.layer.id; if !self.layer_to_track_map.contains_key(&master_layer_id) { diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs index 979abd5..f1b0e21 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs @@ -42,6 +42,7 @@ pub const GRIP_HORIZONTAL: &str = "\u{e0ea}"; pub const CHEVRONS_UP: &str = "\u{e074}"; pub const PLAY: &str = "\u{e13c}"; pub const PAUSE: &str = "\u{e12e}"; +pub const REPEAT: &str = "\u{e146}"; // cycle / loop region toggle pub const SETTINGS: &str = "\u{e154}"; pub const SEARCH: &str = "\u{e151}"; pub const PLUS: &str = "\u{e13d}"; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index c19b724..4ec6c21 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -20,6 +20,18 @@ const MOBILE_LAYER_HEADER_WIDTH: f32 = LAYER_HEIGHT * 0.5; const MIN_PIXELS_PER_SECOND: f32 = 1.0; // Allow zooming out to see 10+ minutes const MAX_PIXELS_PER_SECOND: f32 = 500.0; const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles +/// Height of the cycle lane carved off the *bottom* of the ruler. Dragging here edits the cycle +/// region; the ruler above it still scrubs the playhead as before. It goes at the bottom so the +/// bar numbers (which are drawn at the top of the ruler) stay legible. +const CYCLE_LANE_HEIGHT: f32 = 11.0; + +// Snap "visual coarseness" profiles — the minimum on-screen spacing a grid line may have. +// See `Timeline::quantize_grid_size`. +/// Playhead scrubbing and clip edges: snap fine, down to 16ths when there's room. +const SNAP_PX_FINE: f64 = 15.0; +/// Cycle region: snap coarse, so loops land on whole bars rather than odd subdivisions. +/// Only drops to beats once you're zoomed in far enough to clearly be asking for it. +const SNAP_PX_CYCLE: f64 = 60.0; const LOOP_CORNER_SIZE: f32 = 12.0; // Size of loop corner hotzone at top-right of clip const MIN_CLIP_WIDTH_PX: f32 = 8.0; // Minimum visible width for very short clips (e.g. groups) const AUTOMATION_LANE_HEIGHT: f32 = 40.0; @@ -230,6 +242,20 @@ enum ClipDragType { LoopExtendLeft, } +/// A drag on the cycle strip (the thin lane at the top of the ruler). +/// +/// Mirrors `ClipDragType`'s three-zone model (left edge / body / right edge), reusing +/// `EDGE_DETECTION_PIXELS` for the handles. +#[derive(Debug, Clone, Copy, PartialEq)] +enum CycleDrag { + /// Dragging out a brand-new region; `anchor` is the beat the drag started from. + Create { anchor: Beats }, + /// Sliding the whole region; `grab_offset` is where inside it the user grabbed. + Move { grab_offset: Beats }, + ResizeStart, + ResizeEnd, +} + use lightningbeam_core::document::TimelineMode; /// State for an in-progress layer header drag-to-reorder operation. @@ -267,6 +293,12 @@ pub struct TimelinePane { /// Is the user currently dragging the playhead? is_scrubbing: bool, + /// In-flight drag on the cycle strip, if any. + cycle_drag: Option, + /// Live preview of the cycle region while dragging. The document is only updated on release + /// (via one `SetCycleRegionAction`), so a drag doesn't spam the undo stack — same as clip drags. + cycle_preview: Option<(Beats, Beats)>, + /// Is the user panning the timeline? is_panning: bool, last_pan_pos: Option, @@ -693,6 +725,8 @@ impl TimelinePane { keyframe_diamond_hits: Vec::new(), duration: 10.0, // Default 10 seconds is_scrubbing: false, + cycle_drag: None, + cycle_preview: None, is_panning: false, last_pan_pos: None, lp_time: None, @@ -858,6 +892,35 @@ impl TimelinePane { self.automation_cache.insert(layer_id, lanes); } + /// Toggle the transport cycle (loop) on/off. + /// + /// This is the only way to arm looping; the cycle strip on the ruler is shown (and draggable) + /// only while armed. Arming with no region set seeds a default one at the playhead so the + /// button does something immediately rather than revealing an empty strip. + pub(crate) fn toggle_cycle(&mut self, shared: &mut SharedPaneState) { + let document = shared.action_executor.document(); + let arming = !document.cycle_enabled; + + let region = if arming && document.cycle_region.is_none() { + let tempo_map = document.tempo_map(); + let beats_per_bar = (document.time_signature.numerator.max(1)) as f64; + // Start at the bar containing the playhead, and run for a few bars. + let playhead_beats = tempo_map.seconds_to_beats(Seconds(*shared.playback_time)); + let bar = (playhead_beats.beats_to_f64() / beats_per_bar).floor().max(0.0); + let start = Beats(bar * beats_per_bar); + const DEFAULT_BARS: f64 = 4.0; + Some((start, start + Beats(beats_per_bar * DEFAULT_BARS))) + } else { + document.cycle_region + }; + + let action = + lightningbeam_core::actions::SetCycleRegionAction::new(document, region, arming); + if !action.is_noop() { + shared.pending_actions.push(Box::new(action)); + } + } + /// Toggle recording on/off /// In Auto mode, records to the active layer (audio or video with camera) pub(crate) fn toggle_recording(&mut self, shared: &mut SharedPaneState) { @@ -1418,6 +1481,119 @@ impl TimelinePane { self.viewport_start_time = (self.viewport_start_time + time_delta as f64).max(0.0); } + /// The cycle lane: a thin band carved off the *bottom* of the ruler. Dragging here edits the + /// cycle region; the ruler above it still scrubs the playhead. Bottom rather than top so it + /// doesn't sit on the bar numbers. + fn cycle_lane_rect(ruler_rect: egui::Rect) -> egui::Rect { + let h = CYCLE_LANE_HEIGHT.min(ruler_rect.height()); + egui::Rect::from_min_max( + egui::pos2(ruler_rect.min.x, ruler_rect.max.y - h), + ruler_rect.max, + ) + } + + /// The cycle region currently being shown: the live drag preview if one is in flight, + /// otherwise whatever the document has. + fn shown_cycle_region( + &self, + document: &lightningbeam_core::document::Document, + ) -> Option<(Beats, Beats)> { + self.cycle_preview.or(document.cycle_region) + } + + /// Three-zone hit test on the cycle lane: left edge / body / right edge of the existing region, + /// mirroring how clips detect their trim handles. Anywhere else draws a fresh region. + /// + /// `pos_x` is in screen coords; `content_min_x` is the content area's left edge (which the + /// ruler shares, so beats→x lands in the same space). + fn cycle_drag_at( + &self, + pos_x: f32, + beat: Beats, + content_min_x: f32, + document: &lightningbeam_core::document::Document, + ) -> CycleDrag { + let Some((s, e)) = document.cycle_region else { + return CycleDrag::Create { anchor: beat }; + }; + let sx = content_min_x + self.beats_to_x(s, document.tempo_map()); + let ex = content_min_x + self.beats_to_x(e, document.tempo_map()); + if (pos_x - sx).abs() <= EDGE_DETECTION_PIXELS { + CycleDrag::ResizeStart + } else if (pos_x - ex).abs() <= EDGE_DETECTION_PIXELS { + CycleDrag::ResizeEnd + } else if pos_x > sx && pos_x < ex { + CycleDrag::Move { grab_offset: beat - s } + } else { + CycleDrag::Create { anchor: beat } + } + } + + /// Pixel x (relative to the content area) → beats, snapped on the coarse cycle grid. + fn x_to_beats_cycle_snapped( + &self, + x: f32, + document: &lightningbeam_core::document::Document, + ) -> Beats { + let secs = self.snap_to_grid( + self.x_to_time(x.max(0.0)).max(0.0), + document.tempo_map(), + &document.time_signature, + document.framerate, + SNAP_PX_CYCLE, + ); + document.tempo_map().seconds_to_beats(Seconds(secs.max(0.0))) + } + + /// Paint the cycle lane and the cycle region band, *inside* the ruler. + /// + /// Called from `render_ruler` right after the ruler's background and before its ticks/labels, + /// so the tick marks read through the band and the bar numbers (up top) are never covered. + /// + /// The lane only exists while looping is armed — with cycle off there's no lane and the ruler + /// is entirely the playhead scrubber, exactly as before this feature. + fn paint_cycle_lane( + &self, + ui: &egui::Ui, + ruler_rect: egui::Rect, + theme: &crate::theme::Theme, + tempo_map: &daw_backend::TempoMap, + region: Option<(Beats, Beats)>, + ) { + let painter = ui.painter(); + let lane = Self::cycle_lane_rect(ruler_rect); + + // A faint bed, so the lane still reads as a drag target when there's no region to grab. + painter.rect_filled( + lane, + 0.0, + theme.bg_color(&["#timeline", ".cycle-lane"], ui.ctx(), egui::Color32::from_gray(48)), + ); + + let Some((start, end)) = region else { return }; + if end <= start { + return; + } + + let sx = self.beats_to_x(start, tempo_map); + let ex = self.beats_to_x(end, tempo_map); + if ex < 0.0 || sx > ruler_rect.width() { + return; // off-screen + } + + let fill = theme.bg_color( + &["#timeline", ".cycle-region"], + ui.ctx(), + egui::Color32::from_rgb(230, 190, 60), + ); + + let band = egui::Rect::from_min_max( + egui::pos2((ruler_rect.min.x + sx).max(ruler_rect.min.x), lane.min.y), + egui::pos2((ruler_rect.min.x + ex).min(ruler_rect.max.x), lane.max.y), + ); + painter.rect_filled(band, 2.0, fill); + } + /// Convert time (seconds) to pixel x-coordinate fn time_to_x(&self, time: f64) -> f32 { ((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32 @@ -1459,11 +1635,19 @@ impl TimelinePane { /// - Measures mode: zoom-adaptive (coarser when zoomed out, None when very zoomed in) /// - Frames mode: always 1/framerate regardless of zoom /// - Seconds mode: no snapping + /// + /// `min_grid_px` is the **visual coarseness**: the smallest on-screen spacing a grid line is + /// allowed to have. The finest musical subdivision at least that wide wins, so a larger value + /// snaps to coarser units at the same zoom. Callers pick a profile: [`SNAP_PX_FINE`] for the + /// playhead and clip edges, [`SNAP_PX_CYCLE`] for the cycle region (you nearly always want to + /// loop whole bars — "four bars and an eighth" is essentially never intended; zoom in if you + /// really do want it). fn quantize_grid_size( &self, tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64, + min_grid_px: f64, ) -> Option { match self.time_display_format { TimelineMode::Frames => Some(1.0 / framerate), @@ -1472,17 +1656,17 @@ impl TimelinePane { let beat = beat_duration(0.0, tempo_map); let measure = measure_duration(0.0, tempo_map, time_sig); let pps = self.pixels_per_second as f64; - // Very zoomed in: 16th note > 40px → no snap - if pps * beat / 4.0 > 40.0 { return None; } - // Find finest subdivision with >= 15px spacing (finest → coarsest) - const MIN_PX: f64 = 15.0; + // So zoomed in that even the finest subdivision is a huge target → free positioning. + // Scaled off the coarseness so a coarse profile doesn't give up snapping early. + if pps * beat / 4.0 > min_grid_px * 2.5 { return None; } + // Find the finest subdivision with >= min_grid_px spacing (finest → coarsest) for &sub in &[beat / 4.0, beat / 2.0, beat, beat * 2.0, measure] { - if pps * sub >= MIN_PX { return Some(sub); } + if pps * sub >= min_grid_px { return Some(sub); } } // Very zoomed out: try 2x, 4x, ... multiples of a measure let mut m = measure * 2.0; for _ in 0..10 { - if pps * m >= MIN_PX { return Some(m); } + if pps * m >= min_grid_px { return Some(m); } m *= 2.0; } Some(measure) @@ -1492,14 +1676,16 @@ impl TimelinePane { } /// Snap a time value to the nearest quantization grid point (or return unchanged). + /// See [`Self::quantize_grid_size`] for `min_grid_px` (the visual coarseness). fn snap_to_grid( &self, t: f64, tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64, + min_grid_px: f64, ) -> f64 { - match self.quantize_grid_size(tempo_map, time_sig, framerate) { + match self.quantize_grid_size(tempo_map, time_sig, framerate, min_grid_px) { Some(grid) => (t / grid).round() * grid, None => t, } @@ -1516,7 +1702,7 @@ impl TimelinePane { framerate: f64, ) -> Beats { let anchor = self.drag_anchor_start; // seconds - let target = match self.quantize_grid_size(tempo_map, time_sig, framerate) { + let target = match self.quantize_grid_size(tempo_map, time_sig, framerate, SNAP_PX_FINE) { Some(grid) => ((anchor + self.drag_offset) / grid).round() * grid, None => anchor + self.drag_offset, }; @@ -1564,7 +1750,8 @@ impl TimelinePane { /// Render the time ruler at the top fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme, - tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64) { + tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64, + cycle: Option>) { let painter = ui.painter(); // Background @@ -1572,6 +1759,12 @@ impl TimelinePane { let bg_color = bg_style.background_color().unwrap_or(egui::Color32::from_rgb(34, 34, 34)); painter.rect_filled(rect, 0.0, bg_color); + // Cycle lane goes under the ticks/labels: `Some(region)` when looping is armed (the region + // itself may still be `None`), `None` when it's off and the lane shouldn't exist at all. + if let Some(region) = cycle { + self.paint_cycle_lane(ui, rect, theme, tempo_map, region); + } + let text_style = theme.style(".text-primary", ui.ctx()); let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); @@ -3265,7 +3458,7 @@ impl TimelinePane { } } ClipDragType::TrimLeft => { - let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate).max(0.0).min(clip_dur_secs); + let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(0.0).min(clip_dur_secs); let trim_offset_secs = new_trim - ci.trim_start; start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO); let dur_secs = if let Some(trim_end) = ci.trim_end { @@ -3277,7 +3470,7 @@ impl TimelinePane { } ClipDragType::TrimRight => { let old_trim_end = ci.trim_end.unwrap_or(clip_dur_secs); - let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate).max(ci.trim_start).min(clip_dur_secs); + let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(ci.trim_start).min(clip_dur_secs); let dur_secs = (new_trim_end - ci.trim_start).max(0.0); duration = secs_to_beats_at(start, dur_secs); } @@ -3287,7 +3480,7 @@ impl TimelinePane { let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs); let current_right = ci.timeline_duration.unwrap_or(content_window); let right_edge_secs = tmap.beats_to_seconds(ci.timeline_start + current_right).seconds_to_f64() + self.drag_offset; - let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate); + let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE); let snapped_edge = tmap.seconds_to_beats(Seconds(snapped_edge_secs)); let new_right = (snapped_edge - ci.timeline_start).max(content_window); let loop_before = ci.loop_before.unwrap_or(Beats::ZERO); @@ -3371,7 +3564,7 @@ impl TimelinePane { } ClipDragType::TrimLeft => { // Trim left: calculate new trim_start with snap to adjacent clips - let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate) + let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE) .max(0.0) .min(clip_duration.seconds_to_f64()); @@ -3411,7 +3604,7 @@ impl TimelinePane { ClipDragType::TrimRight => { // Trim right: extend or reduce duration with snap to adjacent clips let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); - let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate) + let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE) .max(clip_instance.trim_start) .min(clip_duration.seconds_to_f64()); @@ -3454,7 +3647,7 @@ impl TimelinePane { let current_right = clip_instance.timeline_duration.unwrap_or(content_window); // Snap the right edge in the seconds/pixel domain (drag_offset is seconds). let right_edge_secs = tmap.beats_to_seconds(ts + current_right).seconds_to_f64() + self.drag_offset; - let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate); + let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE); let snapped_edge = tmap.seconds_to_beats(Seconds(snapped_edge_secs)); let desired_right = (snapped_edge - ts).max(content_window); @@ -4585,7 +4778,7 @@ impl TimelinePane { // New trim_start is snapped then clamped to valid range let desired_trim_start = self.snap_to_grid( - old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, + old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE, ).max(0.0).min(clip_duration.seconds_to_f64()); // Apply overlap prevention when extending left (content-seconds gap). @@ -4633,7 +4826,7 @@ impl TimelinePane { clip_instance.effective_duration(clip_duration, document.tempo_map()); let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); let desired_trim_end = self.snap_to_grid( - old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, + old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE, ).max(clip_instance.trim_start).min(clip_duration.seconds_to_f64()); // Apply overlap prevention when extending right (content-seconds gap). @@ -4715,7 +4908,7 @@ impl TimelinePane { let current_right = clip_instance.timeline_duration.unwrap_or(content_window); // Snap the right edge in the seconds/pixel domain. let right_edge_secs = tmap.beats_to_seconds(ts + current_right).seconds_to_f64() + self.drag_offset; - let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate); + let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE); let desired_right = tmap.seconds_to_beats(Seconds(snapped_edge_secs)) - ts; let new_right = if desired_right > current_right { @@ -4917,14 +5110,120 @@ impl TimelinePane { let visible_height = content_rect.height(); let max_scroll_y = (total_content_height - visible_height).max(0.0); - // Scrubbing (clicking/dragging on ruler, but only when not panning) - let cursor_over_ruler = ruler_rect.contains(ui.input(|i| i.pointer.hover_pos().unwrap_or_default())); + // ---- Cycle region (the lane along the bottom of the ruler) ---- + // The lane only exists while looping is armed; with cycle off the ruler is entirely the + // playhead scrubber, as it was before this feature. + // + // The lane is driven straight off raw pointer state rather than an egui `Response`. It sits + // inside the timeline's content response — which spans the whole ruler + content and already + // drives scrubbing, clip drags and panning — so registering a second widget on the same + // pixels just makes the two contest hover every frame (the cursor visibly flickers and + // neither reliably owns the press). Reading the pointer directly sidesteps egui's widget + // layering entirely. The lane is also carved out of the scrub area below, so a loop drag + // never also yanks the playhead. + let cycle_armed = document.cycle_enabled; + let cycle_lane = Self::cycle_lane_rect(ruler_rect); + let hover_pos = ui.input(|i| i.pointer.hover_pos()); + let (primary_pressed, primary_down, interact_pos) = ui.input(|i| { + ( + i.pointer.primary_pressed(), + i.pointer.primary_down(), + i.pointer.interact_pos(), + ) + }); + + if cycle_armed { + // Begin: press inside the lane. Three-zone hit test picks resize / move / draw-new. + if self.cycle_drag.is_none() && !alt_held && !self.is_panning && primary_pressed { + if let Some(pos) = interact_pos.filter(|p| cycle_lane.contains(*p)) { + let beat = self.x_to_beats_cycle_snapped(pos.x - content_rect.min.x, document); + self.cycle_drag = + Some(self.cycle_drag_at(pos.x, beat, content_rect.min.x, document)); + self.cycle_preview = document.cycle_region; + } + } + + // Telegraph what a press would do: resize at the edges, move over the body. + if let Some(pos) = hover_pos.filter(|p| cycle_lane.contains(*p)) { + let zone = self.cycle_drag.unwrap_or_else(|| { + let beat = self.x_to_beats_cycle_snapped(pos.x - content_rect.min.x, document); + self.cycle_drag_at(pos.x, beat, content_rect.min.x, document) + }); + ui.output_mut(|o| { + o.cursor_icon = match zone { + CycleDrag::ResizeStart | CycleDrag::ResizeEnd => { + egui::CursorIcon::ResizeHorizontal + } + CycleDrag::Move { .. } => egui::CursorIcon::Grab, + CycleDrag::Create { .. } => egui::CursorIcon::Crosshair, + } + }); + } + + if let Some(drag) = self.cycle_drag { + if primary_down { + // Track the pointer even when it leaves the lane, like any other drag. + if let Some(pos) = interact_pos { + let beat = + self.x_to_beats_cycle_snapped(pos.x - content_rect.min.x, document); + let base = self.cycle_preview.or(document.cycle_region); + self.cycle_preview = match drag { + CycleDrag::Create { anchor } => { + let (a, b) = + if beat < anchor { (beat, anchor) } else { (anchor, beat) }; + Some((a, b)) + } + CycleDrag::ResizeStart => base.map(|(_, e)| (beat.min(e), e)), + CycleDrag::ResizeEnd => base.map(|(s, _)| (s, beat.max(s))), + CycleDrag::Move { grab_offset } => base.map(|(s, e)| { + let len = e - s; + let ns = (beat - grab_offset).max(Beats::ZERO); + (ns, ns + len) + }), + }; + } + } else { + // Released — commit the gesture as ONE undoable action (the drag itself only + // ever touched `cycle_preview`, so the undo stack doesn't get a frame-by-frame + // trail). Looping is already armed (the lane wouldn't be there otherwise), so + // `cycle_enabled` is left alone. + let preview = self.cycle_preview.take(); + let collapsed = preview.map_or(true, |(s, e)| e <= s); + let drawing_new = matches!(drag, CycleDrag::Create { .. }); + self.cycle_drag = None; + + // A bare click on empty lane is a zero-width "draw" — treat it as nothing + // happened rather than silently wiping the region out from under the user. + // Collapsing an *existing* region by dragging an edge past the other one is + // still a deliberate "clear it". + if !(collapsed && drawing_new) { + let action = lightningbeam_core::actions::SetCycleRegionAction::new( + document, + preview.filter(|(s, e)| e > s), + document.cycle_enabled, + ); + if !action.is_noop() { + pending_actions.push(Box::new(action)); + } + } + } + } + } else if self.cycle_drag.is_some() { + // Looping was disarmed mid-drag — abandon the gesture rather than commit it. + self.cycle_drag = None; + self.cycle_preview = None; + } + + // Scrubbing (clicking/dragging on ruler, but only when not panning). + let cursor_over_ruler = hover_pos.map_or(false, |p| { + ruler_rect.contains(p) && !(cycle_armed && cycle_lane.contains(p)) + }) && self.cycle_drag.is_none(); // Start scrubbing if cursor is over ruler and we click/drag if cursor_over_ruler && !alt_held && (response.clicked() || (response.dragged() && !self.is_panning)) { if let Some(pos) = response.interact_pointer_pos() { let x = (pos.x - content_rect.min.x).max(0.0); - let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate); + let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE); *playback_time = new_time; self.is_scrubbing = true; // Seek immediately so it works while playing @@ -4938,7 +5237,7 @@ impl TimelinePane { else if self.is_scrubbing && response.dragged() && !self.is_panning { if let Some(pos) = response.interact_pointer_pos() { let x = (pos.x - content_rect.min.x).max(0.0); - let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate); + let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE); *playback_time = new_time; if let Some(controller_arc) = audio_controller { let mut controller = controller_arc.lock().unwrap(); @@ -5179,6 +5478,27 @@ impl PaneRenderer for TimelinePane { self.toggle_recording(shared); } + // Cycle (loop) toggle. This is the only way to arm looping — the cycle strip on the + // ruler is only shown (and only draggable) while it's armed. + let cycle_on = shared.action_executor.document().cycle_enabled; + let cycle_color = if cycle_on { + egui::Color32::from_rgb(230, 190, 60) + } else { + egui::Color32::from_gray(140) + }; + let cycle_button = egui::Button::new( + egui::RichText::new(crate::mobile::icons::REPEAT) + .font(crate::mobile::icons::font(15.0)) + .color(cycle_color), + ); + if ui + .add_sized(button_size, cycle_button) + .on_hover_text("Cycle (loop region)") + .clicked() + { + self.toggle_cycle(shared); + } + // Request repaint while recording for pulse animation if *shared.is_recording { ui.ctx().request_repaint(); @@ -5530,7 +5850,10 @@ 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, document.tempo_map(), &document.time_signature, document.framerate); + let cycle = document + .cycle_enabled + .then(|| self.shown_cycle_region(document)); + self.render_ruler(ui, ruler_rect, shared.theme, document.tempo_map(), &document.time_signature, document.framerate, cycle); // Render layer rows with clipping ui.set_clip_rect(content_rect.intersect(original_clip_rect));